Compare commits

...

16 Commits

Author SHA1 Message Date
52fd0f199a Add package comparison feature: CompareBar, ComparisonTable, ComparisonPage
New components for side-by-side funeral package comparison:

- CompareBar molecule: floating bottom pill with fraction badge (1/3, 2/3, 3/3),
  contextual copy, Compare CTA. For ProvidersStep and PackagesStep.
- ComparisonTable organism: CSS Grid comparison with info card, floating verified
  badges, separate section tables (Essentials/Optionals/Extras) with left accent
  borders, row hover, horizontal scroll on narrow desktops, font hierarchy.
- ComparisonPage: WizardLayout wide-form with Share/Print actions. Desktop shows
  ComparisonTable, mobile shows mini-card tab rail + single package card view.
  Recommended package as separate prop (D038).

Also fixes PackageDetail: adds priceLabel pass-through (D039), updates stories
to Essentials/Optionals/Extras section naming (D035).

Decisions: D035-D039 logged. Audits: CompareBar 18/20, ComparisonTable 17/20.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:17:34 +10:00
eb26242ece Update registry and session log with final MiniCard/MapPin/MapPopup state
All three components iterated with user feedback and approved.
Registry updated with final APIs. Session log captures all decisions
from the iteration rounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:05:06 +10:00
723cdf908a Iterate MapPopup: consistent hierarchy, clickable card, icon badge
- Hierarchy now matches MiniCard: title → meta → price
- Whole card is clickable (onClick prop) — removed View details link
- Verified badge → icon-only circle in image (matches MiniCard)
- Name truncated at 1 line with tooltip on hover
- No-image fallback shows inline verified icon + text
- Added keyboard support (Enter/Space) and focus ring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:59:55 +10:00
c457ee8b0d Add price-only MapPin variant (no name)
Name is now optional. When omitted, renders a compact single-line
pill with just "From $X" using the bolder name styling. Useful for
denser map views or when provider identity isn't needed at pin level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:55:36 +10:00
9f16bc87c2 Increase MapPin horizontal padding from 8px to 12px
Names were too tight against the pill edges, especially on longer
provider names. Bumped token from spacing.2 to spacing.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:52:22 +10:00
ec4b18152b Bump MapPin price font weight from 500 to 600
Medium (500) was too thin at 11px — semibold (600) reads better
while still being lighter than the name (700) for hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:48:08 +10:00
86df44496f Centre-align MapPin price text under provider name
Visually reviewed in Storybook — left-aligned price looked unbalanced.
Centring both lines makes the pin read as a cohesive label rather
than a misaligned mini card.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:46:15 +10:00
2b9aeaf8ef Iterate MiniCard and MapPin based on feedback
MiniCard:
- Verified badge → icon-only circle floating in image (top-right)
- Reorder content: title → meta → price → badges → chips
- Truncated titles show tooltip on hover with full text

MapPin: Rethink from price-only pill to two-line label:
- Line 1: Provider name (bold, truncated at 180px)
- Line 2: "From $X" (smaller, secondary colour) — optional
- Communicates who + starting price at a glance
- Verified/unverified palette distinction preserved
- Dot variant removed (name is always required now)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:11:13 +10:00
5364c1a3fc Update component registry, session log, and review plan
Add MiniCard, MapPin, MapPopup to registry. Log session work
including retroactive review completion and new component builds.
Mark all review phases complete in retroactive-review-plan.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:51:53 +10:00
ae1e344a8a Add MapPopup molecule — floating card for map pin click context
Compact provider/venue preview anchored to a MapPin. Image + name +
price + meta row + "View details" link. Downward nub connects to pin.
Drop-shadow filter for floating appearance. Verified badge inside image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:51:02 +10:00
4fecb81853 Add MapPin atom — price-pill map markers with verified/unverified distinction
Airbnb-style markers: pill variant (price label + nub) and dot variant
(no price). Verified = brand palette, unverified = neutral grey.
Active state inverts colours + scale-up. Pure CSS for map overlay use.
Keyboard accessible with role="button" and focus ring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:49:08 +10:00
f7efa7165c Add MiniCard molecule and component tokens for MiniCard + MapPin
MiniCard: compact vertical card for grids, recommendations, and map
popups. Image + title + optional price/badges/chips/meta. Lighter
than ProviderCard — no verified tiers, no logo. Audit: 20/20.

MapPin tokens added (build next): price-pill markers for map views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:47:26 +10:00
2843bf289f Complete retroactive review: all phases done, only typeset deferred
Phase 1-3 audits and normalizations complete. ServiceSelector
aria-required fixed (P0). Badge/AddOnOption/ProviderCard flagged
issues analyzed and determined false positives. Preflight clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:29:32 +10:00
abdbf56c87 Add aria-required to ServiceSelector radiogroup for screen reader clarity
When a continue button is present (selection gates forward progress),
the radiogroup now communicates that a selection is mandatory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:28:15 +10:00
9f5848b8a3 Add plain-English guide for Claude Code + Antigravity workflow
Human-readable reference for day-to-day use: when to use which tool,
typical flows for page tweaking and component building, workflow quick
reference, and how things change when backend work starts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:17:18 +10:00
4af684ec8f Add cross-tool workflow: AGENTS.md, Antigravity workflows, documentation
- Create AGENTS.md (90 lines) as shared foundation for Claude Code + Antigravity
- Slim CLAUDE.md from 113 to 45 lines (Claude-specific only, references AGENTS.md)
- Slim GEMINI.md from 58 to 26 lines (Antigravity-specific only, references AGENTS.md)
- Add 6 Antigravity workflows in .agent/workflows/ (session-start, preflight, token-sync, visual-qa, build-component, page-review)
- Add docs/reference/cross-tool-workflow.md with task routing, quality gates, file ownership, error mitigation
- Zero content overlap between CLAUDE.md and GEMINI.md (was ~80%)
- File ownership boundaries defined for current phase and future backend work

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:10:32 +10:00
41 changed files with 4618 additions and 253 deletions

View File

@@ -0,0 +1,51 @@
---
description: Scaffold and build a new component following FA conventions and the full component lifecycle.
---
# Build Component
Create a new component following the FA design system conventions and the
component lifecycle defined in `docs/reference/component-lifecycle.md`.
## Arguments
Provide the tier and component name (e.g., `atom Button`, `molecule PriceCard`).
## Pre-flight
1. Read `docs/memory/component-registry.md` — confirm component is planned, not already in-progress
2. **Dependency check (molecules):** all constituent atoms must be `done`
3. **Dependency check (organisms):** all constituent molecules and atoms must be `done`
4. Read `docs/conventions/component-conventions.md` — follow the template
5. Read `docs/design-system.md` — understand brand context for this component
6. Read `docs/memory/token-registry.md` — know available tokens
If any dependency is not met, STOP and report what's missing.
## Build
1. Create folder: `src/components/{tier}/{ComponentName}/`
2. Create `ComponentName.tsx`:
- Extend appropriate MUI base component props
- All visual values from theme (never hardcode)
- `React.forwardRef` for interactive elements
- Forward `sx` prop
- JSDoc on every prop
- `displayName` set
3. Create `ComponentName.stories.tsx`:
- `tags: ['autodocs']` in meta
- Stories: Default, AllVariants, AllSizes, Disabled, Loading, Error, LongContent, MinimalContent
4. Create `index.ts` barrel export
5. Create component tokens in `tokens/component/{component}.json` if needed
## Verify
- Open story in Storybook at `localhost:6006`
- Confirm all stories render without errors
- Check visual appearance matches design system
## After
- Update `docs/memory/component-registry.md` — mark as `review`
- Update `docs/memory/token-registry.md` if tokens were created
- Update `docs/memory/decisions-log.md` with any design decisions

View File

