feat: redesign

Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
2026-04-21 20:23:15 -04:00
parent 420a971ff7
commit 915e0b7cf8
21 changed files with 1401 additions and 537 deletions

View File

@@ -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(
<DialogContent>
<DialogHeader>
<DialogTitle>New Event</DialogTitle>
<DialogDescription>Console form</DialogDescription>
</DialogHeader>
</DialogContent>,
);
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 (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-[10px] py-6 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]",
className,
)}
{...props}
/>
);
}
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"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)] outline-none transition-[box-shadow,color] placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/25",
className,
)}
{...props}
/>
);
}
```
- [ ] **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</button>");
});
});
```
- [ ] **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";
<main className={APP_FRAME_CLASSES}>
<header className={APP_HEADER_SURFACE_CLASSES}>
<div className="flex min-w-0 flex-col">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Local Calendar
</p>
<h1 className="text-[28px] font-semibold tracking-[-0.06em]">
Event timeline
</h1>
</div>
<div className="flex items-center gap-2">
<Badge className={getConnectionBadgeClasses(isOnline)}>
{isOnline ? "Online" : "Offline"}
</Badge>
<ModeToggle />
<Button variant="outline" size="sm">Export</Button>
<DropdownMenu>{/* More: Import, Manual create, Settings */}</DropdownMenu>
</div>
</header>
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<div className="space-y-4">
<AIToolbar {...aiProps} />
</div>
<div className="space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}>{/* timeline */}</section>
</div>
</section>
</main>
```
- [ ] **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
<section className={APP_SECTION_SURFACE_CLASSES}>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="font-mono text-[11px] uppercase text-muted-foreground">
AI capture
</p>
<h2 className="text-2xl font-semibold tracking-[-0.05em]">
Describe or attach event details
</h2>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<Textarea value={aiPrompt} onChange={(event) => setAiPrompt(event.target.value)} />
<Button onClick={onAiCreate}>Generate event</Button>
</div>
<div className="rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
<ImagePicker onSelect={onImagesSelect} />
<div className="mt-3 grid gap-2 sm:grid-cols-2">{/* previews */}</div>
</div>
</div>
</section>
```
- [ ] **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 && (
<div className="rounded-[8px] bg-[#fff4f2] px-3 py-2 text-xs text-[#b42318] shadow-[inset_0_0_0_1px_rgba(180,35,24,0.14)] dark:bg-[#2a1715] dark:text-[#ff8a80]">
Warning: {validationIssues[0]}.
</div>
)}
```
- [ ] **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
<DialogContent className="max-w-2xl rounded-[10px] bg-card p-0 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_12px_40px_rgba(0,0,0,0.12)]">
<DialogHeader className="border-b border-foreground/10 px-6 py-5">
<DialogTitle className="text-[28px] tracking-[-0.06em]">{titleText}</DialogTitle>
<DialogDescription>{descriptionText}</DialogDescription>
</DialogHeader>
<form className="grid gap-6 px-6 py-5">
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">Event details</p>
{/* title, description, url, location */}
</section>
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">Schedule</p>
{/* start, end, all-day */}
</section>
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">Recurrence</p>
{/* recurrence picker */}
</section>
</form>
</DialogContent>
```
```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.

View File

@@ -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 {

View File

@@ -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 (
<DragDropContainer
isDragOver={isDragOver}
@@ -417,12 +437,12 @@ export default function HomePage() {
>
<div className={APP_FRAME_CLASSES}>
<header className={APP_HEADER_SURFACE_CLASSES}>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
Offline-first iCal editor
<div className="flex min-w-0 flex-col">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Local Calendar
</p>
<h1 className="truncate text-lg font-semibold tracking-tight">
LocalCal
<h1 className="truncate text-[28px] font-semibold tracking-[-0.06em]">
Event timeline
</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
@@ -439,6 +459,39 @@ export default function HomePage() {
</Badge>
<SignIn />
<ModeToggle />
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
disabled={events.length === 0}
>
Export
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm">
<MoreHorizontal className="h-4 w-4" />
More
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={openManualEventDialog}>
Manual create
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActiveView("settings")}>
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={handleClearAll}
disabled={events.length === 0}
>
Clear all events
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
@@ -452,22 +505,21 @@ export default function HomePage() {
settings={settings}
/>
) : (
<>
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<div className="order-1 lg:order-none space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
Create with AI
<div className="mb-4 space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
AI capture
</p>
<h2 className="text-lg font-semibold tracking-tight">
Paste details. Generate draft. Review before saving.
<h2 className="text-xl font-semibold tracking-[-0.04em]">
Capture from text or files.
</h2>
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Type or paste a natural-language description, then
generate a draft event for review in the event modal.
<p className="text-sm leading-6 text-muted-foreground">
Start with a prompt, screenshots, or both. Review happens in the
timeline rather than a separate flow.
</p>
</div>
</div>
<AIToolbar
adminAiEnabled={adminAiEnabled}
@@ -489,15 +541,17 @@ export default function HomePage() {
events={events}
/>
</section>
</div>
<div className="order-2 lg:order-none space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Events
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Event timeline
</p>
<h2 className="text-lg font-semibold tracking-tight">
Your local calendar timeline
<h2 className="text-xl font-semibold tracking-[-0.04em]">
Review the calendar by scanning the stream.
</h2>
</div>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
@@ -511,47 +565,20 @@ export default function HomePage() {
onFileSelect={handleImport}
variant="outline"
size="sm"
className="h-9 rounded-xl gap-1.5 text-xs"
>
Import
</IcsFilePicker>
{events.length > 0 && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
className="h-9 rounded-xl gap-1.5 text-xs"
>
Export
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive"
>
Clear
</Button>
</>
)}
<Button
type="button"
size="sm"
onClick={() => {
resetForm();
setDialogSource("manual");
setDialogOpen(true);
}}
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
>
Manual event
</Button>
</div>
</div>
@@ -561,7 +588,8 @@ export default function HomePage() {
onDelete={handleDelete}
/>
</section>
</>
</div>
</section>
)}
</main>
@@ -574,16 +602,7 @@ export default function HomePage() {
onClick={() => setActiveView("list")}
>
<CalendarDays className="h-4 w-4" />
List
</Button>
<Button
type="button"
variant="ghost"
className="flex-1 gap-2 text-muted-foreground"
disabled
>
<ListTodo className="h-4 w-4" />
Tasks
Timeline
</Button>
<Button
type="button"

View File

@@ -281,16 +281,16 @@ export const AIToolbar = ({
</div>
</div>
) : isAuthenticated ? (
<div className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="rounded-[10px] bg-card 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] focus-within:ring-[3px] focus-within:ring-ring/20">
<Textarea
id="ai-event-prompt"
className="wrap-anywhere field-sizing-content min-h-40 w-full resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Type or paste event details"
className="wrap-anywhere field-sizing-content min-h-48 w-full resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="Type or paste event details..."
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
// ⌘↵ — generate
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
@@ -299,7 +299,6 @@ export const AIToolbar = ({
e.preventDefault();
onAiCreate();
}
// ⌘⇧A — attach image
if (
e.key === "A" &&
e.shiftKey &&
@@ -309,7 +308,6 @@ export const AIToolbar = ({
e.preventDefault();
imageTriggerRef.current?.open();
}
// Esc — clear prompt (only when not composing a native action)
if (e.key === "Escape" && aiPrompt) {
e.preventDefault();
setAiPrompt("");
@@ -325,7 +323,7 @@ export const AIToolbar = ({
}
}}
/>
<div className="sticky bottom-0 z-10 border-t border-border/60 bg-background/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="border-t border-border px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Try:
@@ -347,63 +345,7 @@ export const AIToolbar = ({
</div>
</div>
<AnimatePresence>
{hasImages && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex gap-2 overflow-x-auto py-1 ml-3">
{imagePreviews.map((preview, index) => (
<motion.div
key={preview}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.12 }}
className="relative inline-block shrink-0"
>
<Image
src={preview}
alt={`Attached image ${index + 1}`}
className="h-16 w-16 rounded-md object-cover ring-1 ring-primary/30"
width={64}
height={64}
unoptimized
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full"
onClick={() => onImageRemove(index)}
aria-label={`Remove image ${index + 1}`}
>
<X className="h-2.5 w-2.5" />
</Button>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-between gap-2">
<ImagePicker
onFilesSelect={onImagesSelect}
disabled={aiLoading || !canUseAi}
multiple
variant="ghost"
size="sm"
className="gap-1.5 text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
triggerRef={imageTriggerRef}
>
<ImageIcon className="h-3.5 w-3.5" />
Attach image
</ImagePicker>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<HoverCard
openDelay={300}
@@ -424,7 +366,7 @@ export const AIToolbar = ({
</PopoverTrigger>
</HoverCardTrigger>
<PopoverContent
align="end"
align="start"
side="top"
sideOffset={6}
className="w-52 p-3"
@@ -433,7 +375,7 @@ export const AIToolbar = ({
</PopoverContent>
</Popover>
<HoverCardContent
align="end"
align="start"
side="top"
sideOffset={6}
className="w-52 p-3"
@@ -448,16 +390,17 @@ export const AIToolbar = ({
size="sm"
onClick={onAiSummarize}
disabled={aiLoading || !canUseAi}
className="h-9 gap-1.5 rounded-xl px-3 text-xs text-muted-foreground hover:text-primary"
className="h-9 gap-1.5 px-3 text-xs text-muted-foreground hover:text-primary"
>
<Bot className="h-3 w-3" />
Summarize
</Button>
)}
</div>
<Button
size="sm"
className="h-10 gap-1.5 rounded-xl px-4 text-xs"
className="h-10 gap-1.5 px-4 text-xs"
onClick={onAiCreate}
disabled={
aiLoading || !canUseAi || (!aiPrompt.trim() && !hasImages)
@@ -468,10 +411,84 @@ export const AIToolbar = ({
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{aiLoading ? "Generating" : "Generate draft"}
{aiLoading ? "Generating..." : "Generate event"}
</Button>
</div>
</div>
<div className="rounded-[10px] bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Attachments
</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Add screenshots, flyers, or pasted images alongside the prompt.
</p>
</div>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
{imagePreviews.length} file{imagePreviews.length === 1 ? "" : "s"}
</span>
</div>
<ImagePicker
onFilesSelect={onImagesSelect}
disabled={aiLoading || !canUseAi}
multiple
variant="outline"
size="sm"
className="h-10 w-full justify-center gap-2"
triggerRef={imageTriggerRef}
>
<ImageIcon className="h-4 w-4" />
Attach images
</ImagePicker>
<AnimatePresence>
{hasImages ? (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="mt-3 grid gap-2 sm:grid-cols-2"
>
{imagePreviews.map((preview, index) => (
<motion.div
key={preview}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.12 }}
className="relative overflow-hidden rounded-[8px] bg-muted"
>
<Image
src={preview}
alt={`Attached image ${index + 1}`}
className="h-32 w-full object-cover"
width={256}
height={160}
unoptimized
/>
<Button
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-7 w-7 rounded-full"
onClick={() => onImageRemove(index)}
aria-label={`Remove image ${index + 1}`}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</motion.div>
) : (
<div className="mt-3 rounded-[8px] border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
Drop or paste images here to pair them with the prompt.
</div>
)}
</AnimatePresence>
</div>
</div>
) : (
<div className="flex items-center gap-3 py-2">

View File

@@ -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) => {
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3 shrink-0" />
{formatEventRangeLabel(event)}
@@ -83,7 +86,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
<Button
variant="link"
size="sm"
className="h-auto gap-1 p-0 text-xs text-primary/70 hover:text-primary"
className="h-auto gap-1 p-0 text-xs"
asChild
>
<a
@@ -102,6 +105,12 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
{event.recurrenceRule && (
<RRuleDisplay rrule={event.recurrenceRule} start={event.start} />
)}
{validationIssues.length > 0 && (
<div className="rounded-[8px] bg-[#fff4f2] px-3 py-2 text-xs text-[#b42318] shadow-[inset_0_0_0_1px_rgba(180,35,24,0.14)] dark:bg-[#2a1715] dark:text-[#ff8a80]">
Warning: {validationIssues[0]}.
</div>
)}
</div>
<DropdownMenu>

View File

@@ -147,13 +147,15 @@ export const EventDialog = ({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="glass-strong max-w-md">
<DialogHeader>
<DialogTitle className="text-base">{titleText}</DialogTitle>
<DialogContent className="max-w-2xl rounded-[10px] bg-card p-0 shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_12px_40px_rgba(0,0,0,0.12)]">
<DialogHeader className="border-b border-foreground/10 px-6 py-5">
<DialogTitle className="text-[28px] tracking-[-0.06em]">
{titleText}
</DialogTitle>
<DialogDescription>{descriptionText}</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={onSubmit}>
<form className="grid gap-6 px-6 py-5" onSubmit={onSubmit}>
{isAiDraft && (
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
This draft was generated from natural language. Double-check
@@ -161,6 +163,10 @@ export const EventDialog = ({
</div>
)}
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Event details
</p>
<div className="space-y-1.5">
<Label htmlFor="event-title">Title</Label>
<Input
@@ -207,23 +213,12 @@ export const EventDialog = ({
)}
</div>
</div>
</section>
<Controller
name="recurrenceRule"
control={control}
render={({ field }) => (
<RecurrencePicker
value={field.value}
onChange={field.onChange}
start={start}
/>
)}
/>
{errors.recurrenceRule && (
<p className="text-xs text-destructive">
{errors.recurrenceRule.message}
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Schedule
</p>
)}
<div className="flex items-center gap-2 py-1">
<Controller
@@ -304,6 +299,29 @@ export const EventDialog = ({
<p className="text-xs text-destructive">{errors.end.message}</p>
)}
</div>
</section>
<section className="grid gap-3">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Recurrence
</p>
<Controller
name="recurrenceRule"
control={control}
render={({ field }) => (
<RecurrencePicker
value={field.value}
onChange={field.onChange}
start={start}
/>
)}
/>
{errors.recurrenceRule && (
<p className="text-xs text-destructive">
{errors.recurrenceRule.message}
</p>
)}
</section>
<DialogFooter className="gap-2 sm:gap-0">
<Button

View File

@@ -17,13 +17,13 @@ export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-16 text-center"
className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-border/80 bg-muted/30 px-6 py-16 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.35)]"
>
<Calendar1Icon className="h-10 w-10 text-muted-foreground/40 mb-3" />
<h3 className="text-sm font-medium text-foreground">No events yet</h3>
<p className="mt-1 max-w-sm text-xs leading-relaxed text-muted-foreground/70">
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.
</p>
</motion.div>
);

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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",
},
},

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-[10px] py-6 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]",
className,
)}
{...props}

View File

@@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"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/50",
"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-background/80 backdrop-blur-[2px]",
className,
)}
{...props}
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-card text-card-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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 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] duration-200 sm:max-w-lg",
className,
)}
{...props}
@@ -69,7 +69,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="focus:ring-ring/30 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-[6px] p-1 opacity-70 transition-[background-color,opacity] hover:opacity-100 focus:ring-[3px] focus:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2 text-left", className)}
{...props}
/>
);
@@ -110,7 +110,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
className={cn("text-[24px] leading-none font-semibold tracking-[-0.04em]", className)}
{...props}
/>
);
@@ -123,7 +123,7 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-muted-foreground text-sm leading-6", className)}
{...props}
/>
);

View File

@@ -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({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-[6px] py-2 pr-2.5 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-[6px] py-2 pr-2.5 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"px-2.5 py-1.5 text-xs font-medium tracking-[0.08em] text-muted-foreground uppercase data-[inset]:pl-8",
className,
)}
{...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-[6px] px-2.5 py-2 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"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 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden 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}

View File

@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
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}

View File

@@ -83,5 +83,30 @@ export const getEventFormValuesFromEvent = (
recurrenceRule: event?.recurrenceRule || undefined,
});
export const getEventValidationIssues = (
event: Pick<CalendarEvent, "start" | "end" | "url">,
) => {
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);

View File

@@ -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",
);

View File

@@ -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:

View File

@@ -41,4 +41,25 @@ describe("EventCard actions trigger", () => {
expect(markup).toContain("10:0011: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");
});
});

View File

@@ -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");
});
});

View File

@@ -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</button>");
});
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");
});
});

View File

@@ -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");
});
});