Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled

Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with
the paper-and-ink language from the Claude Design handoff bundle: warm
off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders,
muted ink accents (clay/indigo/sage/plum/ochre) used functionally for
calendars and share scopes.

Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette ×
mode × fontPair × density × dashLayout × calView × navStyle. All exposed
in Settings → Appearance and persisted on the users row. Pre-paint script
applies all four data-* attributes from localStorage so reload doesn't
flash.

App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar
on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired
(sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button,
notification bell, "+ New" quick-add, avatar.

Dashboard, calendar, lists, notes, settings, login, public share viewer,
and quick-add sheet all reskinned. Dashboard editor gains a Preset menu
(classic/split/glance) that fills the layout from the registered widgets.
FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via
CSS variable overrides. Public share viewer (/s/<token>) rebuilt around
ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif
title, mini-day + mini-map cards, share-rows.

Schema: drops users.theme; adds theme_palette, theme_font_pair,
theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with
defaults that match the design (clay / serif-sans / regular / classic /
month / rail-desktop). Migration 0014_paper_ink_theme.

Middleware sets x-pathname so the AppShell server component can render
bare for /s/* and /login without a route-group refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-07 01:03:21 -05:00
co-authored by Claude Opus 4.7
parent e130cda6c5
commit 9612a54e52
61 changed files with 4173 additions and 894 deletions
+135 -76
View File
@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { Archive, GripVertical, Plus, Trash2 } from "lucide-react";
import { Archive, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -23,7 +23,6 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
const [draft, setDraft] = useState("");
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const swipeStart = useRef<Record<string, number>>({});
useEffect(() => {
const events = new EventSource(`/api/lists/${initialList.id}/events`);
@@ -95,97 +94,157 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
});
}
const openItems = list.items.filter((i) => !i.done);
const doneItems = list.items.filter((i) => i.done);
return (
<div className="mx-auto grid w-full max-w-3xl gap-5 p-4">
<div className="mx-auto grid w-full max-w-3xl gap-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="min-w-0 flex items-center gap-3 flex-1">
<Input
aria-label="List name"
className="h-auto border-transparent px-0 text-2xl font-semibold shadow-none focus-visible:border-transparent focus-visible:ring-0"
className="serif h-auto border-transparent !bg-transparent px-0 text-[22px] font-medium tracking-tight shadow-none focus-visible:border-transparent focus-visible:ring-0"
value={list.name}
onChange={(event) => setList({ ...list, name: event.target.value })}
onBlur={commitListName}
/>
<div className="text-sm text-muted-foreground">
{list.type} / {list.openCount} open / {list.doneCount} done
</div>
<span className="badge">
{list.type} · {list.openCount} open
</span>
</div>
<div className="flex items-center gap-2">
<ShareButton entityType="lists.list" entityId={list.id} canWrite={true} />
<Button variant="outline" size="sm" onClick={archiveCurrentList} disabled={isPending}>
<Archive className="size-3.5" />
Archive
</Button>
</div>
<ShareButton entityType="lists.list" entityId={list.id} canWrite={true} />
<Button variant="outline" onClick={archiveCurrentList} disabled={isPending}>
<Archive />
Archive list
</Button>
</header>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
submitItem();
}}
<div
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] shadow-[var(--shadow-1)] overflow-hidden"
style={{ borderColor: "var(--hair)" }}
>
<Input
ref={inputRef}
aria-label="Add item"
autoFocus
placeholder={list.type === "shopping" ? "Add milk, eggs, coffee..." : "Add a task..."}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<Button type="submit" disabled={!draft.trim() || isPending}>
<Plus />
Add
</Button>
</form>
<form
className="flex items-center gap-2 px-[14px] py-3 border-b-[0.5px]"
style={{ borderColor: "var(--hair)" }}
onSubmit={(event) => {
event.preventDefault();
submitItem();
}}
>
<Plus className="size-4 text-[var(--ink-mute)]" />
<Input
ref={inputRef}
aria-label="Add item"
autoFocus
placeholder={list.type === "shopping" ? "Add to list…" : "Add a task…"}
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 px-0 h-7"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<span className="kbd"></span>
</form>
<div className="overflow-hidden rounded-lg border bg-background">
{list.items.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">Nothing here yet.</div>
) : (
<ul className="divide-y">
{list.items.map((item) => (
<li
{openItems.length === 0 && doneItems.length === 0 && (
<div className="muted px-[14px] py-8 text-center text-[13px]">Nothing here yet.</div>
)}
{openItems.map((item) => (
<ListItemRow
key={item.id}
item={item}
onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)}
onRemove={() => removeItem(item)}
/>
))}
{doneItems.length > 0 && (
<>
<div className="eyebrow px-[14px] pt-3 pb-[6px]">Done · {doneItems.length}</div>
{doneItems.map((item) => (
<ListItemRow
key={item.id}
className="grid grid-cols-[auto_1fr_auto] items-center gap-3 p-3"
onPointerDown={(event) => {
swipeStart.current[item.id] = event.clientX;
}}
onPointerUp={(event) => {
const start = swipeStart.current[item.id];
if (start !== undefined && event.clientX - start < -60) removeItem(item);
delete swipeStart.current[item.id];
}}
>
<input
aria-label={`Complete ${item.text}`}
type="checkbox"
className="size-5 accent-primary"
checked={item.done}
onChange={(event) => setItemDone(item, event.target.checked)}
/>
<Input
aria-label={`${item.text} text`}
className={item.done ? "text-muted-foreground line-through" : ""}
value={item.text}
onChange={(event) => editItemText(item, event.target.value)}
onBlur={() => commitItemText(item)}
/>
<div className="flex items-center gap-1">
<GripVertical className="size-4 text-muted-foreground" />
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={() => removeItem(item)}
>
<Trash2 />
</Button>
</div>
</li>
item={item}
onToggle={(done) => setItemDone(item, done)}
onEdit={(text) => editItemText(item, text)}
onCommit={() => commitItemText(item)}
onRemove={() => removeItem(item)}
/>
))}
</ul>
</>
)}
</div>
</div>
);
}
function ListItemRow({
item,
onToggle,
onEdit,
onCommit,
onRemove,
}: {
item: ListItemDto;
onToggle: (done: boolean) => void;
onEdit: (text: string) => void;
onCommit: () => void;
onRemove: () => void;
}) {
const swipeStart = useRef<number | null>(null);
return (
<div
className={`list-row ${item.done ? "checked" : ""} group/row`}
onPointerDown={(event) => {
swipeStart.current = event.clientX;
}}
onPointerUp={(event) => {
const start = swipeStart.current;
if (start !== null && event.clientX - start < -60) onRemove();
swipeStart.current = null;
}}
>
<button
type="button"
aria-label={`Complete ${item.text}`}
aria-pressed={item.done}
className={`checkbox ${item.done ? "on" : ""}`}
onClick={() => onToggle(!item.done)}
>
{item.done && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
)}
</button>
<Input
aria-label={`${item.text} text`}
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
value={item.text}
onChange={(event) => onEdit(event.target.value)}
onBlur={onCommit}
/>
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={onRemove}
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
}