diff --git a/FOUNDER-TLDR.md b/FOUNDER-TLDR.md index dc84623..cc84bd5 100644 --- a/FOUNDER-TLDR.md +++ b/FOUNDER-TLDR.md @@ -10,6 +10,18 @@ coming next. Newest updates are at the top. --- +## 2026-06-14 — A calmer, friendlier plan (and a one-time disclaimer you confirm up front) + +**What changed.** The plan used to feel like paperwork: a gold "not legal advice" warning sat over everything, questions used legal codes, and each step buried the to-dos under reference text. We rebuilt the *feel*. The gold banner is gone; instead, right after you answer the questions, a single pop-up shows the legal disclaimer and you tap **"I've read and understand"** once before your plan opens. The plan now leads with **what to do** (the steps), tucking legal citations and fine print into a "Reference" section you can open if you want. Questions use plain words ("Less than a year" instead of "L permit path"), progress shows friendly stages, and you get a small "nice work" nudge as you finish steps. The legal notice is still everywhere it needs to be — the confirm pop-up, the footer on every page, and a short line on each step — just calm, not shouting. + +**Why it matters.** People arrive stressed about a life-changing move; a wall of legal warnings scares them off. This feels like a supportive guide, not a form, while keeping every fact sourced. + +**What you'd notice today.** After the questions: a disclaimer pop-up you must accept, then a cleaner, encouraging plan. (On a review copy — **not live**; the new pop-up-style disclaimer needs your lawyer's OK first.) + +**What's next.** Your review + a lawyer's sign-off on the pop-up disclaimer approach, then it ships. + +--- + ## 2026-06-14 — Swiss plans now say *when* a fee or timeline is set by your canton **What changed.** Many Swiss steps have no single nationwide fee or processing time — the **canton** sets them (and for getting into a school, the **institution** does). Until now those steps simply showed *nothing*, which can read as "free/instant" or leave people guessing. Now each such step carries a plain note: *"Fees & processing time: set by your canton, not federally — confirm the exact amount and timing with your cantonal authorities"* (study admission says "the institution"). The "Your canton" box also now reminds you these vary by canton. Importantly, **we invented no numbers** — we only made the plan honest about where the real figures come from and who to ask. Covers 12 steps (the employer-sponsored work permit, the family-reunification routes, the retirement permit, and the study permits/sub-steps). diff --git a/ROADMAP.md b/ROADMAP.md index e99631c..986a5b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ > > Legend: ✅ done · 🚧 in progress · ⏳ planned · 🔁 ongoing · 🗓️ backlog -Last updated: 2026-06-14 (Cantonal fee/timeline transparency: 12 us-ch steps that have no single federal fee/processing-time figure (the employer-sponsored work permit, the family-reunification routes, the retirement permit, the study permits/sub-steps) now show a user-facing "Fees & processing time" note that these are set by the canton — or, for study admission, the institution — and to confirm locally; driven by a presentation-only `localCostTimeline` task hint (no invented numbers, no new sourced claims, ADR-0007/ADR-0021), plus a "varies by canton" caveat now always shown in the "Your canton" panel. Canton-aware plan-step links: the six "apply at / contact your cantonal authority of the canton where you live/study" steps (family reunification ×3, unmarried-partner concubinage, retirement, study) now deep-link the user's *selected* canton's own migration office (ZH Migrationsamt, GE OCPM, VD SPOP, ZG, BS) instead of the federal SEM directory of all 26 — falling back to that directory for not-yet-sourced cantons, so no dead ends. A `StepLinkSchema.cantonOffice` rendering hint (asserts no fact, no provenance) drives a frontend-only swap to the already-VERIFIED canton migrationOffice claim; static SSR full-guide stays federal (can't know the canton). Build/gate/`astro check`/277 tests green; fact-verifier (accurate, no overreach) + provenance-integrity verified; ADR-0021. Outbound links: the written-guide's 109 external source links now open in a new tab (target=_blank + rel=noopener noreferrer), matching the planner — readers don't lose their place. Plan UI: the desktop plan sidebar (progress / answers / journey) is now collapsible (Hide/Show), giving the task panel full width; mobile already had this. Canton dimension for Switzerland (ADR-0021): corridor-embedded `cantons` schema + provenance gate; CH-only "which canton?" intake step (searchable combobox over 26 cantons, optional) + `cn` share-param + "Your canton" plan panel with a verified SEM-directory fallback; per-canton sourced detail for ZH/GE/VD/ZG/BS in the two-agent pipeline. Earlier: Link audit: all 950 source links render-checked — 147 good, 23 Fedlex "under preparation" links flagged + the us-ch fix applied (79 occurrences → static-HTML, independently fact-verifier re-verified; re-audit now 0 suspect / 170 good). The weekly link-auditor now also checks anchor-reach — a `#article` link that loads but whose section never renders (SPA shell) goes RED on SPA domains (fedlex-class regressions); france-visas added to manual-check (403s the bot, fine for users). Live run: 0 errors / 142 URLs. Plan-personalisation correctness: "Who's joining you?" companion multi-select (partner/children/"It's just me") replaces the children yes/no; family/dependant steps gated to who is actually coming; full-guide route labels; cross-corridor plan-correctness regression suite — ADR-0020. UI/UX "Trust & Authority" design system adopted — audit fixes 1–10: AA-contrast CTAs, 44px touch targets, readable legal text, EB Garamond + Lato self-hosted fonts, brand tokens, consent-overlap fix, home/wizard trust framing, plus polish 11–13 (SVG icons, consistent focus rings, tighter line-length); ADR-0019. Earlier: 8 corridors published + intake availability dots, ADR-0018; source-snapshot cache, ADR-0017; corridor production via agent teams in two runs, ADR-0016; all-routes intake + launch waitlist, ADR-0015; launch-readiness drafts, og:image, dormant consent+analytics, ADR-0013; us-ch founder approval, ADR-0014; go-live blocked on domain + lawyer review) +Last updated: 2026-06-14 (Calmer/encouraging UI overhaul (design-research-led; pending human-lawyer sign-off): removed the gold "not legal advice" banner everywhere; after the intake a REQUIRED disclaimer modal gate (role=dialog, focus-trapped, no bypass, compliance-owned wording single-sourced in `src/utils/disclaimer.ts`, acked in localStorage `rg-disclaimer-ack-v1`) must be confirmed before the plan renders — also gates shared-link opens; task cards re-sequenced action-first (tldr→steps→documents) with key facts/legal citations/detail behind one "Reference" fold and the caveats consolidated into one "Sources & legal notes" fold (a short "not legal advice" line stays always-visible per card per compliance); plain-language intake options (dropped L/B/C permit codes & "concubinage"); stable phase-based progress ("Your route→situation→details") with progressbar a11y; warm all-breakpoint intro; quiet per-step momentum + calmer document gate. Disclaimer still in the modal + footer (every page) + per-card + Impressum + no-JS guide. Compliance verdict: CONDITIONAL — the modal-acknowledgement posture (vs a persistent banner) needs human-lawyer sign-off before merge; astro check 0 / build+gate / 277 tests green; a11y reviewed (modal focus-trap OK). Cantonal fee/timeline transparency: 12 us-ch steps that have no single federal fee/processing-time figure (the employer-sponsored work permit, the family-reunification routes, the retirement permit, the study permits/sub-steps) now show a user-facing "Fees & processing time" note that these are set by the canton — or, for study admission, the institution — and to confirm locally; driven by a presentation-only `localCostTimeline` task hint (no invented numbers, no new sourced claims, ADR-0007/ADR-0021), plus a "varies by canton" caveat now always shown in the "Your canton" panel. Canton-aware plan-step links: the six "apply at / contact your cantonal authority of the canton where you live/study" steps (family reunification ×3, unmarried-partner concubinage, retirement, study) now deep-link the user's *selected* canton's own migration office (ZH Migrationsamt, GE OCPM, VD SPOP, ZG, BS) instead of the federal SEM directory of all 26 — falling back to that directory for not-yet-sourced cantons, so no dead ends. A `StepLinkSchema.cantonOffice` rendering hint (asserts no fact, no provenance) drives a frontend-only swap to the already-VERIFIED canton migrationOffice claim; static SSR full-guide stays federal (can't know the canton). Build/gate/`astro check`/277 tests green; fact-verifier (accurate, no overreach) + provenance-integrity verified; ADR-0021. Outbound links: the written-guide's 109 external source links now open in a new tab (target=_blank + rel=noopener noreferrer), matching the planner — readers don't lose their place. Plan UI: the desktop plan sidebar (progress / answers / journey) is now collapsible (Hide/Show), giving the task panel full width; mobile already had this. Canton dimension for Switzerland (ADR-0021): corridor-embedded `cantons` schema + provenance gate; CH-only "which canton?" intake step (searchable combobox over 26 cantons, optional) + `cn` share-param + "Your canton" plan panel with a verified SEM-directory fallback; per-canton sourced detail for ZH/GE/VD/ZG/BS in the two-agent pipeline. Earlier: Link audit: all 950 source links render-checked — 147 good, 23 Fedlex "under preparation" links flagged + the us-ch fix applied (79 occurrences → static-HTML, independently fact-verifier re-verified; re-audit now 0 suspect / 170 good). The weekly link-auditor now also checks anchor-reach — a `#article` link that loads but whose section never renders (SPA shell) goes RED on SPA domains (fedlex-class regressions); france-visas added to manual-check (403s the bot, fine for users). Live run: 0 errors / 142 URLs. Plan-personalisation correctness: "Who's joining you?" companion multi-select (partner/children/"It's just me") replaces the children yes/no; family/dependant steps gated to who is actually coming; full-guide route labels; cross-corridor plan-correctness regression suite — ADR-0020. UI/UX "Trust & Authority" design system adopted — audit fixes 1–10: AA-contrast CTAs, 44px touch targets, readable legal text, EB Garamond + Lato self-hosted fonts, brand tokens, consent-overlap fix, home/wizard trust framing, plus polish 11–13 (SVG icons, consistent focus rings, tighter line-length); ADR-0019. Earlier: 8 corridors published + intake availability dots, ADR-0018; source-snapshot cache, ADR-0017; corridor production via agent teams in two runs, ADR-0016; all-routes intake + launch waitlist, ADR-0015; launch-readiness drafts, og:image, dormant consent+analytics, ADR-0013; us-ch founder approval, ADR-0014; go-live blocked on domain + lawyer review) > **Current focus:** USA → Switzerland (`us-ch`) **only**, until it is perfected end-to-end. > All other corridors are deferred (see DECISIONS.md **ADR-0006**). India → Germany (`in-de`) diff --git a/src/components/CorridorApp.tsx b/src/components/CorridorApp.tsx index 8ec4458..262c3f6 100644 --- a/src/components/CorridorApp.tsx +++ b/src/components/CorridorApp.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useRef, type ReactNode, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { COUNTRY_OPTIONS, countryName, type CountryOption } from '../utils/countries'; +import { DISCLAIMER_LEAD, DISCLAIMER_BODY, DISCLAIMER_PROGRESS_NOTE, IMPRESSUM_PATH } from '../utils/disclaimer'; import { CH_CANTONS, cantonName } from '../utils/cantons'; import { flagUrl } from '../utils/flags'; import { topoOrder, statusOf, currentTaskId, type TaskStatus } from '../utils/journey'; @@ -211,7 +212,7 @@ const WORK_STATUS: Opt[] = [ const FAMILY_REL: Opt[] = [ { value: 'spouse', label: 'Spouse', description: "We're married" }, { value: 'registered-partner', label: 'Registered partner' }, - { value: 'unmarried-partner', label: 'Unmarried partner', description: 'Long-term partner (concubinage)' }, + { value: 'unmarried-partner', label: 'Unmarried partner', description: "We've been together long-term" }, { value: 'parent', label: 'Parent or child' }, ]; @@ -221,9 +222,9 @@ const STUDY_STATUS: Opt[] = [ ]; const DURATION: Opt[] = [ - { value: 'short', label: 'Less than a year', description: 'Short-stay (L permit) path' }, - { value: 'long', label: 'A year or more', description: 'Standard (B permit) path' }, - { value: 'permanent', label: 'Indefinitely — planning to settle', description: 'B permit now; C permit later' }, + { value: 'short', label: 'Less than a year' }, + { value: 'long', label: 'A year or more' }, + { value: 'permanent', label: "Indefinitely — I'm planning to settle" }, ]; const COMPANIONS: Opt[] = [ @@ -413,7 +414,7 @@ const STEPS: WizardStep[] = [ // know their canton, so it never blocks (isComplete is always true). visibleIf: (a) => a.destination === 'ch', title: 'Which canton are you moving to?', - subtitle: "Switzerland's permits, taxes and registration are run by the canton — this tailors the local details.", + subtitle: "Knowing your canton lets us tailor the local details — you can add it later if you're not sure yet.", fields: [{ kind: 'canton' }], isComplete: () => true, }, @@ -426,6 +427,49 @@ const STEPS: WizardStep[] = [ }, ]; +// ── Wizard phases (stable progress, ADR-0019 follow-up) ────────────────────── +// +// The wizard inserts branch questions (work/family/study/other) mid-flow, so a +// "Step X of N" counter has a denominator that grows the moment a motivation is +// picked — which reads as backsliding ("now there are MORE steps"). Instead we +// group every step into three NAMED phases whose count never changes. The bar +// fills by phase and the eyebrow names the phase, so progress only ever moves +// forward. Every step id maps to exactly one phase. + +const WIZARD_PHASES = [ + { id: 'route', label: 'Your route' }, + { id: 'situation', label: 'Your situation' }, + { id: 'details', label: 'Your details' }, +] as const; + +type WizardPhaseId = (typeof WIZARD_PHASES)[number]['id']; + +/** Which named phase each wizard step belongs to. */ +const STEP_PHASE: Record = { + origin: 'route', + destination: 'route', + passports: 'route', + motivation: 'situation', + 'work-status': 'situation', + 'work-employer': 'situation', + 'family-rel': 'situation', + 'family-status': 'situation', + 'study-status': 'situation', + 'study-inst': 'situation', + 'other-desc': 'situation', + duration: 'details', + canton: 'details', + companions: 'details', +}; + +function phaseOfStep(stepId: string): WizardPhaseId { + return STEP_PHASE[stepId] ?? 'route'; +} + +function phaseIndex(phaseId: WizardPhaseId): number { + return WIZARD_PHASES.findIndex((p) => p.id === phaseId); +} + // ── Hooks ──────────────────────────────────────────────────────────────────── function useIsMobile() { @@ -442,6 +486,92 @@ function useIsMobile() { return isMobile; } +/** localStorage key recording that the user has read & acknowledged the + * post-intake disclaimer in this browser. Bumped to -v2 etc. if the wording + * materially changes and re-acknowledgement is required. */ +const DISCLAIMER_ACK_KEY = 'rg-disclaimer-ack-v1'; + +function hasAckedDisclaimer(): boolean { + try { + return localStorage.getItem(DISCLAIMER_ACK_KEY) === '1'; + } catch { + return false; + } +} + +function persistDisclaimerAck() { + try { + localStorage.setItem(DISCLAIMER_ACK_KEY, '1'); + } catch { + // storage unavailable — the modal will simply reappear next navigation. + } +} + +/** + * Focus trap for a modal dialog. While `active`, Tab / Shift+Tab cycle within + * `containerRef`, focus is moved to `initialFocusRef` (or the first focusable + * element) on open, and focus is restored to the previously-focused element on + * close. Escape does NOT close the modal here — the disclaimer gate requires an + * explicit acknowledgement, so the caller intentionally does not pass an onClose + * for Escape. + */ +function useFocusTrap( + active: boolean, + containerRef: React.RefObject, + initialFocusRef?: React.RefObject, +) { + useEffect(() => { + if (!active) return; + const container = containerRef.current; + if (!container) return; + + const previouslyFocused = document.activeElement as HTMLElement | null; + + const focusableSelector = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; + + function focusables(): HTMLElement[] { + return Array.from(container!.querySelectorAll(focusableSelector)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + } + + // Move focus into the dialog on open. + const target = initialFocusRef?.current ?? focusables()[0] ?? container; + target.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) { + e.preventDefault(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const activeEl = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (activeEl === first || !container!.contains(activeEl)) { + e.preventDefault(); + last.focus(); + } + } else if (activeEl === last || !container!.contains(activeEl)) { + e.preventDefault(); + first.focus(); + } + } + + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + // Restore focus to where it was before the dialog opened. + if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + previouslyFocused.focus(); + } + }; + }, [active, containerRef, initialFocusRef]); +} + // ── Small UI atoms ─────────────────────────────────────────────────────────── function OptionCard({ @@ -1217,6 +1347,25 @@ function TaskCard({ const done = status === 'done'; const { completed: docsHandled, total: docsTotal } = docProgress(task, docState); const docsRemaining = docsTotal - docsHandled; + + // Does this task carry any reference material worth folding away? (Key facts, + // verified cost/timeline, localCostTimeline note, long prose, or step links). + const hasReference = + !!task.keyFacts?.length || + !!task.timeline || + !!task.cost || + !!task.localCostTimeline || + !!task.detail || + !!task.tldr || + task.steps.some((s) => s.links && s.links.length > 0); + + // A step link is "long" (a statute citation / sentence-length legal pointer) + // when its authored text reads as prose rather than a short label. We render + // those as a short label inline (sourceName) with the full authored text on + // hover (title=) and spelled out in full in the reference fold — never dropping + // sourceName / sourceUrl / lastVerified. + const isLongLink = (text: string) => text.trim().length > 48 || text.trim().split(/\s+/).length > 6; + return (
@@ -1241,71 +1390,23 @@ function TaskCard({
- {/* 1–2 sentence "what and why" (ADR-0012). Falls back to the verified + {/* ACTION FIRST (progressive disclosure). (a) one-line "what and why", + then (b) the numbered steps, then (c) documents. The reference grid + and long prose live in a single collapsed fold below. */} + + {/* (a) 1–2 sentence "what and why" (ADR-0012). Falls back to the verified summary claim until the distilled tldr passes the content pipeline. */}

{(task.tldr ?? task.summary).text}

- {/* Key facts: scannable grid. keyFacts come from the content pipeline; - the verified timeline/cost claims are appended automatically (the - researcher does not duplicate them in keyFacts). */} - {(task.keyFacts?.length || task.timeline || task.cost || task.localCostTimeline) && ( -
-

Key facts

-
- {task.keyFacts?.map((fact) => ( -
-
{fact.label}
-
{fact.text}
-
- ))} - {task.timeline && ( -
-
Processing time
-
{task.timeline.text}
-
- )} - {task.cost && ( -
-
Cost
-
{task.cost.text}
-
- )} - {task.localCostTimeline && !task.cost && !task.timeline && ( -
-
Fees & processing time
-
- {task.localCostTimeline === 'institution' - ? 'Set by the educational institution, not at the federal level. Confirm the exact application fee and timing directly with the institution.' - : 'Set by your canton, not at the federal level — there is no single national figure, and it varies across Switzerland. Confirm the exact amount and timing with your cantonal authorities (see “Your canton”).'} -
-
- )} -
-
- )} - + {/* Situational warning stays VISIBLE — it's specific to this step, not + boilerplate (it must not be folded away). */} {task.warning && (
Note: {task.warning}
)} - {/* Full prose, collapsed by default (ADR-0012). When a distilled tldr - exists, the verified long summary moves in here with the detail. */} - {(task.detail || task.tldr) && ( -
- - Read the details - - - -
- {task.tldr &&

{task.summary.text}

} - {task.detail &&

{task.detail}

} -
-
- )} - + {/* (b) The numbered "what you'll do" steps — the hero of the card. */} {task.steps.length > 0 && (

What you'll do

@@ -1325,16 +1426,38 @@ function TaskCard({ // canton's office (sourceName already names the canton); // otherwise keep the authored federal SEM directory link. const useCanton = link.cantonOffice === true && cantonOfficeClaim !== null; - return ( -
  • - {useCanton ? ( + if (useCanton) { + return ( +
  • {selectedCantonName && Your canton — } {cantonOfficeClaim!.sourceName} ↗ - ) : ( - {link.text} ↗ - )} +
  • + ); + } + // Long, sentence-length legal links: show a short label + // (the authority's name) with the full authored text on + // hover. The complete sentence is spelled out in the + // reference fold below, so nothing is lost. + if (isLongLink(link.text)) { + return ( +
  • + + {link.sourceName} ↗ + +
  • + ); + } + return ( +
  • + {link.text} ↗
  • ); })} @@ -1347,6 +1470,7 @@ function TaskCard({
    )} + {/* (c) Documents needed. */} {task.documents.length > 0 && (
    @@ -1360,25 +1484,140 @@ function TaskCard({
    )} + {/* (d) ONE collapsed reference fold: key facts grid (incl. cost/timeline + and the localCostTimeline note), legal basis, the long prose, and the + full text of any sentence-length legal links. Provenance is preserved: + every claim keeps its sourceName / sourceUrl / lastVerified. */} + {hasReference && ( +
    + + Reference: key facts, legal basis & details + + + +
    + {/* Key facts grid (incl. Legal basis facts, cost, timeline, and + the canton/institution "Fees & processing time" note). */} + {(task.keyFacts?.length || task.timeline || task.cost || task.localCostTimeline) && ( +
    +

    Key facts

    +
    + {task.keyFacts?.map((fact) => ( +
    +
    {fact.label}
    +
    {fact.text}
    +
    + ))} + {task.timeline && ( +
    +
    Processing time
    +
    {task.timeline.text}
    +
    + )} + {task.cost && ( +
    +
    Cost
    +
    {task.cost.text}
    +
    + )} + {task.localCostTimeline && !task.cost && !task.timeline && ( +
    +
    Fees & processing time
    +
    + {task.localCostTimeline === 'institution' + ? 'Set by the educational institution, not at the federal level. Confirm the exact application fee and timing directly with the institution.' + : 'Set by your canton, not at the federal level — there is no single national figure, and it varies across Switzerland. Confirm the exact amount and timing with your cantonal authorities (see “Your canton”).'} +
    +
    + )} +
    +
    + )} + + {/* The long authored prose (the former "Read the details"). */} + {(task.detail || task.tldr) && ( +
    + {task.tldr &&

    {task.summary.text}

    } + {task.detail &&

    {task.detail}

    } +
    + )} + + {/* Full text of any sentence-length legal step-links — spelled out + here (collapsed inline above) so the authored wording, the + authority name and the link are all preserved. */} + {task.steps.some((s) => s.links?.some((l) => l.cantonOffice !== true && isLongLink(l.text))) && ( +
    +

    Legal basis & references

    +
      + {task.steps.flatMap((s, si) => + (s.links ?? []) + .map((link, li) => ({ link, key: `${si}-${li}` })) + .filter(({ link }) => link.cantonOffice !== true && isLongLink(link.text)) + .map(({ link, key }) => ( +
    • + {link.text}{' '} + + {link.sourceName} ↗ + + {link.lastVerified && ( + link verified {link.lastVerified} + )} +
    • + )), + )} +
    +
    + )} +
    +
    + )} + + {/* Source stays always-visible; the two boilerplate caveats are + consolidated into one collapsed "Sources & legal notes" disclosure — + present and findable, no longer triplicated. */}

    Source:{' '} {task.summary.sourceName} + {task.summary.lastVerified && ( + verified {task.summary.lastVerified} + )}

    -

    - General guidance only — not legal advice. Confirm with a licensed immigration professional or the relevant authority. -

    -

    - Marking a step done only records your own progress on this site — it is not confirmation - that a legal requirement has been met or an application approved. -

    + {/* Core "not legal advice" caveat kept always-visible on the surface + where users act on claims; the fuller notes live in the disclosure. */} +

    General guidance only — not legal advice.

    +
    + + Sources & legal notes + + + +
    +

    + Source:{' '} + {task.summary.sourceName} + {task.summary.lastVerified && ( + verified {task.summary.lastVerified} + )} +

    +

    + General guidance only — not legal advice. Confirm with a licensed immigration professional or the relevant authority. +

    +

    + {DISCLAIMER_PROGRESS_NOTE} +

    +
    +
    + {/* Calmer gate: a helper near the checklist instead of a scolding line on + a greyed button. Only shows when documents are still outstanding. */} {!done && docsRemaining > 0 && (

    - {docsRemaining} document{docsRemaining === 1 ? '' : 's'} left to check off (or skip) before this step can be marked done. + A few documents to go — check the ones you have, skip what doesn't apply, and you can + mark this step done.

    )}
    @@ -1575,6 +1814,132 @@ function WaitlistPanel({ ); } +// ── Post-intake disclaimer gate (replaces the persistent plan banner) ──────── + +/** + * Blocking disclaimer modal shown the first time a user reaches the plan in this + * browser — both when finishing the wizard and when opening a shared/bookmarked + * plan link cold. The plan renders beneath it, but a full-screen overlay + focus + * trap make the modal the gate: the plan is not readable or interactive until + * the user confirms. There is deliberately NO backdrop-click or Escape dismissal + * — acknowledgement is required (CLAUDE.md rule 7). Wording is single-sourced + * from `utils/disclaimer.ts`, which mirrors `Disclaimer.astro` verbatim. + */ +function DisclaimerModal({ onAcknowledge }: { onAcknowledge: () => void }) { + const dialogRef = useRef(null); + const confirmRef = useRef(null); + + useFocusTrap(true, dialogRef, confirmRef); + + // Prevent background scroll while the gate is up. + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, []); + + return ( +
    + {/* Opaque-enough backdrop so the plan beneath is not readable. No onClick: + clicking the backdrop must NOT dismiss the gate. */} + + ); +} + +// ── Momentum affirmation (calmer, after marking a step done) ───────────────── + +/** + * Brief, dismissible affirmation shown after the user marks a step done. Counts + * ONLY the user's own real progress (no fabricated reassurance). Adds a quiet + * milestone note as the plan crosses 25 / 50 / 75 %. Auto-dismisses; also + * dismissible by the user. + */ +function MomentumToast({ + remaining, pct, onDismiss, +}: { remaining: number; pct: number; onDismiss: () => void }) { + useEffect(() => { + const t = setTimeout(onDismiss, 6000); + return () => clearTimeout(t); + }, [onDismiss]); + + const milestone = + pct >= 75 ? "You're three-quarters of the way there." + : pct >= 50 ? "Halfway through your plan — keep going." + : pct >= 25 ? "A quarter done — you've got momentum." + : null; + + return ( +
    + + + +
    +

    + Step done — nice work.{' '} + {remaining > 0 + ? `${remaining} to go.` + : 'That was the last one.'} +

    + {milestone &&

    {milestone}

    } +
    + +
    + ); +} + // ── Main component ─────────────────────────────────────────────────────────── export default function CorridorApp({ tasks, corridorTitle, originIso2, destinationIso2, availableCorridors, coversMotivations, cantons = [] }: CorridorAppProps) { @@ -1591,6 +1956,12 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat // Desktop: let the user collapse the plan sidebar to give the task panel full width. const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [hydrated, setHydrated] = useState(false); + // Post-intake disclaimer gate. `null` until we've read localStorage after + // mount (must not differ from the server's first render); then true/false. + // The modal shows whenever we're in the plan phase and this is false. + const [disclaimerAcked, setDisclaimerAcked] = useState(null); + // Momentum affirmation after marking a step done (calmer than a banner). + const [momentum, setMomentum] = useState<{ remaining: number; pct: number } | null>(null); const isMobile = useIsMobile(); @@ -1675,6 +2046,10 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat setDocState(clean); } if (saved?.activeTaskId) setActiveTaskId(saved.activeTaskId); + // Disclaimer acknowledgement is read here (after mount) so it never differs + // from the server's first render. The gate appears in the plan phase until + // this is true. + setDisclaimerAcked(hasAckedDisclaimer()); setHydrated(true); }, []); @@ -1828,6 +2203,27 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat const currentStep = visibleSteps[currentIndex] ?? visibleSteps[0]; const ready = currentStep.isComplete(answers); + // Phase-based progress (item 5). The current step maps to one of three stable + // named phases; the bar fills the current phase proportionally to how far the + // user is through that phase's currently-visible steps, so inserting branch + // questions never grows a denominator or pushes progress backward. + const currentPhaseId = phaseOfStep(currentStep.id); + const currentPhase = WIZARD_PHASES[phaseIndex(currentPhaseId)]; + const phaseProgressPct = (() => { + // Stable denominator: the phase's full step set, regardless of which branch + // questions the user's answers have revealed. Branch questions only appear + // after the step that reveals them, so the numerator (visible steps passed) + // stays monotonic and the bar never moves backward. + const stepsInPhase = STEPS.filter((s) => phaseOfStep(s.id) === currentPhaseId); + const visiblePhaseSteps = visibleSteps.filter((s) => phaseOfStep(s.id) === currentPhaseId); + const idxInPhase = visiblePhaseSteps.findIndex((s) => s.id === currentStep.id); + if (stepsInPhase.length === 0) return 0; + // Fill is "steps already passed" within the phase, out of the phase's steps, + // nudged so the very first step of a phase still reads as started. + const passed = Math.max(0, idxInPhase); + return Math.round(((passed + 0.5) / stepsInPhase.length) * 100); + })(); + function resolve(v: T | ((a: Intake) => T)): T { return typeof v === 'function' ? (v as (a: Intake) => T)(answers) : v; } @@ -1873,16 +2269,34 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat setPhase('wizard'); setStepId(targetStepId); setMobilePanelOpen(false); + setMomentum(null); pushHistory('wizard', targetStepId); } function handleMarkDone(id: string) { const nextDone = new Set([...doneIds, id]); setDoneIds(nextDone); + // Affirmation counts ONLY the user's own real progress over applicable tasks. + const doneApplicable = applicableTasks.filter((t) => nextDone.has(t.id)).length; + const totalApplicable = applicableTasks.length; + const remaining = Math.max(0, totalApplicable - doneApplicable); + const pct = totalApplicable > 0 ? Math.round((doneApplicable / totalApplicable) * 100) : 0; + // Don't show the brief toast when the whole plan is finished — the all-done + // panel already celebrates that. + setMomentum(remaining > 0 ? { remaining, pct } : null); const next = currentTaskId(orderedTasks, nextDone); setActiveTaskId(next ?? id); } + function handleAcknowledgeDisclaimer() { + persistDisclaimerAck(); + setDisclaimerAcked(true); + // Move focus somewhere sensible once the gate clears: the active task heading + // (or the all-done heading) so keyboard / screen-reader users land in the + // plan rather than on . Runs after the modal unmounts on the next tick. + setTimeout(() => taskHeadingRef.current?.focus(), 0); + } + /** Toggle a document between unchecked and `mark` ('done' or 'skipped'). */ function handleToggleDoc(taskId: string, docName: string, mark: DocMark) { setDocState((s) => { @@ -1915,6 +2329,7 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat setDocState({}); setActiveTaskId(null); setMobilePanelOpen(false); + setMomentum(null); try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ } } @@ -1951,7 +2366,7 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat

    Relocation Guide

    -

    A few questions, then a step-by-step plan built for your situation

    +

    A few questions, then a plan made for your move — about 2 minutes.

    {currentIndex > 0 && ( @@ -1959,10 +2374,35 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat
    -
    - {visibleSteps.map((s, i) => ( -
    - ))} + {/* Phase-based progress: three STABLE named phases whose count never + grows when branch questions are inserted, so the bar only ever moves + forward. The current phase fills proportionally to honest position. */} +
    + + {currentPhase.label} — phase {phaseIndex(currentPhaseId) + 1} of {WIZARD_PHASES.length} + + {/* Decorative bar segments — the position is announced via the + progressbar role above, so the visual segments stay hidden from AT. */} + {/* The card caps at the available height: question header and footer @@ -1972,29 +2412,49 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat stays a centered single column and the card internals are unchanged. */}
    + {/* Warm intro on EVERY breakpoint above the first country grid. On + lg+ the aside carries the warmth, so this in-card version only + shows below lg (where there is no aside). Shown on the very first + step only. */} + {currentStep.id === STEPS[0].id && ( +
    +

    Let's map out your move.

    +

    + Tell us a little about your situation and we'll build a step-by-step plan for your + exact route — every fact traced to an official source. +

    +
      +
    • Traced to official sources
    • +
    • Independently fact-checked
    • +
    • Dated and re-checked
    • +
    +
    + )}

    - Step {currentIndex + 1} of {visibleSteps.length} + {currentPhase.label}

    {resolve(currentStep.title)}

    {currentStep.subtitle &&

    {resolve(currentStep.subtitle)}

    } @@ -2127,10 +2587,15 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat
    -

    - General guidance only — not legal advice. Rules change frequently; always verify with - the official authority or a licensed immigration adviser before acting.{' '} - Impressum + {/* Slim, calm reassurance line (replaces the old legal-heavy banner). + The full disclaimer lives in the footer + the post-intake modal gate; + the Impressum stays reachable here. */} +

    + Your answers stay on this device{' '} + {' '} + Informational only — not legal advice{' '} + {' '} + Impressum

    @@ -2156,12 +2621,14 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat
    -
    -

    - General guidance only — not legal advice.{' '} - Rules change frequently; always verify with the official authority or a licensed - immigration adviser before acting.{' '} - Impressum + {/* Slim, calm caveat line (replaces the old gold/amber banner, per the + founder's directive — the gold banner is gone everywhere). Mirrors the + intake slim line; the full disclaimer lives in the footer + modal gate. */} +

    +

    + Informational only — not legal advice{' '} + {' '} + Impressum

    @@ -2252,8 +2719,24 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat
    Select a step from your journey to begin.
    ); + // Post-intake gate: show the blocking disclaimer modal the first time the plan + // is reached in this browser — both finishing the wizard AND opening a shared / + // bookmarked link cold (both paths set phase='app'). `disclaimerAcked` is null + // until localStorage is read after mount, so the modal never renders during the + // server / first client render (no hydration mismatch). While gated, the plan + // renders beneath but is inert (aria-hidden + no pointer events) so it can't be + // read or interacted with until acknowledged. + const showDisclaimerGate = disclaimerAcked === false; + return ( -
    + <> +

    @@ -2276,14 +2759,9 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat

    -
    -

    - General guidance only — not legal advice.{' '} - Rules change frequently; always verify with the official authority or a licensed - immigration adviser before acting.{' '} - Impressum -

    -
    + {/* The persistent "not legal advice" banner that used to frame the plan is + replaced by the post-intake DisclaimerModal gate (rendered below). The + full disclaimer still lives in the site footer on every page. */} {!routeCovered && answers.motivation && (
    @@ -2347,5 +2825,20 @@ export default function CorridorApp({ tasks, corridorTitle, originIso2, destinat
    )}
    + + {/* Momentum affirmation (calmer than a banner) — only after the user marks a + real step done; counts their own progress only. Sits above the plan, below + the gate. */} + {momentum && !showDisclaimerGate && ( +
    +
    + setMomentum(null)} /> +
    +
    + )} + + {/* Blocking post-intake disclaimer gate. */} + {showDisclaimerGate && } + ); } diff --git a/src/pages/[origin]/[destination]/index.astro b/src/pages/[origin]/[destination]/index.astro index ede128c..ff14559 100644 --- a/src/pages/[origin]/[destination]/index.astro +++ b/src/pages/[origin]/[destination]/index.astro @@ -24,14 +24,14 @@ const appTasks: TaskData[] = corridor.tasks.map((t) => ({ id: t.id, title: t.title, category: t.category, - summary: { text: t.summary.text, sourceUrl: t.summary.sourceUrl, sourceName: t.summary.sourceName }, + summary: { text: t.summary.text, sourceUrl: t.summary.sourceUrl, sourceName: t.summary.sourceName, lastVerified: t.summary.lastVerified }, tldr: t.tldr ? { text: t.tldr.text, sourceUrl: t.tldr.sourceUrl, sourceName: t.tldr.sourceName } : undefined, keyFacts: t.keyFacts?.map((f) => ({ label: f.label, text: f.text, sourceUrl: f.sourceUrl, sourceName: f.sourceName })), detail: t.detail, steps: t.steps.map((s) => ({ text: s.text, tip: s.tip, - links: s.links?.map((l) => ({ text: l.text, sourceUrl: l.sourceUrl, sourceName: l.sourceName, cantonOffice: l.cantonOffice })), + links: s.links?.map((l) => ({ text: l.text, sourceUrl: l.sourceUrl, sourceName: l.sourceName, cantonOffice: l.cantonOffice, lastVerified: l.lastVerified })), })), documents: t.documents.map((d) => typeof d === 'string' diff --git a/src/utils/disclaimer.ts b/src/utils/disclaimer.ts new file mode 100644 index 0000000..c21c9ce --- /dev/null +++ b/src/utils/disclaimer.ts @@ -0,0 +1,37 @@ +/** + * Single-sourced legal disclaimer wording (CLAUDE.md rule 7). + * + * The exact text below is OWNED BY the `compliance-officer` agent — do not edit + * the wording without compliance-officer sign-off. It mirrors, verbatim, the + * sentences in `src/components/Disclaimer.astro` (the source of record for the + * static no-JS pages and the site footer). + * + * Why this file exists: the React island (`CorridorApp.tsx`) cannot import an + * Astro component, but the post-intake disclaimer modal must show the SAME + * approved wording as the rest of the site. Keeping the sentences here, in one + * plain string, lets the island reuse them without re-typing — so the wording + * stays single-sourced. If the compliance wording changes, update BOTH this file + * and `Disclaimer.astro` together. + */ + +/** Lead sentence (bold on render) — matches Disclaimer.astro verbatim. */ +export const DISCLAIMER_LEAD = 'This site provides general information only.'; + +/** Body sentences — matches Disclaimer.astro verbatim. */ +export const DISCLAIMER_BODY = + 'It is not legal or immigration advice. Visa rules change frequently. Always verify requirements with the official authority or a licensed immigration adviser before taking action.'; + +/** Full plain-text disclaimer (lead + body), for screen readers / aria. */ +export const DISCLAIMER_FULL = `${DISCLAIMER_LEAD} ${DISCLAIMER_BODY}`; + +/** + * Progress-tracking caveat — shown in the post-intake disclaimer modal AND in + * each task's "Sources & legal notes" disclosure. Single-sourced here so the + * exact wording cannot drift between the two places. Owned by compliance-officer; + * do not edit the wording without sign-off. + */ +export const DISCLAIMER_PROGRESS_NOTE = + 'Marking a step done only records your own progress on this site — it is not confirmation that a legal requirement has been met or an application approved.'; + +/** Path to the Impressum (legal notice), reachable from the modal and footer. */ +export const IMPRESSUM_PATH = '/impressum';