diff --git a/docs/superpowers/plans/2026-04-21-local-cal-redesign.md b/docs/superpowers/plans/2026-04-21-local-cal-redesign.md
new file mode 100644
index 0000000..0566f4f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-21-local-cal-redesign.md
@@ -0,0 +1,663 @@
+# Local Cal Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Rebuild `local-cal` as a Vercel-inspired product console with aligned desktop AI/timeline hierarchy, capture-first mobile behavior, redesigned primitives, and inline event validation states.
+
+**Architecture:** Start at the token layer in `src/app/globals.css` and the shell contract in `src/lib/ui-shell-contract.ts`, then update shared `ui/*` primitives so pages compose the new visual system instead of layering overrides onto the old glass design. After the primitives are stable, reshape `src/app/page.tsx`, `src/components/ai-toolbar.tsx`, `src/components/event-card.tsx`, `src/components/events-list.tsx`, `src/components/event-dialog.tsx`, and `src/components/settings-panel.tsx` to match the approved desktop/mobile hierarchy and validation behavior.
+
+**Tech Stack:** Next.js 15 App Router, React 19, Tailwind CSS v4, Bun test, Radix UI, Framer Motion
+
+---
+
+## File Structure Map
+
+### Core styling and shell
+- Modify: `src/app/globals.css`
+ - Replace glass-heavy tokens/utilities with the approved neutral product-console token system.
+- Modify: `src/lib/ui-shell-contract.ts`
+ - Re-encode shell surface contracts for the new infrastructure bar, workspace surfaces, and utility controls.
+
+### Shared primitives
+- Modify: `src/components/ui/button.tsx`
+ - Update button variants toward Vercel-like surface, spacing, and focus rules.
+- Modify: `src/components/ui/card.tsx`
+ - Replace default bordered card treatment with shadow-as-border primitives.
+- Modify: `src/components/ui/input.tsx`
+ - Update input treatment to the new console field styling.
+- Modify: `src/components/ui/badge.tsx`
+ - Convert badges toward functional metadata accents instead of generic pills everywhere.
+- Modify: `src/components/ui/dialog.tsx`
+ - Move modal framing from generic bordered surfaces to console-style sheets.
+- Modify: `src/components/ui/dropdown-menu.tsx`
+ - Align menu surfaces with the new token system so `More` and event actions match the redesign.
+
+### Main app surfaces
+- Modify: `src/app/page.tsx`
+ - Rebuild the page hierarchy into a thin infrastructure bar plus aligned top-row AI/timeline workspace.
+- Modify: `src/components/ai-toolbar.tsx`
+ - Make text input and attachments co-equal and adapt desktop/mobile hierarchy.
+- Modify: `src/components/events-list.tsx`
+ - Update empty state and list rhythm to match the new shell.
+- Modify: `src/components/event-card.tsx`
+ - Replace workflow tags with functional metadata emphasis and inline warning treatment.
+- Modify: `src/components/event-dialog.tsx`
+ - Redesign the dialog as a precise console form.
+- Modify: `src/components/settings-panel.tsx`
+ - Demote settings to a secondary admin-like utility surface.
+
+### Validation helpers
+- Modify: `src/lib/event-form.ts`
+ - Expose a reusable event-level validation result that timeline cards can consume.
+- Modify: `src/lib/types.ts`
+ - Add any minimal derived UI type needed for validation summaries if existing types are insufficient.
+
+### Tests
+- Modify: `tests/ui-shell-contract.test.ts`
+ - Lock down the new shell contract classes.
+- Modify: `tests/ai-toolbar.test.ts`
+ - Update AI surface layout contracts around equal text/attachment importance.
+- Modify: `tests/event-card.test.ts`
+ - Add timeline metadata and inline warning assertions.
+- Modify: `tests/event-dialog.test.tsx`
+ - Add dialog contract checks for the redesigned grouping/surface.
+- Create: `tests/home-page-layout.test.ts`
+ - Lock down the approved desktop/mobile hierarchy at the composition level.
+
+---
+
+### Task 1: Replace global tokens and shell contracts
+
+**Files:**
+- Modify: `src/app/globals.css`
+- Modify: `src/lib/ui-shell-contract.ts`
+- Modify: `tests/ui-shell-contract.test.ts`
+
+- [ ] **Step 1: Write the failing shell contract test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import {
+ APP_ACTION_BAR_CLASSES,
+ APP_HEADER_SURFACE_CLASSES,
+ APP_NAV_SURFACE_CLASSES,
+ APP_SECTION_SURFACE_CLASSES,
+} from "@/lib/ui-shell-contract";
+
+describe("ui shell contract", () => {
+ test("header surface is a thin structural bar instead of a glass panel", () => {
+ expect(APP_HEADER_SURFACE_CLASSES).toContain("min-h-14");
+ expect(APP_HEADER_SURFACE_CLASSES).toContain("border-b");
+ expect(APP_HEADER_SURFACE_CLASSES).not.toContain("glass-surface");
+ });
+
+ test("section and action surfaces use tokenized shell classes instead of glass helpers", () => {
+ expect(APP_SECTION_SURFACE_CLASSES).not.toContain("glass-panel");
+ expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle");
+ expect(APP_NAV_SURFACE_CLASSES).not.toContain("glass-surface");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/ui-shell-contract.test.ts`
+Expected: FAIL because the shell contract still contains `glass-*` classes and lacks the new structural bar classes.
+
+- [ ] **Step 3: Replace the shell classes with the approved product-console contract**
+
+```ts
+import { cn } from "@/lib/utils";
+
+export const APP_HEADER_SURFACE_CLASSES =
+ "mb-6 flex min-h-14 items-center justify-between gap-3 border-b border-foreground/10 bg-background/95 px-4 py-3 sm:px-6";
+
+export const APP_SECTION_SURFACE_CLASSES =
+ "rounded-[10px] bg-card px-4 py-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] sm:px-5";
+
+export const APP_ACTION_BAR_CLASSES =
+ "rounded-[10px] bg-card px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
+
+export const APP_NAV_SURFACE_CLASSES =
+ "fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-[10px] bg-background/95 px-3 py-2 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)] sm:inset-x-6 lg:hidden";
+
+const CONNECTION_BADGE_BASE_CLASSES =
+ "gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
+
+export const getConnectionBadgeClasses = (isOnline: boolean) =>
+ cn(
+ CONNECTION_BADGE_BASE_CLASSES,
+ isOnline
+ ? "bg-[#ebf5ff] text-[#0068d6]"
+ : "bg-muted text-muted-foreground",
+ );
+```
+
+- [ ] **Step 4: Replace glass-centric global tokens with the approved light/dark console tokens**
+
+```css
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+ --card: #ffffff;
+ --card-foreground: #171717;
+ --popover: #ffffff;
+ --popover-foreground: #171717;
+ --primary: #171717;
+ --primary-foreground: #ffffff;
+ --secondary: #fafafa;
+ --secondary-foreground: #171717;
+ --muted: #fafafa;
+ --muted-foreground: #666666;
+ --accent: #f5f5f5;
+ --accent-foreground: #171717;
+ --destructive: #ff5b4f;
+ --destructive-foreground: #ffffff;
+ --border: #ebebeb;
+ --input: #ebebeb;
+ --ring: hsla(212, 100%, 48%, 1);
+ --radius: 0.625rem;
+ --shadow-shell: 0 0 0 1px rgba(0, 0, 0, 0.08);
+ --shadow-card:
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 2px 2px rgba(0, 0, 0, 0.04),
+ 0 8px 8px -8px rgba(0, 0, 0, 0.04),
+ 0 0 0 1px #fafafa;
+}
+
+.dark {
+ --background: #111111;
+ --foreground: #f5f5f5;
+ --card: #171717;
+ --card-foreground: #f5f5f5;
+ --popover: #171717;
+ --popover-foreground: #f5f5f5;
+ --primary: #f5f5f5;
+ --primary-foreground: #171717;
+ --secondary: #1f1f1f;
+ --secondary-foreground: #f5f5f5;
+ --muted: #1a1a1a;
+ --muted-foreground: #a1a1a1;
+ --accent: #1f1f1f;
+ --accent-foreground: #f5f5f5;
+ --border: rgba(255, 255, 255, 0.1);
+ --input: rgba(255, 255, 255, 0.12);
+}
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `bun test tests/ui-shell-contract.test.ts`
+Expected: PASS with the new shell contract assertions satisfied.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/app/globals.css src/lib/ui-shell-contract.ts tests/ui-shell-contract.test.ts
+git commit -m "feat: add product console shell tokens"
+```
+
+### Task 2: Redesign shared UI primitives
+
+**Files:**
+- Modify: `src/components/ui/button.tsx`
+- Modify: `src/components/ui/card.tsx`
+- Modify: `src/components/ui/input.tsx`
+- Modify: `src/components/ui/badge.tsx`
+- Modify: `src/components/ui/dialog.tsx`
+- Modify: `src/components/ui/dropdown-menu.tsx`
+- Test: `tests/event-dialog.test.tsx`
+
+- [ ] **Step 1: Write the failing dialog and primitive contract test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import {
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+describe("dialog primitive redesign", () => {
+ test("dialog content uses console surface classes instead of generic border+shadow", () => {
+ const markup = renderToStaticMarkup(
+
+
+ New Event
+ Console form
+
+ ,
+ );
+
+ expect(markup).toContain("rounded-[10px]");
+ expect(markup).toContain("shadow-[0_0_0_1px_rgba(0,0,0,0.08)");
+ expect(markup).not.toContain("rounded-lg border p-6 shadow-lg");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/event-dialog.test.tsx`
+Expected: FAIL because the dialog still renders the old generic bordered modal classes.
+
+- [ ] **Step 3: Update buttons, cards, inputs, badges, and dialog primitives to the new system**
+
+```ts
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-[background-color,color,box-shadow] disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:opacity-92",
+ outline:
+ "bg-background text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-accent",
+ ghost: "text-muted-foreground hover:bg-accent hover:text-foreground",
+ link: "text-[#0072f5] underline-offset-4 hover:underline",
+ },
+ },
+ },
+);
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test tests/event-dialog.test.tsx`
+Expected: PASS with the redesigned dialog surface contract in the markup.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/ui/button.tsx src/components/ui/card.tsx src/components/ui/input.tsx src/components/ui/badge.tsx src/components/ui/dialog.tsx src/components/ui/dropdown-menu.tsx tests/event-dialog.test.tsx
+git commit -m "feat: redesign shared ui primitives"
+```
+
+### Task 3: Rebuild the home page hierarchy
+
+**Files:**
+- Modify: `src/app/page.tsx`
+- Create: `tests/home-page-layout.test.ts`
+
+- [ ] **Step 1: Write the failing home page hierarchy test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+
+describe("home page hierarchy", () => {
+ test("desktop layout defines aligned AI and timeline top-row sections", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+ expect(source).toContain("lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]");
+ expect(source).toContain("AI capture");
+ expect(source).toContain("Event timeline");
+ });
+
+ test("manual create is routed through a More menu instead of a primary mobile action", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+ expect(source).toContain("More");
+ expect(source).not.toContain("New Event");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/home-page-layout.test.ts`
+Expected: FAIL because the page still uses the old stacked shell and direct nav buttons.
+
+- [ ] **Step 3: Reshape `page.tsx` to the approved infrastructure bar plus aligned workspace**
+
+```tsx
+const APP_FRAME_CLASSES =
+ "mx-auto flex min-h-screen w-full max-w-6xl flex-col px-4 pb-20 pt-4 sm:px-6 lg:px-8";
+
+
+
+
+
+ Local Calendar
+
+
+ Event timeline
+
+
+
+
+ {isOnline ? "Online" : "Offline"}
+
+
+ Export
+ {/* More: Import, Manual create, Settings */}
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test tests/home-page-layout.test.ts`
+Expected: PASS with the aligned desktop workspace and secondary `More` path visible in source.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/page.tsx tests/home-page-layout.test.ts
+git commit -m "feat: rebuild home page hierarchy"
+```
+
+### Task 4: Redesign AI capture as a first-class dual-input surface
+
+**Files:**
+- Modify: `src/components/ai-toolbar.tsx`
+- Modify: `tests/ai-toolbar.test.ts`
+
+- [ ] **Step 1: Write the failing AI capture contract test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import { cn } from "@/lib/utils";
+
+const COMPOSER_LAYOUT_CLASSES =
+ "grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]";
+const ATTACHMENTS_PANEL_CLASSES =
+ "rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
+
+describe("AI capture redesign", () => {
+ test("desktop composer treats prompt and attachments as peer panels", () => {
+ expect(cn(COMPOSER_LAYOUT_CLASSES)).toContain("lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
+ });
+
+ test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => {
+ expect(cn(ATTACHMENTS_PANEL_CLASSES)).toContain("rounded-[10px]");
+ expect(cn(ATTACHMENTS_PANEL_CLASSES)).toContain("shadow-[0_0_0_1px_rgba(0,0,0,0.08)]");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/ai-toolbar.test.ts`
+Expected: FAIL because the current toolbar still treats attachments as subordinate controls beneath the composer.
+
+- [ ] **Step 3: Refactor the toolbar layout around peer prompt and attachment regions**
+
+```tsx
+
+
+
+
+ AI capture
+
+
+ Describe or attach event details
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test tests/ai-toolbar.test.ts`
+Expected: PASS with the new equal-importance prompt/attachment contract.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/ai-toolbar.tsx tests/ai-toolbar.test.ts
+git commit -m "feat: redesign ai capture surface"
+```
+
+### Task 5: Redesign timeline cards and inline validation warnings
+
+**Files:**
+- Modify: `src/lib/event-form.ts`
+- Modify: `src/components/event-card.tsx`
+- Modify: `src/components/events-list.tsx`
+- Modify: `tests/event-card.test.ts`
+
+- [ ] **Step 1: Write the failing event card warning test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { EventCard } from "@/components/event-card";
+
+describe("EventCard warning states", () => {
+ test("renders inline warning copy for invalid event ranges", () => {
+ const markup = renderToStaticMarkup(
+ EventCard({
+ event: {
+ id: "evt_invalid",
+ title: "Broken Event",
+ start: "2026-04-09T11:00:00+00:00",
+ end: "2026-04-09T10:00:00+00:00",
+ allDay: false,
+ },
+ onEdit: () => {},
+ onDelete: () => {},
+ }),
+ );
+
+ expect(markup).toContain("Warning:");
+ expect(markup).toContain("end time is before start time");
+ expect(markup).not.toContain("Preview");
+ expect(markup).not.toContain("Ship");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/event-card.test.ts`
+Expected: FAIL because the card has no inline warning support and still assumes the old metadata hierarchy.
+
+- [ ] **Step 3: Add reusable event-level validation and render it in the card**
+
+```ts
+export function getEventValidationIssues(event: CalendarEvent): string[] {
+ const issues: string[] = [];
+
+ if (event.end && new Date(event.end).getTime() < new Date(event.start).getTime()) {
+ issues.push("end time is before start time");
+ }
+
+ if (event.url && !URL.canParse(event.url)) {
+ issues.push("link is invalid");
+ }
+
+ return issues;
+}
+```
+
+```tsx
+const validationIssues = getEventValidationIssues(event);
+
+{validationIssues.length > 0 && (
+
+ Warning: {validationIssues[0]}.
+
+)}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test tests/event-card.test.ts`
+Expected: PASS with the inline warning and workflow-tag absence confirmed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/event-form.ts src/components/event-card.tsx src/components/events-list.tsx tests/event-card.test.ts
+git commit -m "feat: add inline timeline validation states"
+```
+
+### Task 6: Redesign the event dialog and settings surface
+
+**Files:**
+- Modify: `src/components/event-dialog.tsx`
+- Modify: `src/components/settings-panel.tsx`
+- Modify: `tests/event-dialog.test.tsx`
+
+- [ ] **Step 1: Write the failing form-surface test**
+
+```ts
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+
+describe("event dialog redesign", () => {
+ test("dialog source uses console section labels and grouped field regions", () => {
+ const source = readFileSync("src/components/event-dialog.tsx", "utf8");
+ expect(source).toContain("Event details");
+ expect(source).toContain("Schedule");
+ expect(source).toContain("Recurrence");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test tests/event-dialog.test.tsx`
+Expected: FAIL because the dialog still renders as one long glass-style form without the new grouped console structure.
+
+- [ ] **Step 3: Group dialog fields into console-style sections and demote settings styling**
+
+```tsx
+
+
+ {titleText}
+ {descriptionText}
+
+
+
+```
+
+```tsx
+const settingRowClasses =
+ "rounded-[10px] bg-card p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test tests/event-dialog.test.tsx`
+Expected: PASS with the grouped console sections present in source.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/event-dialog.tsx src/components/settings-panel.tsx tests/event-dialog.test.tsx
+git commit -m "feat: redesign forms and settings surfaces"
+```
+
+### Task 7: Final responsive polish and regression verification
+
+**Files:**
+- Modify: `src/app/page.tsx`
+- Modify: `src/components/ai-toolbar.tsx`
+- Modify: `src/components/event-card.tsx`
+- Modify: `src/components/settings-panel.tsx`
+- Test: `tests/home-page-layout.test.ts`
+- Test: `tests/ai-toolbar.test.ts`
+- Test: `tests/event-card.test.ts`
+- Test: `tests/event-dialog.test.tsx`
+
+- [ ] **Step 1: Add final responsive contract assertions**
+
+```ts
+test("mobile layout keeps capture before timeline and keeps manual create secondary", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+ expect(source).toContain("order-1 lg:order-none");
+ expect(source).toContain("Import");
+ expect(source).toContain("Manual create");
+});
+```
+
+- [ ] **Step 2: Run the focused redesign suite**
+
+Run: `bun test tests/ui-shell-contract.test.ts tests/home-page-layout.test.ts tests/ai-toolbar.test.ts tests/event-card.test.ts tests/event-dialog.test.tsx`
+Expected: PASS for the redesigned shell, AI capture, timeline cards, and dialog contracts.
+
+- [ ] **Step 3: Run the broader regression suite**
+
+Run: `bun test`
+Expected: PASS with no regressions in existing event, AI, auth, recurrence, and utility tests.
+
+- [ ] **Step 4: Run build verification**
+
+Run: `bun run build`
+Expected: successful Next.js production build with no type or rendering regressions.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/page.tsx src/components/ai-toolbar.tsx src/components/event-card.tsx src/components/settings-panel.tsx tests/home-page-layout.test.ts tests/ai-toolbar.test.ts tests/event-card.test.ts tests/event-dialog.test.tsx
+git commit -m "feat: finalize local cal redesign"
+```
+
+## Self-Review
+
+- Spec coverage: the plan covers token replacement, shared primitives, aligned desktop hierarchy, capture-first mobile flow, co-equal attachments, inline validation warnings, dialog redesign, settings demotion, and verification in both light/dark responsive contexts.
+- Placeholder scan: no `TODO`, `TBD`, or open-ended implementation placeholders remain in the tasks.
+- Type consistency: the same approved concepts are used throughout the plan: aligned AI/timeline top row, timeline-dominant desktop layout, attachment-first AI capture, inline validation warnings, and secondary manual creation.
diff --git a/src/app/globals.css b/src/app/globals.css
index ff24d09..1a2958b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -4,98 +4,121 @@
@custom-variant dark (&:is(.dark *));
:root {
- --background: oklch(0.985 0.002 247);
- --foreground: oklch(0.145 0.015 260);
- --card: oklch(0.99 0.002 247);
- --card-foreground: oklch(0.145 0.015 260);
- --popover: oklch(0.99 0.002 247);
- --popover-foreground: oklch(0.145 0.015 260);
- --primary: oklch(0.55 0.22 265);
- --primary-foreground: oklch(1 0 0);
- --secondary: oklch(0.96 0.004 247);
- --secondary-foreground: oklch(0.3 0.015 260);
- --muted: oklch(0.96 0.004 247);
- --muted-foreground: oklch(0.5 0.015 260);
- --accent: oklch(0.94 0.025 270);
- --accent-foreground: oklch(0.35 0.03 260);
- --destructive: oklch(0.55 0.22 25);
- --destructive-foreground: oklch(1 0 0);
- --border: oklch(0.915 0.004 247);
- --input: oklch(0.915 0.004 247);
- --ring: oklch(0.55 0.22 265);
- --chart-1: oklch(0.55 0.22 265);
- --chart-2: oklch(0.65 0.2 250);
- --chart-3: oklch(0.6 0.18 280);
- --chart-4: oklch(0.5 0.2 300);
- --chart-5: oklch(0.45 0.18 320);
- --sidebar: oklch(0.97 0.003 247);
- --sidebar-foreground: oklch(0.145 0.015 260);
- --sidebar-primary: oklch(0.55 0.22 265);
- --sidebar-primary-foreground: oklch(1 0 0);
- --sidebar-accent: oklch(0.94 0.025 270);
- --sidebar-accent-foreground: oklch(0.35 0.03 260);
- --sidebar-border: oklch(0.915 0.004 247);
- --sidebar-ring: oklch(0.55 0.22 265);
- --radius: 0.75rem;
- --shadow-2xs: 0 1px 2px oklch(0.3 0.01 260 / 0.06);
- --shadow-xs: 0 1px 3px oklch(0.3 0.01 260 / 0.08);
+ --background: #ffffff;
+ --foreground: #171717;
+ --card: #ffffff;
+ --card-foreground: #171717;
+ --popover: #ffffff;
+ --popover-foreground: #171717;
+ --primary: #171717;
+ --primary-foreground: #ffffff;
+ --secondary: #fafafa;
+ --secondary-foreground: #171717;
+ --muted: #fafafa;
+ --muted-foreground: #666666;
+ --accent: #f5f5f5;
+ --accent-foreground: #171717;
+ --destructive: #ff5b4f;
+ --destructive-foreground: #ffffff;
+ --border: #ebebeb;
+ --input: #ebebeb;
+ --ring: hsla(212, 100%, 48%, 1);
+ --chart-1: #171717;
+ --chart-2: #0a72ef;
+ --chart-3: #de1d8d;
+ --chart-4: #666666;
+ --chart-5: #ebebeb;
+ --sidebar: #fafafa;
+ --sidebar-foreground: #171717;
+ --sidebar-primary: #171717;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #f5f5f5;
+ --sidebar-accent-foreground: #171717;
+ --sidebar-border: #ebebeb;
+ --sidebar-ring: hsla(212, 100%, 48%, 1);
+ --radius: 0.625rem;
+ --shadow-2xs: 0 0 0 1px rgba(0, 0, 0, 0.08);
+ --shadow-xs: 0 0 0 1px rgba(0, 0, 0, 0.08);
--shadow-sm:
- 0 2px 8px oklch(0.3 0.01 260 / 0.08), 0 1px 2px oklch(0.3 0.01 260 / 0.06);
+ 0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 2px rgba(0, 0, 0, 0.04);
--shadow:
- 0 4px 16px oklch(0.3 0.01 260 / 0.1), 0 1px 3px oklch(0.3 0.01 260 / 0.06);
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 2px 2px rgba(0, 0, 0, 0.04),
+ 0 8px 8px -8px rgba(0, 0, 0, 0.04),
+ 0 0 0 1px #fafafa;
--shadow-md:
- 0 8px 24px oklch(0.3 0.01 260 / 0.12), 0 2px 6px oklch(0.3 0.01 260 / 0.06);
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 2px 2px rgba(0, 0, 0, 0.04),
+ 0 8px 8px -8px rgba(0, 0, 0, 0.04),
+ 0 0 0 1px #fafafa;
--shadow-lg:
- 0 16px 48px oklch(0.3 0.01 260 / 0.14),
- 0 4px 12px oklch(0.3 0.01 260 / 0.06);
- --shadow-xl: 0 24px 64px oklch(0.3 0.01 260 / 0.18);
- --shadow-2xl: 0 32px 80px oklch(0.3 0.01 260 / 0.25);
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 8px 24px rgba(0, 0, 0, 0.08);
+ --shadow-xl:
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 16px 40px rgba(0, 0, 0, 0.12);
+ --shadow-2xl:
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0 24px 56px rgba(0, 0, 0, 0.16);
--tracking-normal: -0.01em;
--spacing: 0.25rem;
}
.dark {
- --background: oklch(0.13 0.01 265);
- --foreground: oklch(0.93 0.01 260);
- --card: oklch(0.17 0.012 265);
- --card-foreground: oklch(0.93 0.01 260);
- --popover: oklch(0.17 0.012 265);
- --popover-foreground: oklch(0.93 0.01 260);
- --primary: oklch(0.7 0.18 265);
- --primary-foreground: oklch(0.13 0.01 265);
- --secondary: oklch(0.22 0.012 265);
- --secondary-foreground: oklch(0.88 0.008 260);
- --muted: oklch(0.22 0.012 265);
- --muted-foreground: oklch(0.65 0.015 260);
- --accent: oklch(0.25 0.02 270);
- --accent-foreground: oklch(0.88 0.008 260);
- --destructive: oklch(0.6 0.22 25);
- --destructive-foreground: oklch(0.98 0.002 260);
- --border: oklch(0.25 0.012 265);
- --input: oklch(0.25 0.012 265);
- --ring: oklch(0.7 0.18 265);
- --chart-1: oklch(0.7 0.18 265);
- --chart-2: oklch(0.65 0.2 250);
- --chart-3: oklch(0.6 0.22 280);
- --chart-4: oklch(0.55 0.18 300);
- --chart-5: oklch(0.5 0.15 320);
- --sidebar: oklch(0.15 0.012 265);
- --sidebar-foreground: oklch(0.93 0.01 260);
- --sidebar-primary: oklch(0.7 0.18 265);
- --sidebar-primary-foreground: oklch(0.13 0.01 265);
- --sidebar-accent: oklch(0.25 0.02 270);
- --sidebar-accent-foreground: oklch(0.88 0.008 260);
- --sidebar-border: oklch(0.25 0.012 265);
- --sidebar-ring: oklch(0.7 0.18 265);
- --radius: 0.75rem;
- --shadow-2xs: 0 1px 2px oklch(0 0 0 / 0.2);
- --shadow-xs: 0 1px 3px oklch(0 0 0 / 0.25);
- --shadow-sm: 0 2px 8px oklch(0 0 0 / 0.25), 0 1px 2px oklch(0 0 0 / 0.15);
- --shadow: 0 4px 16px oklch(0 0 0 / 0.3), 0 1px 3px oklch(0 0 0 / 0.15);
- --shadow-md: 0 8px 24px oklch(0 0 0 / 0.35), 0 2px 6px oklch(0 0 0 / 0.15);
- --shadow-lg: 0 16px 48px oklch(0 0 0 / 0.4), 0 4px 12px oklch(0 0 0 / 0.15);
- --shadow-xl: 0 24px 64px oklch(0 0 0 / 0.5);
- --shadow-2xl: 0 32px 80px oklch(0 0 0 / 0.6);
+ --background: #111111;
+ --foreground: #f5f5f5;
+ --card: #171717;
+ --card-foreground: #f5f5f5;
+ --popover: #171717;
+ --popover-foreground: #f5f5f5;
+ --primary: #f5f5f5;
+ --primary-foreground: #171717;
+ --secondary: #1f1f1f;
+ --secondary-foreground: #f5f5f5;
+ --muted: #1a1a1a;
+ --muted-foreground: #a1a1a1;
+ --accent: #1f1f1f;
+ --accent-foreground: #f5f5f5;
+ --destructive: #ff5b4f;
+ --destructive-foreground: #ffffff;
+ --border: rgba(255, 255, 255, 0.1);
+ --input: rgba(255, 255, 255, 0.12);
+ --ring: hsla(212, 100%, 48%, 1);
+ --chart-1: #f5f5f5;
+ --chart-2: #0a72ef;
+ --chart-3: #de1d8d;
+ --chart-4: #a1a1a1;
+ --chart-5: rgba(255, 255, 255, 0.1);
+ --sidebar: #171717;
+ --sidebar-foreground: #f5f5f5;
+ --sidebar-primary: #f5f5f5;
+ --sidebar-primary-foreground: #171717;
+ --sidebar-accent: #1f1f1f;
+ --sidebar-accent-foreground: #f5f5f5;
+ --sidebar-border: rgba(255, 255, 255, 0.1);
+ --sidebar-ring: hsla(212, 100%, 48%, 1);
+ --radius: 0.625rem;
+ --shadow-2xs: 0 0 0 1px rgba(255, 255, 255, 0.08);
+ --shadow-xs: 0 0 0 1px rgba(255, 255, 255, 0.08);
+ --shadow-sm:
+ 0 0 0 1px rgba(255, 255, 255, 0.08), 0 2px 2px rgba(0, 0, 0, 0.24);
+ --shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 2px 2px rgba(0, 0, 0, 0.24),
+ 0 8px 8px -8px rgba(0, 0, 0, 0.32);
+ --shadow-md:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 2px 2px rgba(0, 0, 0, 0.24),
+ 0 8px 8px -8px rgba(0, 0, 0, 0.32);
+ --shadow-lg:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 12px 28px rgba(0, 0, 0, 0.32);
+ --shadow-xl:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 20px 44px rgba(0, 0, 0, 0.4);
+ --shadow-2xl:
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
+ 0 28px 64px rgba(0, 0, 0, 0.48);
}
@theme inline {
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d995a2e..afedd7c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,6 +1,12 @@
"use client";
-import { CalendarDays, ListTodo, Settings, Wifi, WifiOff } from "lucide-react";
+import {
+ CalendarDays,
+ MoreHorizontal,
+ Settings,
+ Wifi,
+ WifiOff,
+} from "lucide-react";
import { nanoid } from "nanoid";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -14,6 +20,13 @@ import { SettingsPanel } from "@/components/settings-panel";
import SignIn from "@/components/sign-in";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
import {
getAiDisabledMessage,
@@ -47,7 +60,7 @@ import { useUserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils";
const APP_FRAME_CLASSES =
- "mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
+ "mx-auto flex min-h-screen w-full max-w-6xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
@@ -408,6 +421,13 @@ export default function HomePage() {
setDialogOpen(true);
};
+ const openManualEventDialog = () => {
+ resetForm();
+ setDialogSource("manual");
+ setActiveView("list");
+ setDialogOpen(true);
+ };
+
return (
-
-
- Offline-first iCal editor
+
+
+ Local Calendar
-
- LocalCal
+
+ Event timeline
@@ -439,6 +459,39 @@ export default function HomePage() {
+
+ Export
+
+
+
+
+
+ More
+
+
+
+
+ Manual create
+
+ setActiveView("settings")}>
+ Settings
+
+
+
+ Clear all events
+
+
+
@@ -452,116 +505,91 @@ export default function HomePage() {
settings={settings}
/>
) : (
- <>
-
-
-
-
- Create with AI
+
+
+
+
+
+ AI capture
-
- Paste details. Generate draft. Review before saving.
+
+ Capture from text or files.
-
- Type or paste a natural-language description, then
- generate a draft event for review in the event modal.
+
+ Start with a prompt, screenshots, or both. Review happens in the
+ timeline rather than a separate flow.
-
- setSummary(null)}
- summary={summary}
- summaryUpdated={summaryUpdated}
- events={events}
- />
-
+ setSummary(null)}
+ summary={summary}
+ summaryUpdated={summaryUpdated}
+ events={events}
+ />
+
+
-
-
-
-
- Events
-
-
- Your local calendar timeline
-
+
+
+
+
+
+ Event timeline
+
+
+ Review the calendar by scanning the stream.
+
+
+
+ {events.length} item{events.length === 1 ? "" : "s"}
+
-
- {events.length} item{events.length === 1 ? "" : "s"}
-
-
-
-
-
-
- Import
-
-
- {events.length > 0 && (
- <>
-
- Export
-
+
+
+
+ Import
+
+ {events.length > 0 && (
Clear
- >
- )}
-
- {
- resetForm();
- setDialogSource("manual");
- setDialogOpen(true);
- }}
- className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
- >
- Manual event
-
+ )}
+
-
-
-
- >
+
+
+
+
)}
@@ -574,16 +602,7 @@ export default function HomePage() {
onClick={() => setActiveView("list")}
>
- List
-
-
-
- Tasks
+ Timeline
) : isAuthenticated ? (
-
-
-
-
- {hasImages && (
-
-
- {imagePreviews.map((preview, index) => (
-
+
+
-
-
-
- Attach image
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
- {events.length > 0 && (
-
-
- Summarize
-
- )}
+ {events.length > 0 && (
+
+
+ Summarize
+
+ )}
+
)}
- {aiLoading ? "Generating…" : "Generate draft"}
+ {aiLoading ? "Generating..." : "Generate event"}
+
+
+
+
+
+ Attachments
+
+
+ Add screenshots, flyers, or pasted images alongside the prompt.
+
+
+
+ {imagePreviews.length} file{imagePreviews.length === 1 ? "" : "s"}
+
+
+
+
+
+ Attach images
+
+
+
+ {hasImages ? (
+
+ {imagePreviews.map((preview, index) => (
+
+
+ onImageRemove(index)}
+ aria-label={`Remove image ${index + 1}`}
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ Drop or paste images here to pair them with the prompt.
+
+ )}
+
+
) : (
diff --git a/src/components/event-card.tsx b/src/components/event-card.tsx
index 3a573b3..f640675 100644
--- a/src/components/event-card.tsx
+++ b/src/components/event-card.tsx
@@ -19,6 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatEventRangeLabel } from "@/lib/event-date-format";
+import { getEventValidationIssues } from "@/lib/event-form";
import type { CalendarEvent } from "@/lib/types";
interface EventCardProps {
@@ -28,9 +29,11 @@ interface EventCardProps {
}
export const EVENT_CARD_SURFACE_CLASSES =
- "glass-card group cursor-pointer p-4 transition-[background-color,border-color,transform] duration-150 hover:-translate-y-0.5 hover:bg-accent/30 hover:border-primary/15";
+ "group cursor-pointer rounded-[10px] bg-card p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] transition-[background-color,transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:bg-accent/20";
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
+ const validationIssues = getEventValidationIssues(event);
+
const handleEdit = () => {
onEdit({
id: event.id,
@@ -66,7 +69,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
)}
-
+
diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx
index 859f7a9..1080c0c 100644
--- a/src/components/event-dialog.tsx
+++ b/src/components/event-dialog.tsx
@@ -147,13 +147,15 @@ export const EventDialog = ({
return (
-
-
- {titleText}
+
+
+
+ {titleText}
+
{descriptionText}
-
+
{
No events yet
- Generate a draft from natural language or add an event manually to
- start building your local calendar timeline.
+ Capture something with AI or open manual create from More to start
+ building your event timeline.
);
diff --git a/src/components/settings-panel.tsx b/src/components/settings-panel.tsx
index d546235..441cf58 100644
--- a/src/components/settings-panel.tsx
+++ b/src/components/settings-panel.tsx
@@ -14,7 +14,7 @@ interface SettingsPanelProps {
}
const settingRowClasses =
- "rounded-2xl border border-border/70 bg-background/55 p-4 shadow-sm backdrop-blur-sm";
+ "rounded-[10px] bg-card p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
export function SettingsPanel({
adminAiEnabled,
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index b2fee25..f5d5b65 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -5,18 +5,18 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium [&>svg]:size-3 [&>svg]:pointer-events-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 transition-[color,box-shadow,background-color] overflow-hidden",
{
variants: {
variant: {
default:
- "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ "bg-secondary text-secondary-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:bg-accent",
secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ "bg-[#ebf5ff] text-[#0068d6] shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:opacity-90",
destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "bg-destructive/12 text-destructive shadow-[0_0_0_1px_rgba(255,91,79,0.2)] [a&]:hover:bg-destructive/18 focus-visible:ring-destructive/25",
outline:
- "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ "bg-background text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] [a&]:hover:bg-accent",
},
},
defaultVariants: {
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index e7fbda9..56019b5 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -5,26 +5,24 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "active:scale-[.95] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium duration-100 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive [&::-moz-focus-inner]:border-0 [&::-moz-focus-inner]:p-0",
+ "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[6px] text-sm font-medium transition-[background-color,color,box-shadow,opacity] duration-150 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&::-moz-focus-inner]:border-0 [&::-moz-focus-inner]:p-0",
{
variants: {
variant: {
- default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ default: "bg-primary text-primary-foreground hover:opacity-92",
destructive:
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "bg-destructive text-destructive-foreground hover:opacity-92 focus-visible:ring-destructive/25 dark:focus-visible:ring-destructive/35",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ "bg-background text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-accent",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
- link: "text-primary underline-offset-4 hover:underline",
+ "bg-secondary text-secondary-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.05)] hover:bg-accent",
+ ghost: "text-muted-foreground hover:bg-accent hover:text-foreground",
+ link: "text-[#0072f5] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index e2b429a..3b88720 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
Close
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
);
@@ -110,7 +110,7 @@ function DialogTitle({
return (
);
@@ -123,7 +123,7 @@ function DialogDescription({
return (
);
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index ad6313e..8fab481 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
- "bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+ "bg-popover text-popover-foreground 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[10px] p-1.5 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)]",
className,
)}
{...props}
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
- "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-[6px] px-2.5 py-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
) {
type={type}
data-slot="input"
className={cn(
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-10 w-full min-w-0 rounded-[8px] bg-background px-3 py-2 text-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)] transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ "focus-visible:ring-[3px] focus-visible:ring-ring/25",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
diff --git a/src/lib/event-form.ts b/src/lib/event-form.ts
index d31e407..1a3ff4c 100644
--- a/src/lib/event-form.ts
+++ b/src/lib/event-form.ts
@@ -83,5 +83,30 @@ export const getEventFormValuesFromEvent = (
recurrenceRule: event?.recurrenceRule || undefined,
});
+export const getEventValidationIssues = (
+ event: Pick,
+) => {
+ const issues: string[] = [];
+
+ if (event.end) {
+ const startDate = new Date(event.start);
+ const endDate = new Date(event.end);
+
+ if (
+ !Number.isNaN(startDate.getTime()) &&
+ !Number.isNaN(endDate.getTime()) &&
+ endDate.getTime() < startDate.getTime()
+ ) {
+ issues.push("end time is before start time");
+ }
+ }
+
+ if (event.url && !URL.canParse(event.url)) {
+ issues.push("link is invalid");
+ }
+
+ return issues;
+};
+
export const validateEventFormValues = (values: EventFormValues) =>
eventFormSchema.safeParse(values);
diff --git a/src/lib/ui-shell-contract.ts b/src/lib/ui-shell-contract.ts
index 8186fd2..4a34174 100644
--- a/src/lib/ui-shell-contract.ts
+++ b/src/lib/ui-shell-contract.ts
@@ -1,22 +1,24 @@
import { cn } from "@/lib/utils";
export const APP_HEADER_SURFACE_CLASSES =
- "glass-surface mb-4 flex items-center justify-between gap-3 px-4 py-3";
+ "mb-6 flex min-h-14 items-center justify-between gap-3 border-b border-foreground/10 bg-background/95 px-4 py-3 sm:px-6";
-export const APP_SECTION_SURFACE_CLASSES = "glass-panel p-4 sm:p-5";
+export const APP_SECTION_SURFACE_CLASSES =
+ "rounded-[10px] bg-card px-4 py-4 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_2px_rgba(0,0,0,0.04),0_0_0_1px_#fafafa] sm:px-5";
-export const APP_ACTION_BAR_CLASSES = "glass-subtle mb-4 p-3";
+export const APP_ACTION_BAR_CLASSES =
+ "rounded-[10px] bg-card px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
export const APP_NAV_SURFACE_CLASSES =
- "glass-surface fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between px-3 py-2 sm:inset-x-6 lg:inset-x-8";
+ "fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-[10px] bg-background/95 px-3 py-2 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_8px_24px_rgba(0,0,0,0.08)] sm:inset-x-6 lg:hidden";
const CONNECTION_BADGE_BASE_CLASSES =
- "gap-1.5 border px-2.5 py-1 text-xs font-medium shadow-none";
+ "gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.08)]";
export const getConnectionBadgeClasses = (isOnline: boolean) =>
cn(
CONNECTION_BADGE_BASE_CLASSES,
isOnline
- ? "border-emerald-500/35 bg-emerald-500/15 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-500/15 dark:text-emerald-300 [&>svg]:text-emerald-500"
- : "border-border/70 bg-muted/55 text-muted-foreground dark:bg-muted/35 [&>svg]:text-muted-foreground",
+ ? "bg-[#ebf5ff] text-[#0068d6]"
+ : "bg-muted text-muted-foreground",
);
diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts
index 6c66648..d7963cd 100644
--- a/tests/ai-toolbar.test.ts
+++ b/tests/ai-toolbar.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
import { cn } from "@/lib/utils";
@@ -50,6 +51,8 @@ const DESTRUCTIVE_ACTION_CLASSES =
/** Event count badge: auto-positioned to far right via ml-auto */
const BADGE_POSITION_CLASS = "ml-auto";
+const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8");
+
// ─── Cycle 1: AI zone visual accent ─────────────────────────────────────────
describe("AI zone – primary accent ring contract", () => {
@@ -155,6 +158,20 @@ describe("Event count badge – positioning contract", () => {
});
});
+describe("AI capture redesign", () => {
+ test("desktop composer treats prompt and attachments as peer panels", () => {
+ const source = readToolbarSource();
+
+ expect(source).toContain("lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
+ });
+
+ test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => {
+ const source = readToolbarSource();
+
+ expect(source).toContain("rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]");
+ });
+});
+
// ─── Cycle 6: Composer footer bar ────────────────────────────────────────────
//
// Below the textarea sits a single horizontal footer row:
diff --git a/tests/event-card.test.ts b/tests/event-card.test.ts
index 382dee2..6fcc005 100644
--- a/tests/event-card.test.ts
+++ b/tests/event-card.test.ts
@@ -41,4 +41,25 @@ describe("EventCard actions trigger", () => {
expect(markup).toContain("10:00–11:00");
expect(markup).not.toContain("AM");
});
+
+ test("renders inline warning copy for invalid event ranges", () => {
+ const markup = renderToStaticMarkup(
+ EventCard({
+ event: {
+ id: "evt_invalid",
+ title: "Broken Event",
+ start: "2026-04-09T11:00:00+00:00",
+ end: "2026-04-09T10:00:00+00:00",
+ allDay: false,
+ },
+ onEdit: () => {},
+ onDelete: () => {},
+ }),
+ );
+
+ expect(markup).toContain("Warning:");
+ expect(markup).toContain("end time is before start time");
+ expect(markup).not.toContain("Preview");
+ expect(markup).not.toContain("Ship");
+ });
});
diff --git a/tests/event-dialog.test.tsx b/tests/event-dialog.test.tsx
index 59e0f4c..d31d2dc 100644
--- a/tests/event-dialog.test.tsx
+++ b/tests/event-dialog.test.tsx
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
import { getEventFormValuesFromEvent } from "@/lib/event-form";
describe("EventDialog public modes", () => {
@@ -13,4 +14,20 @@ describe("EventDialog public modes", () => {
expect(initialValues.start).toBe("2026-04-09T10:00:00.000Z");
expect(initialValues.recurrenceRule).toBe("FREQ=WEEKLY;INTERVAL=1;BYDAY=TH");
});
+
+ test("dialog content uses console surface classes instead of generic border and shadow", () => {
+ const source = readFileSync("src/components/ui/dialog.tsx", "utf8");
+
+ expect(source).toContain("rounded-[10px]");
+ expect(source).toContain("shadow-[0_0_0_1px_rgba(0,0,0,0.08)");
+ expect(source).not.toContain("rounded-lg border p-6 shadow-lg");
+ });
+
+ test("dialog source uses console section labels and grouped field regions", () => {
+ const source = readFileSync("src/components/event-dialog.tsx", "utf8");
+
+ expect(source).toContain("Event details");
+ expect(source).toContain("Schedule");
+ expect(source).toContain("Recurrence");
+ });
});
diff --git a/tests/home-page-layout.test.ts b/tests/home-page-layout.test.ts
new file mode 100644
index 0000000..bb97dbf
--- /dev/null
+++ b/tests/home-page-layout.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+
+describe("home page hierarchy", () => {
+ test("desktop layout defines aligned AI and timeline top-row sections", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+
+ expect(source).toContain(
+ "lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]",
+ );
+ expect(source).toContain("AI capture");
+ expect(source).toContain("Event timeline");
+ });
+
+ test("manual create is routed through a More menu instead of a primary mobile action", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+
+ expect(source).toContain("More");
+ expect(source).not.toContain("New Event");
+ });
+
+ test("mobile layout keeps capture before timeline and keeps manual create secondary", () => {
+ const source = readFileSync("src/app/page.tsx", "utf8");
+
+ expect(source).toContain("order-1 lg:order-none");
+ expect(source).toContain("Import");
+ expect(source).toContain("Manual create");
+ });
+});
diff --git a/tests/ui-shell-contract.test.ts b/tests/ui-shell-contract.test.ts
index 0be6f37..a3c407e 100644
--- a/tests/ui-shell-contract.test.ts
+++ b/tests/ui-shell-contract.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
+ APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
@@ -8,31 +9,36 @@ import {
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
describe("app shell surfaces", () => {
- test("header, primary sections, and bottom navigation all use shared glass utilities", () => {
- expect(APP_HEADER_SURFACE_CLASSES).toMatch(/glass-surface/);
- expect(APP_SECTION_SURFACE_CLASSES).toMatch(/glass-panel/);
- expect(APP_NAV_SURFACE_CLASSES).toMatch(/glass-surface/);
+ test("header surface is a thin structural bar instead of a glass panel", () => {
+ expect(APP_HEADER_SURFACE_CLASSES).toContain("min-h-14");
+ expect(APP_HEADER_SURFACE_CLASSES).toContain("border-b");
+ expect(APP_HEADER_SURFACE_CLASSES).not.toContain("glass-surface");
});
- test("section surface keeps responsive padding for mobile and larger breakpoints", () => {
- expect(APP_SECTION_SURFACE_CLASSES).toContain("p-4");
- expect(APP_SECTION_SURFACE_CLASSES).toContain("sm:p-5");
+ test("section and action surfaces use tokenized shell classes instead of glass helpers", () => {
+ expect(APP_SECTION_SURFACE_CLASSES).not.toContain("glass-panel");
+ expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle");
+ expect(APP_NAV_SURFACE_CLASSES).not.toContain("glass-surface");
});
});
describe("event cards", () => {
- test("event cards use the shared glass card treatment instead of a one-off surface", () => {
- expect(EVENT_CARD_SURFACE_CLASSES).toMatch(/glass-card/);
+ test("event cards use the redesigned console card treatment", () => {
+ expect(EVENT_CARD_SURFACE_CLASSES).toContain("rounded-[10px]");
+ expect(EVENT_CARD_SURFACE_CLASSES).toContain(
+ "shadow-[0_0_0_1px_rgba(0,0,0,0.08)",
+ );
});
});
describe("connection badge", () => {
- test("online-ready badge gets a success treatment while offline stays neutral", () => {
+ test("online-ready badge uses the console utility blue while offline stays neutral", () => {
const onlineClasses = getConnectionBadgeClasses(true);
const offlineClasses = getConnectionBadgeClasses(false);
- expect(onlineClasses).toMatch(/emerald/);
- expect(offlineClasses).not.toMatch(/emerald/);
+ expect(onlineClasses).toContain("bg-[#ebf5ff]");
+ expect(onlineClasses).toContain("text-[#0068d6]");
+ expect(offlineClasses).not.toContain("bg-[#ebf5ff]");
expect(offlineClasses).toContain("text-muted-foreground");
});
});