Bar's secondary line is now a horizontal cluster of the same six
pills used in the page header, followed by the viewed stage label.
Active pill expands and gains a slow halo (rail-pulse keyframe) when
in the bar; same component runs without the pulse in the static
header. Width transitions smoothly between ranks as the user scrolls,
so the indicator visibly tracks progress through the form.
StageProgress now takes an optional `pulse` prop and tightens its
transition timing for nicer scroll-driven animation.
Repurpose the previously-empty left side of the floating submit bar.
Top line is the org name; secondary line updates as the user scrolls
so the currently viewed stage is always visible even after the
top-of-form header has scrolled out of sight.
IntersectionObserver with a top-biased rootMargin tracks which
section is in view; topmost intersecting section wins ties.
Submit-state feedback (error / in-flight) still takes priority over
the viewing/draft text when active.
Two additions, both touching the form-config story:
1. scripts/sync-help-from-civi.mjs
Diffs per-field help text in config/form.ts against CustomField rows
in CiviCRM and (with --write) updates the file in place. Reads env
from .env.local via Node's --env-file flag. Run as `npm run sync-help`
or `npm run sync-help -- --write`. A --debug mode prints the parser's
field list without calling Civi.
Rationale: this form is low-traffic and help text doesn't change
often once in production. A manual one-off sync is leaner than
coupling every page load (or every build) to a Civi API call.
2. fieldGroups: visual clustering of related fields within a section
New optional FieldGroupConfig overlay on StageSectionConfig — pure
presentation, names existing fields by name so submit/visibility
logic walks them unchanged. StageSection.tsx pulls grouped fields
out of the standalone per-field grid and renders each group as its
own bordered card with an optional heading. Stage 2 now clusters
Market Study, Pro Forma, Business Plan, and Board Self Assessment
(each a date + upload pair) into their own cards.
Two package-lock.json files exist (parent civi-webform/ + this app);
Next 16 silently picked the outer one, so Turbopack watched the parent
node_modules/, .claude-flow/, .swarm/, ruvector.db. Background writes
in those trees triggered a recompile loop that thrashed .next/dev and
leaked memory until the dev server crashed. Setting turbopack.root
keeps the watcher scoped to WebForm-mw/.
- Remap globals.css tokens to FCI brand palette (Eggplant #801d7f,
Spring Pea #96bc33, Seed Grant #679038, Squash #c9ad2d, FCI gray
#4b5657). Existing leaf-* / clay-* class names preserved.
- Switch body font to Open Sans (FCI's free fallback for Museo Sans).
Headings keep Fraunces.
- Add contact identity (first name, last name, email) as readonly
fields at the top of Stage 0. /api/data fetches via APIv4
Contact.get with email_primary.email join; values flow through
FormDataPayload.contact and into the form's evalState so the
readonly renderer displays them. Draft restore re-applies them so
a stale local draft can't override.
- amplify.yml: fetch Amplify Secrets from SSM Parameter Store when
they don't arrive as build-shell env vars (the common failure mode
behind "Refusing to run in production without CIVI_*"). Adds a
length-only diagnostic echo and a hard-fail guard so a missing
required var stops the build with a clear message instead of
bundling empty strings and crashing the SSR Lambda at runtime.
Amplify Gen 2 exposes Environment Variables and Secrets in the build shell
but does not inject them into the SSR Lambda runtime. Writing them to
.env.production during preBuild lets Next.js bundle them into the server
output so process.env reads work at request time.
Amplify Gen 2's console has two separate pages for runtime config:
Environment variables (plaintext) and Secrets (SSM Parameter Store
SecureString). The earlier 'mark as Secret with eye icon' wording was
Gen 1; in Gen 2 you choose by which page you add the value on.
Step 2 rewritten:
- Brief explanation of both pages and how they're injected (both end
up as plain env vars in the app, same name).
- Combined variable table with a Page column showing where each value
lives.
- Rule-of-thumb: anything that would let someone impersonate the app
to CiviCRM or bypass a gate is a Secret; hostnames/usernames are
fine in Environment variables.
- Callout reminding not to duplicate names across both pages
(precedence undefined in Gen 2).
@tailwindcss/postcss lives in devDependencies (along with the rest of
the PostCSS toolchain). When NODE_ENV=production is set in the Amplify
build environment, npm ci skips devDependencies — and next build then
fails resolving @tailwindcss/postcss while compiling globals.css.
amplify.yml now passes --include=dev to npm ci so the build always
installs everything regardless of NODE_ENV. AMPLIFY_DEPLOY.md updated
to warn against setting NODE_ENV=production in the Amplify env vars
panel — it's redundant (Next sets it correctly) and an easy footgun.
- amplify.yml: build spec (preBuild npm ci with offline cache, build
next build, artifacts at .next/**, cache node_modules/.next-cache/.npm).
- .nvmrc: pin Node 20 so Amplify uses the same runtime as local.
- AMPLIFY_DEPLOY.md: first-time walkthrough covering AWS-side setup
(create app, connect GitHub via OAuth/App, branch/auto-detect),
environment variable table with secret-flag guidance, smoke-test via
/healthz and /api/health, optional custom domain + per-PR previews,
cost estimate, and operational notes (cold starts, no static
egress IPs, CloudWatch logs, secret rotation).
- README deploy section: now points at both AMPLIFY_DEPLOY.md and the
existing DEPLOYMENT.md (Render).
- Actual member line now uses the same carry-forward step pattern as
the goal line — between measurements the chart holds the prior value
instead of interpolating diagonally, and the final value extends flat
to the right edge. Eliminates the apparent dips that arose when
diagonal interpolation crossed missing periods or low intermediate
values.
- Per-field Sparkline (and its isNumericField / formatScalarText
helpers) removed entirely. Expanding 'earlier entries' now just shows
the chronological list. Curated multi-metric charts (like the
Membership chart) are the path forward for trend visualization.
- Membership chart relocated from the top-of-report band into the
Stage 0 ('Check-in (organizing)') section, rendered inline after
whichever of Members__current_ / Member_Goal_for_current_Stage
appears last in the section's fields-with-history list. Naturally
scopes the chart to wherever those questions live (no double-render
if config later moves them). Chart props refactored to take field
+ history pairs directly instead of walking the full sections array.
New MembershipChart card sits between the DateTimeline and the section
accordions, rendering whenever Members__current_ or
Member_Goal_for_current_Stage has any historical data.
- Actual member count: smooth leaf-700 polyline with a faint leaf-500
area fill underneath, dots at every measurement, an emphasized dot
on the most recent point with the value labeled inline.
- Goal: dashed clay-600 step line — each goal value is treated as a
target that holds until the next update, then extends flat to the
right edge of the chart. Dots at each update; the most-recent goal
value labeled at the right.
- Y-axis: niceYTicks picks 3–5 round-number ticks (snapped to
1/2/2.5/5/10 × 10^N) spanning [min(0, dataMin), dataMax]; faint
gridlines + tabular-num labels on the left. Anchoring at 0 keeps
growth-from-small-base readable.
- X-axis: reuses generateAxisTicks for adaptive month/year stepping,
matching the timeline above. Today gets a dashed clay vertical
guide when in range.
- Header: title + subtitle + an inline 'NNN of MMM target · X to go'
callout in tabular-nums, color-coded (clay-700 if behind goal,
leaf-700 if above).
- Legend at the bottom with line+dot chips for both series.
Three improvements to the report's DateTimeline:
1. Tooltips on every dot. Each dot is now a focusable span (tabIndex,
role=img, full aria-label). A small ink-tinted card appears above
the dot on mouse hover or keyboard focus, showing field label,
formatted date, stage rank, and an 'Opened' marker for Date_Opened.
Anchor flips to left/center/right based on the dot's position so
tooltips don't overflow the row at the edges.
2. Plot every date field, including stage 0. The previous version
skipped Stage 0 dates (Internal_Startup_Assessment_Date,
Date_Closed_Folded). Now there are six swim lanes (0-5) instead
of five. Stage 0 gets bg-leaf-200 so the gradient extends one
step lighter.
3. Adaptive month/year x-axis under the lanes. generateAxisTicks
picks a 'nice' interval based on the visible span: 1mo / 2mo /
3mo / 6mo / 1yr / 2yr. January-bordered ticks include the year
so the reader has anchors. Today gets its own labeled clay tick
when it falls in range.
Two visual additions to the read-only activity report.
Sparkline: when the user expands earlier-entries on a numeric field
(number/currency/percent) with two or more numeric points, the
expansion now leads with a 240x56 inline SVG trend chart — chronological
polyline, faint area fill, small dots at every measurement, a slightly
larger emphasized dot on the most recent point. Min and max captions
sit beneath in tabular-nums, formatted in the field's native style
(currency uses Intl, percent appends %, etc.). Non-numeric fields are
unchanged.
DateTimeline: a new card between the context header and the section
accordions. Walks every date-type field in stage sections 1-5 (Stage 0
omitted as it isn't a stage in the journey sense), pulls each field's
most-recent entered date, and lays the events out in five horizontal
swim lanes — one per stage rank, labeled at the left. Time axis
spans from the earliest event to max(latest event, Date_Opened).
Stage 5's Date_Opened is rendered as a larger clay-700 dot with a
heavier ring so it reads as the journey's anchor at the right end.
A faint clay-300 dashed vertical line marks 'today' if it falls
within the range. Color scale across stages is leaf-300 / leaf-500
/ leaf-600 / leaf-700 / clay-700 — a sprout-to-fruit gradient that
matches the existing palette. Empty stage rows still draw their lane
line at half opacity so the structure stays readable. SR-only event
list provides screen-reader access to all plotted dates with their
labels.
Stub payload enriched with four cross-stage date entries so the
timeline has content in dev preview.
CiviCRM APIv4 file custom fields return a bare file id by default; an
extra '.file_name' join is required to get the human-readable filename.
Both the form prefill walk (lib/prefill.ts) and the report walk
(app/api/report/route.ts) now request '<civiField>.file_name' for every
file-type field alongside the primary value, and wrap the prefill into
a { id, file_name } object so downstream UI has both. Falls back to
file_name undefined when the join returns null (eg orphaned id).
The form's FilePriorIndicator already accepts the object shape, so it
now shows the filename inline. The report's FormattedValue gets a
matching case: renders the file_name string if present, falls back to
'Attachment #<id>' when only the id came through.
The earlier 'Currently on file' indicator hung off readonlyValue, which
is sourced from evalState (only carries current_stage). For file fields
the prefill lives in RHF state, not in evalState, so the indicator
never fired.
Replaced with a FilePriorIndicator subcomponent that subscribes to the
field's RHF value via useWatch and renders a small leaf-50 banner with
a paperclip glyph when a previous attachment is present. Falls silent
the moment the user picks a new file (RHF value becomes a FileList).
Filename is derived from whatever shape Civi returned — bare string,
object with file_name/name/label/filename, or numeric file id (generic
message in that case).
Removed five default Next.js scaffold SVGs from public/ that were
created by create-next-app and never referenced (file.svg, globe.svg,
next.svg, vercel.svg, window.svg). The actual brand mark
public/fci-logo.png is the only image the app uses.
Removed axios from dependencies — the app uses native fetch
everywhere, and axios hasn't been imported since the initial
scaffolding pass. Lockfile regenerated; build verified clean.
Trimmed the in-the-weeds tech tour (file tree, lib names, custom-field
inventory tutorial) and replaced with a brief functional overview, the
visual/UX highlights that survived the polish passes, the accessibility
features, an env-var table, and the minimum to run/deploy. Outdated
material removed: org-side Framework Stage as authority, 'Org Engagement
Submission' activity type, stage_at_submission write, placeholder
custom_<name> civiField refs, Inter+SourceSerif typography, leaf+stone
palette. Documents stage-from-activity, /report, locked future stages,
draft auto-save, success destination, HTTP-Basic-Auth proxy env vars,
HEALTH_TOKEN, and PREVIEW_ADMIN_TOKEN.
Mirrors the form's IA and Field Almanac aesthetic; same auth (cid/cs
checksum) so the org owner who can fill the form can also view its
history.
New routes:
- GET /api/report — verifies checksum, resolves the org via the
Primary Contact relationship, fires Contact.get + Activity.get +
OptionValue.get in parallel. For every form field with a civiField,
walks all the org's Check-in (organizing) activities and collects
every non-empty value into a sorted-DESC history list. Returns
ReportPayload (orgName, currentStage, activities, fieldHistory,
options). Has stub-mode payload for env-less local dev.
- /report — page entry; same layout shell (SiteHeader + SiteFooter,
3xl page width). Eyebrow "Activity report - Co-op organizing".
ReportView component:
- ReportContextHeader: large org name, progress dots + uppercase
"Current stage" eyebrow + the Civi option *label* on its own line
at display-font xl/2xl leaf-800 (matches the form's header). Below
it a 3-up stat band: total check-ins, fields tracked, date span.
- One accordion card per stage section, in stage-rank order. Only
sections that have at least one field-with-entries render — past,
current, or "future-with-data" all welcome; truly empty stages stay
hidden so the page is calm.
- Same journey rail (md+) and mobile stem (md-) with past =
check-filled-leaf, current = filled-leaf-with-ring, future = dashed
hollow ring; solid leaf line vs dashed muted between markers.
- Within each card: divide-y rows. Field label and help on the left,
most-recent value on the right in display-font lg leaf-800, dated
beneath with an "{N} earlier entries" disclosure that expands a
small vertical timeline (date on left, value on right).
- FormattedValue handles currency (Intl), percent, number (tabular
nums), date (long, timezone-safe for YYYY-MM-DD), boolean (Yes/No),
select/readonly (resolved via option group), multiselect (handles
array or delimited string), file (filename), text-like (as-is).
- Loading / empty / error states match the form's treatments.
types/form.ts: new FieldHistoryEntry, ActivitySummary, ReportPayload.
The fieldHistory map keys by FieldConfig.name and only includes fields
that have at least one non-empty entry.
- New STAGE_OPTION_GROUP_ID constant (=75) added to config and consumed
by both the in-card readonly and the header. Because the field carries
optionGroupId, /api/data auto-includes the Stage option group in its
parallel fetch (via the existing optionGroupIds derivation).
- SubmissionContextHeader: dropped the small inline 'Stage Organizing'
line. Replaced with an uppercase 'Current stage' eyebrow next to the
progress dots, followed by a display-font 20/24px leaf-toned label
underneath. Resolves the stored option value ('Organizing') to its
Civi option label ('Stage 1 — Convene & Prepare') via the new
resolveStageLabel helper; falls back to the raw value if the option
group hasn't loaded.
- Stage 0 'Check-in (organizing)' section: re-added a current_stage
readonly field at the top. With optionGroupId set, FieldRenderer's
readonly branch resolves the value to the Civi label for display.
The current_stage readonly field is removed from Stage 0 — the activity-
derived stage value already displays in the SubmissionContextHeader, so
showing it again in-card was redundant. current_stage remains in form
state (RHF defaults from /api/data) because every Stage 1-5 visibleWhen
rule and the journey rail still key off it; it just isn't rendered.
Section labels for Stages 1-5 updated to match the CiviCRM Stage option
group labels exactly (ampersand instead of 'and'; Stage 2 corrected from
'Feasibility' to 'Grow & Plan'). Stage 0 keeps its 'Check-in (organizing)'
label since it represents the always-visible core fields, not the Inquiry
stage rank.
Also folded in: in-progress visibleWhen additions on several Stage 0
fields and commented-out field stubs from your working tree.
Stage authority moves from Organization.Food_Co_op_Organizing.Stage to the
most recent Check-in (organizing) activity whose Stage custom field is set.
Staff create these activities manually to mark transitions; org-owner form
submissions no longer write the Stage field at all, so they cannot override
a staff-set transition.
- /api/data: removed the Contact.get for org-side Stage; added an
Activity.get filtered to ACTIVITY_TYPE_NAME + ACTIVITY_STAGE_FIELD IS
NOT EMPTY, ordered by activity_date_time DESC, id DESC, limit 1.
Fallback when no such activity exists: Inquiry (rank 0). Org-name
lookup, stage activity, prefill, and option-group fetch all run in
parallel via Promise.all.
- /api/submit: removed the stageAtSubmission read + the
[ACTIVITY_STAGE_FIELD] write on the activity record. The form's
activities are stage-null by design now.
- config/form.ts: dropped the stage_at_submission readonly field (no
longer being set or displayed). Kept ACTIVITY_STAGE_FIELD export — it's
now used by /api/data to find stage-bearing activities. Updated the
current_stage field comment to reflect the new source.
- components/EngagementForm.tsx: dropped stage_at_submission from
evalState (no longer referenced by any visibility rule or readonly
display).
Org.Food_Co_op_Organizing.Stage remains in CiviCRM for staff list views;
the middleware no longer reads or writes it. No backfill required —
orgs without a stage-bearing activity simply read as Inquiry.
Drops the stylized SVG mark in favor of public/fci-logo.png (the official
Food Co-op Initiative logo, including its wordmark). Because the logo now
contains the 'Food Co-op Initiative' text, the duplicate display-font
wordmark is removed; the 'Co-op Check-in' eyebrow stays as a quiet hairline
divider to the right (sm+ only, hidden on mobile to keep the header tight).
Gutter shrinks from md:pl-20 (80px) to md:pl-12 (48px) — a 32px (40%) gain
back to the form column. Markers, pulse halo, and 'Now' pill scale down to
match so the rail still reads at a glance:
- Marker container: -left-14 w-12 -> -left-9 w-7
- Past/future markers: h-6 -> h-5
- Current marker: h-7 ring-4 -> h-6 ring-2; rank label 11px -> 10px
- 'Now' pill: 9px -> 8px; tracking and offset re-balanced for the shorter
drop from the smaller marker bottom
- Rail-pulse keyframe shadow radius: 8px -> 6px to suit the smaller disc
Future stages now render as preview-only "look ahead" cards instead of being
hidden. A user at stage 2 can see headers and contents for stages 3, 4, 5,
but those sections are visibly locked and uneditable.
Locked-card treatment:
- Dashed-rule border, paper-2 fill, no shadow — visually quieter than
active cards
- "Upcoming" pill in the header with a small lock glyph
- Muted stage rank mark (dashed badge, low-opacity icon)
- Panel content wrapped in fieldset[disabled] so every form control inside
is natively non-interactive, with an opacity tweak for affordance
- "A look ahead" banner explaining that fields will become editable when
the co-op reaches this stage
Section visibleWhen is still consulted on submit, so locked-stage values
never get written back to CiviCRM even if data is prefilled.
Journey rail:
- Vertical rail (md+) in a new left gutter; each card carries an aligned
marker. Past stages = filled leaf circle with check; current = filled
leaf disc with rank number, leaf-100 halo ring, and a subtle rail-pulse
box-shadow animation (motion-safe). A "Now" pill sits beneath the
current marker. Future stages = dashed hollow ring with lock glyph.
- Connector segments between markers are solid leaf when the next stage
is past-or-current, dashed muted when future — so the transition from
"traveled" to "ahead" reads at the right place in the journey.
- Mobile fallback: a small vertical stem in the gap between adjacent
cards, styled the same way (solid vs dashed) so the progression cue
still reads on narrow viewports.
- H3: Live currency preview below currency inputs shows the value formatted
with thousands separators (en-US, USD) using Intl.NumberFormat. Skipped
inside matrix cells to keep the Y1 monthly table compact.
- M1: Date inputs now apply min/max bounds. Default window is 1900-01-01 to
2100-12-31; per-field override via FieldConfig.min/max as ISO strings.
- H6: On successful submit, replace the form with a SuccessDestination card
(large checkmark, org name, "Submit another" + "safe to close" affordance).
Prevents accidental duplicate submits from back-button / autofill replay.
- M6: Sticky submit bar respects iOS safe-area-inset-bottom.
FieldRenderer now takes a control prop so the currency preview can subscribe
to its single field via useWatch without re-rendering the whole form.
- Initialize Next.js project with Tailwind CSS
- Create CiviCRM APIv4 integration layer
- Implement stage-based form visibility (stages 0-5)
- Add field mapping configuration for CRM-to-form linking
- Create API routes for data retrieval and submission
- Record form submissions as CiviCRM activities
- Support dynamic contactId and orgId via URL parameters
- Ensure robust form state management with react-hook-form
Co-authored-by: joelbrock <52835+joelbrock@users.noreply.github.com>