Add spec and plan for the five-layer services accordion

This commit is contained in:
David 2026-11-03 18:02:20 +02:00
parent 5d4de0868f
commit 59e659d5ef
2 changed files with 437 additions and 0 deletions

View file

@ -0,0 +1,315 @@
# MEGA Services — "Five layers" accordion, Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the MEGA Services section with a five-slice horizontal accordion that expands on hover or keyboard focus.
**Architecture:** A pure-CSS flex accordion — each slice is `flex-1` and transitions `flex-grow` to `4.2` on hover and on `:focus-within`. No JavaScript state, no new dependency. The slice data is derived from the existing `services` array so the two cannot drift apart.
**Tech Stack:** React 19, TypeScript 5.8, Vite 6, Tailwind 3.4.
## Global Constraints
- **No new runtime dependency.** `app/package.json` gains no `dependencies` entry.
- **Pure CSS interaction.** No `useState` for the accordion; hover and `:focus-within` do the work.
- **`services` must not change.** The quote form's service dropdown uses all seven services.
- **`erasableSyntaxOnly` and `verbatimModuleSyntax` are on.** Type-only imports use `import type`.
- **`allowImportingTsExtensions` is on.** Relative imports of local modules carry the `.ts` extension.
- **`#services` anchor must survive**, and the section keeps its `01` label.
- **All copy lives in `src/data/content.ts`.**
- **Commit to `main` directly.** No PR workflow; the client cannot use the Forgejo web UI to merge.
---
### Task 1: Derive the five layers from `services`
**Files:**
- Modify: `app/src/data/content.ts`
**Interfaces:**
- Consumes: the existing `services: Service[]` array.
- Produces: `export type Layer = { id: string; kicker: string; title: string; body: string; scope: string[]; image: string }` and `export const layers: Layer[]` — exactly five entries, ordered earthworks → municipal → stormwater → roads → electrical.
- [ ] **Step 1: Add the layer type and the derived array**
In `app/src/data/content.ts`, immediately after the `services` array, add:
```ts
export type Layer = {
id: string
kicker: string
title: string
body: string
scope: string[]
image: string
}
// The five layers every site is built from, in the order they go into the
// ground. `services` stays the full list the quote form offers.
const layerIds = ['earthworks', 'municipal', 'stormwater', 'roads', 'electrical'] as const
const layerTitles: Record<string, string> = { municipal: 'Water & Sewer', roads: 'Roads & Paving' }
const layerKickers = ['Layer one', 'Layer two', 'Layer three', 'Layer four', 'Layer five']
export const layers: Layer[] = layerIds.flatMap((id, i) => {
const s = services.find((x) => x.id === id)
if (!s) return []
return [
{
id: s.id,
kicker: layerKickers[i],
title: layerTitles[s.id] ?? s.title,
body: s.short,
scope: s.scope,
image: s.image,
},
]
})
```
- [ ] **Step 2: Verify the derivation produces five layers**
Run: `cd app && npx tsx -e "..."` — if `tsx` is unavailable, verify by reading the file instead. Simpler check that needs no extra tooling:
Run: `cd /Users/david/DEVELOPMENT/MEGA-WEBSITE && grep -n "const layerIds" app/src/data/content.ts`
Expected: the five ids appear in the order `earthworks, municipal, stormwater, roads, electrical`.
- [ ] **Step 3: Type-check**
Run: `cd app && npx tsc -b`
Expected: PASS with no errors. `services.find()` returns `Service | undefined`, which the `if (!s) return []` guard handles.
- [ ] **Step 4: Commit**
```bash
git add app/src/data/content.ts
git commit -m "Derive the five site layers from the services list"
```
---
### Task 2: The accordion component
**Files:**
- Create: `app/src/components/LayerAccordion.tsx`
**Interfaces:**
- Consumes: `layers` and `img` from `@/data/content`.
- Produces: `export function LayerAccordion(): JSX.Element`.
- [ ] **Step 1: Write the component**
Create `app/src/components/LayerAccordion.tsx`:
```tsx
import { img, layers, type Layer } from '@/data/content'
// One slice of the accordion. On desktop the row is a flex line and the slice
// grows on hover or keyboard focus; on mobile it is a full-width card with
// the copy always visible.
function Slice({ layer }: { layer: Layer }) {
return (
<article
tabIndex={0}
aria-label={layer.title}
className="group relative flex flex-col overflow-hidden sm:block sm:min-w-0 sm:flex-1 sm:transition-[flex-grow] sm:duration-[800ms] sm:ease-expo sm:hover:flex-grow-[4.2] sm:focus-within:flex-grow-[4.2]"
>
{/* Photograph */}
<div className="relative h-44 shrink-0 sm:absolute sm:inset-0 sm:h-auto">
<div
className="absolute inset-0 bg-cover bg-center transition-[transform,filter] duration-[1200ms] ease-expo sm:scale-[1.08] sm:saturate-[0.55] sm:brightness-[0.42] sm:group-hover:scale-[1.02] sm:group-hover:saturate-100 sm:group-hover:brightness-[0.72]"
style={{ backgroundImage: `url(${img(layer.image, true)})` }}
aria-hidden="true"
/>
</div>
{/* Scrim, so the copy stays legible over the photograph */}
<div className="absolute inset-0 hidden bg-gradient-to-t from-ink via-ink/55 to-ink/15 sm:block" aria-hidden="true" />
{/* Vertical title, desktop only */}
<span className="absolute inset-x-0 bottom-0 hidden p-[clamp(1rem,2vw,1.75rem)] font-display text-[clamp(1.1rem,1.7vw,1.6rem)] font-medium leading-none text-bone [text-shadow:0_2px_16px_rgba(12,12,10,0.9)] [writing-mode:vertical-rl] rotate-180 transition-colors duration-500 group-hover:text-gold sm:block">
{layer.title}
</span>
{/* Panel */}
<div className="relative flex flex-col justify-end p-6 sm:absolute sm:inset-0 sm:p-[clamp(1.4rem,3vw,2.5rem)] sm:opacity-0 sm:translate-y-6 sm:transition-[opacity,transform] sm:duration-700 sm:ease-expo sm:group-hover:opacity-100 sm:group-hover:translate-y-0 sm:group-focus-within:opacity-100 sm:group-focus-within:translate-y-0">
<p className="eyebrow text-gold">{layer.kicker}</p>
<h3 className="display mt-3 text-[clamp(1.5rem,2.6vw,2.4rem)] font-normal leading-[0.98] tracking-[-0.02em] text-bone">
{layer.title}
</h3>
<p className="mt-4 max-w-[34ch] text-[0.97rem] leading-relaxed text-bone/75">{layer.body}</p>
<ul className="mt-5 flex flex-wrap gap-2">
{layer.scope.map((t) => (
<li key={t} className="rounded-full border border-bone/20 px-3 py-1 text-xs text-bone/70">
{t}
</li>
))}
</ul>
</div>
</article>
)
}
export function LayerAccordion() {
return (
<div className="flex flex-col sm:h-[clamp(22rem,58vh,34rem)] sm:flex-row sm:overflow-hidden sm:border-y sm:border-bone/12">
{layers.map((layer) => (
<Slice key={layer.id} layer={layer} />
))}
</div>
)
}
```
- [ ] **Step 2: Type-check**
Run: `cd app && npx tsc -b`
Expected: PASS with no errors.
- [ ] **Step 3: Commit**
```bash
git add app/src/components/LayerAccordion.tsx
git commit -m "Add the five-layer services accordion"
```
---
### Task 3: Rewire the Services section
**Files:**
- Modify: `app/src/components/Services.tsx`
**Interfaces:**
- Consumes: `LayerAccordion` from `./LayerAccordion`.
- Produces: `export function Services(): JSX.Element` — unchanged export, unchanged `id="services"`. `WhyUs` is untouched.
- [ ] **Step 1: Replace the Services component**
In `app/src/components/Services.tsx`, replace everything from the top of the file down to the end of the `Services` function (that is, keep `export function WhyUs()` and everything after it exactly as it is) with:
```tsx
import { ArrowUpRight } from 'lucide-react'
import { pillars } from '@/data/content'
import { LayerAccordion } from './LayerAccordion'
import { Reveal, SectionLabel } from './Reveal'
export function Services() {
return (
<section id="services" className="scroll-mt-20 bg-ink py-24 sm:py-32 lg:py-40">
<div className="container-x">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end">
<Reveal>
<SectionLabel index="01">Services</SectionLabel>
<h2 className="display mt-8 max-w-[820px] text-[clamp(2.2rem,5vw,4.6rem)] font-light leading-[1.02]">
Five layers. <span className="text-gold">None of them optional.</span>
</h2>
</Reveal>
<Reveal delay={150}>
<p className="max-w-[420px] leading-relaxed text-bone/60">
This is the whole method, start to finish. Five layers on every site, in the same order, since 2007 — and it is not
going to start now.
</p>
</Reveal>
</div>
<div className="mt-14 lg:mt-20">
<LayerAccordion />
<p className="mt-8 max-w-[520px] text-sm leading-relaxed text-bone/45">
Civil design and residential development are also in-house.{' '}
<a
href="#contact"
className="text-gold underline decoration-gold/40 underline-offset-4 transition-colors hover:text-bone"
>
Tell us what you need
</a>
.
</p>
</div>
</div>
</section>
)
}
```
The old `useState`, `Plus`, `img`, `services` and `LoopVideo` imports are no longer needed by `Services`; `WhyUs` needs only `pillars`, `Reveal` and `SectionLabel`. Remove the imports that are no longer referenced and keep the `WhyUs` function byte-for-byte as it is.
- [ ] **Step 2: Confirm the old per-service anchors are gone**
Run: `cd /Users/david/DEVELOPMENT/MEGA-WEBSITE && grep -rn "service-" app/src || echo "no references"`
Expected: `no references` — nothing outside `Services.tsx` ever linked to `#service-<id>`.
- [ ] **Step 3: Build and test**
Run: `cd app && npm run build && npm test`
Expected: the build succeeds and all 12 unit tests still pass.
- [ ] **Step 4: Commit**
```bash
git add app/src/components/Services.tsx
git commit -m "Replace the services list with the five-layer accordion"
```
---
### Task 4: Verify in the browser
**Files:**
- No file changes expected. Fix anything the checks surface, then re-run this task.
**Interfaces:**
- Consumes: the site from Task 3.
- [ ] **Step 1: Start the dev server**
Run: `cd app && npm run dev` (background)
Expected: listening on port 5173.
- [ ] **Step 2: Check the desktop accordion**
Open `http://localhost:5173/#services` in the in-app Browser. Confirm:
- five slices, each collapsed to an equal width, each showing its vertical title;
- hovering a slice widens it and brightens its photograph, and the panel fades in with kicker, title, description and chips;
- moving the pointer away collapses it again.
Take a screenshot of a slice expanded.
- [ ] **Step 3: Check keyboard focus**
Tab into the accordion and confirm the focused slice expands exactly as it does on hover. Confirm the tab order walks the five slices.
- [ ] **Step 4: Check the mobile stack**
Use chrome-devtools MCP `emulate` with viewport `375x812x2,mobile,touch`. Confirm the slices stack vertically as full-width cards, the copy is visible without any interaction, and `document.documentElement.scrollWidth` equals the viewport width.
- [ ] **Step 5: Check the console and network**
Query the browser console for errors and the network log for failed requests. Expect no errors and no 404s — every `img/photos/*.webp` must resolve.
- [ ] **Step 6: Commit any fixes**
If the checks surfaced bugs, fix them, re-run `npm test` and `npm run build`, and commit:
```bash
git add -A app/src
git commit -m "Fix services accordion issues found in browser verification"
```
If nothing needed fixing, skip this step.
---
## Self-review against the spec
| Spec requirement | Covered by |
|---|---|
| Exactly five slices, in build order | Task 1, Task 4 step 2 |
| Hover expands and brightens | Task 2 (Tailwind hover variants), Task 4 step 2 |
| Keyboard focus expands identically | Task 2 (`:focus-within`), Task 4 step 3 |
| Kicker, title, description, chips on the panel | Task 2 |
| Mobile stacks vertically, no overflow | Task 2 (layout classes), Task 4 step 4 |
| Heading and intro | Task 3 |
| "Also in-house" line linking to `#contact` | Task 3 |
| Build and tests pass | Task 3 step 3, Task 4 |
| No new dependency; `services` unchanged | Global Constraints, Task 1 |
| `#services` anchor and quote dropdown intact | Task 3 (id kept), Global Constraints |

