diff --git a/app/globals.css b/app/globals.css index 6cd4cbb..b3a0f6e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,84 +1,172 @@ @import "tailwindcss"; /* ───────────────────────────────────────────────────────────────────────── - * Theme — Food Co-op Initiative palette + * Theme — "Field Almanac" * - * "leaf" is the primary green. Inspired by the warm, grassroots-organic - * feel of fci.coop: not corporate teal, not jewel-toned. A balanced - * field-green that sits comfortably alongside warm cream/stone neutrals. + * Inspiration: 1970s ecological-movement print, agricultural cooperative + * publications, hand-bound field journals. Warm cream paper, deep + * botanical green ink, sparing terracotta accent for emphasis. * - * "stone" is the neutral ramp — slightly warm, never icy. - * - * Both are built as Tailwind 4 color tokens, so utility classes like - * `bg-leaf-700`, `text-stone-600` work everywhere. + * The palette is built in OKLCH for perceptual uniformity. Every neutral + * is tinted toward the leaf hue (h≈130) so cream and green feel like they + * came from the same press run. * ─────────────────────────────────────────────────────────────────────── */ @theme { - --font-sans: var(--font-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-serif: var(--font-serif), ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + /* Typography */ + --font-display: var(--font-display), ui-serif, Georgia, "Times New Roman", serif; + --font-body: var(--font-body), ui-sans-serif, system-ui, -apple-system, sans-serif; - --color-leaf-50: #f3f8f0; - --color-leaf-100: #e3eed9; - --color-leaf-200: #c8dcb3; - --color-leaf-300: #a3c280; - --color-leaf-400: #7ea455; - --color-leaf-500: #5e8638; - --color-leaf-600: #4a6c2a; - --color-leaf-700: #3a5520; - --color-leaf-800: #2c401a; - --color-leaf-900: #1f2d12; + /* Custom utility names */ + --color-paper: oklch(97.5% 0.012 95); /* warm cream */ + --color-paper-2: oklch(95.5% 0.018 90); /* deeper cream — section bands */ + --color-ink: oklch(20% 0.02 130); /* near-black, slight green tint */ + --color-ink-soft: oklch(35% 0.018 130); + --color-ink-mute: oklch(55% 0.014 120); + --color-rule: oklch(82% 0.025 100); /* hairline rules — like ink on cream */ + --color-rule-soft: oklch(89% 0.02 100); - --color-stone-50: #fafaf7; - --color-stone-100: #f3f2ed; - --color-stone-200: #e7e4dc; - --color-stone-300: #d4cfc1; - --color-stone-400: #aaa494; - --color-stone-500: #807a6a; - --color-stone-600: #5b574b; - --color-stone-700: #403d35; - --color-stone-800: #2a2823; - --color-stone-900: #1a1815; + /* Botanical green — primary */ + --color-leaf-50: oklch(97% 0.025 135); + --color-leaf-100: oklch(93% 0.05 135); + --color-leaf-200: oklch(87% 0.085 135); + --color-leaf-300: oklch(78% 0.115 135); + --color-leaf-400: oklch(68% 0.13 135); + --color-leaf-500: oklch(57% 0.135 135); + --color-leaf-600: oklch(47% 0.13 135); + --color-leaf-700: oklch(38% 0.115 135); + --color-leaf-800: oklch(30% 0.09 135); + --color-leaf-900: oklch(22% 0.06 135); + + /* Terracotta — accent. Used sparingly: current-stage marker, key CTAs. */ + --color-clay-100: oklch(94% 0.04 50); + --color-clay-200: oklch(86% 0.075 50); + --color-clay-400: oklch(70% 0.13 45); + --color-clay-500: oklch(63% 0.15 42); + --color-clay-600: oklch(54% 0.155 40); + --color-clay-700: oklch(45% 0.135 38); + + /* Stone — kept as a familiar alias mapping to our warm neutrals so any + * earlier `text-stone-*` / `border-stone-*` references stay readable. */ + --color-stone-50: var(--color-paper); + --color-stone-100: var(--color-paper-2); + --color-stone-200: var(--color-rule-soft); + --color-stone-300: var(--color-rule); + --color-stone-400: oklch(70% 0.018 110); + --color-stone-500: var(--color-ink-mute); + --color-stone-600: oklch(48% 0.015 120); + --color-stone-700: var(--color-ink-soft); + --color-stone-800: oklch(28% 0.018 130); + --color-stone-900: var(--color-ink); } -/* Base / reset */ +/* Base — paper texture and ink defaults */ :root { color-scheme: light; - --background: var(--color-stone-50); - --foreground: var(--color-stone-900); } html { -webkit-text-size-adjust: 100%; + font-feature-settings: "kern", "liga", "calt", "ss01"; } body { - background: var(--background); - color: var(--foreground); + background-color: var(--color-paper); + color: var(--color-ink); + /* Subtle paper grain — two diagonal repeating linear gradients combined + * with a soft radial overlay. No external image. Survives dark mode if + * we ever add one. */ + background-image: + radial-gradient(ellipse 90% 60% at 50% 0%, oklch(99% 0.01 95 / 0.7), transparent 60%), + repeating-linear-gradient( + 105deg, + oklch(96% 0.02 95 / 0.4) 0, + oklch(96% 0.02 95 / 0.4) 1px, + transparent 1px, + transparent 6px + ), + repeating-linear-gradient( + 15deg, + oklch(94% 0.02 95 / 0.25) 0, + oklch(94% 0.02 95 / 0.25) 1px, + transparent 1px, + transparent 9px + ); } -/* Larger, more readable form controls on small screens */ -input, -select, -textarea, -button { +/* Typography defaults */ +.font-display { font-family: var(--font-display); font-feature-settings: "ss01", "cv01"; } +.font-body { font-family: var(--font-body); } + +/* Long-form headings get optical-size tuning for richer letterforms */ +h1.font-display, h2.font-display, h3.font-display { + font-variation-settings: "opsz" 144, "SOFT" 50; + letter-spacing: -0.015em; +} + +/* Forms: shared input rhythm */ +input, select, textarea, button { font: inherit; } -/* Ensure focus is always perceptible (in addition to Tailwind's ring) */ +/* A more deliberate focus ring — uses the leaf token */ *:focus-visible { - outline: 2px solid transparent; + outline: 2px solid var(--color-leaf-500); outline-offset: 2px; + border-radius: 4px; } -/* Make Chrome's date input chevron less garish */ +/* Date input chevron styled to match */ input[type="date"]::-webkit-calendar-picker-indicator { - opacity: 0.6; + opacity: 0.5; cursor: pointer; + filter: hue-rotate(70deg); } -/* Prevent layout shift when the sticky submit bar appears */ -@media (min-width: 640px) { - main { - padding-bottom: 5rem; +/* Print: lay it out like a real field-journal page when a user hits Cmd+P */ +@media print { + body { background: white; color: black; } + body { background-image: none; } + details { display: block !important; } + details > div[role="region"] { display: block !important; } + .no-print { display: none !important; } +} + +/* Reduced motion: defer to user preference */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } } + +/* A small cohort of utility classes — rule lines, rough underlines, etc. */ +.rule-soft { + background: linear-gradient( + 90deg, + transparent 0%, + var(--color-rule) 12%, + var(--color-rule) 88%, + transparent 100% + ); +} + +/* Ink-bloom focus — used on text inputs to feel handwritten */ +.input-ink { + background-image: linear-gradient( + to top, + var(--color-leaf-200) 0%, + var(--color-leaf-200) 1px, + transparent 1px, + transparent 100% + ); + background-size: 0% 100%; + background-repeat: no-repeat; + background-position: 0 100%; + transition: background-size 280ms cubic-bezier(0.4, 0, 0.2, 1); +} +.input-ink:focus { + background-size: 100% 100%; +} diff --git a/app/layout.tsx b/app/layout.tsx index f93fcca..f638c3e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,22 +1,36 @@ import type { Metadata } from "next"; -import { Inter, Source_Serif_4 } from "next/font/google"; +import { Fraunces, DM_Sans } from "next/font/google"; import "./globals.css"; -const inter = Inter({ - variable: "--font-sans", +/** + * Display face: Fraunces. A variable serif with strong optical interest — + * elegant in long descenders and bracketed serifs, reads like a field guide + * or almanac. We use the SOFT axis to round the inktraps slightly so it + * doesn't read as "old print" but as "warm modern editorial." + */ +const display = Fraunces({ + variable: "--font-display", subsets: ["latin"], + axes: ["SOFT", "opsz"], display: "swap", }); -const serif = Source_Serif_4({ - variable: "--font-serif", +/** + * Body face: DM Sans. A humanist geometric sans with a slightly soft feel — + * legible at small sizes for forms, doesn't read as cold/corporate the way + * Inter or Helvetica does. + */ +const body = DM_Sans({ + variable: "--font-body", subsets: ["latin"], display: "swap", }); export const metadata: Metadata = { title: "Co-op Check-in · Food Co-op Initiative", - description: "Update your co-op's progress through the FCI organizing framework.", + description: + "Update your co-op's progress through the FCI organizing framework. A monthly check-in for food co-ops in development.", + robots: { index: false, follow: false }, // Form pages are tokenized; not for crawlers. }; export default function RootLayout({ @@ -27,9 +41,11 @@ export default function RootLayout({ return ( - {children} + + {children} + ); } diff --git a/app/page.tsx b/app/page.tsx index aedf619..389b29c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,24 +12,16 @@ export default async function Page({ searchParams }: PageProps) { return ( <> + + Skip to content + -
- - Skip to content - -
-
-

- {formConfig.title} -

- {formConfig.subtitle && ( -

{formConfig.subtitle}

- )} -
- +
+
+ {!cid || !cs ? ( ) : ( @@ -44,14 +36,30 @@ export default async function Page({ searchParams }: PageProps) { ); } +function PageIntro() { + return ( +
+

+ Monthly check-in · Co-op organizing +

+

+ {formConfig.title} +

+

+ {formConfig.subtitle} +

+
+
+ ); +} + function MissingLinkParams() { return ( -
-

This link is missing required information.

-

+

+

+ This link is missing required information. +

+

Open the form using the personalized link from your email. If you no longer have it, please contact your engagement coordinator and ask for a fresh link.

diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index 7ef5b6e..49396e8 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import type { FormConfig, FormDataPayload, SubmitPayload } from "@/types/form"; import { evaluate } from "@/lib/conditional"; import { StageSection } from "./StageSection"; +import { loadDraft, saveDraft, clearDraft } from "@/lib/draft"; interface EngagementFormProps { config: FormConfig; @@ -17,33 +18,38 @@ type LoadState = | { kind: "error"; message: string } | { kind: "ready"; data: FormDataPayload }; -/** - * Top-level form orchestrator. Responsible for: - * - fetching prefill data + the org's current stage from /api/data - * - hydrating react-hook-form with prefill values - * - re-evaluating section visibility against live form values on every change - * - submitting to /api/submit and surfacing success / error feedback - */ +type SubmitStatus = + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "success" } + | { kind: "error"; message: string }; + +const STAGE_RANK: Record = { + Inquiry: 0, + Organizing: 1, + Feasibility: 2, + "Business feasibility": 3, + "Store Implementation": 4, + "Stabilize newly opened co-op": 5, +}; + export function EngagementForm({ config, cid, cs }: EngagementFormProps) { const [load, setLoad] = useState({ kind: "loading" }); - const [submitState, setSubmitState] = useState< - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "success" } - | { kind: "error"; message: string } - >({ kind: "idle" }); + const [submitState, setSubmitState] = useState({ kind: "idle" }); + const [draftSavedAt, setDraftSavedAt] = useState(null); + const [draftRestored, setDraftRestored] = useState(false); const { register, handleSubmit, reset, watch, - formState: { errors }, + formState: { errors, isDirty }, } = useForm({ mode: "onBlur" }); - // Subscribe to all form values so section visibility re-evaluates live. const formValues = watch(); + // ── Initial load ─────────────────────────────────────────────────────── useEffect(() => { let cancelled = false; async function go() { @@ -54,18 +60,31 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { const res = await fetch(url.toString(), { cache: "no-store" }); if (!res.ok) { const text = await res.text(); - if (!cancelled) { - setLoad({ kind: "error", message: `Could not load form (${res.status}). ${text}` }); - } + let message = `Could not load form (HTTP ${res.status}).`; + try { + const j = JSON.parse(text) as { error?: string }; + if (j.error) message = j.error; + } catch { /* keep default */ } + if (!cancelled) setLoad({ kind: "error", message }); return; } const data: FormDataPayload = await res.json(); if (cancelled) return; - // Merge stage value + per-field prefill into a single defaults map. - const defaults = { + + // Build defaults: server prefill + current stage. Then check if a + // local draft exists newer than the server data; if so, layer it on top. + const defaults: Record = { [config.stageField]: data.currentStage, ...data.prefill, }; + const draft = loadDraft(cid); + if (draft && Object.keys(draft.values).length > 0) { + Object.assign(defaults, draft.values); + // Re-set the stage from server (draft can never override the org's actual stage). + defaults[config.stageField] = data.currentStage; + setDraftRestored(true); + setDraftSavedAt(draft.savedAt); + } reset(defaults); setLoad({ kind: "ready", data }); } catch (e) { @@ -83,40 +102,52 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { }; }, [cid, cs, reset, config.stageField]); - const sectionsToRender = useMemo(() => { - return config.sections.map((s) => ({ - section: s, - sectionVisible: evaluate(s.visibleWhen, formValues), - })); - }, [config.sections, formValues]); + // ── Auto-save draft on idle ──────────────────────────────────────────── + // Debounce — save 1.5s after the user stops editing. + const saveTimer = useRef | null>(null); + useEffect(() => { + if (load.kind !== "ready") return; + if (!isDirty) return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + saveDraft(cid, formValues); + setDraftSavedAt(new Date().toISOString()); + }, 1500); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [formValues, isDirty, load.kind, cid]); - // The "current" rank: highest section rank whose visibility rule matches - // the current stage value. If nothing matches, fall back to 0 (Inquiry). - const currentRank = useMemo(() => { - const visibleRanks = sectionsToRender - .filter((s) => s.sectionVisible) - .map((s) => s.section.rank); - return visibleRanks.length > 0 ? Math.max(...visibleRanks) : 0; - }, [sectionsToRender]); + // ── Section ordering ─────────────────────────────────────────────────── + const sectionsToRender = useMemo( + () => + config.sections.map((s) => ({ + section: s, + sectionVisible: evaluate(s.visibleWhen, formValues), + })), + [config.sections, formValues], + ); - if (load.kind === "loading") { - return ; - } - if (load.kind === "error") { - return ; - } + const currentStageValue = formValues[config.stageField] as string | undefined; + const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; + + // ── Render ───────────────────────────────────────────────────────────── + if (load.kind === "loading") return ; + if (load.kind === "error") return ; const onSubmit = async (values: Record) => { setSubmitState({ kind: "submitting" }); try { - // Strip values for fields whose section or field rule isn't currently visible — - // those aren't part of the user's intent in this submission. + // Strip values for hidden fields — never write data the user couldn't see. const visibleFieldNames = new Set(); for (const s of config.sections) { if (!evaluate(s.visibleWhen, values)) continue; for (const f of s.fields) { - if (evaluate(f.visibleWhen, values)) { - visibleFieldNames.add(f.name); + if (evaluate(f.visibleWhen, values)) visibleFieldNames.add(f.name); + } + for (const m of s.matrixGroups ?? []) { + for (const row of m.rows) { + for (const fname of row.fields) visibleFieldNames.add(fname); } } } @@ -132,9 +163,17 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { }); if (!res.ok) { const text = await res.text(); - setSubmitState({ kind: "error", message: `Submit failed (${res.status}). ${text}` }); + let message = `Submit failed (HTTP ${res.status}).`; + try { + const j = JSON.parse(text) as { error?: string }; + if (j.error) message = j.error; + } catch { /* keep default */ } + setSubmitState({ kind: "error", message }); return; } + // Successful submit — clear draft and show confirmation. + clearDraft(cid); + setDraftSavedAt(null); setSubmitState({ kind: "success" }); } catch (e) { setSubmitState({ @@ -149,38 +188,43 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { - {sectionsToRender.map(({ section, sectionVisible }) => { - if (!sectionVisible) return null; - return ( - - ); - })} + {draftRestored && draftSavedAt && ( + { + clearDraft(cid); + setDraftRestored(false); + setDraftSavedAt(null); + // Force re-fetch by bumping a key — simplest is full page reload. + window.location.reload(); + }} /> + )} -
- - -
+
    + {sectionsToRender.map(({ section, sectionVisible }) => { + if (!sectionVisible) return null; + return ( +
  1. + +
  2. + ); + })} +
+ + ); } @@ -188,62 +232,167 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { function SubmissionContextHeader({ orgName, currentStage, + currentRank, }: { orgName: string; currentStage: string; + currentRank: number; }) { return ( -
-

Check-in for

-

{orgName}

-

- Framework Stage:{" "} - - {currentStage || "—"} +

+

Check-in for

+

+ {orgName} +

+
+ + + Stage{" "} + {currentStage || "—"} -

+
+
+ ); +} + +/** + * Six small dots representing the six stages, with the current rank filled + * and earlier ranks marked as visited. Quietly conveys progression without + * pretending to be a "100% complete" progress bar. + */ +function StageProgress({ currentRank }: { currentRank: number }) { + return ( +
+ {[0, 1, 2, 3, 4, 5].map((r) => ( + + ))}
); } -function SubmitFeedback({ - state, +function DraftRestoredNotice({ + savedAt, + onDiscard, }: { - state: - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "success" } - | { kind: "error"; message: string }; + savedAt: string; + onDiscard: () => void; }) { - if (state.kind === "idle") { - return

Your changes will be saved as a new check-in record.

; - } - if (state.kind === "submitting") { + const ago = formatRelative(savedAt); + return ( +
+

+ Draft restored from {ago}. Your in-progress edits were + saved locally and have been re-applied. +

+ +
+ ); +} + +function SubmitBar({ + state, + isDirty, + draftSavedAt, +}: { + state: SubmitStatus; + isDirty: boolean; + draftSavedAt: string | null; +}) { + return ( +
+
+ + {state.kind === "idle" && ( +

+ {isDirty + ? draftSavedAt + ? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)` + : "Editing — your draft will save automatically" + : "Submitting saves a new check-in record on your co-op."} +

+ )} +
+ +
+ ); +} + +function SubmitFeedback({ state }: { state: SubmitStatus }) { + if (state.kind === "submitting") return ( -

+

Saving your check-in…

); - } - if (state.kind === "success") { + if (state.kind === "success") return (

✓ Check-in saved. Thank you.

); - } + if (state.kind === "error") + return ( +

+ ✕ {state.message} +

+ ); + return null; +} + +function Spinner() { return ( -

- ✕ {state.message} -

+ + + ); } function LoadingState() { return ( -
-
-

Loading your check-in form…

+
+
+

Loading your check-in…

); } @@ -252,14 +401,31 @@ function ErrorState({ message }: { message: string }) { return (
-

We couldn't load your check-in form.

-

{message}

-

- If this problem persists, please contact your engagement coordinator and ask for a fresh +

+ We couldn't open your check-in. +

+

{message}

+

+ If this keeps happening, please contact your engagement coordinator and ask for a fresh link.

); } + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const seconds = Math.round((Date.now() - then) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 30) return `${days} days ago`; + if (days < 365) return `${Math.round(days / 30)} months ago`; + return `${Math.round(days / 365)} years ago`; +} diff --git a/components/SiteChrome.tsx b/components/SiteChrome.tsx index cb2b9a6..d8ce418 100644 --- a/components/SiteChrome.tsx +++ b/components/SiteChrome.tsx @@ -2,22 +2,27 @@ import Link from "next/link"; export function SiteHeader() { return ( -
-
+
+
- - Food Co-op Initiative - Co-op Check-in - +
+ + Food Co-op Initiative + + + Co-op Check-in + +