From 9103ccaf9da8bd940f0b96ba2afe4829fb9c833e Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Mon, 11 May 2026 13:05:34 -0700 Subject: [PATCH] Locked future-stage cards + journey rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/globals.css | 13 ++++ components/EngagementForm.tsx | 141 ++++++++++++++++++++++++++++++---- components/StageSection.tsx | 120 ++++++++++++++++++++++++----- 3 files changed, 242 insertions(+), 32 deletions(-) diff --git a/app/globals.css b/app/globals.css index b3a0f6e..a5833cd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -142,6 +142,19 @@ input[type="date"]::-webkit-calendar-picker-indicator { } } +/* Journey rail — gentle "you are here" pulse on the current stage marker. + * Animates the ring shadow only; the marker disc stays at full opacity so + * the cue reads as a halo rather than a fade. Reduced-motion users get the + * static state thanks to the global override above. */ +@keyframes rail-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 oklch(72% 0.14 135 / 0.55); + } + 60% { + box-shadow: 0 0 0 8px oklch(72% 0.14 135 / 0); + } +} + /* A small cohort of utility classes — rule lines, rough underlines, etc. */ .rule-soft { background: linear-gradient( diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index e5d0637..a84a430 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -37,6 +37,8 @@ type SubmitStatus = | { kind: "success" } | { kind: "error"; message: string }; +type PathwayState = "past" | "current" | "future"; + const STAGE_RANK: Record = { Inquiry: 0, Organizing: 1, @@ -157,18 +159,25 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { }; }, [load.kind, cid, watch]); - // ── Section ordering ─────────────────────────────────────────────────── + const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; + + // Section pathway state — past / current / future is determined entirely + // by stage rank vs the org's current rank. Sections never hide; future + // stages render in a locked treatment so users can preview what's ahead. + // + // Section-level visibleWhen is still consulted on submit to strip + // locked-stage values from the payload (see onSubmit below), so a future + // stage's data never gets accidentally written. const sectionsToRender = useMemo( () => - config.sections.map((s) => ({ - section: s, - sectionVisible: evaluate(s.visibleWhen, evalState), - })), - [config.sections, evalState], + config.sections.map((s) => { + const pathwayState: PathwayState = + s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future"; + return { section: s, pathwayState, locked: pathwayState === "future" }; + }), + [config.sections, currentRank], ); - const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; - // Track which sections the user has manually opened/closed so we can // programmatically re-open them when validation surfaces an error within. // Used by the onInvalid handler below. @@ -290,19 +299,30 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { }} /> )} -
    - {sectionsToRender.map(({ section, sectionVisible }) => { - if (!sectionVisible) return null; +
      + {sectionsToRender.map((entry, i) => { + const { section, pathwayState, locked } = entry; + const nextState = + i < sectionsToRender.length - 1 + ? sectionsToRender[i + 1].pathwayState + : undefined; return ( -
    1. +
    2. + 0} state={pathwayState} /> +
    3. @@ -545,6 +565,99 @@ function ErrorState({ message }: { message: string }) { ); } +/** + * Vertical journey rail marker, visible on md+ only. Sits in the OL's left + * gutter, aligned to the card header. Includes a downward connecting line + * toward the next marker; the line's style is determined by whether the + * NEXT stage is past/current (solid leaf) or future (dashed muted) — that + * way the transition from "traveled" to "ahead" happens right after the + * current marker, which reads correctly as "you've come this far." + */ +function RailMarker({ + rank, + state, + nextState, +}: { + rank: number; + state: PathwayState; + nextState?: PathwayState; +}) { + return ( +
      + {nextState && ( + + )} + + {state === "current" && ( + + Now + + )} +
      + ); +} + +function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) { + if (state === "past") { + return ( + + + + + Stage {rank} (completed) + + ); + } + if (state === "current") { + return ( + + {rank} + Stage {rank} (current) + + ); + } + return ( + + + + + + Stage {rank} (upcoming) + + ); +} + +/** + * Mobile-only inter-card stem. Sits centered in the gap between adjacent + * stage cards. Solid leaf when arriving at a past-or-current stage; dashed + * muted when arriving at a future (locked) stage. + */ +function MobileStem({ show, state }: { show: boolean; state: PathwayState }) { + if (!show) return null; + return ( +
      + +
      + ); +} + function formatRelative(iso: string): string { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return iso; diff --git a/components/StageSection.tsx b/components/StageSection.tsx index 3d759e0..02fc9b1 100644 --- a/components/StageSection.tsx +++ b/components/StageSection.tsx @@ -22,6 +22,12 @@ interface StageSectionProps { formValues: Record; /** Whether this is the section that matches the current org stage. */ isCurrent: boolean; + /** + * Whether this stage is ahead of the org's current stage. Locked sections + * still render so the user can see what's coming, but the fields inside + * are wrapped in a disabled fieldset and never written on submit. + */ + locked: boolean; /** Whether the section starts open. */ defaultOpen: boolean; /** Option groups fetched from CiviCRM, keyed by option_group_id. */ @@ -46,6 +52,7 @@ export function StageSection({ errors, formValues, isCurrent, + locked, defaultOpen, options, }: StageSectionProps) { @@ -83,15 +90,22 @@ export function StageSection({ 0, ); + const cardClass = isCurrent + ? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95" + : locked + ? "border border-dashed border-rule bg-paper-2/30 shadow-none" + : "border border-rule bg-white/95 shadow-sm"; + + const buttonClass = isCurrent + ? "bg-leaf-50/60" + : locked + ? "hover:bg-paper-2/50" + : "hover:bg-paper-2/60"; + return (

      ); @@ -181,20 +214,35 @@ export function StageSection({ * icon offset behind it. When current, the ring is leaf-green and the * number is white-on-green; otherwise it's quiet. */ -function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { +function StageRankMark({ + rank, + isCurrent, + locked, +}: { + rank: number; + isCurrent: boolean; + locked: boolean; +}) { + const iconClass = isCurrent + ? "text-leaf-700 opacity-100" + : locked + ? "text-ink-mute opacity-30" + : "text-leaf-600 opacity-50"; + const badgeClass = isCurrent + ? "bg-leaf-700 text-paper" + : locked + ? "bg-paper text-ink-mute border border-dashed border-rule" + : "bg-paper text-ink-soft border border-rule"; return ( @@ -204,6 +252,42 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean } ); } +function LockGlyph() { + return ( + + + + + ); +} + +function LockedBanner() { + return ( +
      + + + +

      + A look ahead.{" "} + These fields will become editable when your co-op reaches this stage. They're visible + now so you can preview the framework you'll be working through. +

      +
      + ); +} + function Chevron({ open }: { open: boolean }) { return (