From 9cd79c9c6f8385febec840eaa8e9bff9f5aecd09 Mon Sep 17 00:00:00 2001 From: Rohan Patnaik Date: Fri, 6 Feb 2026 23:16:52 +0530 Subject: [PATCH 01/10] feat: polish zen UI theme system and capacity docs --- apps/web/.env.example | 9 +- apps/web/convex/capacity.ts | 14 +- apps/web/convex/lib/auth.ts | 45 +- apps/web/convex/lib/limits.ts | 23 +- apps/web/convex/schema.ts | 4 +- apps/web/convex/users.ts | 3 +- apps/web/src/app/globals.css | 556 ++++++++++++------ apps/web/src/app/layout.tsx | 44 +- apps/web/src/app/page.tsx | 282 +++++---- apps/web/src/app/providers.tsx | 55 +- .../src/app/sign-in/[[...sign-in]]/page.tsx | 6 +- .../src/app/sign-up/[[...sign-up]]/page.tsx | 6 +- apps/web/src/app/tools/page.tsx | 405 +++++++++---- apps/web/src/app/usage-capacity/page.tsx | 223 ++++--- apps/web/src/components/DonateBookmark.tsx | 125 ++++ apps/web/src/components/SiteHeader.tsx | 50 +- apps/web/src/components/ThemeModeProvider.tsx | 63 ++ apps/web/src/components/ThemeToggle.tsx | 54 ++ apps/web/src/lib/convex.ts | 5 +- apps/web/src/lib/limits.ts | 26 +- apps/web/tailwind.config.ts | 12 +- .../tests/integration/import-meta-glob.d.ts | 3 + docs/ARCHITECTURE.md | 7 +- docs/PRD.md | 22 +- docs/RELEASE_CHECKLIST.md | 2 +- docs/ROADMAP.md | 8 +- docs/SELF_HOST.md | 2 +- docs/UI_PRACTICAL_REDESIGN_CHECKLIST.md | 50 ++ 28 files changed, 1408 insertions(+), 696 deletions(-) create mode 100644 apps/web/src/components/DonateBookmark.tsx create mode 100644 apps/web/src/components/ThemeModeProvider.tsx create mode 100644 apps/web/src/components/ThemeToggle.tsx create mode 100644 apps/web/tests/integration/import-meta-glob.d.ts create mode 100644 docs/UI_PRACTICAL_REDESIGN_CHECKLIST.md diff --git a/apps/web/.env.example b/apps/web/.env.example index 9738b62..f0f9706 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,11 +16,6 @@ ZENPDF_FREE_ACCOUNT_MAX_MB_PER_FILE= ZENPDF_FREE_ACCOUNT_MAX_CONCURRENT_JOBS= ZENPDF_FREE_ACCOUNT_MAX_JOBS_PER_DAY= ZENPDF_FREE_ACCOUNT_MAX_DAILY_MINUTES= -ZENPDF_PREMIUM_MAX_FILES_PER_JOB= -ZENPDF_PREMIUM_MAX_MB_PER_FILE= -ZENPDF_PREMIUM_MAX_CONCURRENT_JOBS= -ZENPDF_PREMIUM_MAX_JOBS_PER_DAY= -ZENPDF_PREMIUM_MAX_DAILY_MINUTES= ZENPDF_GLOBAL_MAX_CONCURRENT_JOBS= ZENPDF_GLOBAL_MAX_JOBS_PER_DAY= ZENPDF_GLOBAL_MAX_DAILY_MINUTES= @@ -28,6 +23,10 @@ ZENPDF_GLOBAL_JOB_MAX_ATTEMPTS= ZENPDF_GLOBAL_LEASE_DURATION_MS= ZENPDF_GLOBAL_ARTIFACT_TTL_HOURS= ZENPDF_WORKER_TOKEN= +NEXT_PUBLIC_DONATE_UPI_ID= +NEXT_PUBLIC_DONATE_UPI_NAME=ZenPDF +NEXT_PUBLIC_DONATE_UPI_NOTE=Support ZenPDF +NEXT_PUBLIC_DONATE_UPI_QR_URL= NEXT_PUBLIC_COMPRESS_TIMEOUT_BASE_SECONDS=120 NEXT_PUBLIC_COMPRESS_TIMEOUT_MAX_SECONDS=900 NEXT_PUBLIC_COMPRESS_TIMEOUT_PER_MB_SECONDS=3 diff --git a/apps/web/convex/capacity.ts b/apps/web/convex/capacity.ts index 31321fd..30131f8 100644 --- a/apps/web/convex/capacity.ts +++ b/apps/web/convex/capacity.ts @@ -35,26 +35,28 @@ export const getUsageSnapshot = query({ const { userId, tier } = await resolveUser(ctx); const anonId = args.anonId?.trim() || undefined; const resolvedAnonId = userId ? undefined : anonId; - const [planLimits, anonLimits, freeLimits, premiumLimits, budget, usageResult] = + const [planLimits, anonLimits, freeLimits, globalLimits, budget, usageResult, globalUsageResult] = await Promise.all([ resolvePlanLimits(ctx, tier), resolvePlanLimits(ctx, "ANON"), resolvePlanLimits(ctx, "FREE_ACCOUNT"), - resolvePlanLimits(ctx, "PREMIUM"), + resolveGlobalLimits(ctx), resolveBudgetState(ctx, now), resolveUsageCounter(ctx, userId, resolvedAnonId, now), + resolveGlobalUsageCounter(ctx, now), ]); const planSnapshot = { ANON: anonLimits, FREE_ACCOUNT: freeLimits, - PREMIUM: premiumLimits, }; const { counter, periodStart } = usageResult; + const { counter: globalCounter, periodStart: globalPeriodStart } = globalUsageResult; return { tier, planLimits, plans: planSnapshot, + globalLimits, budget, usage: counter ?? { periodStart, @@ -62,6 +64,12 @@ export const getUsageSnapshot = query({ minutesUsed: 0, bytesProcessed: 0, }, + globalUsage: globalCounter ?? { + periodStart: globalPeriodStart, + jobsUsed: 0, + minutesUsed: 0, + bytesProcessed: 0, + }, }; }, }); diff --git a/apps/web/convex/lib/auth.ts b/apps/web/convex/lib/auth.ts index 155c772..4bb1285 100644 --- a/apps/web/convex/lib/auth.ts +++ b/apps/web/convex/lib/auth.ts @@ -10,48 +10,22 @@ type Ctx = MutationCtx | QueryCtx; export type ResolvedUser = { identity: UserIdentity | null; userId: Id<"users"> | undefined; - tier: "ANON" | "FREE_ACCOUNT" | "PREMIUM"; - adsFree: boolean; + tier: "ANON" | "FREE_ACCOUNT"; }; -const parseEnvList = (value: string | undefined) => - value - ?.split(",") - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - const resolveTier = (identity: UserIdentity | null, storedTier?: ResolvedUser["tier"]) => { if (!identity) { - return { tier: "ANON" as const, adsFree: false }; - } - - const premiumEmails = parseEnvList(process.env.ZENPDF_PREMIUM_EMAILS).map((email) => - email.toLowerCase(), - ); - const premiumClerkIds = parseEnvList(process.env.ZENPDF_PREMIUM_CLERK_IDS); - const hasAllowlist = premiumEmails.length > 0 || premiumClerkIds.length > 0; - const email = normalizeOptionalEmail(identity.email); - const isPremium = - (email ? premiumEmails.includes(email) : false) || - premiumClerkIds.includes(identity.subject); - - if (isPremium) { - return { tier: "PREMIUM" as const, adsFree: true }; + return { tier: "ANON" as const }; } - - if (hasAllowlist) { - return { tier: "FREE_ACCOUNT" as const, adsFree: false }; - } - const tier = storedTier ?? "FREE_ACCOUNT"; - return { tier, adsFree: tier === "PREMIUM" }; + return { tier }; }; export const resolveUser = async (ctx: Ctx): Promise => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { - return { identity: null, userId: undefined, tier: "ANON", adsFree: false }; + return { identity: null, userId: undefined, tier: "ANON" }; } const clerkUserId = identity.subject; @@ -60,13 +34,13 @@ export const resolveUser = async (ctx: Ctx): Promise => { .withIndex("by_clerk_id", (q) => q.eq("clerkUserId", clerkUserId)) .unique(); - const { tier, adsFree } = resolveTier(identity, existing?.tier); + const { tier } = resolveTier(identity, existing?.tier); if (existing) { - return { identity, userId: existing._id, tier, adsFree }; + return { identity, userId: existing._id, tier }; } - return { identity, userId: undefined, tier, adsFree }; + return { identity, userId: undefined, tier }; }; export const resolveOrCreateUser = async ( @@ -84,7 +58,6 @@ export const resolveOrCreateUser = async ( email: normalizeOptionalEmail(resolved.identity.email), name: resolved.identity.name ?? resolved.identity.nickname, tier: resolved.tier, - adsFree: resolved.adsFree, createdAt: now, updatedAt: now, }); @@ -93,11 +66,9 @@ export const resolveOrCreateUser = async ( } const existing = await ctx.db.get(resolved.userId); - const storedAdsFree = existing?.adsFree ?? false; - if (existing && (existing.tier !== resolved.tier || storedAdsFree !== resolved.adsFree)) { + if (existing && existing.tier !== resolved.tier) { await ctx.db.patch(resolved.userId, { tier: resolved.tier, - adsFree: resolved.adsFree, updatedAt: now, }); } diff --git a/apps/web/convex/lib/limits.ts b/apps/web/convex/lib/limits.ts index 3659f12..c7553f9 100644 --- a/apps/web/convex/lib/limits.ts +++ b/apps/web/convex/lib/limits.ts @@ -1,6 +1,6 @@ import type { MutationCtx, QueryCtx } from "../_generated/server"; -export type PlanTier = "ANON" | "FREE_ACCOUNT" | "PREMIUM"; +export type PlanTier = "ANON" | "FREE_ACCOUNT"; export type PlanLimits = { maxFilesPerJob: number; @@ -22,31 +22,24 @@ export type GlobalLimits = { const DEFAULT_PLAN_LIMITS: Record = { ANON: { maxFilesPerJob: 1, - maxMbPerFile: 10, + maxMbPerFile: 25, maxConcurrentJobs: 1, - maxJobsPerDay: 3, + maxJobsPerDay: 5, maxDailyMinutes: 10, }, FREE_ACCOUNT: { maxFilesPerJob: 3, - maxMbPerFile: 50, + maxMbPerFile: 75, maxConcurrentJobs: 2, - maxJobsPerDay: 25, + maxJobsPerDay: 30, maxDailyMinutes: 60, }, - PREMIUM: { - maxFilesPerJob: 10, - maxMbPerFile: 250, - maxConcurrentJobs: 4, - maxJobsPerDay: 200, - maxDailyMinutes: 240, - }, }; const DEFAULT_GLOBAL_LIMITS: GlobalLimits = { - maxConcurrentJobs: 20, - maxJobsPerDay: 1500, - maxDailyMinutes: 12000, + maxConcurrentJobs: 3, + maxJobsPerDay: 200, + maxDailyMinutes: 120, jobMaxAttempts: 3, leaseDurationMs: 2 * 60 * 1000, artifactTtlHours: 24, diff --git a/apps/web/convex/schema.ts b/apps/web/convex/schema.ts index d0b13fc..cee0b20 100644 --- a/apps/web/convex/schema.ts +++ b/apps/web/convex/schema.ts @@ -4,7 +4,6 @@ import { v } from "convex/values"; const tier = v.union( v.literal("ANON"), v.literal("FREE_ACCOUNT"), - v.literal("PREMIUM"), ); const jobStatus = v.union( @@ -20,8 +19,9 @@ export default defineSchema({ clerkUserId: v.string(), email: v.optional(v.string()), name: v.optional(v.string()), - tier, + // Legacy field kept optional so existing records remain schema-valid. adsFree: v.optional(v.boolean()), + tier, createdAt: v.number(), updatedAt: v.number(), }) diff --git a/apps/web/convex/users.ts b/apps/web/convex/users.ts index 08106d8..8d5b2d9 100644 --- a/apps/web/convex/users.ts +++ b/apps/web/convex/users.ts @@ -5,10 +5,9 @@ import { resolveUser } from "./lib/auth"; export const getViewer = query({ args: {}, handler: async (ctx) => { - const { identity, tier, adsFree } = await resolveUser(ctx); + const { identity, tier } = await resolveUser(ctx); return { tier, - adsFree, signedIn: Boolean(identity), }; }, diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 651f8c0..d94bdcb 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,263 +1,471 @@ @import "tailwindcss"; :root { - --paper-50: 248 243 232; - --paper-100: 242 232 214; - --paper-200: 229 213 187; - --paper-300: 214 191 154; - --ink-900: 52 40 30; - --ink-700: 90 72 58; - --ink-500: 119 101 85; - --forest-700: 46 78 55; - --forest-600: 63 98 70; - --forest-500: 83 125 86; - --sage-200: 210 224 206; - --rose-100: 246 235 224; - --gold-200: 229 210 179; - --wood-800: 84 56 36; - --wood-700: 101 68 43; - --wood-600: 123 84 55; - --wood-500: 145 104 71; - --wood-400: 171 130 96; - --leaf-300: 199 214 188; - --leaf-500: 108 149 112; - --leaf-700: 73 111 77; - --paper-noise: url("data:image/svg+xml;utf8,"); - --leaf-pattern: url("data:image/svg+xml;utf8,"); - --leaf-icon: url("data:image/svg+xml;utf8,"); + color-scheme: light; + --paper-50: 249 251 250; + --paper-100: 243 247 245; + --paper-200: 229 235 232; + --paper-300: 211 221 215; + --ink-900: 15 23 42; + --ink-700: 51 65 85; + --ink-500: 100 116 139; + --forest-700: 18 105 74; + --forest-600: 22 136 95; + --forest-500: 52 173 127; + --sage-200: 220 246 233; + --rose-100: 254 242 242; + --gold-200: 254 243 199; + --wood-800: 55 65 81; + --wood-700: 71 85 105; + --wood-600: 100 116 139; + --wood-500: 148 163 184; + --wood-400: 203 213 225; + --leaf-300: 187 247 208; + --leaf-500: 34 197 94; + --leaf-700: 21 128 61; + --page-bg-top: 248 252 250; + --page-bg-bottom: 255 255 255; + --page-accent: 220 246 233; + --shadow-ink: 15 23 42; } +:root[data-theme="dark"] { + color-scheme: dark; + /* Medium-to-dark grays (less black-heavy) */ + --paper-50: 28 28 28; + --paper-100: 34 34 34; + --paper-200: 48 48 48; + --paper-300: 74 74 74; + --ink-900: 250 250 250; + --ink-700: 196 196 196; + --ink-500: 150 150 150; + /* Single Supabase green accent */ + --forest-700: 63 207 142; + --forest-600: 63 207 142; + --forest-500: 63 207 142; + --sage-200: 34 34 34; + --rose-100: 66 29 29; + --gold-200: 66 52 22; + --wood-800: 23 23 23; + --wood-700: 28 28 28; + --wood-600: 34 34 34; + --wood-500: 48 48 48; + --wood-400: 74 74 74; + --leaf-300: 169 241 202; + --leaf-500: 63 207 142; + --leaf-700: 63 207 142; + --page-bg-top: 28 28 28; + --page-bg-bottom: 34 34 34; + --page-accent: 34 34 34; + --shadow-ink: 8 8 8; +} + +html, body { min-height: 100%; - background-color: rgb(var(--paper-50)); - background-image: - radial-gradient( - circle at top, - rgb(252 246 234 / 0.9), - transparent 55% - ), - radial-gradient( - circle at 12% 25%, - rgb(229 212 186 / 0.55), - transparent 45% - ), - var(--leaf-pattern); - background-size: auto, auto, 260px 260px; - background-repeat: no-repeat, no-repeat, repeat; - background-attachment: fixed; - color: rgb(var(--ink-900)); - font-family: var(--font-body), serif; - position: relative; - isolation: isolate; -} - -body::before { - content: ""; - position: fixed; - inset: 0; - background-image: var(--paper-noise); - opacity: 0.22; - pointer-events: none; - z-index: 0; -} - -body::after { - content: ""; - position: fixed; - inset: 12px; - border-radius: 26px; - border: 6px solid rgb(var(--wood-500) / 0.35); - box-shadow: - inset 0 0 0 2px rgb(255 255 255 / 0.35), - 0 22px 45px -30px rgba(84, 56, 36, 0.65); - pointer-events: none; - z-index: 1; } -body > * { - position: relative; - z-index: 2; +body { + background: + radial-gradient(circle at top right, rgb(var(--page-accent) / 0.28), transparent 35%), + linear-gradient(180deg, rgb(var(--page-bg-top)), rgb(var(--page-bg-bottom))); + color: rgb(var(--ink-900)); + font-family: var(--font-body), sans-serif; + line-height: 1.5; } a { color: inherit; + text-decoration: none; } h1, h2, h3, h4 { - font-family: var(--font-display), serif; + font-family: var(--font-display), sans-serif; color: rgb(var(--ink-900)); - letter-spacing: -0.01em; + letter-spacing: -0.015em; +} + +:focus-visible { + outline: 2px solid rgb(var(--forest-600)); + outline-offset: 2px; } .paper-card { - background: - linear-gradient( - 165deg, - rgb(var(--paper-50)), - rgb(var(--paper-100)) - ), - var(--leaf-pattern); - background-size: auto, 220px 220px; - background-repeat: no-repeat, repeat; - border-radius: 26px; - border: 2px solid rgb(var(--wood-400) / 0.55); + background: rgb(var(--paper-50) / 0.96); + border: 1px solid rgb(var(--paper-200)); + border-radius: 16px; box-shadow: - 0 18px 40px -28px rgba(60, 40, 22, 0.65), - 0 8px 16px -10px rgba(60, 40, 22, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.75); + 0 1px 2px rgb(var(--shadow-ink) / 0.2), + 0 12px 24px -20px rgb(var(--shadow-ink) / 0.45); } .paper-stack { - background: linear-gradient( - 160deg, - rgb(var(--paper-100)), - rgb(var(--paper-200)) - ); - border-radius: 32px; - border: 2px solid rgb(var(--wood-400) / 0.4); + background: rgb(var(--paper-100)); + border: 1px solid rgb(var(--paper-200)); + border-radius: 18px; box-shadow: - 0 28px 60px -40px rgba(60, 40, 22, 0.55), - 0 10px 22px -16px rgba(60, 40, 22, 0.3); + inset 0 1px 0 rgb(255 255 255 / 0.08), + 0 16px 30px -24px rgb(var(--shadow-ink) / 0.6); } -.paper-button { +.paper-button, +.paper-button--ghost, +.paper-button--danger { display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; - background: linear-gradient( - 180deg, - rgb(var(--forest-500)), - rgb(var(--forest-700)) - ); - color: rgb(var(--paper-50)); - border-radius: 999px; - padding: 0.7rem 1.6rem; + border-radius: 10px; + padding: 0.625rem 0.95rem; + font-size: 0.875rem; font-weight: 600; - letter-spacing: 0.01em; - box-shadow: - 0 12px 24px -18px rgba(40, 62, 38, 0.75), - inset 0 1px 0 rgba(255, 255, 255, 0.25); - transition: transform 0.2s ease, box-shadow 0.2s ease, - background 0.2s ease; + line-height: 1; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, + box-shadow 0.18s ease, transform 0.12s ease; +} + +.paper-button { + border: 1px solid rgb(var(--forest-700)); + background: rgb(var(--forest-600)); + color: rgb(255 255 255); + box-shadow: 0 10px 22px -16px rgb(18 105 74 / 0.6); } .paper-button::before { - content: ""; - width: 16px; - height: 16px; - background-image: var(--leaf-icon); - background-size: contain; - background-repeat: no-repeat; - display: inline-block; + content: none; } .paper-button:hover { - background: linear-gradient( - 180deg, - rgb(var(--forest-500)), - rgb(var(--forest-600)) - ); - transform: translateY(-1px); + background: rgb(var(--forest-700)); +} + +.paper-button:active { + transform: translateY(1px); +} + +.paper-button:disabled { + cursor: not-allowed; + opacity: 0.65; } .paper-button--ghost { + border: 1px solid rgb(var(--paper-300)); background: rgb(var(--paper-50)); color: rgb(var(--ink-700)); - border-radius: 999px; - padding: 0.55rem 1.3rem; - border: 2px solid rgb(var(--wood-400) / 0.5); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.75), - 0 8px 16px -12px rgba(60, 40, 22, 0.35); - transition: background 0.2s ease, color 0.2s ease; } .paper-button--ghost:hover { - background: rgb(var(--paper-100)); - color: rgb(var(--ink-900)); + border-color: rgb(var(--forest-500) / 0.5); + color: rgb(var(--forest-700)); +} + +.paper-button--danger { + border: 1px solid rgb(220 38 38 / 0.45); + background: rgb(254 242 242); + color: rgb(153 27 27); +} + +.paper-button--danger:hover { + background: rgb(254 226 226); } .ink-label { text-transform: uppercase; - letter-spacing: 0.24em; - font-size: 0.65rem; + letter-spacing: 0.16em; + font-size: 0.68rem; + font-weight: 600; color: rgb(var(--ink-500)); } .ink-divider { height: 1px; - background: linear-gradient( - 90deg, - rgb(var(--ink-900) / 0), - rgb(var(--wood-600) / 0.35), - rgb(var(--ink-900) / 0) - ); + background: rgb(var(--paper-200)); } .wood-nav { - background: - linear-gradient( - 180deg, - rgb(var(--wood-500)), - rgb(var(--wood-700)) - ); - border-radius: 999px; - border: 2px solid rgb(var(--wood-800) / 0.6); + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-50) / 0.9); + border-radius: 14px; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.25), - 0 14px 24px -18px rgba(60, 40, 22, 0.6); - padding: 0.65rem 1.25rem; + 0 1px 2px rgb(var(--shadow-ink) / 0.24), + 0 10px 24px -22px rgb(var(--shadow-ink) / 0.6); } .wood-nav a, .wood-nav button { - color: rgb(var(--paper-50)); + color: inherit; } .wood-nav .paper-button--ghost { - background: rgb(var(--paper-50) / 0.16); - color: rgb(var(--paper-50)); - border-color: rgb(255 255 255 / 0.25); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); + background: transparent; } .wood-nav .paper-button { - background: linear-gradient( - 180deg, - rgb(var(--leaf-500)), - rgb(var(--leaf-700)) - ); + color: rgb(255 255 255); +} + +.wood-nav .paper-button:hover, +.wood-nav .paper-button:focus-visible { + color: rgb(255 255 255); +} + +.nav-link { + border: 1px solid transparent; + border-radius: 10px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 600; + color: rgb(var(--ink-700)); + transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; +} + +.nav-link:hover { + border-color: rgb(var(--paper-300)); + background: rgb(var(--paper-50) / 0.85); +} + +.nav-link--active { + border-color: rgb(var(--forest-500) / 0.5); + background: rgb(var(--sage-200) / 0.7); + color: rgb(var(--forest-700)); } .tool-card { - background: - linear-gradient( - 160deg, - rgb(var(--paper-50)), - rgb(var(--paper-100)) - ), - var(--leaf-pattern); - border: 2px solid rgb(var(--wood-400) / 0.65); - box-shadow: - 0 16px 28px -22px rgba(60, 40, 22, 0.6), - inset 0 1px 0 rgba(255, 255, 255, 0.7); + border-color: rgb(var(--paper-200)); +} + +.tool-card:hover { + border-color: rgb(var(--forest-500) / 0.5); +} + +.tool-group-label { + margin-top: 0.9rem; + margin-bottom: 0.35rem; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgb(var(--ink-500)); +} + +.task-step { + border-radius: 10px; + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-50) / 0.95); + padding: 0.75rem; +} + +.task-step-index { + display: inline-flex; + height: 1.5rem; + width: 1.5rem; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-100)); + font-size: 0.72rem; + font-weight: 700; + color: rgb(var(--forest-700)); +} + +.surface-muted { + border: 1px solid rgb(var(--paper-200)); + background: rgb(var(--paper-100)); + border-radius: 12px; +} + +.status-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid rgb(var(--paper-300)); + padding: 0.35rem 0.7rem; + font-size: 0.72rem; + font-weight: 600; + color: rgb(var(--ink-700)); +} + +.status-pill--success { + border-color: rgb(var(--forest-500) / 0.4); + background: rgb(var(--sage-200)); + color: rgb(var(--forest-700)); +} + +.status-pill--warning { + border-color: rgb(202 138 4 / 0.35); + background: rgb(var(--gold-200) / 0.35); + color: rgb(133 77 14); +} + +.status-pill--error { + border-color: rgb(220 38 38 / 0.35); + background: rgb(var(--rose-100)); + color: rgb(153 27 27); +} + +.alert { + border-radius: 12px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-100)); + padding: 0.75rem 0.9rem; + font-size: 0.82rem; + color: rgb(var(--ink-700)); +} + +.alert--success { + border-color: rgb(var(--forest-500) / 0.4); + background: rgb(var(--sage-200)); + color: rgb(var(--forest-700)); +} + +.alert--error { + border-color: rgb(220 38 38 / 0.35); + background: rgb(var(--rose-100)); + color: rgb(153 27 27); +} + +.field-label { + display: block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgb(var(--ink-500)); +} + +.field-input { + margin-top: 0.45rem; + width: 100%; + border-radius: 10px; + border: 1px solid rgb(var(--paper-300)); + background: rgb(var(--paper-50)); + padding: 0.67rem 0.75rem; + font-size: 0.875rem; + color: rgb(var(--ink-900)); +} + +.field-input::placeholder { + color: rgb(var(--ink-500)); +} + +.field-input:focus { + border-color: rgb(var(--forest-500)); + box-shadow: 0 0 0 3px rgb(var(--forest-500) / 0.15); + outline: none; +} + +.field-helper { + margin-top: 0.35rem; + font-size: 0.74rem; + color: rgb(var(--ink-500)); +} + +.theme-toggle { + width: 2.25rem; + height: 2.25rem; + min-width: 2.25rem; + border-radius: 999px; + padding: 0; +} + +.theme-toggle svg { + width: 1.1rem; + height: 1.1rem; +} + +:root[data-theme="dark"] body { + background: linear-gradient(180deg, rgb(var(--page-bg-top)), rgb(var(--page-bg-bottom))); +} + +:root[data-theme="dark"] .paper-button { + border-color: rgb(var(--forest-500) / 0.55); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); + box-shadow: none; +} + +:root[data-theme="dark"] .paper-button:hover, +:root[data-theme="dark"] .paper-button:focus-visible { + border-color: rgb(var(--forest-500) / 0.75); + background: rgb(var(--paper-50)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .wood-nav .paper-button, +:root[data-theme="dark"] .wood-nav .paper-button:hover, +:root[data-theme="dark"] .wood-nav .paper-button:focus-visible { + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .nav-link--active { + border-color: rgb(var(--forest-500) / 0.55); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .status-pill--success { + border-color: rgb(var(--forest-500) / 0.45); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .alert--success { + border-color: rgb(var(--forest-500) / 0.45); + background: rgb(var(--paper-100)); + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .paper-button--ghost:hover { + color: rgb(var(--forest-500)); +} + +:root[data-theme="dark"] .status-pill--warning { + border-color: rgb(251 191 36 / 0.45); + color: rgb(253 230 138); +} + +:root[data-theme="dark"] .status-pill--error { + border-color: rgb(248 113 113 / 0.45); + color: rgb(254 202 202); +} + +:root[data-theme="dark"] .alert--error { + border-color: rgb(248 113 113 / 0.45); + color: rgb(254 202 202); +} + +:root[data-theme="dark"] .paper-button--danger { + color: rgb(254 202 202); } .fade-up { - animation: fade-up 0.75s ease both; + animation: fade-up 0.55s ease both; } @keyframes fade-up { from { opacity: 0; - transform: translateY(16px); + transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + +@media (max-width: 640px) { + .paper-card, + .paper-stack { + border-radius: 14px; + } + + .paper-button, + .paper-button--ghost, + .paper-button--danger { + width: 100%; + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index abb8417..4044f22 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,16 +1,18 @@ import type { Metadata } from "next"; -import { Playfair_Display, Source_Serif_4 } from "next/font/google"; +import { Manrope, Public_Sans } from "next/font/google"; + +import DonateBookmark from "@/components/DonateBookmark"; import Providers from "./providers"; import "./globals.css"; -const displayFont = Playfair_Display({ +const displayFont = Manrope({ variable: "--font-display", subsets: ["latin"], - weight: ["600", "700"], + weight: ["600", "700", "800"], }); -const bodyFont = Source_Serif_4({ +const bodyFont = Public_Sans({ variable: "--font-body", subsets: ["latin"], weight: ["400", "500", "600"], @@ -19,18 +21,44 @@ const bodyFont = Source_Serif_4({ export const metadata: Metadata = { title: "ZenPDF", description: - "ZenPDF is an open-source PDF workbench with clear usage limits and a tactile dossier-style interface.", + "ZenPDF is an open-source PDF workbench with clear usage limits and a clean, readable interface.", }; +const themeInitScript = ` +(() => { + try { + const key = "zenpdf-theme"; + const saved = window.localStorage.getItem(key); + const theme = saved === "light" || saved === "dark" + ? saved + : "light"; + document.documentElement.dataset.theme = theme; + } catch (_) { + document.documentElement.dataset.theme = "light"; + } +})(); +`; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - - - {children} + + + +