View file

@ -0,0 +1,122 @@
# MEGA Services — "Five layers" accordion
**Date:** 2026-11-03
**Status:** approved (design), pending implementation
**Reference:** Stackless Coffee "Ritual" section (`src/components/Ritual.jsx` + the `.acc` rules in `src/index.css`)
---
## 1. Goal
Replace the current Services section — a sticky portrait photograph beside an expandable list of all seven services — with the horizontal accordion treatment from the Stackless Coffee site: five vertical slices that expand across the frame on hover or keyboard focus, each carrying its own photograph, kicker, title, one-line description and scope chips.
## 2. Decisions taken
| Decision | Choice |
|---|---|
| What becomes a slice | The **five site-building layers**: bulk earthworks, water & sewer, stormwater, roads & paving, electrical reticulation |
| The current layout | The sticky photograph and the service list are **both replaced** by the accordion |
| Mobile | The slices **stack vertically**, each a full-width card with the copy always visible |
| Heading | **"Five layers. None of them optional."** |
| Design & Residential Development | Kept as a quiet line under the accordion, not as slices |
| Interaction | Pure CSS — `flex-grow` on hover, `:focus-within` for keyboard. No JavaScript state |
## 3. The reference mechanic
The Stackless accordion is pure CSS:
- The row is a flex container; each slice is `flex: 1 1 0` with `overflow: hidden`.
- On hover (or focus) the slice's `flex-grow` transitions to `4.2`, so it takes roughly 41% of the row and its neighbours compress.
- The photograph fills the slice, dimmed (`saturate(0.55) brightness(0.42)`) and scaled up slightly; on hover it brightens and settles.
- A vertical title label (`writing-mode: vertical-rl`, rotated 180°) sits at the bottom of the collapsed slice and turns ember on hover.
- A panel — kicker, title, body — sits over the slice with `opacity: 0` and a small downward offset; on hover it fades in and rises.
MEGA keeps the mechanic but swaps the ember accent for gold and the display weights for MEGA's editorial type.
## 4. Architecture
### Files
| File | Change |
|---|---|
| `app/src/data/content.ts` | Adds `Layer` type and a `layers` array derived from `services`. `services` itself is untouched. |
| `app/src/components/LayerAccordion.tsx` | **New.** The accordion and its slices. |
| `app/src/components/Services.tsx` | The Services section becomes header + accordion + the "also in-house" line. `WhyUs` is unchanged. |
### The five layers
| # | Kicker | Title | Source service | Photo |
|---|---|---|---|---|
| 1 | Layer one | Bulk Earthworks | `earthworks` | `dji_0587` |
| 2 | Layer two | Water & Sewer | `municipal` | `310a6423` |
| 3 | Layer three | Stormwater | `stormwater` | `310a6304` |
| 4 | Layer four | Roads & Paving | `roads` | `road-paving` |
| 5 | Layer five | Electrical Reticulation | `electrical` | `310a6904` |
The order is the order the layers go into the ground, which is what makes the section read as a method rather than a list.
### Data derivation
`layers` is derived from `services` by id, not written out separately:
- `body` comes from the service's `short` field — currently unused data that was written for exactly this purpose.
- `title` comes from the service title, with two overrides: `municipal` → "Water & Sewer" and `roads` → "Roads & Paving" (the slice titles are shorter and more concrete than the service names).
- `scope` and `image` are taken from the service unchanged.
Deriving rather than duplicating means the accordion cannot drift away from the service data. `services` itself stays exactly as it is, because the quote form's service dropdown still uses all seven.
### Section copy
- Eyebrow: `01` + "Services" (the existing `SectionLabel`).
- Heading: **"Five layers. None of them optional."** — "None of them optional." in gold.
- Intro: *"This is the whole method, start to finish. Five layers on every site, in the same order, since 2007 — and it is not going to start now."* — "since 2007" is the AEB Construction founding date already in `company`.
- Under the accordion: *"Civil design and residential development are also in-house."* with a link to `#contact`.
## 5. Layout
### Desktop (`sm` and up)
- Row: `flex`, height `clamp(22rem, 58vh, 34rem)`, `overflow: hidden`, hairline borders top and bottom.
- Slice: `flex-1`, `min-w-0`, `overflow: hidden`, `transition-[flex-grow] 800ms ease-expo`; `hover:flex-grow-[4.2]` and `focus-within:flex-grow-[4.2]`.
- Photograph: absolutely positioned, `background-size: cover`, `scale(1.08) saturate(.55) brightness(.42)`; on hover `scale(1.02) saturate(1) brightness(.72)`.
- Scrim: `from-ink via-ink/55 to-ink/15`, top to bottom.
- Label: absolute, full width at the bottom, `writing-mode: vertical-rl` + `rotate(180deg)`, display face at `clamp(1.1rem, 1.7vw, 1.6rem)`, with a text shadow; turns gold on hover.
- Panel: absolute, full slice, content pushed to the bottom with `clamp(1.4rem, 3vw, 2.5rem)` padding. `opacity: 0` + `translateY(1.5rem)`; on hover/focus `opacity: 1` + `translateY(0)`.
- Kicker: eyebrow, gold.
- Title: display face, `clamp(1.5rem, 2.6vw, 2.4rem)`.
- Body: `short`, `max-w-[34ch]`.
- Scope chips: the service's scope tags.
### Mobile (below `sm`)
One markup tree, two layouts:
- The row becomes `flex-col` with no fixed height and no clipping.
- Each slice is a column: the photograph is a `h-44` banner, and the panel sits below it in normal flow with the copy always visible.
- The vertical label and the scrim are desktop-only.
## 6. Interaction and accessibility
- Each slice is focusable (`tabIndex={0}`) with an `aria-label` carrying the title. `:focus-within` reveals the panel, so keyboard users get the same reveal as hover — this is the reference's own approach.
- The panel content stays in the accessibility tree at all times, so a screen reader reaches every layer's description and scope whether or not the panel is expanded.
- Tapping a slice on a touch device focuses it, which expands it.
- The site's existing global rule collapses every transition under `prefers-reduced-motion`, so the expansion snaps rather than animates. No extra work needed.
## 7. Out of scope
- The aerial clip on the Roads service (`/video/road-paving.mp4`). The accordion slices use still photographs; a landscape clip cropped into a narrow slice does not read. The asset stays in the repository and in the data.
- Any change to `WhyUs`, `Company`, `Team`, `Contact` or the fly-through.
- Any change to `services` — the quote form's dropdown still offers all seven.
## 8. Acceptance criteria
1. The Services section shows exactly five slices, in the order earthworks → water & sewer → stormwater → roads & paving → electrical.
2. Hovering a slice expands it across the row and brightens its photograph; moving away collapses it again.
3. Tabbing to a slice expands it identically to hovering.
4. Each expanded slice shows its kicker, title, one-line description and scope chips.
5. Below `sm` the slices stack vertically as full-width cards with the copy always visible and no horizontal overflow.
6. The heading reads "Five layers. None of them optional." and the intro paragraph sits beside it on desktop.
7. The "also in-house" line names civil design and residential development and links to `#contact`.
8. `npm run build` passes and `npm test` still passes.
9. No new runtime dependency, and the `services` array is unchanged.
10. The `#services` anchor and the quote form's service dropdown still work.