Apply paper-and-ink design system across all surfaces
Release / build-and-push (push) Has been cancelled
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:
co-authored by
Claude Opus 4.7
parent
e130cda6c5
commit
9612a54e52
@@ -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 - Tacos - 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,48 @@ async function NotesWidget({ config }: { config: unknown; ctx: WidgetContext })
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-[var(--ink-mute)]">
|
||||
{parsed.filter === "pinned" ? "No pinned notes" : "No notes"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{notes.map((note) => (
|
||||
<li key={note.id} className="space-y-0.5">
|
||||
<p className="text-sm font-medium leading-snug">{note.title}</p>
|
||||
{note.body && <p className="line-clamp-2 text-xs text-muted-foreground">{note.body}</p>}
|
||||
</li>
|
||||
<a key={note.id} href={`/notes/${note.id}`} className="note-card">
|
||||
{parsed.filter === "pinned" && (
|
||||
<svg
|
||||
className="pin"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 4h6l1 6 3 3H5l3-3 1-6z" />
|
||||
</svg>
|
||||
)}
|
||||
<h4 className="text-[14px]">{note.title}</h4>
|
||||
{note.body && (
|
||||
<p
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{note.body}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user