diff --git a/docs/frontend-testids.md b/docs/frontend-testids.md index 95b9c054..b661bd8d 100644 --- a/docs/frontend-testids.md +++ b/docs/frontend-testids.md @@ -80,6 +80,10 @@ renders the element. | Calendar | `calendar` | `frontend/src/components/screens/Calendar.tsx` (the `/calendar` screen — today just the #185 load-failure banner + retry) | | Gradebook | `gradebook` | `frontend/src/components/screens/Gradebook/Landing.tsx` + `Course.tsx` (the `/gradebook` screens), `frontend/src/components/Gradebook/TranscriptModal.tsx` (the transcript modal), `frontend/src/components/Gradebook/CourseCard.tsx` (the term-aware card links), `frontend/src/components/Gradebook/AssignmentList.tsx` + `AssignmentModal.tsx` (the add-assignment flow the #468 mutation leg drives) — added with the #139 term-switcher/transcript journey | | Admin analytics | `admin-analytics` | `frontend/src/components/screens/AdminAnalytics.tsx` (the `/admin/analytics` dashboard — range presets/inputs, cost group-by toggle, per-panel retry) — added with the #121 data layer | +| Landing knowledge graph demo | `landing-graph` | `frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx` (the pre-auth landing page's interactive course-picker + laid-out concept graph, wearing the `landing-surface` chrome with an inspector rail and mastery legend since #344 step 3) | +| Landing feature bands | `landing-band` | `frontend/src/components/marketing/FeatureBand.tsx` (the three full-width bands below the graph; content + side-alternation in `featureBands.tsx`, #344 step 2) | +| Landing surface bento | `landing-bento` | `frontend/src/components/marketing/SurfaceBento.tsx` (the four-tile grid of built product surfaces, #344 step 2) | +| Landing product surfaces | `landing-surface` | `frontend/src/components/marketing/surfaces/*.tsx` (the seven in-page recreations the bands and bento mount, #344 step 2) | Two surfaces do **not** carry their testids in the screen file named by the route: @@ -324,6 +328,64 @@ Added with the upload → SSE → library journey (#387). | `library-concepts-toggle-all` | detail panel "Expand all" / "Collapse all" | | `library-concept-toggle-{idx}` | one concept accordion toggle (render index) | +### `landing-graph` + +Added with the #344 landing-page knowledge-graph demo. Ships the static, +fully laid-out render (chips to switch course, nodes/edges for the selected +graph), the helical assembly animation, and hover interaction (detail-panel +swap + copy fade on first engagement); click-to-expand is a later task in the +same spec and will reuse these same testids. + +Step 3 wrapped the whole thing in the same `landing-surface` chrome the bands +and bento use — a titled bar over a `--bg-mesh` canvas, an inspector rail and a +mastery legend — so the ids below split into the section's own controls and the +screen's panes. + +| testid | element | +| --- | --- | +| `landing-graph` | demo section root (`aria-label` names the selected course) | +| `landing-graph-chip-{courseId}` | one course-picker chip, per `COURSE_GRAPHS` entry — `aria-pressed` marks the active course | +| `landing-graph-copy` | the section's instructional copy block (eyebrow label + heading) — `data-engaged` flips `"true"` on the visitor's first node hover and stays that way across course switches | +| `landing-graph-eyebrow` | the "Your knowledge, mapped" eyebrow label — deliberately *not* faded on engagement (WCAG AA at 0.7rem) | +| `landing-graph-headline` | the "Pick a course. Watch it grow." heading — carries the engagement fade (`ENGAGED_HEADLINE_OPACITY`) | +| `landing-graph-surface` | the product-chrome frame the whole screen sits in (`landing-surface`) | +| `landing-graph-meta` | the chrome bar's right-hand status — `{code} · {conceptCount} concepts · {n}% mastery`, all read off the selected `CourseGraph` | +| `landing-graph-svg` | the `` itself — its `viewBox` is the desktop or the phone `GraphView` (`graph/layout.ts`), swapped at the mobile breakpoint | +| `landing-graph-node-{nodeId}` | one SVG node group (``) in the selected course's graph. Its **first `` is the tier-painted disc at the full node radius** — the phone legibility gate measures that element, so nothing may be drawn in front of it | +| `landing-graph-detail` | the inspector rail's frame. Never empty: with nothing hovered it shows the course (the root node) | +| `landing-graph-detail-name` | the inspected node's name | +| `landing-graph-detail-tier` | its mastery tier as a labelled chip (`Course` for the root, which is an anchor rather than a status) | +| `landing-graph-detail-mastery` | its mastery as a percentage | +| `landing-graph-blurb` | the reserved-height paragraph in the panel that shows the inspected node's one-sentence blurb | +| `landing-graph-legend` | the mastery legend pinned to the foot of the rail | +| `landing-graph-legend-{tier}` | one legend row — `mastered`, `learning`, `struggling` or `unexplored`, each with the tier's own count of drawn concepts | + +### `landing-band` / `landing-bento` / `landing-surface` + +Added with the #344 step-2 build (feature bands + surface bento). These +surfaces are **static recreations** of shipped product screens — there is +deliberately not a single `
+
+ + Your knowledge, mapped + +

+ Pick a course. Watch it grow. +

+
+ +
+ {COURSE_GRAPHS.map((g) => { + const active = g.id === graph.id; + return ( + + ); + })} +
+ + + {graph.edges.map((e) => { + const a = points.get(e.source); + const b = points.get(e.target); + if (!a || !b) return null; + return ( + + ); + })} + + {graph.nodes.map((n) => { + const p = points.get(n.id); + if (!p) return null; + return ( + + + + {n.label} + + + ); + })} + +
+ + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/components/marketing/graph/KnowledgeGraphDemo.test.tsx` +Expected: PASS + +- [ ] **Step 5: Add the `.landing-page`-scoped styles** + +Append inside the existing `.landing-page` block region of `frontend/src/app/globals.css` — do **not** create tokens outside that scope: + +```css +.landing-page .landing-graph-chip { + font-family: var(--font-jetbrains), 'JetBrains Mono', monospace; + font-size: 0.78rem; + letter-spacing: 0.08em; + padding: 7px 16px; + border-radius: 9999px; + border: 1px solid rgba(107, 114, 128, 0.28); + background: rgba(255, 255, 255, 0.55); + color: var(--text-dim); + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} +.landing-page .landing-graph-chip:hover { border-color: var(--brand-forest); } +.landing-page .landing-graph-chip.is-active { + background: var(--brand-forest); + border-color: var(--brand-forest); + color: #fff; +} +.landing-page .landing-graph-copy { transition: opacity 600ms ease; } +``` + +- [ ] **Step 6: Register the E2E surface** + +In `docs/frontend-testids.md`, add a row to the surface table and the four testids to the inventory: +`landing-graph`, `landing-graph-chip-`, `landing-graph-node-`, `landing-graph-copy`. + +In `frontend/eslint.config.mjs`, add `"src/components/marketing/graph/KnowledgeGraphDemo.tsx"` to the surface block's `files` array. + +- [ ] **Step 7: Verify lint and types** + +Run: `cd frontend && npm run lint && npx tsc --noEmit` +Expected: both clean. The lint rule will name any button missing a `data-testid`. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/components/marketing/graph/ frontend/src/app/globals.css frontend/eslint.config.mjs docs/frontend-testids.md +git commit -m "feat(landing): knowledge graph demo — chips and laid-out render (#344)" +``` + +--- + +### Task 4: Helical assembly, parked under reduced motion + +**Files:** +- Modify: `frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx` +- Modify: `frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx` + +**Interfaces:** +- Consumes: `helixEntry` (Task 2), `IS_TEST_MODE` and `now` from `@/lib/testMode`. +- Produces: no new exports. + +- [ ] **Step 1: Write the failing test** + +Append to `KnowledgeGraphDemo.test.tsx`: + +```tsx +describe('KnowledgeGraphDemo — motion', () => { + it('parks fully assembled when reduced motion is requested', () => { + // jsdom has no matchMedia; supply one that reports "reduce". + window.matchMedia = ((q: string) => ({ + matches: q.includes('prefers-reduced-motion'), + media: q, + addEventListener() {}, + removeEventListener() {}, + })) as unknown as typeof window.matchMedia; + + render(); + const g = COURSE_GRAPHS[0]; + + // Every node present AND at full opacity — parked means complete. + for (const n of g.nodes) { + const el = screen.getByTestId(`landing-graph-node-${n.id}`); + expect(el).toBeInTheDocument(); + expect(el.getAttribute('opacity')).toBe('1'); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/components/marketing/graph/KnowledgeGraphDemo.test.tsx -t "parks fully assembled"` +Expected: FAIL — nodes carry no `opacity` attribute yet. + +- [ ] **Step 3: Implement the assembly loop** + +Add to the component's imports: + +```tsx +import { useEffect, useRef } from 'react'; +import { IS_TEST_MODE, now } from '@/lib/testMode'; +import { helixEntry } from './layout'; +``` + +Add the reduced-motion check and progress state (`1` means fully assembled, which is the parked value): + +```tsx +const ASSEMBLE_MS = 1100; + +const prefersReduced = + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const parked = IS_TEST_MODE || prefersReduced; + +const [progress, setProgress] = useState(parked ? 1 : 0); +const rafRef = useRef(0); + +useEffect(() => { + if (parked) { + setProgress(1); + return; + } + setProgress(0); + const start = now(); + const tick = () => { + const p = Math.min(1, (now() - start) / ASSEMBLE_MS); + setProgress(p); + if (p < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + // Re-runs on course change so each pick re-assembles. +}, [graph.id, parked]); +``` + +Give each node a staggered sub-progress and drive it through `helixEntry`. Replace the node `` body: + +```tsx +{graph.nodes.map((n, i) => { + const p = points.get(n.id); + if (!p) return null; + // Stagger: later nodes start later, all finish by progress = 1. + const span = 1 / (graph.nodes.length + 2); + const local = Math.min(1, Math.max(0, (progress - i * span) / (1 - i * span))); + const h = helixEntry(p, { x: VIEW_W / 2, y: VIEW_H / 2 }, local); + const r = nodeRadius(graph, n.id); + return ( + + + + {n.label} + + + ); +})} +``` + +Edges fade in with the whole assembly — set `strokeOpacity={0.28 * progress}` on the ``. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/components/marketing/graph/KnowledgeGraphDemo.test.tsx` +Expected: PASS — including the earlier "renders fully laid out" test, because `IS_TEST_MODE` is false under vitest but the parked test supplies its own `matchMedia`. + +> If the non-parked tests now see `progress = 0`, park them the same way: vitest's jsdom has no `matchMedia`, so add the same stub in a `beforeEach` returning `matches: true`. The parked frame is the correct assertion target for unit tests either way. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/marketing/graph/ +git commit -m "feat(landing): helical assembly, parked under reduced motion (#344)" +``` + +--- + +### Task 5: Interaction — hover blurb, click to expand, copy fade + +**Files:** +- Modify: `frontend/src/components/marketing/graph/KnowledgeGraphDemo.tsx` +- Modify: `frontend/src/components/marketing/graph/KnowledgeGraphDemo.test.tsx` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: testid `landing-graph-blurb`. + +- [ ] **Step 1: Write the failing test** + +```tsx +describe('KnowledgeGraphDemo — interaction', () => { + it('shows a concept blurb on hover', () => { + render(); + const n = COURSE_GRAPHS[0].nodes[1]; + fireEvent.mouseEnter(screen.getByTestId(`landing-graph-node-${n.id}`)); + expect(screen.getByTestId('landing-graph-blurb')).toHaveTextContent(n.blurb); + }); + + it('fades the instructional copy once the visitor interacts', () => { + render(); + const copy = screen.getByTestId('landing-graph-copy'); + expect(copy).toHaveAttribute('data-engaged', 'false'); + + fireEvent.mouseEnter( + screen.getByTestId(`landing-graph-node-${COURSE_GRAPHS[0].nodes[1].id}`), + ); + expect(copy).toHaveAttribute('data-engaged', 'true'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/components/marketing/graph/KnowledgeGraphDemo.test.tsx -t "interaction"` +Expected: FAIL — no `landing-graph-blurb`, no `data-engaged`. + +- [ ] **Step 3: Implement** + +```tsx +const [hovered, setHovered] = useState(null); +const [engaged, setEngaged] = useState(false); + +function onNodeEnter(id: string) { + setHovered(id); + setEngaged(true); +} +``` + +Put `data-engaged={engaged ? 'true' : 'false'}` and `style={{ opacity: engaged ? 0.35 : 1 }}` on the copy block, add `onMouseEnter={() => onNodeEnter(n.id)}` and `onMouseLeave={() => setHovered(null)}` to each node ``, and render the blurb below the svg: + +```tsx +

+ {hovered ? graph.nodes.find((n) => n.id === hovered)?.blurb : ''} +

+``` + +`min-h` is load-bearing: without it the paragraph appearing and disappearing shifts everything below it on every hover. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/components/marketing/graph/KnowledgeGraphDemo.test.tsx` +Expected: PASS + +- [ ] **Step 5: Register the new testid** + +Add `landing-graph-blurb` to the inventory in `docs/frontend-testids.md`. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/marketing/graph/ docs/frontend-testids.md +git commit -m "feat(landing): graph hover blurbs and copy fade on engagement (#344)" +``` + +--- + +### Task 6: Wire into the page; delete HowItWorks and the old catalog + +**Files:** +- Modify: `frontend/src/app/(public)/page.tsx` +- Delete: `frontend/src/components/marketing/HowItWorks.tsx` +- Modify: `frontend/src/app/globals.css` (remove rules orphaned by the deletions) + +**Interfaces:** +- Consumes: `KnowledgeGraphDemo` (Tasks 3–5). +- Produces: nothing. + +- [ ] **Step 1: Swap the dynamic import** + +Replace the `HowItWorks` dynamic import block. Keep SSR on — `ssr: false` would drop the copy from the HTML crawlers see, which `public-seo.spec.ts` guards: + +```tsx +const KnowledgeGraphDemo = dynamic( + () => import('@/components/marketing/graph/KnowledgeGraphDemo'), + { + // Placeholder height matches the section's resolved height so nothing + // below shifts while the chunk loads. + loading: () =>
, + }, +); +``` + +- [ ] **Step 2: Replace the sections** + +In the JSX, delete the entire `
` block (heading, the six-item `
    `, and the "— end of catalog" rule) and replace `` with ``. The resulting order below the hero is: ``, the existing final-CTA `
    `, then the `