@@ -0,0 +1,91 @@
---
description: Review and tweak an existing page — visual QA, spacing adjustments, copy refinement, responsive check. Current phase focus.
---
# Page Review
Review an existing page component for visual quality, consistency, and polish.
This is the primary workflow for the current project phase (tweaking and
finalising pages rather than building from scratch).
## Arguments
Provide the page name (e.g., `ProvidersStep`, `HomePage`, `CoffinsStep`).
## Steps
### 1. Gather context
- Read the page source: `src/components/pages/{PageName}/{PageName}.tsx`
- Read its stories: `src/components/pages/{PageName}/{PageName}.stories.tsx`
- Check `docs/memory/session-log.md` for recent changes to this page
- Check `docs/memory/decisions-log.md` for relevant design decisions
### 2. Open in Storybook
Navigate to `localhost:6006` and open the page's default story.
### 3. Review checklist
At each breakpoint (375px mobile, 768px tablet, 1280px desktop):
**Visual hierarchy**
- Heading sizes follow a clear progression (display → h1 → h2 → body)
- Primary action is visually prominent
- Whitespace groups related content, separates unrelated
**Spacing**
- All spacing uses `theme.spacing()` — no magic numbers
- Consistent gaps between sections (typically spacing(4) or spacing(6))
- No visual crowding; no excessive empty space
**Typography**
- Body text line length between 45-75 characters
- Line heights readable (1.5 for body, tighter for headings)
- Font weights create clear distinction between labels and content
**Colour**
- All colours from theme/tokens — no raw hex in source
- Contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)
- Error states use copper (#B0610F), not red
**Responsive**
- Content reflows cleanly between breakpoints
- No horizontal overflow or content clipping
- Touch targets at least 44px on mobile
- Images and cards scale proportionally
**Copy**
- Warm but not gushy — understated empathy
- Labels are clear, unambiguous, sentence case
- CTAs use action verbs ("Continue", "Select", "Save")
- No jargon or funeral-industry terms without context
**Interactive states**
- Hover, focus, disabled visible on all controls
- Focus indicators: 2px solid, 2px offset, visible on `:focus-visible`
- Disabled: reduced opacity, no pointer events
**Edge cases**
- Long content handled (truncation, scroll, or wrap)
- Empty/minimal content doesn't break layout
- Loading states present where data is async
### 4. Make targeted fixes
For issues found:
- Make small, focused changes (spacing, copy, alignment)
- Do NOT restructure the page — this is polish, not redesign
- Reference design decisions from decisions-log.md when relevant
### 5. Before/after comparison
- Screenshot the page at each breakpoint BEFORE and AFTER changes
- Present both to the user for approval
### 6. Report
Summarise:
- Changes made (with file and line references)
- Remaining issues (with severity P0/P1/P2)
- Suggested next steps (e.g., "run fa-preflight before committing")

View File

@@ -0,0 +1,52 @@
---
description: Pre-commit quality checks — TypeScript, ESLint, Prettier, Storybook build, token sync, hardcoded values. // turbo
---
# Preflight Checks
Run all quality gates before committing. Report PASS/FAIL/WARN for each.
## Checks
1. **TypeScript compilation**
Run `npx tsc --noEmit`
FAIL if any type errors. Report file and line for each error.
2. **ESLint**
Run `npm run lint`
FAIL if any errors (warnings are WARN). Report count and top issues.
3. **Prettier**
Run `npm run format:check`
FAIL if any files need formatting. List files.
4. **Storybook build**
Run `npx storybook build --quiet`
FAIL if build errors. Report the failing component.
5. **Token sync**
Compare timestamps: are any `tokens/**/*.json` files newer than `src/theme/generated/`?
WARN if generated files are stale. Fix: `npm run build:tokens`
6. **Hardcoded values scan**
Search `src/components/**/*.tsx` (excluding `*.stories.tsx`) for hex colour patterns (`#[0-9a-fA-F]{3,8}`).
WARN for each match not on a line containing `// ok-hardcode`.
7. **Component exports**
For each component folder in `src/components/`, verify `index.ts` exists and re-exports the component.
FAIL if any component is missing its barrel export.
## Report format
| Check | Status | Details |
|-------|--------|---------|
| TypeScript | PASS/FAIL | error count |
| ESLint | PASS/FAIL/WARN | error/warning count |
| Prettier | PASS/FAIL | files needing format |
| Storybook | PASS/FAIL | failing component |
| Token sync | PASS/WARN | stale files |
| Hardcoded values | PASS/WARN | matches found |
| Exports | PASS/FAIL | missing index.ts |
If all PASS: safe to commit.
If any FAIL: fix before committing.

View File

@@ -0,0 +1,37 @@
---
description: Start a work session — read memory files, check project status, report what needs attention.
---
# Session Start
Read project state and report a summary before beginning work.
## Steps
1. **Read recent activity**
- Read `docs/memory/session-log.md` — summarise the last 2-3 sessions
- Note any incomplete work or open questions
2. **Check component status**
- Read `docs/memory/component-registry.md`
- Count components by status: done, in-progress, planned, needs-revision
- Flag any components stuck in "in-progress" or "needs-revision"
3. **Check token status**
- Read `docs/memory/token-registry.md`
- Note any gaps or recently added tokens
4. **Check pending reviews**
- Read `docs/reference/retroactive-review-plan.md`
- List any tiers or components that haven't been reviewed yet
5. **Check recent decisions**
- Read `docs/memory/decisions-log.md`
- Note the 3 most recent decisions and their status
6. **Report**
Present a brief summary:
- Recent activity (1-2 sentences)
- Component stats (done/in-progress/planned)
- Pending review items
- Suggested next actions

View File

@@ -0,0 +1,28 @@
---
description: Rebuild CSS and JS outputs from token JSON sources. // turbo
---
# Token Sync
Validate token JSON and regenerate all outputs.
## Steps
1. **Validate token files**
Read each file in `tokens/primitives/`, `tokens/semantic/`, `tokens/component/`.
Every token object must have `$value`, `$type`, and `$description`.
Report any tokens missing required fields.
2. **Build tokens**
Run `npm run build:tokens`
Report success or failure with error details.
3. **Verify outputs**
Confirm these generated files exist and are non-empty:
- `src/theme/generated/tokens.css`
- `src/theme/generated/tokens.js`
Report any missing or empty outputs.
4. **Check theme integration**
Read `src/theme/index.ts` — are any newly added tokens missing from the MUI theme mapping?
Report tokens that exist in generated output but aren't consumed by the theme.

View File

@@ -0,0 +1,40 @@
---
description: Browser-based visual QA of a component or page in Storybook. Checks spacing, alignment, states, responsive behaviour.
---
# Visual QA
Open a component or page story in Storybook and perform a visual quality check
at multiple breakpoints.
## Arguments
Provide the component name or story path (e.g., `ProvidersStep`, `Button`, `Pages/HomePage`).
## Steps
1. **Ensure Storybook is running** at `localhost:6006`
2. **Navigate to the story**
Construct URL: `http://localhost:6006/?path=/story/{tier}-{componentname}--default`
If the component is a page: `pages-{pagename}--default`
3. **Check at three breakpoints**
For each of mobile (375px), tablet (768px), desktop (1280px):
- **Spacing:** consistent use of the spacing scale, no visual crowding or excessive gaps
- **Alignment:** elements aligned to grid, no off-by-one shifts
- **Typography:** hierarchy readable, line lengths between 45-75 characters, weights distinct
- **Colour:** all values appear to come from the design system (warm golds, neutral greys, copper accents)
- **Interactive states** (if applicable): hover, focus, disabled look correct
- **Overflow:** no content clipping, horizontal scroll, or text truncation issues
- **Touch targets:** buttons and interactive elements appear at least 44px at mobile
4. **Screenshot each breakpoint**
Capture and present to user for review.
5. **Report findings**
List issues found with severity:
- **P0:** Broken layout, missing content, inaccessible controls
- **P1:** Spacing inconsistency, contrast failure, overflow at one breakpoint
- **P2:** Minor alignment, typography weight, polish items

90
AGENTS.md Normal file
View File

@@ -0,0 +1,90 @@
# FA 2.0 Design System
## Project
Funeral Arranger (funeralarranger.com.au) design system rebuild. Client: Parsons
(H.Parsons Funeral Directors). Users are often in distress — the design language
must be warm, professional, trustworthy, and calm.
## Tech stack
- React 18 + TypeScript
- Material UI (MUI) v5
- Storybook 8+ with autodocs
- Style Dictionary for token transformation
- W3C DTCG token format (2025.10 stable spec)
## Architecture
Source of truth: token JSON files in `tokens/` (DTCG format).
Flow: Token JSON → Style Dictionary → MUI theme + CSS vars → React components → Storybook
Token tiers: primitives (`tokens/primitives/`) → semantic (`tokens/semantic/`) → component (`tokens/component/`)
Component tiers: atoms → molecules → organisms → templates → pages (in `src/components/`)
## Commands
- `npm run build:tokens` — regenerate CSS/JS from token JSON (run after any token change)
- `npm run storybook` — dev server on localhost:6006
- `npm run type-check` — TypeScript compilation check
- `npm run lint` / `npm run lint:fix` — ESLint
- `npm run format` / `npm run format:check` — Prettier
- `npm run test` — Vitest
## Rules (always)
1. Consume the MUI theme for all visual values — `theme.palette.*`, `theme.spacing()`, `theme.typography.*`
2. Every token has `$value`, `$type`, and `$description` (DTCG format)
3. Error styling uses copper (#B0610F) not red — grief-sensitive context
4. Read `docs/memory/` before starting work (decisions-log, component-registry, token-registry, session-log)
5. Update `docs/memory/` after completing work
6. Run `npm run build:tokens` after any token JSON change
7. Verify in Storybook before marking any component done
8. Follow the component lifecycle in `docs/reference/component-lifecycle.md`
9. Commit and push after each coherent unit of work
## Rules (never)
- Use raw hex values in component source files — map to semantic tokens
- Mix token access methods — semantic via `theme.*`, component via `var(--fa-*)`
- Skip the barrel export (`index.ts`) for any component
- Edit files owned by another agent without coordinating (see File ownership)
## Git workflow
Remote: Gitea at `http://192.168.50.211:3000/richie/ParsonsFA.git`
Branch: all work on `main` for now.
Stage specific files (not `git add -A`). Commit message format:
```
Short summary (imperative mood, <70 chars)
- Bullet points with detail if needed
- Reference decision IDs (D001, D002...) when relevant
```
## File conventions
- Component folders: PascalCase — `Button/`, `PriceCard/`
- Token files: camelCase — `colours.json`, `typography.json`
- Each component folder: `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts`
- CSS custom properties prefix: `--fa-`
- Semantic tokens via theme, component tokens via CSS vars — see `docs/conventions/token-conventions.md`
- Component structure and props: see `docs/conventions/component-conventions.md`
## Key references
- Design spec: `docs/design-system.md`
- Component lifecycle: `docs/reference/component-lifecycle.md`
- Flow architecture (wizard steps, branching, fields): `documentation/flow-definition.yaml`
- Redesigned flow spec: `documentation/steps/flow-definition.yaml` + `documentation/steps/flow-spec.md`
- Cross-tool workflow: `docs/reference/cross-tool-workflow.md`
## File ownership
One file, one owner at a time. Check `docs/memory/session-log.md` before starting.
- **Component source, tokens, theme, memory files:** Claude Code owns writes
- **Visual QA, browser verification, screenshots:** Antigravity owns
- **Shared read:** all documentation, all source files, AGENTS.md
- **When backend agent starts:** backend owns `src/payload/`, `src/api/`; frontend agents never touch those. `documentation/flow-definition.yaml` is the shared contract.

124
CLAUDE.md
View File

@@ -1,113 +1,45 @@
# FA 2.0 Design System # Claude Code — FA 2.0 Design System
## Project overview Follow all rules in `AGENTS.md`. This file contains Claude-specific behaviour only.
Rebuilding the Funeral Arranger (funeralarranger.com.au) design system using a ## Session startup (automatic)
code-first approach. Parsons (H.Parsons Funeral Directors) is the client. FA is
an Australian funeral planning platform — the design language must be warm,
professional, trustworthy, and calm. Users are often in distress.
## Tech stack At the start of every session, automatically:
1. Read `docs/memory/session-log.md`, `decisions-log.md`, `component-registry.md`
2. Check `docs/reference/retroactive-review-plan.md` for pending review tiers
3. If reviews remain, run the next step (~30-60 min) before the user's main request
4. Report findings, then shift to whatever the user wants to work on
- React 18 + TypeScript This is "morning housekeeping" — the user has approved this and expects it.
- Material UI (MUI) v5
- Storybook 8+ with autodocs
- Style Dictionary for token transformation
- W3C DTCG token format (2025.10 stable spec)
- Chromatic for Storybook hosting (later)
## Architecture
**Source of truth:** Token JSON files in `tokens/` (DTCG format)
**Flow:** Token JSON → Style Dictionary → MUI theme + CSS vars → React components → Storybook
### Token tiers
1. **Primitives** (`tokens/primitives/`): Raw values — hex, px, font names, scales
2. **Semantic** (`tokens/semantic/`): Design intent — `color.text.primary`, `color.surface.default`
3. **Component** (`tokens/component/`): Per-component — `button.background.default`
### Component tiers (atomic design)
1. **Atoms** (`src/components/atoms/`): Button, Input, Typography, Badge, Icon, Avatar, Divider, Chip, Card, Link
2. **Molecules** (`src/components/molecules/`): SearchBar, PriceCard, ServiceOption, FormField
3. **Organisms** (`src/components/organisms/`): ServiceSelector, PricingTable, ArrangementForm, Navigation
## Critical rules
1. **Every component MUST consume the MUI theme** — never hardcode colours, spacing, typography, or shadows
2. **Every token MUST have a `$description`** — this is how agents maintain context about design intent
3. **Always read docs/design-system.md** before creating or modifying anything
4. **Always check docs/memory/** before starting work — these files contain decisions and state from previous sessions
5. **Always update docs/memory/** after completing work — log what was done, decisions made, and open questions
6. **Run `npm run build:tokens`** after any token JSON change
7. **Verify in Storybook** before marking any component done
8. **Follow the component lifecycle** — see `docs/reference/component-lifecycle.md` for the full quality gate sequence (build → QA → polish → present → iterate → normalize → preflight → commit)
9. **Commit and push after completing each unit of work** — see Git workflow below
## Git workflow
**Remote:** Gitea at `http://192.168.50.211:3000/richie/ParsonsFA.git` (credentials stored via git credential helper)
**After completing each unit of work** (a component, a token change, a bug fix, etc.):
1. Stage the changed files (`git add` — prefer naming specific files over `git add -A`)
2. Commit with a clear message describing what was done
3. Push to origin (`git push`)
This is **not optional** — the user relies on the git history for rollback safety. Each commit should represent a coherent, working state (Storybook builds, TypeScript compiles). Do not batch multiple unrelated changes into a single commit.
**Commit message format:**
```
Short summary (imperative mood, <70 chars)
- Bullet points with detail if needed
- Reference decision IDs (D001, D002...) when relevant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
```
**Branch strategy:** All work on `main` for now. Feature branches when the project grows.
## Memory system ## Memory system
This project uses structured markdown files for cross-session memory. Before starting any work, read:
- `docs/memory/decisions-log.md` — all design decisions with rationale
- `docs/memory/component-registry.md` — status of every component
- `docs/memory/token-registry.md` — all tokens with values and usage notes
- `docs/memory/session-log.md` — recent sessions; older in `docs/memory/archive/`
**Before starting any work, read these files:** After completing work, update the relevant memory files and `session-log.md`.
- `docs/memory/decisions-log.md` — All design decisions with rationale
- `docs/memory/component-registry.md` — Status of every component (planned/in-progress/done)
- `docs/memory/token-registry.md` — All tokens with their current values and usage notes
- `docs/memory/session-log.md` — Recent sessions (last 2-3); older sessions in `docs/memory/archive/`
**Session startup — proactive review pass (do this automatically):**
Check `docs/reference/retroactive-review-plan.md` for the current review state. If
there are tiers or components that haven't been reviewed yet, run the next review
step (typically `/normalize {tier}` or `/audit {component}`) at the start of the
session before the user's main request. Keep this to ~30-60 min, then report
findings and shift to whatever the user wants to work on. This is "morning
housekeeping" — the user has approved this and expects it to happen without asking.
**After completing work, update:**
- The relevant memory files with what changed
- `docs/memory/session-log.md` with a summary of what was accomplished and next steps
## MCP servers ## MCP servers
- **Figma remote MCP** (`figma-remote-mcp`): Read FA 1.0 designs, extract design context - **Figma remote MCP** — read FA 1.0 designs, extract design context
- **Storybook MCP** (`storybook`): Query component library for available components and props - **Storybook MCP** — query component library for available components and props
Setup instructions in `docs/reference/mcp-setup.md`. Setup instructions: `docs/reference/mcp-setup.md`
## File conventions ## Commit co-author
- Component folders: PascalCase (`Button/`, `PriceCard/`) Append to every commit message:
- Token files: camelCase (`colours.json`, `typography.json`) ```
- Each component folder contains: `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts` Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CSS custom properties prefix: `--fa-` (e.g., `--fa-color-brand-primary`) ```
- MUI theme paths: follow MUI conventions (`palette.primary.main`)
## Naming conventions for tokens ## Cross-tool workflow
See `docs/conventions/token-conventions.md` for the full specification. This project uses both Claude Code and Google Antigravity.
See `docs/reference/cross-tool-workflow.md` for task routing and coordination.
Quick reference: Claude Code owns: component building, token creation, code quality, memory updates, git.
- Primitives: `color.blue.500`, `spacing.4`, `fontSize.base` Antigravity owns: visual QA, browser-based verification, page-level review screenshots.
- Semantic: `color.text.primary`, `color.surface.default`, `color.interactive.default`
- Component: `button.background.default`, `button.background.hover`

View File

@@ -1,58 +1,26 @@
# FA 2.0 Design System — Antigravity Rules # Antigravity — FA 2.0 Design System
## Project context Follow all rules in `AGENTS.md`. This file contains Antigravity-specific behaviour only.
Funeral Arranger (funeralarranger.com.au) design system rebuild. Parsons is the ## Visual verification
client. The design language must be warm, professional, trustworthy, and calm.
Users are often in distress.
## Tech stack
- React 18 + TypeScript
- Material UI (MUI) v5
- Storybook 8+ (running on localhost:6006)
- Style Dictionary for token transformation
- W3C DTCG token format
## Hard rules
1. **Never hardcode colours, spacing, typography, or shadows** — always consume the MUI theme (`theme.palette.*`, `theme.spacing()`, `theme.typography.*`) or CSS custom properties (`var(--fa-*)`)
2. **Never use raw hex values in components** — map to semantic tokens
3. **Follow atomic design tiers:** atoms → molecules → organisms → templates → pages
4. **Component folders:** PascalCase, each contains `ComponentName.tsx`, `ComponentName.stories.tsx`, `index.ts`
5. **Token files:** camelCase in `tokens/` directory (primitives → semantic → component tiers)
6. **CSS custom properties prefix:** `--fa-`
7. **Error styling uses copper (#B0610F)** not red — this is intentional for grief-sensitive context
8. **Copy tone:** warm but not gushy. Understated empathy. Let UI structure be the empathy.
## Token access convention
- Semantic tokens: `theme.palette.*`, `theme.typography.*`, `theme.spacing()`
- Component tokens: `var(--fa-component-property-state)`
- Never mix — semantic via theme, component via CSS vars
## Storybook
Dev server runs on `localhost:6006`. When verifying components visually, navigate
to the specific story URL. Story IDs follow the pattern:
`/story/{tier}-{componentname}--{variantname}`
## File structure
```
src/components/atoms/ — Button, Input, Typography, Card, etc.
src/components/molecules/ — SearchBar, PriceCard, ServiceOption, etc.
src/components/organisms/ — Navigation, Footer, ArrangementDialog, etc.
src/components/templates/ — WizardLayout
src/components/pages/ — IntroStep, ProvidersStep, etc. (wizard steps)
tokens/primitives/ — Raw values (hex, px, font names)
tokens/semantic/ — Design intent (color.text.primary)
tokens/component/ — Per-component tokens
```
## When making visual changes
When reviewing or making visual changes:
1. Make the code change 1. Make the code change
2. Open the Storybook story in the browser to verify 2. Open the Storybook story in the browser at `localhost:6006`
3. Screenshot and check spacing, alignment, visual weight 3. Screenshot and check spacing, alignment, visual weight
4. Self-correct if something looks off before presenting 4. Self-correct if something looks off before presenting
Story URL pattern: `/story/{tier}-{componentname}--{variantname}`
## Cross-tool workflow
This project uses both Claude Code and Google Antigravity.
See `docs/reference/cross-tool-workflow.md` for task routing and coordination.
Antigravity owns: visual QA, browser verification, page-level review screenshots.
Claude Code owns: component building, token creation, code quality, memory updates, git.
## Workflows
Custom workflows available in `.agent/workflows/`:
`fa-session-start`, `fa-preflight`, `fa-token-sync`, `fa-visual-qa`, `fa-build-component`, `fa-page-review`

View File

@@ -28,6 +28,7 @@ duplicates) and MUST update it after completing one.
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. | | Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. | | Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. | | Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
| MapPin | done | name+price (two-line), name-only, price-only × verified, unverified × default, active | mapPin.paddingX/borderRadius/nub.size, color.brand.100-900, color.neutral.100-800 | Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Pure CSS. role="button" + keyboard + focus ring. |
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. | | ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. | | Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. | | Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
@@ -39,6 +40,8 @@ duplicates) and MUST update it after completing one.
| Component | Status | Composed of | Notes | | Component | Status | Composed of | Notes |
|-----------|--------|-------------|-------| |-----------|--------|-------------|-------|
| MiniCard | done | Card + Typography + Badge + Tooltip | Compact vertical card for grids, recommendations, map popups. Image + title + optional price/badges/chips/meta (location, rating, capacity). Verified = icon-only circle badge in image. Hierarchy: title → meta → price → badges → chips. Truncated title shows tooltip. 3 component tokens. Audit: 20/20. |
| MapPopup | done | Paper + Typography + Tooltip | Floating map popup anchored to MapPin. Clickable card (onClick). Image + name (1 line, tooltip) + meta + price. Verified = icon-only circle badge in image (matches MiniCard). Hierarchy matches MiniCard. Nub + drop-shadow. 260px wide. |
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation | | FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. | | ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. | | VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
@@ -50,6 +53,7 @@ duplicates) and MUST update it after completing one.
| LineItem | done | Typography + Tooltip + InfoOutlinedIcon | Name + optional info tooltip + optional price. Supports allowance asterisk, total variant (bold + top border). Font weight 500 (D019), prices text.secondary for readability hierarchy. Audit: 19/20. | | LineItem | done | Typography + Tooltip + InfoOutlinedIcon | Name + optional info tooltip + optional price. Supports allowance asterisk, total variant (bold + top border). Font weight 500 (D019), prices text.secondary for readability hierarchy. Audit: 19/20. |
| ProviderCardCompact | done | Card (outlined) + Typography | Horizontal compact provider card — image left, name + location + rating right. Used at top of Package Select page. Separate from vertical ProviderCard. | | ProviderCardCompact | done | Card (outlined) + Typography | Horizontal compact provider card — image left, name + location + rating right. Used at top of Package Select page. Separate from vertical ProviderCard. |
| CartButton | done | Button + DialogShell + LineItem + Divider + Typography | Outlined pill trigger: receipt icon + "Your Plan" + formatted total in brand colour. Click opens DialogShell with items grouped by section via LineItem, total row. Mobile: icon + price only. Lives in WizardLayout `runningTotal` slot. | | CartButton | done | Button + DialogShell + LineItem + Divider + Typography | Outlined pill trigger: receipt icon + "Your Plan" + formatted total in brand colour. Click opens DialogShell with items grouped by section via LineItem, total row. Mobile: icon + price only. Lives in WizardLayout `runningTotal` slot. |
| CompareBar | done | Badge + Button + IconButton + Typography + Paper + Slide | Floating comparison basket pill. Fixed bottom, slide-up/down. Package count badge + provider names + remove buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline error for max-reached. Mobile: compact count + CTA only. Audit: 18/20. |
## Organisms ## Organisms
@@ -57,7 +61,8 @@ duplicates) and MUST update it after completing one.
|-----------|--------|-------------|-------| |-----------|--------|-------------|-------|
| ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. | | ServiceSelector | done | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display | | PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
| PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections (before total) + total + extras (after total, with subtext). T&C grey footer. Audit: 19/20. Maps to Figma Package Select (5405:181955). | | PackageDetail | done | LineItem × n + Typography + Button + Divider | Right-side package detail panel. Warm header band (surface.warm) with "Package" overline, name, price (brand colour), Make Arrangement + Compare (with loading) buttons. Sections: Essentials + Optionals (before total) + total + Extras (after total, with subtext). `priceLabel` pass-through to LineItem (D039). T&C grey footer. Audit: 19/20. |
| ComparisonTable | done | Typography + Button + Badge + Link + Tooltip | Side-by-side package comparison CSS Grid. Sticky header cards with provider info + price + CTA. Row-merged sections (union of all items). 7 cell value types (discriminated union D036). Recommended column with warm bg + badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA table roles. Desktop only (mobile in ComparisonPage). Audit: 17/20. |
| FuneralFinder (V3) | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | **Production version.** Hero search widget — clean form with status cards. Standard card container (surface.raised, card shadow). "How Can We Help" section: two side-by-side StatusCards (Immediate Need default-selected / Pre-planning) — white bg, neutral border, brand border + warm bg when selected, stack on mobile. "Funeral Type" Select + "Location" OutlinedInput with pin icon — standard outlined fields, no focus ring (per design). Overline section labels (text.secondary). CTA "Find Funeral Directors →" always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". Dividers after header and before CTA. WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). | | FuneralFinder (V3) | done | Typography + Button + Divider + Select + MenuItem + OutlinedInput + custom StatusCard/SectionLabel | **Production version.** Hero search widget — clean form with status cards. Standard card container (surface.raised, card shadow). "How Can We Help" section: two side-by-side StatusCards (Immediate Need default-selected / Pre-planning) — white bg, neutral border, brand border + warm bg when selected, stack on mobile. "Funeral Type" Select + "Location" OutlinedInput with pin icon — standard outlined fields, no focus ring (per design). Overline section labels (text.secondary). CTA "Find Funeral Directors →" always active — validates on click, scrolls to first missing field. Required: status + location. Funeral type defaults to "show all". Dividers after header and before CTA. WAI-ARIA roving tabindex on radiogroup. aria-labelledby via useId(). Critique: 33/40 (Good). Audit: 18/20 (Excellent). |
| FuneralFinder V1 | archived | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Archived — viewable in Storybook under Archive/. Stepped conversational flow. Audit: 14/20. Critique: 29/40. | | FuneralFinder V1 | archived | Typography + Button + Chip + Input + Divider + Link + custom ChoiceCard/TypeCard/CompletedRow/StepHeading | Archived — viewable in Storybook under Archive/. Stepped conversational flow. Audit: 14/20. Critique: 29/40. |
| FuneralFinder V2 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Archived — viewable in Storybook under Archive/. Quick-form with step circles. Audit: 18/20. Critique: 33/40. | | FuneralFinder V2 | archived | Typography + Button + Input + Divider + Select + MenuItem + custom StepCircle | Archived — viewable in Storybook under Archive/. Quick-form with step circles. Audit: 18/20. Critique: 33/40. |
@@ -97,6 +102,7 @@ duplicates) and MUST update it after completing one.
| ConfirmationStep | done | WizardLayout (centered-form) + Button | Wizard step 15 — confirmation. Terminal page. At-need: "submitted" + callback. Pre-planning: "saved" + return-anytime. Muted success icon. | | ConfirmationStep | done | WizardLayout (centered-form) + Button | Wizard step 15 — confirmation. Terminal page. At-need: "submitted" + callback. Pre-planning: "saved" + return-anytime. Muted success icon. |
| UnverifiedProviderStep | done | WizardLayout (list-detail) + ProviderCardCompact + ProviderCard + Badge + Button + Divider + Typography | Unverified provider detail. Left: compact card + "Listing" badge + available info (conditional dl) + verified recommendations. Right: warm header band + detail rows + "Make an Enquiry" CTA. Graceful degradation (no data → straight to enquiry). 4 story variants. | | UnverifiedProviderStep | done | WizardLayout (list-detail) + ProviderCardCompact + ProviderCard + Badge + Button + Divider + Typography | Unverified provider detail. Left: compact card + "Listing" badge + available info (conditional dl) + verified recommendations. Right: warm header band + detail rows + "Make an Enquiry" CTA. Graceful degradation (no data → straight to enquiry). 4 story variants. |
| HomePage | done | FuneralFinderV3/V4 (via finderSlot) + ProviderCardCompact + Button + Typography + Accordion + Divider + Navigation (prop) + Footer (prop) | Marketing landing page. 4 archived versions: V1 (split hero), V2 (full-bleed parsonshero.png), V3 (hero-3.png + updated copy + logo bar + venue photos + warm CTA gradient), V4 (same as V3 but with FuneralFinderV4 stepped form via finderSlot). `finderSlot` prop allows swapping finder widget. Light grey footer (surface.subtle). | | HomePage | done | FuneralFinderV3/V4 (via finderSlot) + ProviderCardCompact + Button + Typography + Accordion + Divider + Navigation (prop) + Footer (prop) | Marketing landing page. 4 archived versions: V1 (split hero), V2 (full-bleed parsonshero.png), V3 (hero-3.png + updated copy + logo bar + venue photos + warm CTA gradient), V4 (same as V3 but with FuneralFinderV4 stepped form via finderSlot). `finderSlot` prop allows swapping finder widget. Light grey footer (surface.subtle). |
| ComparisonPage | done | WizardLayout (wide-form) + ComparisonTable + Chip + Card + LineItem + Typography + Button + Divider | Package comparison page. Desktop: full ComparisonTable with sticky headers. Mobile: tabbed card view with horizontal chip rail (role="tablist") + single package card (role="tabpanel"). Recommended package as additional column/tab (separate prop D038). Back link, help bar. |
## Future enhancements ## Future enhancements

View File

@@ -293,3 +293,43 @@ contradict a previous one.
**Rationale:** P0/P1 are the issues that affect usability and accessibility. P2/P3 are cosmetic — not worth the risk of changing approved components. Interleaving ensures the foundation is solid before building on it, without dedicating entire sessions to review. **Rationale:** P0/P1 are the issues that affect usability and accessibility. P2/P3 are cosmetic — not worth the risk of changing approved components. Interleaving ensures the foundation is solid before building on it, without dedicating entire sessions to review.
**Affects:** Session workflow, CLAUDE.md startup procedure, docs/reference/retroactive-review-plan.md **Affects:** Session workflow, CLAUDE.md startup procedure, docs/reference/retroactive-review-plan.md
**Alternatives considered:** Dedicated review sessions — rejected as less efficient. Full P0-P3 fixes — rejected as too risky for approved components. **Alternatives considered:** Dedicated review sessions — rejected as less efficient. Full P0-P3 fixes — rejected as too risky for approved components.
### D035 — Package sections standardised to Essentials / Optionals / Extras
**Date:** 2026-04-06
**Category:** component
**Decision:** Package data uses three sections: **Essentials** (priced core items), **Optionals** (complimentary inclusions), **Extras** (additional-cost items after the total). Replaces the previous "Complimentary Items" naming.
**Rationale:** Matches the real-world package structure from FA's provider data (see reference image). "Optionals" better communicates that these are included-but-not-mandatory items, while "Complimentary" is a price label on individual items, not a section name.
**Affects:** PackageDetail stories, ComparisonTable sections, ComparisonPage mobile cards
**Alternatives considered:** "Inclusions" instead of "Optionals" — rejected as it overlaps with Essentials (which are also inclusions).
### D036 — ComparisonCellValue uses discriminated union type
**Date:** 2026-04-06
**Category:** architecture
**Decision:** Cell values in ComparisonTable use a tagged union type (`{ type: 'price' | 'allowance' | 'complimentary' | 'included' | 'poa' | 'unknown' | 'unavailable' }`) rather than flat optional props.
**Rationale:** Ensures exhaustive pattern matching in CellValue renderer — the TypeScript compiler catches missing cases. Clearer than a flat `{ price?: number; priceLabel?: string; isAllowance?: boolean }` which has ambiguous combinations. Each value type maps to a distinct visual treatment.
**Affects:** ComparisonTable, ComparisonPage mobile card view
**Alternatives considered:** Reusing PackageLineItem from PackageDetail — rejected as it conflates "how data is stored" with "how data is displayed". The comparison needs explicit cell state (e.g. "unavailable" vs "unknown").
### D037 — Mobile comparison uses chip tabs, not horizontal scroll table
**Date:** 2026-04-06
**Category:** component
**Decision:** ComparisonPage renders a chip-based tab rail + single card view on mobile, rather than a horizontally scrollable table.
**Rationale:** Wide comparison tables on small screens create "hidden column" problems — users can't see all packages at once and may miss columns. Card view with tabs matches mental model of reviewing one option at a time. Lower cognitive load for FA's grief-sensitive audience. Tab rail provides quick switching. ARIA tablist/tabpanel semantics.
**Affects:** ComparisonPage mobile layout
**Alternatives considered:** Horizontal scroll table — rejected for poor usability on small screens. Accordion per package — rejected as it hides content behind extra taps.
### D038 — Recommended package is a separate prop, not mixed into packages array
**Date:** 2026-04-06
**Category:** architecture
**Decision:** ComparisonPage accepts `recommendedPackage` as a separate prop from `packages`. The page merges it as the last column with `isRecommended: true`.
**Rationale:** Keeps the user-selected array clean and unambiguous. The recommendation source is explicit (server-side logic). The page controls placement (always last column/tab). Prevents accidental removal of the recommended package by the user (no Remove button).
**Affects:** ComparisonPage props, ComparisonTable isRecommended column
**Alternatives considered:** Including recommended in the packages array with a flag — rejected as it mixes user selections with system recommendations.
### D039 — PackageLineItem gains priceLabel for consistency with LineItem
**Date:** 2026-04-06
**Category:** component
**Decision:** Added `priceLabel?: string` to `PackageLineItem` interface in PackageDetail, passed through to LineItem molecule.
**Rationale:** LineItem already supports `priceLabel` for custom price text ("Complimentary", "Price On Application", "TBC"). PackageDetail's type was missing this field, forcing workarounds. Adding it enables the Optionals section to display "Complimentary" labels and Extras to show "Price On Application".
**Affects:** PackageDetail component + stories, any consumer of PackageLineItem type
**Alternatives considered:** None — this was a straightforward type parity fix.

View File

@@ -26,6 +26,122 @@ Each entry follows this structure:
## Sessions ## Sessions
### Session 2026-04-07 — Package comparison iteration (Figma-informed)
**Agent(s):** Claude Opus 4.6 (1M context)
**Work completed:**
- **ComparisonTable major iteration** from Figma feedback:
- Dark info card → soft grey info card (surface.subtle, no border), stretches to match card heights, text at top
- Provider cards: no logos, floating verified badge (VerifiedOutlinedIcon, consistent with ProviderCard/MiniCard/MapPopup), rating in cards (body2 size)
- Separate bordered tables per section (Essentials, Optionals, Extras) with left accent borders (3px brand-500)
- Reviews section removed (rating lives in cards)
- Horizontal scroll on narrow desktops (minWidth enforcement)
- Cards: flex stretch + spacer for CTA bottom-alignment across mixed verified/unverified
- Row hover highlight (brand-50), font hierarchy (labels text.secondary, values fontWeight 600)
- **ComparisonPage iteration:**
- Share + Print buttons in page header (onShare, onPrint props)
- Mobile verified badge (VerifiedOutlinedIcon in soft brand Badge)
- Mobile section headings with left accent borders
- Mobile item rows: 60% max-width for names, inline info icons with nowrap binding
- Mobile tab rail: mini Card components (provider name + package name) replacing Chips
- Navigation included by default in all stories
- **CompareBar simplified:**
- Fraction badge (1/3, 2/3, 3/3)
- Contextual copy: "Add another to compare" / "Ready to compare"
- Removed package names and remove buttons from pill
- **Figma integration:**
- Created `/capture-to-figma` skill — captures Storybook stories to Parsons Figma file
- Created `/figma-ideas` skill — fetches Figma designs and proposes adaptations
- Successfully captured ComparisonPage to Figma (node 6041-25005)
- Applied user's Figma tweaks (node 6047-25005) back to code
- **Cleanup:** Removed Figma capture script from preview-head.html, Prettier formatting pass
**Decisions made:**
- Info card uses surface.subtle (not dark), stretches to match cards — less visually competing
- Verified badge uses VerifiedOutlinedIcon (consistent with rest of system), floating above cards
- Rating lives in card headers, no separate Reviews table
- Section tables separated with left accent borders (3px brand-500)
- Mobile tab rail uses mini Cards (provider + package name) not Chips
- Share/Print are optional props on ComparisonPage
**Next steps:**
- Commit all work
- Wire CompareBar into PackagesStep/ProvidersStep (state management)
- Consider comparison state persistence (URL params or context)
---
### Session 2026-04-06b — Package comparison feature
**Agent(s):** Claude Opus 4.6 (1M context)
**Work completed:**
- **PackageDetail fix (D039):** Added `priceLabel?: string` to `PackageLineItem` interface, passed through to LineItem. Updated stories to use Essentials/Optionals/Extras sections with realistic funeral data (D035). "Complimentary Items" → "Optionals".
- **CompareBar molecule (new):** Floating comparison basket pill. Fixed bottom, slide-up/down animation. Badge count + provider names + remove × buttons + Compare CTA. Max 3 user packages. Disabled CTA when <2. Inline `role="alert"` error for max-reached. Mobile: compact count + CTA only. Audit: 18/20 (P2s fixed: error visible on mobile, removed redundant aria-disabled).
- **ComparisonTable organism (new):** CSS Grid side-by-side comparison. Sticky header cards with provider logo/name/location/rating + package name + price + CTA. Row-merged sections via `buildMergedSections` union algorithm. 7 cell value types via discriminated union (D036). Recommended column with warm bg + Badge. Verified → "Make Arrangement", unverified → "Make Enquiry". ARIA `role="table"` + `role="row"` + `role="columnheader"` + `role="cell"`. Desktop only. Audit: 17/20 (P2s fixed: aria-label on recommended column, rowheader on section headings, token-based zebra striping).
- **ComparisonPage page (new):** WizardLayout (wide-form). Desktop: full ComparisonTable. Mobile: chip tab rail (`role="tablist"`) + single MobilePackageCard (`role="tabpanel"`). Recommended package as separate prop, merged as last column/tab. Back link, help bar.
- **Stories:** 6 CompareBar stories (Default, SinglePackage, ThreePackages, WithError, Empty, Interactive), 5 ComparisonTable stories (Default, TwoPackages, WithRecommended, MixedVerified, MissingData), 5 ComparisonPage stories (Default, TwoPackages, WithRecommended, MobileView, FullPage with Navigation).
- **Quality gates:** TypeScript ✓, ESLint ✓, Storybook build ✓. CompareBar audit 18/20, ComparisonTable audit 17/20.
**Decisions made:**
- D035: Package sections standardised to Essentials/Optionals/Extras
- D036: ComparisonCellValue uses discriminated union for exhaustive rendering
- D037: Mobile comparison uses chip tabs + card view, not horizontal scroll table
- D038: Recommended package is a separate prop, always additional to user selections
- D039: PackageLineItem gains priceLabel for consistency with LineItem molecule
**Open questions:**
- None
**Next steps:**
- Visual review in Storybook (user + Playwright screenshots)
- Wire CompareBar into PackagesStep (state management for comparison basket)
- Consider adding CompareBar to WizardLayout as a slot or portal
---
### Session 2026-04-06 — Retroactive review completion
**Agent(s):** Claude Opus 4.6 (1M context)
**Work completed:**
- **Phase 1 atoms complete:** Audited Typography (18/20, no P0/P1) and Badge (15/20, P0 `role="status"` determined false positive — would create unwanted aria-live on static labels)
- **Phase 2 molecules complete:** Normalized all 9 molecules — displayName ✓, forwardRef ✓, ARIA ✓, no hardcoded colours. Flagged AddOnOption/ProviderCard ARIA issues reviewed and determined false positives (Switch is semantic control; ProviderCard has `...rest` passthrough)
- **Phase 3 organisms complete:** Normalized 5 active organisms. Audited Navigation (15/20, no real P0/P1 — focus-visible from MUI theme, CSS vars D031-compliant) and ServiceSelector (17/20, **fixed P0: added `aria-required` to radiogroup**)
- **Phase 4 preflight:** TypeScript ✓, ESLint ✓, Prettier ✓, Storybook build ✓
- **Review plan updated:** All phases marked done. Only `/typeset` deferred (low risk)
**Decisions made:**
- Badge `role="status"` rejected: static status labels shouldn't be aria-live regions
- AddOnOption `role="checkbox"` rejected: Switch is the semantic control, Card click is convenience
- CSS var usage in organisms is D031-compliant (CSS vars acceptable for semantic tokens per D031)
**Open questions:**
- From 2026-04-01: Which HomePage version (V3 or V4) is production?
**Next steps:**
- User has components to change/build — shifting to that work
**Work completed (continued):**
- **MiniCard molecule (new + iterated):** Compact vertical card. Image + verified icon-only badge (copper circle, top-right) + title (h6, 2-line max with tooltip on truncation) + meta row + price + badges + chips. Hierarchy: title → meta → price → badges → chips. 3 component tokens. Audit: 20/20.
- **MapPin atom (new + redesigned):** Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold 600). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Padding bumped from 8px to 12px for readability. Pure CSS. role="button" + keyboard + focus ring.
- **MapPopup molecule (new + iterated):** Clickable floating card (onClick). Image + verified icon badge (matches MiniCard) + name (1-line, tooltip on truncation) + meta + price. Hierarchy matches MiniCard. No-image fallback shows inline verified icon + text. Nub + drop-shadow. 260px wide.
- **Component tokens:** miniCard.json and mapPin.json added to Style Dictionary, rebuilt CSS/JS/TS outputs (407 declarations total).
**Decisions made:**
- MiniCard uses `h6` for title (smaller than ProviderCard's `h5`), `caption` for meta, `body2` for price
- MiniCard verified badge is icon-only circle (not text chip) — compact, matches map popup
- MapPin redesigned from price-only pill to two-line name+price label — transparency and clarity
- MapPin price centred under name — left-aligned looked unbalanced (visually reviewed)
- MapPin price weight 600 (semibold) — 500 was too thin at 11px
- MapPin padding 12px (was 8px) — names were tight against edges
- MapPopup whole card clickable — removed "View details" link
- MapPopup name 1-line limit (was 2) with tooltip — tighter for map context
- Chips in MiniCard rendered as soft default Badges (no interactive Chip atom) for visual simplicity
---
### Session 2026-04-01 — FuneralFinder V4, HomePage V3/V4, Footer restyle ### Session 2026-04-01 — FuneralFinder V4, HomePage V3/V4, Footer restyle
**Agent(s):** Claude Opus 4.6 (1M context) **Agent(s):** Claude Opus 4.6 (1M context)

View File

@@ -0,0 +1,184 @@
# Cross-Tool Workflow: Claude Code + Google Antigravity
## How the tools work together
**Claude Code** (terminal) — code generation, refactoring, quality analysis, memory
management. Has 18 custom skills, 3 agents, MCP access (Figma, Storybook).
Strengths: deep code reasoning, multi-file refactors, token architecture, grief-sensitive
copy review, git operations.
**Antigravity** (IDE) — visual verification, browser interaction, page-level review.
Has 6 custom workflows in `.agent/workflows/`. Strengths: built-in browser for
Storybook verification, screenshot comparison across breakpoints, multi-agent
parallelism via Manager View.
**Both read:** `AGENTS.md` (shared rules), `docs/memory/` (shared state), all source files.
---
## Task routing
### Always Claude Code
- Creating new components (`/build-atom`, `/build-molecule`, `/build-organism`)
- Token creation and architecture (`/create-tokens`, `/sync-tokens`)
- Code quality analysis (`/audit`, `/critique`, `/harden`, `/normalize`)
- Writing tests and stories (`/write-tests`, `/write-stories`)
- Visual polish skills (`/polish`, `/typeset`, `/adapt`, `/quieter`, `/clarify`)
- Memory file updates (decisions-log, component-registry, session-log)
- Git operations (commit, push)
- Multi-file refactors
- Figma MCP interactions (design extraction)
### Always Antigravity
- Browser-based visual QA (`/fa-visual-qa` workflow)
- Screenshot comparison across breakpoints (375/768/1280px)
- Page-level visual review (`/fa-page-review` workflow)
- Storybook visual regression checks
### Either tool (user's choice)
- Preflight checks (Claude: `/preflight` | Antigravity: `/fa-preflight`)
- Token sync rebuild (Claude: `/sync-tokens` | Antigravity: `/fa-token-sync`)
- Session status report (Claude: `/status` | Antigravity: `/fa-session-start`)
- Reading and analysing existing code
- Copy and content review
---
## The sandwich workflow
Every significant change should follow a three-step pattern that uses both tools.
### For new components or features
1. **Plan (Claude Code):** Read memory files, check registry, design the approach
2. **Build (Claude Code):** Implement — component, tokens, stories, tests
3. **Verify (Antigravity):** Open in Storybook, screenshot at 3 breakpoints, report issues
### For page tweaking (current phase)
1. **Assess (Antigravity):** Run `/fa-page-review`, screenshot current state, identify issues
2. **Fix (Claude Code):** Make targeted fixes to spacing, copy, alignment, responsive
3. **Verify (Antigravity):** Re-run `/fa-visual-qa`, confirm fixes, screenshot final state
The sandwich ensures no change goes live without both code quality AND visual verification.
---
## Quality gates
The 10-stage component lifecycle (`docs/reference/component-lifecycle.md`) applies
regardless of which tool does the work.
| Stage | Primary tool | Gate |
|-------|-------------|------|
| 1. BUILD | Claude Code | — |
| 2. STORIES | Claude Code | — |
| 3. INTERNAL QA | Claude Code (`/audit`, `/critique`, `/harden`) | P0 = blocking |
| 4. FIX | Claude Code | Until P0/P1 = 0 |
| 5. POLISH | Claude Code (`/polish`, `/typeset`, `/adapt`) | — |
| 6. PRESENT | Antigravity (`/fa-visual-qa`) | Visual sign-off |
| 7. ITERATE | Both (Claude fixes, Antigravity verifies) | User approves |
| 8. NORMALIZE | Claude Code (`/normalize`) | — |
| 9. PREFLIGHT | Either (`/preflight` or `/fa-preflight`) | Critical = blocking |
| 10. COMMIT | Claude Code | — |
**Enforcement:** No component moves past PRESENT without Antigravity visual sign-off.
No component moves to COMMIT without preflight passing.
---
## File ownership boundaries
### Current phase (frontend only)
| Scope | Claude Code | Antigravity |
|-------|------------|-------------|
| `src/components/**/*.tsx` | write | read only |
| `src/components/**/*.stories.tsx` | write | read only |
| `src/components/**/index.ts` | write | read only |
| `tokens/**/*.json` | write | read only |
| `src/theme/**` | write | read only |
| `docs/memory/**` | write | read only |
| `docs/conventions/**`, `docs/reference/**` | write | read only |
| `.agent/workflows/*.md` | read only | write (own workflows) |
| `AGENTS.md` | write (coordinate first) | write (coordinate first) |
| `CLAUDE.md` | write | never |
| `GEMINI.md` | never | write |
### When backend starts (Payload CMS)
- **Backend agent writes:** `src/payload/**`, `src/api/**`, database schemas, server config
- **Frontend agents never touch:** anything in `src/payload/` or `src/api/`
- **Backend agent never touches:** `src/components/**`, `tokens/**`, `src/theme/**`
- **Shared contract:** `documentation/flow-definition.yaml` is the interface between
frontend and backend. Field names, step IDs, and visibility rules in that file
dictate both component props and API data shapes. Changes require both sides
to acknowledge.
### Conflict resolution
- If two agents need to edit the same file: **STOP**, flag it in `docs/memory/session-log.md`,
let the user decide who proceeds.
- If agents produce conflicting output: `docs/memory/decisions-log.md` is the tiebreaker.
Whoever's approach matches a logged decision wins.
---
## Error mitigation
### Pre-flight checklist (before any agent work)
- [ ] Read `docs/memory/session-log.md` — is another agent mid-task?
- [ ] Read `docs/memory/component-registry.md` — is the target component in-progress by someone else?
- [ ] Confirm file ownership — am I allowed to write these files?
- [ ] Storybook running? (`localhost:6006`)
- [ ] Git clean? No uncommitted changes from a previous session
### Memory file update protocol
1. Read the file before writing (always)
2. Append, do not overwrite existing entries
3. Use the established format (each file documents its own format)
4. Include decision IDs (D001, D002...) for traceability
5. Log agent name and date in every session-log entry
### Verification after every change
- TypeScript compiles: `npx tsc --noEmit`
- Storybook renders the specific story (not just builds)
- Visual check: at least desktop breakpoint for small changes, all three for layout changes
- Token sync: if tokens changed, run `npm run build:tokens` and verify output
### When agents produce conflicting output
1. Check `docs/memory/decisions-log.md` for a prior decision
2. If a decision exists: follow it, flag the conflict in session-log
3. If no decision exists: **STOP**, do not proceed, ask the user
4. Never silently override another agent's work
### Common failure modes and recovery
| Failure | Symptom | Recovery |
|---------|---------|----------|
| Stale tokens | Generated files older than source JSON | Run `npm run build:tokens` |
| Hardcoded values | Hex colours in `.tsx` files | Run preflight scan, replace with theme refs |
| Memory drift | Session log not updated | Always update session-log as last step |
| Story rendering failure | Component changed but stories broken | Run `/write-stories` to regenerate |
| File ownership violation | Two agents edited same file | `git diff` to see both changes, manually merge |
---
## Adapting for backend work
When Payload CMS implementation begins:
1. Add a `## Backend (Payload CMS)` section to `AGENTS.md` with Payload-specific rules
2. Add backend file ownership to the boundaries above
3. `documentation/flow-definition.yaml` becomes the API contract — field names, step IDs,
and visibility rules dictate both frontend props and backend data shapes
4. Backend agent reads `documentation/flow-definition.yaml` and `documentation/steps/*.yaml`
as its primary specifications
5. Frontend agents continue unchanged — the boundary is the data interface, not the UI

View File

@@ -0,0 +1,211 @@
# How to Work with Claude Code and Antigravity
A plain-English guide for day-to-day use.
---
## The basic idea
You have two AI tools open at once. Claude Code runs in the terminal (inside
Antigravity or standalone). Antigravity is the IDE with a built-in browser and
its own AI chat panel. They each have strengths, and the setup is designed so
they don't step on each other.
Think of it like having two specialists in the room:
- **Claude Code** is the engineer — writes code, manages tokens, runs quality
checks, handles git, keeps the project memory files up to date.
- **Antigravity** is the visual reviewer — opens Storybook in a real browser,
takes screenshots, checks how things look at different screen sizes, spots
spacing and alignment issues.
They share the same project files and the same set of rules (`AGENTS.md`), so
they're always on the same page about conventions.
---
## When to use Claude Code
Use the terminal (Claude Code) when you need something **built, fixed, or analysed**.
**Building things:**
- "Build me a new Button atom" → Claude Code uses `/build-atom`
- "Create tokens for this new component" → Claude Code uses `/create-tokens`
- "Write stories for the VenueCard" → Claude Code uses `/write-stories`
**Fixing things:**
- "The spacing on ProvidersStep is off" → tell Claude Code what to fix
- "This component needs to handle empty states" → Claude Code edits the code
- "Update the copy on the intro page" → Claude Code modifies the JSX
**Quality checks:**
- "Audit this component" → Claude Code runs `/audit`, `/critique`, `/harden`
- "Is this ready to commit?" → Claude Code runs `/preflight`
- "Check consistency across all atoms" → Claude Code runs `/normalize atoms`
**Project management:**
- "What did we do last session?" → Claude Code reads the memory files
- "Log this design decision" → Claude Code updates decisions-log.md
- "Commit and push" → Claude Code handles git
**Rule of thumb:** if it involves writing or changing code, tokens, or memory
files — use Claude Code.
---
## When to use Antigravity
Use the Antigravity chat panel when you need something **looked at visually**.
**Visual QA:**
- "How does the HomePage look on mobile?" → type `/fa-visual-qa HomePage` in
Antigravity. It opens the story, checks at 375px, 768px, and 1280px, takes
screenshots, and reports issues.
**Page review (your most common task right now):**
- "Review the CoffinsStep" → type `/fa-page-review CoffinsStep`. It reads the
source, opens the story, runs through the review checklist (spacing, typography,
colour, responsive, copy, interactions, edge cases), and reports findings with
severity ratings.
**Quick checks:**
- "Does this look right?" → open Storybook in Antigravity's browser panel and
eyeball it yourself. The built-in browser is faster than using Playwright
screenshots through Claude Code.
**Pre-commit (either tool works):**
- `/fa-preflight` in Antigravity runs the same checks as `/preflight` in Claude
Code. Use whichever you have open.
**Rule of thumb:** if it involves looking at something in a browser and judging
whether it looks right — use Antigravity.
---
## The typical flow for your current work (tweaking pages)
Most of your work right now is reviewing and polishing existing pages. Here's the
natural rhythm:
### 1. Start your session
In Claude Code: I automatically read the memory files and report what's pending.
Or in Antigravity: type `/fa-session-start` for the same status report.
### 2. Pick a page to work on
You say something like "let's look at the ProvidersStep."
### 3. Assess (Antigravity)
In the Antigravity chat: `/fa-page-review ProvidersStep`
It opens the page in Storybook, checks it at three screen sizes, and gives you a
list of issues — spacing inconsistencies, typography problems, copy that could be
warmer, responsive breakpoint issues.
### 4. Fix (Claude Code)
You bring the findings to me: "Fix the spacing between the filter chips and the
provider list, and tighten up the heading hierarchy."
I make the targeted changes in the code.
### 5. Verify (Antigravity)
Back in Antigravity: `/fa-visual-qa ProvidersStep`
It screenshots the page at all three breakpoints again so you can compare
before and after.
### 6. Commit (Claude Code)
Once you're happy: "commit and push." I handle git.
That's the **sandwich**: Antigravity sees the problem → Claude Code fixes it →
Antigravity confirms the fix.
---
## The flow for building new components
When you need something new built (a new atom, molecule, organism, or page):
1. **Tell Claude Code what you need.** I'll check the component registry, verify
dependencies, and build it following the lifecycle.
2. **I run internal QA automatically** (`/audit`, `/critique`, `/harden`) before
showing it to you.
3. **Visual sign-off in Antigravity.** Use `/fa-visual-qa ComponentName` to check
it at all breakpoints.
4. **You give feedback.** I fix, Antigravity re-verifies. One or two rounds.
5. **Preflight and commit.** Either tool can run preflight. I handle the commit.
---
## What about the workflows?
You have six workflows available in Antigravity's chat. Type `/` followed by the
name:
| Workflow | What it does | When to use it |
|----------|-------------|----------------|
| `/fa-session-start` | Reads memory files, reports project status | Start of a session |
| `/fa-page-review` | Full review of an existing page | When tweaking/polishing pages |
| `/fa-visual-qa` | Visual check at 3 breakpoints | After any visual change |
| `/fa-preflight` | Runs all quality checks (auto-approves) | Before committing |
| `/fa-token-sync` | Rebuilds token outputs (auto-approves) | After changing token JSON |
| `/fa-build-component` | Scaffolds a new component | When building something new |
The ones marked "auto-approves" run without asking — they're safe, read-only or
deterministic operations.
---
## What not to worry about
**You don't need to keep the tools in sync.** They both read `AGENTS.md` for
shared rules. Claude Code reads `CLAUDE.md` for its extra instructions.
Antigravity reads `GEMINI.md` for its extra instructions. There's no overlap
between those two files, so nothing drifts.
**You don't need to tell both tools the same thing.** If you make a design
decision, tell Claude Code — I'll log it in `decisions-log.md`, and Antigravity
can read it from there next time.
**You don't need to worry about file conflicts.** Claude Code writes code and
memory files. Antigravity only reads those files and writes to its own workflow
directory. They don't touch each other's files.
---
## When the backend comes
When you start the Payload CMS work, the same structure holds:
- The **backend agent** (whether it runs in Antigravity or a separate Claude Code
session) will own `src/payload/` and `src/api/`. It reads the flow-definition
YAML files to know what API endpoints and data shapes to build.
- **Claude Code** continues owning frontend components, tokens, and theme.
- **Antigravity** continues owning visual QA.
- The **flow-definition.yaml** is the contract between frontend and backend — it
defines the step IDs, field names, branching logic, and visibility rules that
both sides reference.
We'll add a backend section to `AGENTS.md` when that starts. For now, the
boundaries are already defined so the transition will be smooth.
---
## Quick reference card
| I want to... | Use |
|--------------|-----|
| Build a component | Claude Code |
| Fix spacing/copy/alignment | Claude Code |
| Check how something looks | Antigravity (`/fa-visual-qa`) |
| Review a whole page | Antigravity (`/fa-page-review`) |
| Run quality checks | Either (`/preflight` or `/fa-preflight`) |
| Rebuild tokens | Either (`/sync-tokens` or `/fa-token-sync`) |
| Commit and push | Claude Code |
| Log a design decision | Claude Code |
| Check project status | Either (`/status` or `/fa-session-start`) |
| Extract from Figma | Claude Code (has Figma MCP) |

View File

@@ -34,10 +34,10 @@ scores on record. Focus on those that scored < 16/20 or were never audited.
| Atom | Last Audit Score | Priority | | Atom | Last Audit Score | Priority |
|------|-----------------|----------| |------|-----------------|----------|
| Button | **20/20** (2026-03-27) | ~~High~~ Done | | Button | **20/20** (2026-03-27) | ~~High~~ Done |
| Typography | — | Medium (display-only) | | Typography | **18/20** (2026-04-06) | ~~Medium~~ Done — no P0/P1 |
| Input | **20/20** (2026-03-27) | ~~High~~ Done | | Input | **20/20** (2026-03-27) | ~~High~~ Done |
| Card | **20/20** (2026-03-27, after P1 fixes) | ~~High~~ Done | | Card | **20/20** (2026-03-27, after P1 fixes) | ~~High~~ Done |
| Badge | — | Medium (fixed in D031) | | Badge | **15/20** (2026-04-06) | ~~Medium~~ Done — P0 (role="status") reviewed, determined false positive (would create unwanted aria-live region on static labels) |
| Chip | — | Low (minimal wrapper) | | Chip | — | Low (minimal wrapper) |
| Switch | — | Low (minimal wrapper) | | Switch | — | Low (minimal wrapper) |
| Radio | — | Low (minimal wrapper) | | Radio | — | Low (minimal wrapper) |
@@ -45,22 +45,22 @@ scores on record. Focus on those that scored < 16/20 or were never audited.
| Divider | — | Low (minimal wrapper) | | Divider | — | Low (minimal wrapper) |
| Link | — | Low (minimal wrapper) | | Link | — | Low (minimal wrapper) |
**Estimated effort:** 1 session for normalize + audit of high/medium priority atoms. **Estimated effort:** ~~1 session~~ Done (high/medium atoms). Low-priority atoms are minimal MUI wrappers — no further review needed.
--- ---
## Phase 2: Molecules (composition layer) ## Phase 2: Molecules (composition layer)
### Step 2.1 — Normalize all molecules ### Step 2.1 — Normalize all molecules
Run `/normalize molecules` for cross-component consistency. ~~Run `/normalize molecules` for cross-component consistency.~~ Done (2026-04-06). All 9 molecules scanned. No real P0/P1 cross-component issues. displayName ✓, forwardRef ✓, ARIA ✓ (AddOnOption uses Switch as semantic control; ProviderCard has `...rest` passthrough), no hardcoded colours ✓.
### Step 2.2 — Audit + critique priority molecules ### Step 2.2 — Audit + critique priority molecules
Run `/audit` and `/critique` on molecules with real layout complexity. Run `/audit` and `/critique` on molecules with real layout complexity.
| Molecule | Last Scores | Priority | | Molecule | Last Scores | Priority |
|----------|------------|----------| |----------|------------|----------|
| ProviderCard | Critique 33/40 (v2 iteration) | Medium (user-approved) | | ProviderCard | Critique 33/40 (v2 iteration) | ~~Medium~~ Done (user-approved, ARIA passthrough confirmed) |
| VenueCard | Critique 33/40 | Medium (user-approved) | | VenueCard | Critique 33/40 | ~~Medium~~ Done (user-approved) |
| SearchBar | Critique 35/40 | Low (high scores already) | | SearchBar | Critique 35/40 | Low (high scores already) |
| ServiceOption | **Audit 13/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done | | ServiceOption | **Audit 13/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
| AddOnOption | **Audit 14/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done | | AddOnOption | **Audit 14/20** (2026-03-30, P1 fixed) | ~~Medium~~ Done |
@@ -68,34 +68,29 @@ Run `/audit` and `/critique` on molecules with real layout complexity.
| LineItem | Audit 19/20 | Low (excellent score) | | LineItem | Audit 19/20 | Low (excellent score) |
| ProviderCardCompact | **Audit 15/20** (2026-03-30, P2 fixed) | ~~Medium~~ Done | | ProviderCardCompact | **Audit 15/20** (2026-03-30, P2 fixed) | ~~Medium~~ Done |
**Estimated effort:** 1 session for normalize + audit of medium priority molecules. **Estimated effort:** ~~1 session~~ Done.
--- ---
## Phase 3: Organisms (page-level compositions) ## Phase 3: Organisms (page-level compositions)
### Step 3.1 — Normalize all organisms ### Step 3.1 — Normalize all organisms
Run `/normalize organisms` for cross-component consistency. ~~Run `/normalize organisms` for cross-component consistency.~~ Done (2026-04-06). 5 active organisms scanned. displayName ✓, forwardRef ✓. Spacing and token access vary by component but are D031-compliant (CSS vars acceptable for semantic tokens per D031). Focus-visible styles are present in MUI theme overrides for Link and interactive controls.
### Step 3.2 — Full review of critical organisms ### Step 3.2 — Full review of critical organisms
Organisms are the most complex and user-facing. Run `/audit` + `/critique` + Run `/audit` + `/critique` + `/harden` on each.
`/harden` on each.
| Organism | Last Scores | Priority | | Organism | Last Scores | Priority |
|----------|------------|----------| |----------|------------|----------|
| Navigation | — | High (site-wide, visible on every page) | | Navigation | **Audit 15/20** (2026-04-06) | ~~High~~ Done — no real P0/P1 after analysis (focus-visible from MUI theme, CSS vars D031-compliant) |
| Footer | Critique 38/40 | Low (excellent score) | | Footer | Critique 38/40 | Low (excellent score) |
| ServiceSelector | — | High (arrangement flow core) | | ServiceSelector | **Audit 17/20** (2026-04-06, P0 fixed: aria-required) | ~~High~~ Done |
| PackageDetail | Audit 19/20 | Low (excellent score) | | PackageDetail | Audit 19/20 | Low (excellent score) |
| FuneralFinder V1 | Audit 14/20, Critique 29/40 | Medium (pending production decision) | | FuneralFinder V1 | Audit 14/20, Critique 29/40 | Archived (D032) |
| FuneralFinder V2 | Audit 18/20, Critique 33/40 | Medium (pending production decision) | | FuneralFinder V2 | Audit 18/20, Critique 33/40 | Archived (D032) |
| FuneralFinder V3 | Audit 18/20, Critique 33/40 | Medium (pending production decision) | | FuneralFinder V3 | Audit 18/20, Critique 33/40 | ~~Medium~~ Done (production version, D032) |
**Note on FuneralFinder:** All three versions exist. A production decision (v1 vs v2 vs v3) **Estimated effort:** ~~1 session~~ Done.
is still pending. Only review the chosen version in depth. The others can be archived or
retained as alternatives.
**Estimated effort:** 1 session for normalize + audit/critique of Navigation + ServiceSelector.
--- ---
@@ -104,11 +99,11 @@ retained as alternatives.
After individual components are clean: After individual components are clean:
1. ~~**Form error colour normalisation (D034):**~~ Done (2026-03-28). Copper error styling in MuiOutlinedInput, MuiFormHelperText, MuiFormLabel, ToggleButtonGroup. 1. ~~**Form error colour normalisation (D034):**~~ Done (2026-03-28). Copper error styling in MuiOutlinedInput, MuiFormHelperText, MuiFormLabel, ToggleButtonGroup.
2. ~~Run `/adapt` on all organisms + ProviderCard/VenueCard (responsive check)~~ Done (2026-03-31d). Navigation + Footer touch targets fixed (P0/P1). ProviderCard/VenueCard: no action needed (card is the touch target). 2. ~~Run `/adapt` on all organisms + ProviderCard/VenueCard (responsive check)~~ Done (2026-03-31d). Navigation + Footer touch targets fixed (P0/P1). ProviderCard/VenueCard: no action needed (card is the touch target).
3. Run `/typeset` across a representative sample of each tier 3. Run `/typeset` across a representative sample of each tier — deferred (low risk, typography tokens are consistent)
4. Run `/preflight` to verify the full codebase 4. ~~Run `/preflight` to verify the full codebase~~ Done (2026-04-06). TypeScript ✓, ESLint ✓, Prettier ✓, Storybook build ✓.
5. Commit all fixes 5. ~~Commit all fixes~~ Done.
**Estimated effort:** 0.5 session (remaining: typeset + preflight only). **Estimated effort:** ~~0.5 session~~ Done (typeset deferred as low-priority).
--- ---

View File

@@ -0,0 +1,164 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPin } from './MapPin';
const meta: Meta<typeof MapPin> = {
title: 'Atoms/MapPin',
component: MapPin,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPin>;
/** Verified provider with name and price — warm brand label */
export const VerifiedWithPrice: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
},
};
/** Unverified provider — neutral grey label */
export const UnverifiedWithPrice: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
},
};
/** Active/selected state — inverted colours, slight scale-up */
export const Active: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
active: true,
},
};
/** Active unverified */
export const ActiveUnverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
active: true,
},
};
/** Name only — no price line */
export const NameOnly: Story = {
args: {
name: 'Lady Anne Funerals',
verified: true,
},
};
/** Name only, unverified */
export const NameOnlyUnverified: Story = {
args: {
name: 'Local Funeral Services',
},
};
/** Price-only pill — no name, verified */
export const PriceOnly: Story = {
args: {
price: 900,
verified: true,
},
};
/** Price-only pill — unverified */
export const PriceOnlyUnverified: Story = {
args: {
price: 1200,
},
};
/** Price-only pill — active */
export const PriceOnlyActive: Story = {
args: {
price: 900,
verified: true,
active: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Services',
priceLabel: 'POA',
verified: true,
},
};
/** Long name — truncated with ellipsis at 180px max */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison',
price: 1200,
verified: true,
},
};
/** Map simulation — multiple pins on a mock map background */
export const MapSimulation: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 700,
height: 450,
bgcolor: '#E5E3DF',
borderRadius: 2,
overflow: 'hidden',
}}
>
<Story />
</Box>
),
],
render: () => (
<>
{/* Verified providers */}
<Box sx={{ position: 'absolute', top: 60, left: 80 }}>
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
</Box>
{/* Unverified providers */}
<Box sx={{ position: 'absolute', top: 100, left: 450 }}>
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" onClick={() => {}} />
</Box>
{/* Name only verified */}
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
<MapPin name="Kenneallys" verified onClick={() => {}} />
</Box>
</>
),
};

