663
docs/superpowers/plans/2026-04-21-local-cal-redesign.md
Normal file
663
docs/superpowers/plans/2026-04-21-local-cal-redesign.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
143
src/app/page.tsx
143
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 (
|
||||
<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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
29
tests/home-page-layout.test.ts
Normal file
29
tests/home-page-layout.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user