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
+86 -25
View File
@@ -1,8 +1,7 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
import { cn } from "@/lib/utils";
import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -15,11 +14,34 @@ import { QuickAddSheet } from "@/components/quick-add-sheet";
import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
const sourceSerif = Source_Serif_4({
subsets: ["latin"],
variable: "--serif-source",
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--serif-newsreader",
display: "swap",
});
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--serif-fraunces",
display: "swap",
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--mono-jb",
display: "swap",
});
export const viewport: Viewport = {
themeColor: "#4F46E5",
themeColor: "#1F1B16",
};
export const metadata: Metadata = {
@@ -36,34 +58,60 @@ export const metadata: Metadata = {
},
};
// Runs before paint reads localStorage / prefers-color-scheme and applies
// data-theme + dark class to <html> so signed-out pages also get the right theme.
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
const prePaintScript = `(function(){
try {
var t = localStorage.getItem('theme') || 'default';
var m = localStorage.getItem('themeMode') || 'system';
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', t);
if (dark) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
var mode = localStorage.getItem('themeMode') || 'system';
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
var density = localStorage.getItem('themeDensity') || 'regular';
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
var dataNav = navStyle === 'compact-rail' ? 'rail'
: navStyle === 'top-nav' ? 'top'
: navStyle === 'fab-only' ? 'fab' : 'sidebar';
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
}
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var html = document.documentElement;
html.setAttribute('data-theme', palette);
html.setAttribute('data-font-pair', fontPair);
html.setAttribute('data-density', density);
html.setAttribute('data-nav', dataNav);
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
} catch(e) {}
})();`;
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let theme = "default";
let themeMode = "system";
let palette: Palette = DEFAULT_THEME.palette;
let mode: ThemeMode = DEFAULT_THEME.mode;
let fontPair: FontPair = DEFAULT_THEME.fontPair;
let density: Density = DEFAULT_THEME.density;
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
const session = await auth();
if (session?.user?.id) {
signedIn = true;
const [row] = await db
.select({ theme: users.theme, themeMode: users.themeMode })
.select({
themePalette: users.themePalette,
themeMode: users.themeMode,
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (row) {
theme = row.theme;
themeMode = row.themeMode;
palette = row.themePalette as Palette;
mode = row.themeMode as ThemeMode;
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
}
userDashboards = await db
.select({
@@ -78,25 +126,38 @@ export default async function RootLayout({ children }: { children: React.ReactNo
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
}
// For system mode we can't know the preference on the server — the inline
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
// before paint by the inline script). Avoids a flash on signed-in users.
const isDark = mode === "dark";
const initialDataNav = navStyleToDataNav(navStyle);
const quickAdds = getQuickAdds();
const fontVars = cn(
inter.variable,
sourceSerif.variable,
newsreader.variable,
fraunces.variable,
jetbrains.variable,
);
return (
<html
lang="en"
data-theme={theme}
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
data-theme={palette}
data-font-pair={fontPair}
data-density={density}
data-nav={initialDataNav}
className={cn(fontVars, isDark ? "dark" : "")}
>
<head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<body>
<QuickAddProvider actions={quickAdds}>
<AppNav dashboards={userDashboards} />
<main>{children}</main>
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
{children}
</AppShell>
<QuickAddSheet />
<CommandPalette />
<InstallPrompt />