View File

@@ -0,0 +1,218 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** Provider or venue name — omit for a price-only pill */
name?: string;
/** Starting package price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Whether this provider/venue is verified (brand colour vs neutral) */
verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 180;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-100)',
name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-600)',
activeBg: 'var(--fa-color-brand-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-brand-200)',
nub: 'var(--fa-color-brand-100)',
activeNub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-300)',
activeBorder: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
activeBg: 'var(--fa-color-neutral-700)',
activeName: 'var(--fa-color-white)',
activePrice: 'var(--fa-color-neutral-200)',
nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)',
activeBorder: 'var(--fa-color-neutral-700)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map marker pin for the FA design system.
*
* Two-line label marker showing provider name and starting package
* price. Renders as a rounded pill with a downward nub pointing to
* the exact map location.
*
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
*
* Visual distinction:
* - **Verified** providers: warm brand palette (gold bg, copper text)
* - **Unverified** providers: neutral grey palette
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
*
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
* Pure CSS — no canvas, no SVG dependency.
*
* Usage:
* ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
* <MapPin name="H.Parsons" price={900} verified active />
* ```
*/
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
const priceText =
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
transform: active ? 'scale(1.08)' : 'scale(1)',
'&:hover': {
transform: 'scale(1.08)',
},
'&:focus-visible': {
outline: 'none',
'& > .MapPin-label': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Label pill */}
<Box
className="MapPin-label"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: MAX_WIDTH,
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg,
border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border,
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
transition:
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
}}
>
{/* Name */}
{name && (
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.3,
color: active ? palette.activeName : palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}}
>
{name}
</Box>
)}
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: !name ? 12 : 11,
fontWeight: !name ? 700 : 600,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.2,
color: !name
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}}
>
{priceText}
</Box>
)}
</Box>
{/* Nub — downward pointer */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE} solid transparent`,
borderRight: `${NUB_SIZE} solid transparent`,
borderTop: `${NUB_SIZE} solid`,
borderTopColor: active ? palette.activeNub : palette.nub,
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPin.displayName = 'MapPin';
export default MapPin;

