Locked future-stage cards + journey rail

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.
This commit is contained in:
Joel Brock
2026-05-11 13:05:34 -07:00
parent a804650f65
commit 9103ccaf9d
3 changed files with 242 additions and 32 deletions

View File

@@ -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(

View File

@@ -37,6 +37,8 @@ type SubmitStatus =
| { kind: "success" }
| { kind: "error"; message: string };
type PathwayState = "past" | "current" | "future";
const STAGE_RANK: Record<string, number> = {
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) {
}} />
)}
<ol className="space-y-5">
{sectionsToRender.map(({ section, sectionVisible }) => {
if (!sectionVisible) return null;
<ol className="relative space-y-5 md:pl-20">
{sectionsToRender.map((entry, i) => {
const { section, pathwayState, locked } = entry;
const nextState =
i < sectionsToRender.length - 1
? sectionsToRender[i + 1].pathwayState
: undefined;
return (
<li key={section.id} className="list-none">
<li key={section.id} className="relative list-none">
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker
rank={section.rank}
state={pathwayState}
nextState={nextState}
/>
<StageSection
section={section}
register={register}
control={control}
errors={errors}
formValues={evalState}
isCurrent={section.rank === currentRank}
defaultOpen={section.rank === currentRank || section.rank === 0}
isCurrent={pathwayState === "current"}
locked={locked}
defaultOpen={pathwayState === "current" || section.rank === 0}
options={load.data.options ?? {}}
/>
</li>
@@ -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 (
<div
aria-hidden
className="pointer-events-none hidden md:block absolute -left-14 top-0 bottom-0 w-12"
>
{nextState && (
<span
className={
"absolute left-1/2 -translate-x-1/2 top-12 -bottom-11 " +
(nextState === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
)}
<MarkerCircle rank={rank} state={state} />
{state === "current" && (
<span className="absolute left-1/2 -translate-x-1/2 top-[60px] whitespace-nowrap rounded-full bg-leaf-700 px-1.5 py-[1px] text-[9px] font-medium uppercase tracking-[0.12em] text-paper shadow-sm">
Now
</span>
)}
</div>
);
}
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
if (state === "past") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
<path d="M3 6.5l2 2 4-5" />
</svg>
<span className="sr-only">Stage {rank} (completed)</span>
</span>
);
}
if (state === "current") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-7 w-7 items-center justify-center rounded-full bg-leaf-700 text-paper text-[11px] font-semibold shadow-md ring-4 ring-leaf-100 motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite]">
{rank}
<span className="sr-only">Stage {rank} (current)</span>
</span>
);
}
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
<span className="sr-only">Stage {rank} (upcoming)</span>
</span>
);
}
/**
* 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 (
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
<span
className={
"block h-4 " +
(state === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
</div>
);
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return iso;

View File

@@ -22,6 +22,12 @@ interface StageSectionProps {
formValues: Record<string, unknown>;
/** 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 (
<section
aria-labelledby={headingId}
className={
"overflow-hidden rounded-lg bg-white/95 transition " +
(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)]"
: "border border-rule shadow-sm")
}
className={"overflow-hidden rounded-lg transition " + cardClass}
>
<h2 id={headingId} className="m-0">
<button
@@ -101,12 +115,17 @@ export function StageSection({
aria-controls={panelId}
className={
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
buttonClass
}
>
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
<StageRankMark rank={section.rank} isCurrent={isCurrent} locked={locked} />
<span className="flex-1 min-w-0">
<span className="block font-display text-lg font-medium text-ink leading-tight tracking-tight sm:text-xl">
<span
className={
"block font-display text-lg font-medium leading-tight tracking-tight sm:text-xl " +
(locked ? "text-ink-soft" : "text-ink")
}
>
{section.label}
</span>
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
@@ -116,6 +135,12 @@ export function StageSection({
Current stage
</span>
)}
{locked && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-rule bg-paper px-2 py-0.5 font-medium text-ink-mute uppercase tracking-[0.08em]">
<LockGlyph />
Upcoming
</span>
)}
<span className="tabular-nums">
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
</span>
@@ -130,9 +155,17 @@ export function StageSection({
role="region"
aria-labelledby={headingId}
hidden={!open}
className="border-t border-rule-soft"
className={"border-t " + (locked ? "border-rule" : "border-rule-soft")}
>
<div className="px-5 py-6 sm:px-7 sm:py-7">
<fieldset
disabled={locked}
aria-disabled={locked || undefined}
className={
"px-5 py-6 sm:px-7 sm:py-7 " +
(locked ? "opacity-80" : "")
}
>
{locked && <LockedBanner />}
{section.intro && (
<p className="mb-6 max-w-prose text-[15px] leading-relaxed text-ink-soft">
{section.intro}
@@ -170,7 +203,7 @@ export function StageSection({
})}
</div>
)}
</div>
</fieldset>
</div>
</section>
);
@@ -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 (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
<StageIcon
rank={rank}
className={
"absolute inset-0 h-12 w-12 transition-opacity " +
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
}
className={"absolute inset-0 h-12 w-12 transition-opacity " + iconClass}
/>
<span
className={
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
badgeClass
}
style={{ marginLeft: 28, marginTop: 28 }}
>
@@ -204,6 +252,42 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }
);
}
function LockGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
</svg>
);
}
function LockedBanner() {
return (
<div
role="note"
className="mb-5 flex items-start gap-3 rounded-md border border-dashed border-rule bg-paper px-4 py-3"
>
<span aria-hidden className="mt-0.5 text-ink-mute">
<LockGlyph />
</span>
<p className="text-xs leading-relaxed text-ink-soft">
<span className="font-medium text-ink-soft">A look ahead.</span>{" "}
These fields will become editable when your co-op reaches this stage. They&apos;re visible
now so you can preview the framework you&apos;ll be working through.
</p>
</div>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg