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
+32 -18
View File
@@ -64,35 +64,40 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
}
return (
<div className="mx-auto grid w-full max-w-6xl gap-5 p-4">
<div className="mx-auto grid w-full max-w-6xl gap-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">{currentNote ? currentNote.title : "New note"}</h1>
<p className="text-sm text-muted-foreground">Markdown notes shared with the household.</p>
<div className="flex items-center gap-2 min-w-0 flex-1">
{pinned && <Pin className="size-3.5 text-[var(--accent)]" />}
<h2 className="serif text-[24px] font-medium tracking-tight truncate">
{currentNote ? currentNote.title : "New note"}
</h2>
</div>
<div className="flex flex-wrap gap-2">
{currentNote ? (
<Button variant="outline" onClick={togglePinned} disabled={isPending}>
{pinned ? <PinOff /> : <Pin />}
{pinned ? "Unpin note" : "Pin note"}
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
{pinned ? "Unpin" : "Pin"}
</Button>
) : null}
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
{currentNote ? (
<Button variant="destructive" onClick={removeNote} disabled={isPending}>
<Trash2 />
Delete note
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
<Trash2 className="size-3.5" />
Delete
</Button>
) : null}
<Button onClick={saveNote} disabled={!title.trim() || isPending}>
<Save />
Save note
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
<Save className="size-3.5" />
Save
</Button>
</div>
</header>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
<section className="grid gap-4 rounded-lg border bg-background p-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
<section
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)]"
style={{ borderColor: "var(--hair)" }}
>
<div className="space-y-1.5">
<Label htmlFor="note-title">Title</Label>
<Input
@@ -106,7 +111,13 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
<textarea
id="note-body"
aria-label="Body"
className="min-h-80 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
className="min-h-80 w-full rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2 leading-[1.6] outline-none transition-colors placeholder:text-[var(--ink-faint)] focus-visible:border-[var(--ink)] focus-visible:ring-3 focus-visible:ring-[var(--ink)]/8"
style={{
fontFamily: "var(--serif)",
fontSize: "15px",
color: "var(--ink-2)",
borderColor: "var(--hair-2)",
}}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="# Dinner ideas&#10;&#10;- Tacos&#10;- Soup"
@@ -123,8 +134,11 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
</div>
</section>
<aside className="rounded-lg border bg-card p-4 text-card-foreground">
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Preview</h2>
<aside
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)]"
style={{ borderColor: "var(--hair)" }}
>
<div className="eyebrow mb-3">Preview</div>
<MarkdownPreview markdown={body} />
</aside>
</div>
+96 -27
View File
@@ -3,50 +3,119 @@ import { Plus, Pin } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import type { NoteDto } from "../server/queries";
function relTime(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const days = Math.round(diffMs / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 7) return `${days}d ago`;
if (days < 30) return `${Math.round(days / 7)}w ago`;
return `${Math.round(days / 30)}mo ago`;
}
export function NotesIndex({ notes }: { notes: NoteDto[] }) {
const pinned = notes.filter((n) => n.pinned);
const others = notes.filter((n) => !n.pinned);
return (
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
<div className="mx-auto grid w-full max-w-5xl gap-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Notes</h1>
<p className="text-sm text-muted-foreground">
<h2 className="serif text-[22px] tracking-tight">Notes</h2>
<p className="muted text-[13px] mt-1">
Shared reminders, reference notes, and loose household details.
</p>
</div>
<Link href="/notes/new" className={buttonVariants()}>
<Plus />
<Link href="/notes/new" className={buttonVariants({ size: "sm" })}>
<Plus className="size-3.5" />
New note
</Link>
</div>
{notes.length === 0 ? (
<div className="rounded-lg border bg-background p-8 text-center text-sm text-muted-foreground">
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-8 text-center text-[13px] muted"
style={{ borderColor: "var(--hair)" }}
>
No notes yet.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{notes.map((note) => (
<Link
key={note.id}
href={`/notes/${note.id}`}
className="grid min-h-32 gap-3 rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
<>
{pinned.length > 0 && (
<div>
<div className="eyebrow mb-2 flex items-center gap-1.5">
<Pin className="size-3" /> Pinned
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{pinned.map((note) => (
<NoteCardLink key={note.id} note={note} />
))}
</div>
</div>
)}
<div>
<div className="eyebrow mb-2">All notes · {others.length}</div>
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] overflow-hidden"
style={{ borderColor: "var(--hair)" }}
>
<div className="flex items-start justify-between gap-3">
<h2 className="font-medium">{note.title}</h2>
{note.pinned ? (
<Pin aria-label="Pinned" className="mt-0.5 size-4 shrink-0 text-primary" />
) : null}
</div>
<p className="line-clamp-3 text-sm text-muted-foreground">
{note.body || "No body text."}
</p>
<div className="text-xs text-muted-foreground">
Updated {new Date(note.updatedAt).toLocaleDateString()}
</div>
</Link>
))}
</div>
{others.map((note, i) => (
<Link
key={note.id}
href={`/notes/${note.id}`}
className="flex gap-3 px-[14px] py-3 hover:bg-[var(--shade)]"
style={{
borderBottom: i === others.length - 1 ? "0" : "0.5px solid var(--hair)",
}}
>
<div className="flex-1 min-w-0">
<h4 className="serif text-[15px] font-medium text-[var(--ink)] m-0">
{note.title}
</h4>
<div
className="muted text-[12.5px] mt-0.5 truncate"
style={{ color: "var(--ink-mute)" }}
>
{note.body || "No body text."}
</div>
</div>
<div className="muted text-[11.5px] shrink-0 self-center">
{relTime(note.updatedAt)}
</div>
</Link>
))}
{others.length === 0 && (
<div className="muted px-[14px] py-6 text-center text-[13px]">No notes match.</div>
)}
</div>
</div>
</>
)}
</div>
);
}
function NoteCardLink({ note }: { note: NoteDto }) {
return (
<Link href={`/notes/${note.id}`} className="note-card">
{note.pinned && <Pin className="pin size-3" />}
<h4 className="text-[15.5px]">{note.title}</h4>
<p
style={{
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{note.body || "No body text."}
</p>
<div className="flex items-center gap-1.5 mt-1 text-[11.5px] muted">
<span>{relTime(note.updatedAt)}</span>
</div>
</Link>
);
}
+43 -11
View File
@@ -1,26 +1,58 @@
import { Pin } from "lucide-react";
import { FileText, Pin } from "lucide-react";
import type { NoteShareData } from "../server/share-queries";
import { ShareEyebrow } from "@/components/share/share-eyebrow";
export function NoteSharedView({ data }: { data: NoteShareData }) {
const updatedAt = new Date(data.updatedAt).toLocaleDateString(undefined, {
const updated = new Date(data.updatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="mx-auto max-w-xl space-y-4 p-4">
<header className="space-y-1">
<>
<ShareEyebrow>
<FileText className="size-3" />
Note
</ShareEyebrow>
<h1
className="serif"
style={{
fontSize: "clamp(28px, 6vw, 38px)",
lineHeight: 1.15,
letterSpacing: "-0.02em",
color: "var(--ink)",
margin: "0 0 8px",
textWrap: "pretty",
}}
>
{data.title}
</h1>
<p className="muted text-[13px] mb-6 inline-flex items-center gap-2">
{data.pinned && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Pin className="size-3" />
<span className="inline-flex items-center gap-1">
<Pin className="size-3 text-[var(--accent)]" />
Pinned
</span>
)}
<h1 className="text-2xl font-semibold">{data.title}</h1>
<p className="text-xs text-muted-foreground">Updated {updatedAt}</p>
</header>
{data.body && <p className="whitespace-pre-wrap text-sm leading-relaxed">{data.body}</p>}
</div>
{data.pinned && <span>·</span>}
<span>Updated {updated}</span>
</p>
{data.body && (
<div
className="serif"
style={{
fontSize: 16,
lineHeight: 1.7,
color: "var(--ink-2)",
whiteSpace: "pre-wrap",
textWrap: "pretty",
}}
>
{data.body}
</div>
)}
</>
);
}