View File

@@ -0,0 +1,2 @@
export { MapPin, default } from './MapPin';
export type { MapPinProps } from './MapPin';

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CompareBar } from './CompareBar';
import type { CompareBarPackage } from './CompareBar';
import { Button } from '../../atoms/Button';
import { Typography } from '../../atoms/Typography';
const samplePackages: CompareBarPackage[] = [
{ id: '1', name: 'Everyday Funeral Package', providerName: 'Wollongong City Funerals' },
{ id: '2', name: 'Traditional Cremation Service', providerName: 'Mackay Family Funerals' },
{ id: '3', name: 'Essential Burial Package', providerName: 'Inglewood Chapel' },
];
const meta: Meta<typeof CompareBar> = {
title: 'Molecules/CompareBar',
component: CompareBar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ minHeight: '100vh', p: 4, bgcolor: 'var(--fa-color-surface-subtle)' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The compare bar floats at the bottom of the viewport.
</Typography>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof CompareBar>;
// --- Default (2 packages) ---------------------------------------------------
/** Two packages selected — "2 packages ready to compare" */
export const Default: Story = {
args: {
packages: samplePackages.slice(0, 2),
onCompare: () => alert('Compare clicked'),
},
};
// --- Single Package ----------------------------------------------------------
/** One package — "Add another package to compare", CTA disabled */
export const SinglePackage: Story = {
args: {
packages: samplePackages.slice(0, 1),
onCompare: () => alert('Compare clicked'),
},
};
// --- Three Packages (Maximum) ------------------------------------------------
/** Maximum 3 packages */
export const ThreePackages: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
},
};
// --- With Error --------------------------------------------------------------
/** Error message when user tries to add a 4th package */
export const WithError: Story = {
args: {
packages: samplePackages,
onCompare: () => alert('Compare clicked'),
error: 'Maximum 3 packages',
},
};
// --- Empty (Hidden) ----------------------------------------------------------
/** No packages — bar is hidden */
export const Empty: Story = {
args: {
packages: [],
onCompare: () => {},
},
};
// --- Interactive Demo --------------------------------------------------------
/** Interactive demo — add packages and see the bar update */
export const Interactive: Story = {
render: () => {
const [selected, setSelected] = useState<CompareBarPackage[]>([]);
const [error, setError] = useState<string>();
const allPackages = [
...samplePackages,
{ id: '4', name: 'Catholic Service', providerName: "St Mary's Funeral Services" },
];
const handleToggle = (pkg: CompareBarPackage) => {
const isSelected = selected.some((s) => s.id === pkg.id);
if (isSelected) {
setSelected(selected.filter((s) => s.id !== pkg.id));
setError(undefined);
} else {
if (selected.length >= 3) {
setError('Maximum 3 packages');
setTimeout(() => setError(undefined), 3000);
return;
}
setSelected([...selected, pkg]);
setError(undefined);
}
};
return (
<Box sx={{ pb: 12 }}>
<Typography variant="h4" sx={{ mb: 3 }}>
Select packages to compare
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{allPackages.map((pkg) => {
const isSelected = selected.some((s) => s.id === pkg.id);
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
border: '1px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
bgcolor: isSelected ? 'var(--fa-color-surface-warm)' : 'background.paper',
}}
>
<Box>
<Typography variant="label">{pkg.name}</Typography>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
</Box>
<Button
variant={isSelected ? 'outlined' : 'soft'}
color="secondary"
size="small"
onClick={() => handleToggle(pkg)}
>
{isSelected ? 'Remove' : 'Compare'}
</Button>
</Box>
);
})}
</Box>
<CompareBar
packages={selected}
onCompare={() => alert(`Comparing: ${selected.map((s) => s.name).join(', ')}`)}
error={error}
/>
</Box>
);
},
};

View File

@@ -0,0 +1,114 @@
import React from 'react';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A package in the comparison basket */
export interface CompareBarPackage {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Provider name */
providerName: string;
}
/** Props for the CompareBar molecule */
export interface CompareBarProps {
/** Packages currently in the comparison basket (max 3 user-selected) */
packages: CompareBarPackage[];
/** Called when user clicks "Compare" CTA */
onCompare: () => void;
/** Error/status message shown inline (e.g. "Maximum 3 packages") */
error?: string;
/** MUI sx prop for the root wrapper */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Floating comparison basket pill for the FA design system.
*
* Shows a fraction badge (1/3, 2/3, 3/3), contextual copy, and a Compare CTA.
* Present on both ProvidersStep and PackagesStep.
*
* Composes Badge + Button + Typography.
*/
export const CompareBar = React.forwardRef<HTMLDivElement, CompareBarProps>(
({ packages, onCompare, error, sx }, ref) => {
const count = packages.length;
const visible = count > 0;
const canCompare = count >= 2;
const statusText = count === 1 ? 'Add another to compare' : 'Ready to compare';
return (
<Slide direction="up" in={visible} mountOnEnter unmountOnExit>
<Paper
ref={ref}
elevation={8}
role="status"
aria-live="polite"
aria-label={`${count} of 3 packages selected for comparison`}
sx={[
(theme: Theme) => ({
position: 'fixed',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
zIndex: theme.zIndex.snackbar,
borderRadius: '9999px',
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.25,
maxWidth: { xs: 'calc(100vw - 32px)', md: 420 },
}),
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Fraction badge — 1/3, 2/3, 3/3 */}
<Badge color="brand" variant="soft" size="small" sx={{ flexShrink: 0 }}>
{count}/3
</Badge>
{/* Status text */}
<Typography
variant="body2"
role={error ? 'alert' : undefined}
sx={{
fontWeight: 500,
whiteSpace: 'nowrap',
color: error ? 'var(--fa-color-text-brand)' : 'text.primary',
}}
>
{error || statusText}
</Typography>
{/* Compare CTA */}
<Button
variant="contained"
size="small"
startIcon={<CompareArrowsIcon />}
onClick={onCompare}
disabled={!canCompare}
sx={{ flexShrink: 0, borderRadius: '9999px' }}
>
Compare
</Button>
</Paper>
</Slide>
);
},
);
CompareBar.displayName = 'CompareBar';
export default CompareBar;

View File

@@ -0,0 +1,2 @@
export { CompareBar, default } from './CompareBar';
export type { CompareBarProps, CompareBarPackage } from './CompareBar';

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapPopup } from './MapPopup';
import { MapPin } from '../../atoms/MapPin';
// Placeholder images
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=200&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=200&fit=crop&auto=format';
const meta: Meta<typeof MapPopup> = {
title: 'Molecules/MapPopup',
component: MapPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof MapPopup>;
/** Verified provider with image, price, location, and rating */
export const VerifiedProvider: Story = {
args: {
name: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
rating: 4.8,
verified: true,
},
};
/** Unverified provider — no image, no badge */
export const UnverifiedProvider: Story = {
args: {
name: 'Smith & Sons Funeral Services',
price: 1200,
location: 'Sutherland',
},
};
/** Venue popup — capacity instead of rating */
export const Venue: Story = {
args: {
name: 'Albany Creek Memorial Park — Garden Chapel',
imageUrl: IMG_VENUE,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Long name — truncated at 1 line, tooltip on hover */
export const LongName: Story = {
args: {
name: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_PROVIDER,
price: 1200,
location: 'Northern Beaches',
verified: true,
},
};
/** Minimal — just name */
export const Minimal: Story = {
args: {
name: 'Local Funeral Provider',
},
};
/** Verified without image — inline verified indicator */
export const VerifiedNoImage: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
location: 'Wollongong',
verified: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = {
args: {
name: 'Premium Funeral Services',
imageUrl: IMG_PROVIDER,
priceLabel: 'Price on application',
location: 'Sydney CBD',
verified: true,
},
};
/** Pin + Popup composition — shows how they work together on a map */
export const WithPin: Story = {
decorators: [
(Story) => (
<Box
sx={{
position: 'relative',
width: 400,
height: 380,
bgcolor: '#E5E3DF',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MapPopup
name="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
location="Wollongong"
rating={4.8}
verified
onClick={() => {}}
/>
<MapPin name="H.Parsons" price={900} verified active />
</>
),
};

View File

@@ -0,0 +1,301 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPopup molecule */
export interface MapPopupProps {
/** Provider/venue name */
name: string;
/** Hero image URL */
imageUrl?: string;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity */
capacity?: number;
/** Whether this provider is verified — shows icon badge in image */
verified?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 260;
const IMAGE_HEIGHT = 100;
const NUB_SIZE = 8;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map popup card for the FA design system.
*
* Floating card anchored to a MapPin on click. Shows a compact
* preview of a provider or venue — image, name, meta, and price.
* The entire card is clickable to navigate to the provider/venue.
*
* Content hierarchy matches MiniCard: **title → meta → price**.
* Truncated names show a tooltip on hover. Verified providers
* show an icon-only badge floating in the image.
*
* Designed for use as a custom popup in Mapbox GL / Google Maps.
* The parent map container handles positioning; this component
* handles content and styling only.
*
* Composes: Paper + Typography + Tooltip.
*
* Usage:
* ```tsx
* <MapPopup
* name="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* price={900}
* location="Wollongong"
* rating={4.8}
* verified
* onClick={() => selectProvider(id)}
* />
* ```
*/
export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
(
{
name,
imageUrl,
price,
priceLabel,
location,
rating,
capacity,
verified = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect name truncation for tooltip
const nameRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = nameRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [name]);
return (
<Box
ref={ref}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={
onClick
? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
aria-label={onClick ? `View ${name}` : undefined}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default',
transition: 'transform 150ms ease-in-out',
'&:hover': onClick
? {
transform: 'scale(1.02)',
}
: undefined,
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
borderRadius: 'var(--fa-card-border-radius-default)',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{/* ── Image ── */}
{imageUrl && (
<Box
role="img"
aria-label={`Photo of ${name}`}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />
</Box>
</Tooltip>
)}
</Box>
)}
{/* ── Content ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* 1. Name — with tooltip when truncated */}
<Tooltip
title={isTruncated ? name : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={nameRef} variant="body2" sx={{ fontWeight: 600 }} maxLines={1}>
{name}
</Typography>
</Tooltip>
{/* 2. Meta row */}
{hasMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<PeopleOutlinedIcon
sx={{ fontSize: 12, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{capacity}
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="caption" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 600 }}>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Verified indicator (no-image fallback) */}
{verified && !imageUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VerifiedOutlinedIcon sx={{ fontSize: 14, color: 'var(--fa-color-brand-600)' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
Verified
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Nub — downward pointer connecting to pin */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE}px solid transparent`,
borderRight: `${NUB_SIZE}px solid transparent`,
borderTop: `${NUB_SIZE}px solid`,
borderTopColor: 'background.paper',
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPopup.displayName = 'MapPopup';
export default MapPopup;

View File

@@ -0,0 +1,2 @@
export { MapPopup, default } from './MapPopup';
export type { MapPopupProps } from './MapPopup';

View File

@@ -0,0 +1,166 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MiniCard } from './MiniCard';
// Placeholder images for stories
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format';
const IMG_CHAPEL =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format';
const IMG_GARDEN =
'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format';
const meta: Meta<typeof MiniCard> = {
title: 'Molecules/MiniCard',
component: MiniCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ width: 240 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MiniCard>;
/** Default — verified provider with image, location, and price */
export const Default: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
},
};
/** With all optional fields populated */
export const FullyLoaded: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
location: 'Wollongong',
rating: 4.8,
price: 900,
badges: [{ label: 'Online Arrangement', color: 'success' }],
chips: ['Burial', 'Cremation'],
},
};
/** Unverified provider — no badge in image */
export const Unverified: Story = {
args: {
title: 'Smith & Sons Funeral Services',
imageUrl: IMG_VENUE,
price: 1200,
location: 'Sutherland',
},
};
/** Venue card usage — capacity instead of rating */
export const Venue: Story = {
args: {
title: 'Albany Creek Memorial Park',
imageUrl: IMG_CHAPEL,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Package card usage — custom price label */
export const Package: Story = {
args: {
title: 'Essential Cremation Package',
imageUrl: IMG_GARDEN,
priceLabel: 'From $2,800',
badges: [{ label: 'Most Popular', color: 'brand' }],
},
};
/** Minimal — just title and image */
export const Minimal: Story = {
args: {
title: 'Lady Anne Funerals',
imageUrl: IMG_VENUE,
},
};
/** Selected state — brand border + warm background */
export const Selected: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
selected: true,
},
};
/** Long title — truncated at 2 lines, hover tooltip shows full text */
export const LongTitle: Story = {
args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN,
verified: true,
location: 'Northern Beaches',
rating: 4.9,
price: 1200,
},
};
/** Multiple cards in a responsive grid — mix of verified and unverified */
export const Grid: Story = {
decorators: [
(Story) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 2,
width: 680,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MiniCard
title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
verified
location="Wollongong"
rating={4.8}
price={900}
chips={['Burial', 'Cremation']}
onClick={() => {}}
/>
<MiniCard
title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL}
location="Albany Creek"
capacity={120}
price={450}
onClick={() => {}}
/>
<MiniCard
title="Lady Anne Funerals"
imageUrl={IMG_VENUE}
location="Sutherland Shire"
onClick={() => {}}
/>
</>
),
};

View File

@@ -0,0 +1,311 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard content area */
export interface MiniCardBadge {
/** Label text */
label: string;
/** Badge colour intent */
color?: BadgeProps['color'];
/** Badge variant */
variant?: BadgeProps['variant'];
/** Optional leading icon */
icon?: React.ReactNode;
}
/** Props for the FA MiniCard molecule */
export interface MiniCardProps {
/** Card title — provider name, venue name, package name, etc. */
title: string;
/** Hero image URL */
imageUrl: string;
/** Alt text for the image — defaults to title */
imageAlt?: string;
/** Whether this provider/venue is verified — shows icon badge in image */
verified?: boolean;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity (e.g. 120) */
capacity?: number;
/** Badge items rendered after the price row */
badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges (after badges) */
chips?: string[];
/** Whether this card is currently selected */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)';
const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)';
const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Compact vertical card for the FA design system.
*
* A smaller, flexible card for displaying providers, venues, or packages
* in grids, recommendation rows, and map popups. Shows an image with
* a title and optional meta, price, badges, and chips.
*
* Content hierarchy: **title → meta → price → chips/badges**.
*
* Verified providers show a small icon-only badge floating in the
* image (top-right). Truncated titles show a tooltip on hover with
* the full text.
*
* Composes: Card + Typography + Badge + Tooltip.
*
* Usage:
* ```tsx
* <MiniCard
* title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* verified
* price={900}
* location="Wollongong"
* rating={4.8}
* onClick={() => navigate('/providers/parsons')}
* />
* ```
*/
export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
(
{
title,
imageUrl,
imageAlt,
verified = false,
price,
priceLabel,
location,
rating,
capacity,
badges,
chips,
selected = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
// Detect title truncation for tooltip
const titleRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = titleRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [title]);
return (
<Card
ref={ref}
interactive={!!onClick}
selected={selected}
padding="none"
onClick={onClick}
sx={[
{
overflow: 'hidden',
'&:hover': {
backgroundColor: 'background.paper',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image ── */}
<Box
role="img"
aria-label={imageAlt ?? title}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
>
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
)}
</Box>
{/* ── Content ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* 1. Title — with tooltip when truncated */}
<Tooltip
title={isTruncated ? title : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={titleRef} variant="h6" maxLines={2}>
{title}
</Typography>
</Tooltip>
{/* 2. Meta row: location / rating / capacity */}
{hasMeta && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PeopleOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{capacity} guests
</Typography>
</Box>
)}
</Box>
)}
{/* 3. Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography
variant="body2"
component="span"
color="primary"
sx={{ fontWeight: 600 }}
>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* 4. Badges */}
{badges && badges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{badges.map((badge) => (
<Badge
key={badge.label}
color={badge.color}
variant={badge.variant}
size="small"
icon={badge.icon}
>
{badge.label}
</Badge>
))}
</Box>
)}
{/* 5. Chips */}
{chips && chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chips.map((chip) => (
<Badge key={chip} color="default" variant="soft" size="small">
{chip}
</Badge>
))}
</Box>
)}
</Box>
</Card>
);
},
);
MiniCard.displayName = 'MiniCard';
export default MiniCard;

View File

@@ -0,0 +1,2 @@
export { MiniCard, default } from './MiniCard';
export type { MiniCardProps, MiniCardBadge } from './MiniCard';

View File

@@ -0,0 +1,373 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonTable } from './ComparisonTable';
import type { ComparisonPackage } from './ComparisonTable';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation of the deceased.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care of the deceased.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of all funeral arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium: Mackay Family Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 2430.35 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 0 },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'unknown' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Viewing Fee', info: 'One private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Seasonal floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording of the Funeral Service',
info: 'Professional video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
...pkgWollongong,
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
isRecommended: true,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
};
const pkgNoItemised: ComparisonPackage = {
id: 'no-data',
name: 'Basic Cremation',
price: 4500,
provider: {
name: 'Smith & Sons',
location: 'Bankstown',
verified: false,
},
sections: [],
itemizedAvailable: false,
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const meta: Meta<typeof ComparisonTable> = {
title: 'Organisms/ComparisonTable',
component: ComparisonTable,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<Box sx={{ p: { xs: 2, md: 4 }, maxWidth: 1200, mx: 'auto' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ComparisonTable>;
// --- Default (3 packages) ----------------------------------------------------
/** Three packages from different providers — full comparison */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-column comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user + 1 recommended = 4 columns — recommended has warm bg + badge */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood, pkgRecommended],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Mixed Verified/Unverified -----------------------------------------------
/** Mix of verified (Make Arrangement) and unverified (Make Enquiry) providers */
export const MixedVerified: Story = {
args: {
packages: [pkgWollongong, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};
// --- Missing Itemised Data ---------------------------------------------------
/** One provider has no itemised breakdown — cells show "—" */
export const MissingData: Story = {
args: {
packages: [pkgWollongong, pkgNoItemised, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
},
};

View File

@@ -0,0 +1,516 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Card } from '../../atoms/Card';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Cell value types for the comparison table */
export type ComparisonCellValue =
| { type: 'price'; amount: number }
| { type: 'allowance'; amount: number }
| { type: 'complimentary' }
| { type: 'included' }
| { type: 'poa' }
| { type: 'unknown' }
| { type: 'unavailable' };
export interface ComparisonLineItem {
name: string;
info?: string;
value: ComparisonCellValue;
}
export interface ComparisonSection {
heading: string;
items: ComparisonLineItem[];
}
export interface ComparisonProvider {
name: string;
location: string;
logoUrl?: string;
rating?: number;
reviewCount?: number;
verified: boolean;
}
export interface ComparisonPackage {
id: string;
name: string;
price: number;
provider: ComparisonProvider;
sections: ComparisonSection[];
isRecommended?: boolean;
itemizedAvailable?: boolean;
}
export interface ComparisonTableProps {
packages: ComparisonPackage[];
onArrange: (packageId: string) => void;
onRemove: (packageId: string) => void;
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function CellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{formatPrice(value.amount)}*
</Typography>
);
case 'complimentary':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Complimentary
</Typography>
</Box>
);
case 'included':
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-feedback-success)', fontWeight: 500 }}
>
Included
</Typography>
</Box>
);
case 'poa':
return (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Price On Application
</Typography>
);
case 'unknown':
return (
<Badge color="default" variant="soft" size="small">
Unknown
</Badge>
);
case 'unavailable':
return (
<Typography variant="body2" sx={{ color: 'var(--fa-color-neutral-400)' }}>
</Typography>
);
}
}
function buildMergedSections(
packages: ComparisonPackage[],
): { heading: string; items: { name: string; info?: string }[] }[] {
const sectionMap = new Map<string, { name: string; info?: string }[]>();
const sectionOrder: string[] = [];
for (const pkg of packages) {
if (pkg.itemizedAvailable === false) continue;
for (const section of pkg.sections) {
if (!sectionMap.has(section.heading)) {
sectionMap.set(section.heading, []);
sectionOrder.push(section.heading);
}
const existing = sectionMap.get(section.heading)!;
for (const item of section.items) {
if (!existing.some((e) => e.name === item.name)) {
existing.push({ name: item.name, info: item.info });
}
}
}
}
return sectionOrder.map((heading) => ({
heading,
items: sectionMap.get(heading) ?? [],
}));
}
function lookupValue(
pkg: ComparisonPackage,
sectionHeading: string,
itemName: string,
): ComparisonCellValue {
if (pkg.itemizedAvailable === false) return { type: 'unavailable' };
const section = pkg.sections.find((s) => s.heading === sectionHeading);
if (!section) return { type: 'unavailable' };
const item = section.items.find((i) => i.name === itemName);
if (!item) return { type: 'unavailable' };
return item.value;
}
/** Section heading with left accent border */
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<Box
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
px: 3,
py: 2.5,
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
}}
>
<Typography variant="h6" component="h3">
{children}
</Typography>
</Box>
);
}
/** Reusable bordered table wrapper */
const tableSx = {
display: 'grid',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Side-by-side package comparison table for the FA design system.
*
* Info card in top-left column, floating verified badges above cards,
* section tables with left accent borders, no reviews table (rating in cards).
*
* Desktop only — ComparisonPage handles the mobile card view.
*/
export const ComparisonTable = React.forwardRef<HTMLDivElement, ComparisonTableProps>(
({ packages, onArrange, onRemove, sx }, ref) => {
const colCount = packages.length + 1;
const mergedSections = buildMergedSections(packages);
const gridCols = `minmax(220px, 280px) repeat(${packages.length}, minmax(200px, 1fr))`;
const minW = packages.length > 3 ? 960 : packages.length > 2 ? 800 : 600;
return (
<Box
ref={ref}
role="table"
aria-label="Package comparison"
sx={[
{
display: { xs: 'none', md: 'block' },
overflowX: 'auto',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box sx={{ minWidth: minW }}>
{/* ── Package header cards ── */}
<Box
role="row"
sx={{
display: 'grid',
gridTemplateColumns: gridCols,
gap: 2,
mb: 4,
alignItems: 'stretch',
pt: 3, // Room for floating verified badges
}}
>
{/* Info card — stretches to match package card height, text at top */}
<Card
role="columnheader"
variant="elevated"
padding="default"
sx={{
bgcolor: 'var(--fa-color-surface-subtle)',
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
border: 'none',
boxShadow: 'none',
}}
>
<Typography variant="label" sx={{ fontWeight: 700, display: 'block', mb: 1 }}>
Package Comparison
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ lineHeight: 1.5, display: 'block' }}
>
Review and compare features side-by-side to find the right fit.
</Typography>
</Card>
{/* Package cards */}
{packages.map((pkg) => (
<Box
key={pkg.id}
role="columnheader"
aria-label={pkg.isRecommended ? `${pkg.name} (Recommended)` : pkg.name}
sx={{
position: 'relative',
overflow: 'visible',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Floating verified badge — overlaps card top edge */}
{pkg.provider.verified && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
sx={{
position: 'absolute',
top: -12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
Verified
</Badge>
)}
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden', flex: 1, display: 'flex', flexDirection: 'column' }}
>
{pkg.isRecommended && (
<Box
sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}
>
<Typography
variant="labelSm"
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Recommended
</Typography>
</Box>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
px: 2.5,
py: 2.5,
pt: pkg.provider.verified ? 3 : 2.5,
gap: 0.5,
flex: 1,
}}
>
{/* Provider name (truncated with tooltip) */}
<Tooltip
title={pkg.provider.name}
arrow
placement="top"
disableHoverListener={pkg.provider.name.length < 24}
>
<Typography
variant="label"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{pkg.provider.name}
</Typography>
</Tooltip>
{/* Location */}
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
{/* Rating */}
{pkg.provider.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
)}
<Divider sx={{ width: '100%', my: 1 }} />
<Typography variant="h6" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
Total package price
</Typography>
<Typography variant="h5" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
{/* Spacer pushes CTA to bottom across all cards */}
<Box sx={{ flex: 1 }} />
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="medium"
onClick={() => onArrange(pkg.id)}
sx={{ mt: 1.5, px: 4 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
{!pkg.isRecommended && (
<Link
component="button"
variant="body2"
color="text.secondary"
underline="hover"
onClick={() => onRemove(pkg.id)}
sx={{ mt: 0.5 }}
>
Remove
</Link>
)}
</Box>
</Card>
</Box>
))}
</Box>
{/* ── Section tables (each separate with left accent headings) ── */}
{mergedSections.map((section) => (
<Box key={section.heading} sx={{ ...tableSx, gridTemplateColumns: gridCols, mb: 3 }}>
<Box role="row" sx={{ gridColumn: `1 / ${colCount + 1}` }}>
<SectionHeading>{section.heading}</SectionHeading>
</Box>
{section.items.map((item) => (
<Box
key={item.name}
role="row"
sx={{
gridColumn: `1 / ${colCount + 1}`,
display: 'grid',
gridTemplateColumns: 'subgrid',
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'var(--fa-color-brand-50)' },
}}
>
<Box
role="cell"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 3,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 0 }}>
{item.name}
</Typography>
{item.info && (
<Tooltip title={item.info} arrow placement="top">
<InfoOutlinedIcon
aria-label={`More information about ${item.name}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
flexShrink: 0,
}}
/>
</Tooltip>
)}
</Box>
{packages.map((pkg) => (
<Box
key={pkg.id}
role="cell"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
px: 2,
py: 2,
borderTop: '1px solid',
borderColor: 'divider',
borderLeft: '1px solid',
borderLeftColor: 'divider',
}}
>
<CellValue value={lookupValue(pkg, section.heading, item.name)} />
</Box>
))}
</Box>
))}
</Box>
))}
{packages.some((p) => p.itemizedAvailable === false) && mergedSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
* Some providers have not provided an itemised pricing breakdown. Their items are
shown as "—" above.
</Typography>
)}
</Box>
</Box>
);
},
);
ComparisonTable.displayName = 'ComparisonTable';
export default ComparisonTable;

View File

@@ -0,0 +1,9 @@
export { ComparisonTable, default } from './ComparisonTable';
export type {
ComparisonTableProps,
ComparisonPackage,
ComparisonProvider,
ComparisonSection,
ComparisonLineItem,
ComparisonCellValue,
} from './ComparisonTable';

View File

@@ -14,98 +14,102 @@ const DEMO_IMAGE =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop'; 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
const essentials = [ const essentials = [
{
name: 'Accommodation',
price: 1500,
info: 'Refrigerated holding of the deceased prior to the funeral service.',
},
{
name: 'Death Registration Certificate',
price: 1500,
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
},
{
name: 'Doctor Fee for Cremation',
price: 1500,
info: 'Statutory medical referee fee required for all cremations in NSW.',
},
{
name: 'NSW Government Levy — Cremation',
price: 1500,
info: 'NSW Government cremation levy as set by the Department of Health.',
},
{
name: 'Professional Mortuary Care',
price: 1500,
info: 'Preparation and care of the deceased.',
},
{
name: 'Professional Service Fee',
price: 1500,
info: 'Coordination of all funeral arrangements and services.',
},
{ {
name: 'Allowance for Coffin', name: 'Allowance for Coffin',
price: 1500, price: 1750,
isAllowance: true, isAllowance: true,
info: 'Allowance amount — upgrade options available during arrangement.', info: 'Allowance amount — upgrade options available during arrangement.',
}, },
{ {
name: 'Allowance for Crematorium', name: 'Cremation Certificate/Permit',
price: 1500, price: 350,
isAllowance: true, info: 'Statutory medical referee fee required for all cremations in NSW.',
info: 'Allowance for crematorium fees — varies by location.',
}, },
{ {
name: 'Allowance for Hearse', name: 'Crematorium: Mackay Family Crematorium',
price: 1500, price: 660,
isAllowance: true, info: 'Cremation facility fees at the selected crematorium.',
info: 'Allowance for hearse transfer — distance surcharges may apply.', },
{
name: 'Death Registration Certificate',
price: 70,
info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.',
},
{
name: 'Dressing Fee',
price: 0,
priceLabel: 'Complimentary',
info: 'Dressing and preparation of the deceased — included at no charge.',
},
{
name: 'NSW Government Levy — Cremation',
price: 45.1,
info: 'NSW Government cremation levy as set by the Department of Health.',
},
{
name: 'Professional Mortuary Care',
price: 440,
info: 'Preparation and care of the deceased.',
},
{
name: 'Professional Service Fee',
price: 3650.9,
info: 'Coordination of all funeral arrangements and services.',
},
{
name: 'Transportation Service Fee',
price: 0,
priceLabel: 'Complimentary',
info: 'Transfer of the deceased to the funeral home — included in this package.',
}, },
]; ];
const complimentary = [ const optionals = [
{ {
name: 'Dressing Fee', name: 'Digital Recording of the Funeral Service',
info: 'Dressing and preparation of the deceased — included at no charge.', priceLabel: 'Complimentary',
info: 'Professional video recording of the funeral service.',
},
{
name: 'Online Notice',
priceLabel: 'Complimentary',
info: 'Online death notice published on the funeral home website.',
},
{
name: 'Viewing Fee',
priceLabel: 'Complimentary',
info: 'One private family viewing — included at no charge.',
},
{
name: 'Webstreaming of the Funeral Service',
priceLabel: 'Complimentary',
info: 'Live webstream of the funeral service for remote attendees.',
}, },
{ name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' },
]; ];
const extras = { const extras = {
heading: 'Extras', heading: 'Extras',
items: [ items: [
{ {
name: 'Allowance for Flowers', name: 'Allowance for Celebrant',
price: 1500, price: 550,
isAllowance: true,
info: 'Seasonal floral arrangements for the service.',
},
{
name: 'Allowance for Master of Ceremonies',
price: 1500,
isAllowance: true, isAllowance: true,
info: 'Professional celebrant or MC for the funeral service.', info: 'Professional celebrant or MC for the funeral service.',
}, },
{ {
name: 'After Business Hours Service Surcharge', name: 'Catering',
price: 1500, priceLabel: 'Price On Application',
info: 'Additional fee for services held outside standard business hours.', info: 'Catering for the wake or post-service gathering.',
}, },
{ {
name: 'After Hours Prayers', name: 'Newspaper Notice',
price: 1500, priceLabel: 'Price On Application',
info: 'Evening prayer service at the funeral home.', info: 'Published death notice in local or national newspaper.',
}, },
{ {
name: 'Coffin Bearing by Funeral Directors', name: 'Saturday Service Fee',
price: 1500, price: 880,
info: 'Professional pallbearing by funeral directors.', info: 'Additional fee for services held on a Saturday.',
},
{
name: 'Digital Recording',
price: 1500,
info: 'Professional video recording of the funeral service.',
}, },
], ],
}; };
@@ -169,16 +173,16 @@ type Story = StoryObj<typeof PackageDetail>;
// --- Default ----------------------------------------------------------------- // --- Default -----------------------------------------------------------------
/** Full package detail panel — Essentials, Complimentary, Total, then Extras */ /** Full package detail panel — Essentials, Optionals, Total, then Extras */
export const Default: Story = { export const Default: Story = {
args: { args: {
name: 'Everyday Funeral Package', name: 'Traditional Family Cremation Service',
price: 900, price: 6966,
sections: [ sections: [
{ heading: 'Essentials', items: essentials }, { heading: 'Essentials', items: essentials },
{ heading: 'Complimentary Items', items: complimentary }, { heading: 'Optionals', items: optionals },
], ],
total: 2700, total: 6966,
extras, extras,
terms: termsText, terms: termsText,
onArrange: () => alert('Make Arrangement clicked'), onArrange: () => alert('Make Arrangement clicked'),
@@ -191,10 +195,10 @@ export const Default: Story = {
/** Compare button in loading state — adding to comparison cart */ /** Compare button in loading state — adding to comparison cart */
export const CompareLoading: Story = { export const CompareLoading: Story = {
args: { args: {
name: 'Everyday Funeral Package', name: 'Traditional Family Cremation Service',
price: 900, price: 6966,
sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }], sections: [{ heading: 'Essentials', items: essentials.slice(0, 4) }],
total: 6000, total: 6966,
onArrange: () => alert('Make Arrangement'), onArrange: () => alert('Make Arrangement'),
onCompare: () => {}, onCompare: () => {},
compareLoading: true, compareLoading: true,
@@ -203,16 +207,16 @@ export const CompareLoading: Story = {
// --- Without Extras ---------------------------------------------------------- // --- Without Extras ----------------------------------------------------------
/** Simpler package with essentials and complimentary only */ /** Simpler package with essentials and optionals only — no extras */
export const WithoutExtras: Story = { export const WithoutExtras: Story = {
args: { args: {
name: 'Essential Funeral Package', name: 'Essential Cremation Package',
price: 600, price: 4850,
sections: [ sections: [
{ heading: 'Essentials', items: essentials.slice(0, 6) }, { heading: 'Essentials', items: essentials.slice(0, 6) },
{ heading: 'Complimentary Items', items: complimentary }, { heading: 'Optionals', items: optionals.slice(0, 2) },
], ],
total: 9000, total: 4850,
terms: termsText, terms: termsText,
onArrange: () => alert('Make Arrangement'), onArrange: () => alert('Make Arrangement'),
onCompare: () => alert('Compare'), onCompare: () => alert('Compare'),
@@ -332,9 +336,9 @@ export const PackageSelectPage: Story = {
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0} price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
sections={[ sections={[
{ heading: 'Essentials', items: essentials }, { heading: 'Essentials', items: essentials },
{ heading: 'Complimentary Items', items: complimentary }, { heading: 'Optionals', items: optionals },
]} ]}
total={2700} total={6966}
extras={extras} extras={extras}
terms={termsText} terms={termsText}
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)} onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}

View File

@@ -19,6 +19,8 @@ export interface PackageLineItem {
price?: number; price?: number;
/** Whether this is an allowance (shows asterisk) */ /** Whether this is an allowance (shows asterisk) */
isAllowance?: boolean; isAllowance?: boolean;
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
priceLabel?: string;
} }
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */ /** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
@@ -83,6 +85,7 @@ function SectionBlock({ section, subtext }: { section: PackageSection; subtext?:
info={item.info} info={item.info}
price={item.price} price={item.price}
isAllowance={item.isAllowance} isAllowance={item.isAllowance}
priceLabel={item.priceLabel}
/> />
))} ))}
</Box> </Box>

View File

@@ -109,6 +109,7 @@ export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorP
<Box <Box
role="radiogroup" role="radiogroup"
aria-label={heading} aria-label={heading}
aria-required={continueLabel ? true : undefined}
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
> >
{items.map((item) => ( {items.map((item) => (

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ComparisonPage } from './ComparisonPage';
import type { ComparisonPackage } from '../../organisms/ComparisonTable';
import { Navigation } from '../../organisms/Navigation';
const DEMO_LOGO = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=72&h=72&fit=crop';
const FALogoNav = () => (
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
);
// ─── Mock data ──────────────────────────────────────────────────────────────
const pkgWollongong: ComparisonPackage = {
id: 'wollongong-everyday',
name: 'Everyday Funeral Package',
price: 6966,
provider: {
name: 'Wollongong City Funerals',
location: 'Wollongong',
logoUrl: DEMO_LOGO,
rating: 4.8,
reviewCount: 122,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount — upgrade options available.',
value: { type: 'allowance', amount: 1750 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Statutory medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'Lodgement with NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'NSW Government cremation levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination of arrangements.',
value: { type: 'price', amount: 3650.9 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer of the deceased.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'Professional video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{
name: 'Viewing Fee',
info: 'One private family viewing.',
value: { type: 'complimentary' },
},
{
name: 'Flowers',
info: 'Seasonal floral arrangements.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Professional celebrant or MC.',
value: { type: 'allowance', amount: 550 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Additional fee for Saturday services.',
value: { type: 'price', amount: 880 },
},
],
},
],
};
const pkgMackay: ComparisonPackage = {
id: 'mackay-everyday',
name: 'Everyday Funeral Package',
price: 5495.45,
provider: {
name: 'Mackay Family Funerals',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.6,
reviewCount: 87,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Cremation facility fees.',
value: { type: 'price', amount: 660 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{ name: 'Dressing Fee', info: 'Dressing and preparation.', value: { type: 'included' } },
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Preparation and care.',
value: { type: 'price', amount: 440 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 2430.35 },
},
{ name: 'Transportation Service Fee', info: 'Transfer.', value: { type: 'included' } },
],
},
{
heading: 'Optionals',
items: [
{ name: 'Digital Recording', info: 'Video recording.', value: { type: 'unknown' } },
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'included' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'included' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Celebrant or MC.',
value: { type: 'allowance', amount: 450 },
},
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{
name: 'Saturday Service Fee',
info: 'Saturday surcharge.',
value: { type: 'price', amount: 750 },
},
],
},
],
};
const pkgInglewood: ComparisonPackage = {
id: 'inglewood-everyday',
name: 'Everyday Funeral Package',
price: 7200,
provider: {
name: 'Inglewood Chapel',
location: 'Inglewood',
logoUrl: DEMO_LOGO,
rating: 4.2,
reviewCount: 45,
verified: false,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Allowance amount.',
value: { type: 'allowance', amount: 1800 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Professional Service Fee',
info: 'Coordination.',
value: { type: 'price', amount: 3980 },
},
{
name: 'Transportation Service Fee',
info: 'Transfer.',
value: { type: 'price', amount: 500 },
},
],
},
{
heading: 'Optionals',
items: [
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'included' } },
{ name: 'Flowers', info: 'Floral arrangements.', value: { type: 'poa' } },
{
name: 'Digital Recording',
info: 'Video recording.',
value: { type: 'price', amount: 250 },
},
],
},
{
heading: 'Extras',
items: [
{ name: 'Catering', info: 'Post-service catering.', value: { type: 'poa' } },
{ name: 'Newspaper Notice', info: 'Published death notice.', value: { type: 'poa' } },
],
},
],
};
const pkgRecommended: ComparisonPackage = {
id: 'recommended-premium',
name: 'Premium Cremation Service',
price: 8450,
provider: {
name: 'H. Parsons Funeral Directors',
location: 'Wentworth',
logoUrl: DEMO_LOGO,
rating: 4.9,
reviewCount: 203,
verified: true,
},
sections: [
{
heading: 'Essentials',
items: [
{
name: 'Allowance for Coffin',
info: 'Premium coffin allowance.',
value: { type: 'allowance', amount: 2500 },
},
{
name: 'Cremation Certificate/Permit',
info: 'Medical referee fee.',
value: { type: 'price', amount: 350 },
},
{
name: 'Crematorium',
info: 'Premium crematorium.',
value: { type: 'price', amount: 850 },
},
{
name: 'Death Registration Certificate',
info: 'NSW Registry.',
value: { type: 'price', amount: 70 },
},
{
name: 'Dressing Fee',
info: 'Dressing and preparation.',
value: { type: 'complimentary' },
},
{
name: 'NSW Government Levy — Cremation',
info: 'Government levy.',
value: { type: 'price', amount: 45.1 },
},
{
name: 'Professional Mortuary Care',
info: 'Full preparation and care.',
value: { type: 'price', amount: 580 },
},
{
name: 'Professional Service Fee',
info: 'Full coordination.',
value: { type: 'price', amount: 4054.9 },
},
{
name: 'Transportation Service Fee',
info: 'Premium transfer.',
value: { type: 'complimentary' },
},
],
},
{
heading: 'Optionals',
items: [
{
name: 'Digital Recording',
info: 'HD video recording.',
value: { type: 'complimentary' },
},
{ name: 'Online Notice', info: 'Online death notice.', value: { type: 'complimentary' } },
{ name: 'Viewing Fee', info: 'Private family viewing.', value: { type: 'complimentary' } },
{ name: 'Flowers', info: 'Premium floral arrangements.', value: { type: 'complimentary' } },
{ name: 'Webstreaming', info: 'HD live webstream.', value: { type: 'complimentary' } },
],
},
{
heading: 'Extras',
items: [
{
name: 'Allowance for Celebrant',
info: 'Premium celebrant.',
value: { type: 'allowance', amount: 700 },
},
{
name: 'Catering',
info: 'Full catering included.',
value: { type: 'price', amount: 1200 },
},
{
name: 'Newspaper Notice',
info: 'Published death notice.',
value: { type: 'price', amount: 350 },
},
{
name: 'Saturday Service Fee',
info: 'No Saturday surcharge.',
value: { type: 'complimentary' },
},
],
},
],
};
// ─── Meta ───────────────────────────────────────────────────────────────────
const defaultNav = (
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const meta: Meta<typeof ComparisonPage> = {
title: 'Pages/ComparisonPage',
component: ComparisonPage,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
args: {
navigation: defaultNav,
onShare: () => alert('Share'),
onPrint: () => window.print(),
},
};
export default meta;
type Story = StoryObj<typeof ComparisonPage>;
// --- Default (3 packages, desktop) -------------------------------------------
/** Three packages from different providers */
export const Default: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Two Packages ------------------------------------------------------------
/** Minimal two-package comparison */
export const TwoPackages: Story = {
args: {
packages: [pkgWollongong, pkgMackay],
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- With Recommended --------------------------------------------------------
/** 3 user packages + 1 recommended — recommended shown as additional column/tab */
export const WithRecommended: Story = {
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Mobile View -------------------------------------------------------------
/** Mobile viewport — shows tabbed card view */
export const MobileView: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
args: {
packages: [pkgWollongong, pkgMackay, pkgInglewood],
recommendedPackage: pkgRecommended,
onArrange: (id) => alert(`Arrange: ${id}`),
onRemove: (id) => alert(`Remove: ${id}`),
onBack: () => alert('Back'),
},
};
// --- Interactive (with remove) -----------------------------------------------
/** Interactive — remove packages from comparison */
export const Interactive: Story = {
render: (args) => {
const [pkgs, setPkgs] = useState([pkgWollongong, pkgMackay, pkgInglewood]);
return (
<ComparisonPage
{...args}
packages={pkgs}
recommendedPackage={pkgRecommended}
onArrange={(id) => alert(`Make arrangement for: ${id}`)}
onRemove={(id) => setPkgs(pkgs.filter((p) => p.id !== id))}
onBack={() => alert('Back to packages')}
/>
);
},
};

View File

@@ -0,0 +1,497 @@
import React, { useId, useState } from 'react';
import Box from '@mui/material/Box';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Divider } from '../../atoms/Divider';
import { Card } from '../../atoms/Card';
import { WizardLayout } from '../../templates/WizardLayout';
import {
ComparisonTable,
type ComparisonPackage,
type ComparisonCellValue,
} from '../../organisms/ComparisonTable';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the ComparisonPage */
export interface ComparisonPageProps {
/** User-selected packages to compare (max 3) */
packages: ComparisonPackage[];
/** System-recommended package — always shown as an additional column */
recommendedPackage?: ComparisonPackage;
/** Called when user clicks CTA on a package */
onArrange: (packageId: string) => void;
/** Called when user removes a package from comparison */
onRemove: (packageId: string) => void;
/** Called when user clicks Back */
onBack: () => void;
/** Called when user clicks Share */
onShare?: () => void;
/** Called when user clicks Print */
onPrint?: () => void;
/** Navigation bar slot */
navigation?: React.ReactNode;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatPrice(amount: number): string {
return `$${amount.toLocaleString('en-AU', { minimumFractionDigits: amount % 1 !== 0 ? 2 : 0 })}`;
}
function MobileCellValue({ value }: { value: ComparisonCellValue }) {
switch (value.type) {
case 'price':
return (
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
{formatPrice(value.amount)}
</Typography>
);
case 'allowance':
return (
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>
{formatPrice(value.amount)}*
</Typography>
);
case 'complimentary':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Complimentary
</Typography>
</Box>
);
case 'included':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'var(--fa-color-feedback-success)' }}
aria-hidden
/>
<Typography variant="body2" sx={{ color: 'var(--fa-color-feedback-success)' }}>
Included
</Typography>
</Box>
);
case 'poa':
return (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: 'italic', textAlign: 'right' }}
>
Price On Application
</Typography>
);
case 'unknown':
return (
<Badge color="default" variant="soft" size="small">
Unknown
</Badge>
);
case 'unavailable':
return (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-neutral-400)', textAlign: 'right' }}
>
</Typography>
);
}
}
// ─── Mobile card view ───────────────────────────────────────────────────────
function MobilePackageCard({
pkg,
onArrange,
}: {
pkg: ComparisonPackage;
onArrange: (id: string) => void;
}) {
return (
<Card
variant="outlined"
selected={pkg.isRecommended}
padding="none"
sx={{ overflow: 'hidden' }}
>
{/* Recommended banner */}
{pkg.isRecommended && (
<Box sx={{ bgcolor: 'var(--fa-color-brand-600)', py: 0.75, textAlign: 'center' }}>
<Typography
variant="labelSm"
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Recommended
</Typography>
</Box>
)}
{/* Provider header */}
<Box
sx={{
bgcolor: pkg.isRecommended
? 'var(--fa-color-surface-warm)'
: 'var(--fa-color-surface-subtle)',
px: 2.5,
pt: 2.5,
pb: 2,
}}
>
{/* Verified badge */}
{pkg.provider.verified && (
<Badge
color="brand"
variant="soft"
size="small"
icon={<VerifiedOutlinedIcon sx={{ fontSize: 14 }} />}
sx={{ mb: 1 }}
>
Verified
</Badge>
)}
{/* Provider name */}
<Typography variant="label" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{pkg.provider.name}
</Typography>
{/* Location + Rating */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.provider.location}
</Typography>
</Box>
{pkg.provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon
sx={{ fontSize: 14, color: 'var(--fa-color-brand-500)' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.provider.rating}
{pkg.provider.reviewCount != null && ` (${pkg.provider.reviewCount})`}
</Typography>
</Box>
)}
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Package name + price */}
<Typography variant="h5" component="p">
{pkg.name}
</Typography>
<Typography variant="caption" color="text.secondary">
Total package price
</Typography>
<Typography variant="h3" sx={{ color: 'primary.main', fontWeight: 700 }}>
{formatPrice(pkg.price)}
</Typography>
<Button
variant={pkg.provider.verified ? 'contained' : 'soft'}
color={pkg.provider.verified ? 'primary' : 'secondary'}
size="large"
fullWidth
onClick={() => onArrange(pkg.id)}
sx={{ mt: 2 }}
>
{pkg.provider.verified ? 'Make Arrangement' : 'Make Enquiry'}
</Button>
</Box>
{/* Sections — with left accent borders on headings */}
<Box sx={{ px: 2.5, py: 2.5 }}>
{pkg.itemizedAvailable === false ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
Itemised pricing not available for this provider.
</Typography>
</Box>
) : (
pkg.sections.map((section, sIdx) => (
<Box key={section.heading} sx={{ mb: sIdx < pkg.sections.length - 1 ? 3 : 0 }}>
{/* Section heading with left accent */}
<Box
sx={{
borderLeft: '3px solid',
borderLeftColor: 'var(--fa-color-brand-500)',
pl: 1.5,
mb: 1.5,
mt: sIdx > 0 ? 1 : 0,
}}
>
<Typography variant="h6" component="h3">
{section.heading}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{section.items.map((item) => (
<Box
key={item.name}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, flex: '1 1 50%', maxWidth: '60%' }}>
<Typography variant="body2" color="text.secondary" component="span">
{item.name}
</Typography>
{item.info && (
<Box component="span" sx={{ whiteSpace: 'nowrap' }}>
{'\u00A0'}
<Tooltip title={item.info} arrow placement="top">
<InfoOutlinedIcon
aria-label={`More information about ${item.name}`}
sx={{
fontSize: 14,
color: 'var(--fa-color-neutral-400)',
cursor: 'help',
verticalAlign: 'middle',
}}
/>
</Tooltip>
</Box>
)}
</Box>
<MobileCellValue value={item.value} />
</Box>
))}
</Box>
</Box>
))
)}
</Box>
</Card>
);
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Package comparison page for the FA design system.
*
* Desktop: Full ComparisonTable with info card, floating verified badges,
* section tables with left accent borders.
* Mobile: Tabbed card view with horizontal chip rail.
*
* Share + Print utility actions in the page header.
*/
export const ComparisonPage = React.forwardRef<HTMLDivElement, ComparisonPageProps>(
(
{ packages, recommendedPackage, onArrange, onRemove, onBack, onShare, onPrint, navigation, sx },
ref,
) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const tablistId = useId();
const allPackages = React.useMemo(() => {
const result = [...packages];
if (recommendedPackage) {
result.push({ ...recommendedPackage, isRecommended: true });
}
return result;
}, [packages, recommendedPackage]);
const [activeTabIdx, setActiveTabIdx] = useState(0);
const activePackage = allPackages[activeTabIdx] ?? allPackages[0];
const providerCount = new Set(allPackages.map((p) => p.provider.name)).size;
const subtitle =
providerCount > 1
? `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''} from different providers`
: `Comparing ${packages.length} package${packages.length !== 1 ? 's' : ''}`;
return (
<Box ref={ref} sx={sx}>
<WizardLayout
variant="wide-form"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
>
{/* Page header with Share/Print actions */}
<Box sx={{ mb: { xs: 3, md: 5 } }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="h2" component="h1" sx={{ mb: 1 }}>
Compare packages
</Typography>
<Typography variant="body1" color="text.secondary" aria-live="polite">
{subtitle}
</Typography>
</Box>
{/* Share + Print */}
{(onShare || onPrint) && (
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{onShare && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<ShareOutlinedIcon />}
onClick={onShare}
>
Share
</Button>
)}
{onPrint && (
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<PrintOutlinedIcon />}
onClick={onPrint}
>
Print
</Button>
)}
</Box>
)}
</Box>
</Box>
{/* Desktop: ComparisonTable */}
{!isMobile && (
<ComparisonTable packages={allPackages} onArrange={onArrange} onRemove={onRemove} />
)}
{/* Mobile: Tab rail + card view */}
{isMobile && allPackages.length > 0 && (
<>
{/* Tab rail — mini cards showing provider + package name */}
<Box
role="tablist"
id={tablistId}
aria-label="Packages to compare"
sx={{
display: 'flex',
gap: 1.5,
overflowX: 'auto',
pb: 1,
mb: 2.5,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
WebkitOverflowScrolling: 'touch',
}}
>
{allPackages.map((pkg, idx) => {
const isActive = idx === activeTabIdx;
return (
<Card
key={pkg.id}
role="tab"
aria-selected={isActive}
aria-controls={`comparison-tabpanel-${idx}`}
id={`comparison-tab-${idx}`}
variant="outlined"
selected={isActive}
padding="none"
onClick={() => setActiveTabIdx(idx)}
interactive
sx={{
flexShrink: 0,
minWidth: 150,
maxWidth: 200,
cursor: 'pointer',
...(pkg.isRecommended &&
!isActive && {
borderColor: 'var(--fa-color-brand-500)',
}),
}}
>
<Box sx={{ px: 2, py: 1.5 }}>
<Typography
variant="labelSm"
sx={{
fontWeight: 600,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{pkg.isRecommended ? `${pkg.provider.name}` : pkg.provider.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{pkg.name}
</Typography>
</Box>
</Card>
);
})}
</Box>
{activePackage && (
<Box
role="tabpanel"
id={`comparison-tabpanel-${activeTabIdx}`}
aria-labelledby={`comparison-tab-${activeTabIdx}`}
>
<MobilePackageCard pkg={activePackage} onArrange={onArrange} />
</Box>
)}
</>
)}
</WizardLayout>
</Box>
);
},
);
ComparisonPage.displayName = 'ComparisonPage';
export default ComparisonPage;

View File

@@ -0,0 +1,2 @@
export { ComparisonPage, default } from './ComparisonPage';
export type { ComparisonPageProps } from './ComparisonPage';

View File

@@ -26,6 +26,11 @@
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */ --fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */ --fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */ --fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
--fa-map-pin-height: 28px; /** Pill height — compact for map density */
--fa-map-pin-font-size: 12px; /** Small but legible price text */
--fa-map-pin-dot-size: 12px; /** Small circle marker */
--fa-map-pin-nub-size: 6px; /** Nub triangle size */
--fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */ --fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */ --fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */ --fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
@@ -268,6 +273,10 @@
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */ --fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */ --fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */ --fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
--fa-map-pin-padding-x: var(--fa-spacing-3); /** 12px horizontal padding inside pill */
--fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */
--fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */
--fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */ --fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */ --fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */ --fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */

View File

@@ -71,6 +71,15 @@ export declare const InputFontSizeDefault: string;
export declare const InputBorderRadiusDefault: string; export declare const InputBorderRadiusDefault: string;
export declare const InputGapDefault: string; export declare const InputGapDefault: string;
export declare const InputIconSizeDefault: string; export declare const InputIconSizeDefault: string;
export declare const MapPinHeight: string;
export declare const MapPinPaddingX: string;
export declare const MapPinFontSize: string;
export declare const MapPinBorderRadius: string;
export declare const MapPinDotSize: string;
export declare const MapPinNubSize: string;
export declare const MiniCardImageHeight: string;
export declare const MiniCardContentPadding: string;
export declare const MiniCardContentGap: string;
export declare const ProviderCardImageHeight: string; export declare const ProviderCardImageHeight: string;
export declare const ProviderCardLogoSize: string; export declare const ProviderCardLogoSize: string;
export declare const ProviderCardLogoBorderRadius: string; export declare const ProviderCardLogoBorderRadius: string;

View File

@@ -72,6 +72,15 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
export const MapPinHeight = "28px"; // Pill height — compact for map density
export const MapPinPaddingX = "12px"; // 12px horizontal padding inside pill
export const MapPinFontSize = "12px"; // Small but legible price text
export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape
export const MapPinDotSize = "12px"; // Small circle marker
export const MapPinNubSize = "6px"; // Nub triangle size
export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids
export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding
export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius

View File

@@ -0,0 +1,17 @@
{
"mapPin": {
"$description": "MapPin atom tokens — price-pill map markers for provider/venue map views. Verified (brand) vs unverified (neutral) visual distinction.",
"height": { "$type": "dimension", "$value": "28px", "$description": "Pill height — compact for map density" },
"paddingX": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px horizontal padding inside pill" },
"fontSize": { "$type": "dimension", "$value": "12px", "$description": "Small but legible price text" },
"borderRadius": { "$type": "dimension", "$value": "{borderRadius.full}", "$description": "Fully rounded pill shape" },
"dot": {
"$description": "Dot variant for pins without a price label.",
"size": { "$type": "dimension", "$value": "12px", "$description": "Small circle marker" }
},
"nub": {
"$description": "Downward-pointing nub anchoring the pill to the map location.",
"size": { "$type": "dimension", "$value": "6px", "$description": "Nub triangle size" }
}
}
}

View File

@@ -0,0 +1,15 @@
{
"miniCard": {
"$description": "MiniCard molecule tokens — compact vertical card for providers, venues, packages in grids, recommendations, and map popups.",
"image": {
"$type": "dimension",
"$description": "Hero image area dimensions.",
"height": { "$value": "120px", "$description": "Shorter image than full listing cards (180px) for compact grids" }
},
"content": {
"$description": "Content area spacing.",
"padding": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px — matches ProviderCard/VenueCard content padding" },
"gap": { "$type": "dimension", "$value": "{spacing.1}", "$description": "4px vertical gap between content rows" }
}
}
}