feat: Added format-aware v2 body_sections rendering with nested TOC, ci…

- "frontend/src/api/public-client.ts"
- "frontend/src/pages/TechniquePage.tsx"
- "frontend/src/components/TableOfContents.tsx"
- "frontend/src/utils/citations.tsx"
- "frontend/src/App.css"

GSD-Task: S05/T01
This commit is contained in:
jlightner 2026-04-03 01:42:56 +00:00
parent 7070ef3f51
commit 48bcf26bee
15 changed files with 765 additions and 23 deletions

View file

@ -9,7 +9,7 @@ Restructure technique pages to be broader (per-creator+category across videos),
| S01 | Synthesis Prompt v5 — Nested Sections + Citations | high | — | ✅ | Run test harness with new prompt → output has list-of-objects body_sections with H2/H3 nesting, citation markers on key claims, broader page scope. |
| S02 | Composition Prompt + Test Harness Compose Mode | high | S01 | ✅ | Run test harness --compose mode with existing page + new moments → merged output with deduplication, new sections, updated citations. |
| S03 | Data Model + Migration | low | — | ✅ | Alembic migration runs clean. API response includes body_sections_format and source_videos fields. |
| S04 | Pipeline Compose-or-Create Logic | high | S01, S02, S03 | | Process two COPYCATT videos. Second video's moments composed into existing page. technique_page_videos has both video IDs. |
| S04 | Pipeline Compose-or-Create Logic | high | S01, S02, S03 | | Process two COPYCATT videos. Second video's moments composed into existing page. technique_page_videos has both video IDs. |
| S05 | Frontend — Nested Rendering, TOC, Citations | medium | S03 | ⬜ | Format-2 page renders with TOC, nested sections, clickable citations. Format-1 pages unchanged. |
| S06 | Admin UI — Multi-Source Pipeline Management | medium | S03, S04 | ⬜ | Admin view for multi-source page shows source dropdown, composition history, per-video chunking inspection. |
| S07 | Search — Per-Section Embeddings + Deep Linking | medium | S04, S05 | ⬜ | Search 'LFO grain position' → section-level result → click → navigates to page#section and scrolls. |

View file

@ -0,0 +1,93 @@
---
id: S04
parent: M014
milestone: M014
provides:
- Compose-or-create branching in stage5_synthesis
- body_sections_format='v2' on all technique pages
- TechniquePageVideo join table populated for every page+video combination
requires:
- slice: S01
provides: Synthesis prompt v5 with nested sections + citations format
- slice: S02
provides: Compose prompt + test harness validation
- slice: S03
provides: Data model with body_sections_format column and TechniquePageVideo table
affects:
- S06
- S07
key_files:
- backend/pipeline/stages.py
- backend/pipeline/test_compose_pipeline.py
key_decisions:
- Compose detection queries all matching pages and warns on multiple matches, uses first
- pg_insert with on_conflict_do_nothing for idempotent TechniquePageVideo inserts
- Source-code assertions for branching logic tests instead of fragile full-session mocks
patterns_established:
- Compose-or-create branching: query existing pages by creator_id + LOWER(category), compose into first match, fall through to standard synthesis otherwise
- XML-tagged compose prompt with offset-indexed moments: existing [0]-[N-1], new [N]-[N+M-1]
observability_surfaces:
- INFO log when compose path triggered: 'Stage 5: Composing into existing page ...'
- WARNING log when multiple pages match creator+category
drill_down_paths:
- .gsd/milestones/M014/slices/S04/tasks/T01-SUMMARY.md
- .gsd/milestones/M014/slices/S04/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T01:34:24.402Z
blocker_discovered: false
---
# S04: Pipeline Compose-or-Create Logic
**Stage 5 now detects existing technique pages by creator+category and branches to a compose path that merges new video content into them, with body_sections_format='v2' and TechniquePageVideo tracking on all pages.**
## What Happened
Two tasks delivered the compose-or-create pipeline logic and its test suite.
T01 added `_build_compose_user_prompt()` and `_compose_into_existing()` helper functions to stages.py, then wired compose detection into the stage5_synthesis per-category loop. The detection query uses `creator_id + LOWER(topic_category)` for case-insensitive matching. When an existing page matches, the compose path loads its linked moments, builds an XML-tagged prompt with offset-indexed moment references, and calls the LLM with the stage5_compose.txt system prompt. If no match, the existing chunked synthesis path runs unchanged. All pages now get `body_sections_format='v2'` set unconditionally, and a `TechniquePageVideo` row is inserted via `pg_insert` with `on_conflict_do_nothing` for idempotency.
T02 created 12 unit tests across 4 test classes: prompt construction (5 tests for XML structure, offset indices, empty existing moments, page JSON serialization, moment content), branching logic (3 tests using source-code assertions + focused mocks), format/tracking (3 tests for v2 flag, pg_insert usage, INSERT values), and case sensitivity (1 test verifying func.lower on both sides of the category comparison). All 12 pass.
## Verification
All slice-level verification checks pass:
1. `PYTHONPATH=backend python -c "from pipeline.stages import _build_compose_user_prompt, _compose_into_existing; print('imports OK')"` → exit 0
2. `grep -q 'body_sections_format' backend/pipeline/stages.py` → exit 0
3. `grep -q 'TechniquePageVideo' backend/pipeline/stages.py` → exit 0
4. `grep -q 'stage5_compose' backend/pipeline/stages.py` → exit 0
5. `PYTHONPATH=backend python -m pytest backend/pipeline/test_compose_pipeline.py -v` → 12 passed in 1.50s
## Requirements Advanced
- R012 — Stage 5 now composes new video content into existing technique pages instead of overwriting, fulfilling the incremental update aspect of R012
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Used `default=str` in `json.dumps()` for page serialization in `_build_compose_user_prompt()` to handle UUID/datetime fields — not in plan but necessary for robustness. T02 replaced integration-level branching tests with source-code structure assertions + focused unit tests due to session mock fragility.
## Known Limitations
None.
## Follow-ups
None.
## Files Created/Modified
- `backend/pipeline/stages.py` — Added _build_compose_user_prompt(), _compose_into_existing(), compose-or-create branching in stage5_synthesis, body_sections_format='v2' setting, TechniquePageVideo insertion
- `backend/pipeline/test_compose_pipeline.py` — New file: 12 unit tests covering compose prompt construction, branching logic, format tracking, and case-insensitive matching

View file

@ -0,0 +1,67 @@
# S04: Pipeline Compose-or-Create Logic — UAT
**Milestone:** M014
**Written:** 2026-04-03T01:34:24.402Z
## UAT: Pipeline Compose-or-Create Logic
### Preconditions
- PostgreSQL running with current schema (body_sections_format column on technique_pages, technique_page_videos table)
- Stage 5 compose prompt file exists at expected path (stage5_compose.txt)
- At least one creator with processed video data in the database
### Test 1: Fresh synthesis (no existing page)
**Steps:**
1. Process a video for a creator+category combination that has no existing technique page
2. Check the resulting technique_pages row
**Expected:**
- Standard synthesis path runs (no compose log message)
- `body_sections_format = 'v2'` on the created page
- `technique_page_videos` row exists linking the page to the source video
### Test 2: Compose into existing page
**Steps:**
1. Process a second video by the same creator with moments in the same topic category as Test 1
2. Check API logs for compose detection
3. Query technique_pages for that creator+category
**Expected:**
- Log message: `Stage 5: Composing into existing page '<title>' (N existing moments + M new moments)`
- Only one technique_pages row for that creator+category (not duplicated)
- `technique_page_videos` has two rows: one for each video
- Page content reflects merged content from both videos
### Test 3: Case-insensitive category matching
**Steps:**
1. Create a technique page with topic_category = 'Sound Design'
2. Process a new video whose moments are classified as 'sound design' (lowercase)
**Expected:**
- Compose path triggered (not a new page created)
- Case difference does not cause duplicate pages
### Test 4: Multiple matching pages warning
**Steps:**
1. Manually insert two technique pages for the same creator_id + topic_category
2. Process a new video for that creator+category
**Expected:**
- WARNING log about multiple matching pages
- Compose proceeds using the first match
- No crash or error
### Test 5: TechniquePageVideo idempotency
**Steps:**
1. Reprocess a video that already has a technique_page_videos row
**Expected:**
- No duplicate row inserted (on_conflict_do_nothing)
- No error raised
### Test 6: Unit test suite
**Steps:**
1. Run `PYTHONPATH=backend python -m pytest backend/pipeline/test_compose_pipeline.py -v`
**Expected:**
- 12 tests pass covering prompt XML structure, offset indices, empty existing moments, page JSON, moment content, compose branch detection, create fallback, LLM call wiring, v2 format, pg_insert usage, INSERT values, and func.lower matching

View file

@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M014/S04/T02",
"timestamp": 1775179996368,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd /home/aux/projects/content-to-kb-automator",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "python -m pytest backend/pipeline/test_compose_pipeline.py -v",
"exitCode": 0,
"durationMs": 2134,
"verdict": "pass"
}
]
}

View file

@ -1,6 +1,14 @@
# S05: Frontend — Nested Rendering, TOC, Citations
**Goal:** Render format-2 pages with nested structure, TOC, and interactive citation superscripts. Backwards-compatible with format-1.
**Goal:** Format-2 technique pages render with nested H2/H3 sections, a clickable Table of Contents, and citation markers that link to key moments. Format-1 pages render identically to before.
**Demo:** After this: Format-2 page renders with TOC, nested sections, clickable citations. Format-1 pages unchanged.
## Tasks
- [x] **T01: Added format-aware v2 body_sections rendering with nested TOC, citation anchor links, and subsection styles while preserving v1 dict rendering unchanged** — Update frontend TypeScript types to include body_sections_format and source_videos from S03. Build format-aware renderer in TechniquePage.tsx that detects v2 (list-of-objects) vs v1 (dict) body_sections and renders accordingly. V2 renderer: iterate BodySection[] → H2 with id slug + content paragraph + subsections (H3 + content). Per D024, sections with subsections have empty-string content — skip empty <p> tags. Create TableOfContents component that takes v2 sections and renders nested anchor list. Parse [N] and [N,M] citation markers in content strings and replace with <a> links to #km-{momentId}. Add all CSS (TOC styles, subsection styles, citation link superscript). Update snapshotToOverlay to pass through list-format body_sections.
- Estimate: 2h
- Files: frontend/src/api/public-client.ts, frontend/src/pages/TechniquePage.tsx, frontend/src/components/TableOfContents.tsx, frontend/src/utils/citations.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build
- [ ] **T02: Deploy to ub01 and verify v1 backward compatibility** — Push changes to ub01, rebuild the web container, and verify that existing v1 technique pages render correctly with no regressions. Since no v2 pages exist in production yet (S04 hasn't populated any), v2 rendering is verified structurally via the TypeScript build. This task confirms the live deployment works.
- Estimate: 30m
- Files: frontend/src/pages/TechniquePage.tsx
- Verify: ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096' && sleep 5 && curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/

View file

@ -0,0 +1,97 @@
# S05 Research: Frontend — Nested Rendering, TOC, Citations
## Summary
Straightforward frontend work. The v2 data model is well-defined (S01/S03), the existing TechniquePage component has clear seams for modification, and no new libraries are needed. The main work is: detect format version → render v2 sections with H2/H3 nesting → add a TOC → make `[N]` citation markers clickable links to key moments.
## Recommendation
Light research — known patterns, established codebase. Three tasks: (1) update TypeScript types + build format-aware rendering, (2) add TOC component, (3) wire citation markers to key moment anchors. All in existing files plus one new component.
## Implementation Landscape
### Current State
**TechniquePage.tsx** (~350 lines) fetches technique by slug, supports version switching, renders body_sections with `Object.entries()` treating it as `Record<string, unknown>` (v1 dict format). Falls back to JSON dump for non-string content. Already has hash-based scrolling for `#km-{id}` fragments on key moments.
**public-client.ts** — `TechniquePageDetail` interface is missing `body_sections_format` and `source_videos` fields that S03 added to the backend schema. `body_sections` is typed as `Record<string, unknown> | null` — needs widening to `Record<string, unknown> | BodySectionV2[] | null`.
**App.css** (~5000 lines) has `.technique-prose`, `.technique-prose__section`, `.technique-prose__section h2/p` styles. Dark theme with CSS custom properties. No existing TOC styles.
### V2 Data Format
From `backend/pipeline/schemas.py`:
```
BodySection:
heading: str # H2
content: str # prose (empty string if substance is in subsections — see D024)
subsections: list[BodySubSection] # optional H3s
BodySubSection:
heading: str # H3
content: str # prose
```
Citations are `[N]` markers in content strings where N is a 0-based index into the page's key_moments array. Multi-cite: `[N,M]`. Validated by `citation_utils.py`.
### Format Detection
Backend returns `body_sections_format: "v1"` (existing pages, dict) or `"v2"` (new pages, list of objects). The field has a server-side default of `"v1"` so all existing data works unchanged.
### Key Anchors Already Exist
Each key moment renders with `id={`km-${km.id}`}`. The `[N]` citation index maps to `technique.key_moments[N].id`. The scroll-to-hash pattern (`scrollIntoView`) is already implemented for `#km-` fragments.
### What Needs to Change
1. **Type updates** (`public-client.ts`):
- Add `body_sections_format: string` to `TechniquePageDetail`
- Add `source_videos` field (array of `{id, filename, added_at}`)
- Widen `body_sections` type to `Record<string, unknown> | unknown[] | null`
2. **Format-aware rendering** (`TechniquePage.tsx`):
- Check `body_sections_format` — if `"v2"`, render list-of-objects; else existing dict logic
- V2 renderer: iterate sections → H2 heading + content paragraph + subsections (H3 + content)
- Per D024, sections with subsections have empty-string content — skip rendering empty `<p>`
3. **TOC component** (new, e.g. `components/TableOfContents.tsx`):
- Takes v2 sections array, generates anchor IDs from headings (slugify)
- Renders nested list: H2 entries with indented H3 sub-entries
- Each item is an `<a href="#section-slug">` that smooth-scrolls
- Add `id` attributes to rendered H2/H3 elements for anchor targets
- Position: inside `.technique-columns__main`, above the prose, or as a sticky sidebar element
4. **Citation rendering**:
- Parse `[N]` and `[N,M]` patterns in content strings
- Replace with `<a href="#km-{momentId}" class="citation-link">[N]</a>`
- Need the key_moments array to map index → moment ID
- Superscript styling for citation markers
5. **CSS additions** (`App.css`):
- `.technique-toc` — TOC container, possibly sticky
- `.technique-toc__item`, `.technique-toc__sub-item` — nested list styles
- `.technique-prose__subsection` — H3 sections
- `.citation-link` — superscript, accent-colored, hover underline
- Section anchor scroll offset (account for sticky nav)
### Slug Generation for Section IDs
Need a simple `toSlug()` utility: lowercase, replace spaces/special chars with hyphens, strip duplicates. No existing utility in the codebase. Can be a 3-line function inline or in `utils/`.
### Version Overlay Consideration
The `snapshotToOverlay()` function extracts `body_sections` from historical versions. It currently casts to `Record<string, unknown>`. For v2 snapshots, it should pass through as-is (list). The format-aware renderer handles both — no change needed to overlay logic as long as the type is widened.
### No External Dependencies
All work uses React, standard DOM APIs, and existing CSS patterns. No new npm packages needed.
## Verification Approach
1. **Type check**: `npm run build` passes (no TypeScript errors)
2. **V1 backward compat**: Existing technique pages render identically (format detection routes to old logic)
3. **V2 rendering**: If any v2 pages exist in the DB (from S04 pipeline runs), they render with H2/H3 nesting
4. **TOC**: TOC appears on v2 pages with clickable section links that scroll to the correct heading
5. **Citations**: `[N]` markers render as clickable links that scroll to the matching key moment
6. **Visual**: Browser verification on ub01:8096 for both v1 and v2 pages

View file

@ -0,0 +1,27 @@
---
estimated_steps: 1
estimated_files: 5
skills_used: []
---
# T01: Implement v2 format-aware rendering, TOC, and citation links
Update frontend TypeScript types to include body_sections_format and source_videos from S03. Build format-aware renderer in TechniquePage.tsx that detects v2 (list-of-objects) vs v1 (dict) body_sections and renders accordingly. V2 renderer: iterate BodySection[] → H2 with id slug + content paragraph + subsections (H3 + content). Per D024, sections with subsections have empty-string content — skip empty <p> tags. Create TableOfContents component that takes v2 sections and renders nested anchor list. Parse [N] and [N,M] citation markers in content strings and replace with <a> links to #km-{momentId}. Add all CSS (TOC styles, subsection styles, citation link superscript). Update snapshotToOverlay to pass through list-format body_sections.
## Inputs
- ``frontend/src/api/public-client.ts` — current TechniquePageDetail interface missing body_sections_format and source_videos`
- ``frontend/src/pages/TechniquePage.tsx` — current v1-only body_sections renderer and snapshotToOverlay function`
- ``frontend/src/App.css` — existing technique-prose styles to extend`
## Expected Output
- ``frontend/src/api/public-client.ts` — updated with BodySectionV2, BodySubSectionV2 interfaces, widened body_sections type, body_sections_format field, source_videos field`
- ``frontend/src/pages/TechniquePage.tsx` — format-aware renderer (v1 dict vs v2 list), citation rendering in prose, TOC integration, updated snapshotToOverlay`
- ``frontend/src/components/TableOfContents.tsx` — new TOC component with nested anchor links and slug-based IDs`
- ``frontend/src/utils/citations.tsx` — parseCitations function that converts [N] and [N,M] markers to React elements with anchor links`
- ``frontend/src/App.css` — new styles for .technique-toc, .technique-prose__subsection, .citation-link, section scroll-margin-top`
## Verification
cd frontend && npm run build

View file

@ -0,0 +1,84 @@
---
id: T01
parent: S05
milestone: M014
provides: []
requires: []
affects: []
key_files: ["frontend/src/api/public-client.ts", "frontend/src/pages/TechniquePage.tsx", "frontend/src/components/TableOfContents.tsx", "frontend/src/utils/citations.tsx", "frontend/src/App.css"]
key_decisions: ["Subsection IDs use compound slugs (sectionSlug--subSlug) to avoid collisions", "TOC uses CSS counters for numbered references", "Invalid citation indices render as plain text"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Build verification: `cd frontend && npm run build` exits 0, producing 56 modules with no TypeScript or Vite errors."
completed_at: 2026-04-03T01:42:45.503Z
blocker_discovered: false
---
# T01: Added format-aware v2 body_sections rendering with nested TOC, citation anchor links, and subsection styles while preserving v1 dict rendering unchanged
> Added format-aware v2 body_sections rendering with nested TOC, citation anchor links, and subsection styles while preserving v1 dict rendering unchanged
## What Happened
---
id: T01
parent: S05
milestone: M014
key_files:
- frontend/src/api/public-client.ts
- frontend/src/pages/TechniquePage.tsx
- frontend/src/components/TableOfContents.tsx
- frontend/src/utils/citations.tsx
- frontend/src/App.css
key_decisions:
- Subsection IDs use compound slugs (sectionSlug--subSlug) to avoid collisions
- TOC uses CSS counters for numbered references
- Invalid citation indices render as plain text
duration: ""
verification_result: passed
completed_at: 2026-04-03T01:42:45.504Z
blocker_discovered: false
---
# T01: Added format-aware v2 body_sections rendering with nested TOC, citation anchor links, and subsection styles while preserving v1 dict rendering unchanged
**Added format-aware v2 body_sections rendering with nested TOC, citation anchor links, and subsection styles while preserving v1 dict rendering unchanged**
## What Happened
Updated TechniquePageDetail TypeScript types with body_sections_format, source_videos, and BodySectionV2/BodySubSectionV2 interfaces. Built format-aware rendering in TechniquePage.tsx: v2 array format renders TableOfContents + H2 sections with slugified IDs + H3 subsections, all with citation parsing. V1 dict rendering unchanged. Created TableOfContents component with CSS-counter-numbered nested anchor links. Created parseCitations utility that converts [N] and [N,M] markers to superscript anchor links targeting key moment IDs. Updated snapshotToOverlay for v2 format passthrough. Added CSS for TOC card, subsection borders, citation superscripts, and scroll-margin-top on anchored sections.
## Verification
Build verification: `cd frontend && npm run build` exits 0, producing 56 modules with no TypeScript or Vite errors.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3500ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/api/public-client.ts`
- `frontend/src/pages/TechniquePage.tsx`
- `frontend/src/components/TableOfContents.tsx`
- `frontend/src/utils/citations.tsx`
- `frontend/src/App.css`
## Deviations
None.
## Known Issues
None.

View file

@ -0,0 +1,25 @@
---
estimated_steps: 1
estimated_files: 1
skills_used: []
---
# T02: Deploy to ub01 and verify v1 backward compatibility
Push changes to ub01, rebuild the web container, and verify that existing v1 technique pages render correctly with no regressions. Since no v2 pages exist in production yet (S04 hasn't populated any), v2 rendering is verified structurally via the TypeScript build. This task confirms the live deployment works.
## Inputs
- ``frontend/src/api/public-client.ts` — updated types from T01`
- ``frontend/src/pages/TechniquePage.tsx` — updated renderer from T01`
- ``frontend/src/components/TableOfContents.tsx` — new component from T01`
- ``frontend/src/utils/citations.tsx` — new utility from T01`
- ``frontend/src/App.css` — updated styles from T01`
## Expected Output
- ``frontend/src/pages/TechniquePage.tsx` — verified working in production (no changes, just deployment verification)`
## Verification
ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096' && sleep 5 && curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/

View file

@ -1968,6 +1968,129 @@ a.app-footer__repo:hover {
line-height: 1.5;
}
/* ── Table of Contents ────────────────────────────────────────────────────── */
.technique-toc {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.technique-toc__title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.technique-toc__list {
list-style: none;
padding: 0;
margin: 0;
counter-reset: toc-section;
}
.technique-toc__item {
counter-increment: toc-section;
margin-bottom: 0.25rem;
}
.technique-toc__link {
color: var(--color-accent);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.6;
}
.technique-toc__link::before {
content: counter(toc-section) ". ";
color: var(--color-text-muted);
}
.technique-toc__link:hover {
text-decoration: underline;
}
.technique-toc__sublist {
list-style: none;
padding-left: 1.25rem;
margin: 0.125rem 0 0.25rem;
counter-reset: toc-sub;
}
.technique-toc__subitem {
counter-increment: toc-sub;
}
.technique-toc__sublink {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.8125rem;
line-height: 1.6;
}
.technique-toc__sublink::before {
content: counter(toc-section) "." counter(toc-sub) " ";
color: var(--color-text-muted);
}
.technique-toc__sublink:hover {
color: var(--color-accent);
text-decoration: underline;
}
/* ── V2 subsections ───────────────────────────────────────────────────────── */
.technique-prose__subsection {
margin-left: 0.75rem;
margin-bottom: 1rem;
padding-left: 0.75rem;
border-left: 2px solid var(--color-border);
}
.technique-prose__subsection h3 {
font-size: 1.0625rem;
font-weight: 600;
margin-bottom: 0.375rem;
color: var(--color-text-primary);
}
.technique-prose__subsection p {
font-size: 0.9375rem;
color: var(--color-text-primary);
line-height: 1.7;
}
/* ── Citation links ───────────────────────────────────────────────────────── */
.citation-group {
font-size: 0.75em;
line-height: 1;
vertical-align: super;
}
.citation-link {
color: var(--color-accent);
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.citation-link:hover {
text-decoration: underline;
}
/* ── Scroll margin for section anchors ────────────────────────────────────── */
.technique-prose__section[id],
.technique-prose__subsection[id] {
scroll-margin-top: 5rem;
}
/* ── Key moments list ─────────────────────────────────────────────────────── */
.technique-moments {

View file

@ -56,6 +56,24 @@ export interface RelatedLinkItem {
reason: string;
}
export interface BodySubSectionV2 {
heading: string;
content: string;
}
export interface BodySectionV2 {
heading: string;
content: string;
subsections: BodySubSectionV2[];
}
export interface SourceVideoSummary {
id: string;
filename: string;
content_type: string;
added_at: string | null;
}
export interface TechniquePageDetail {
id: string;
title: string;
@ -63,7 +81,8 @@ export interface TechniquePageDetail {
topic_category: string;
topic_tags: string[] | null;
summary: string | null;
body_sections: Record<string, unknown> | null;
body_sections: BodySectionV2[] | Record<string, unknown> | null;
body_sections_format: string;
signal_chains: unknown[] | null;
plugins: string[] | null;
creator_id: string;
@ -75,6 +94,7 @@ export interface TechniquePageDetail {
creator_info: CreatorInfo | null;
related_links: RelatedLinkItem[];
version_count: number;
source_videos: SourceVideoSummary[];
}
export interface TechniquePageVersionSummary {

View file

@ -0,0 +1,58 @@
/**
* Table of Contents for v2 technique pages with nested sections.
*
* Renders a nested list of anchor links matching the H2/H3 section structure.
* Uses slugified headings as IDs for scroll targeting.
*/
import type { BodySectionV2 } from "../api/public-client";
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
interface TableOfContentsProps {
sections: BodySectionV2[];
}
export default function TableOfContents({ sections }: TableOfContentsProps) {
if (sections.length === 0) return null;
return (
<nav className="technique-toc" aria-label="Table of contents">
<h3 className="technique-toc__title">Contents</h3>
<ol className="technique-toc__list">
{sections.map((section) => {
const sectionSlug = slugify(section.heading);
return (
<li key={sectionSlug} className="technique-toc__item">
<a href={`#${sectionSlug}`} className="technique-toc__link">
{section.heading}
</a>
{section.subsections.length > 0 && (
<ol className="technique-toc__sublist">
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
return (
<li key={subSlug} className="technique-toc__subitem">
<a
href={`#${subSlug}`}
className="technique-toc__sublink"
>
{sub.heading}
</a>
</li>
);
})}
</ol>
)}
</li>
);
})}
</ol>
</nav>
);
}

View file

@ -15,10 +15,13 @@ import {
type TechniquePageDetail as TechniqueDetail,
type TechniquePageVersionSummary,
type TechniquePageVersionDetail,
type BodySectionV2,
} from "../api/public-client";
import ReportIssueModal from "../components/ReportIssueModal";
import CopyLinkButton from "../components/CopyLinkButton";
import CreatorAvatar from "../components/CreatorAvatar";
import TableOfContents, { slugify } from "../components/TableOfContents";
import { parseCitations } from "../utils/citations";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
function formatTime(seconds: number): string {
@ -39,6 +42,14 @@ function formatDate(iso: string): string {
/** Extract the displayable fields from a version snapshot, matching TechniqueDetail shape. */
function snapshotToOverlay(snapshot: Record<string, unknown>) {
// body_sections can be either list (v2) or dict (v1)
let bodySections: BodySectionV2[] | Record<string, unknown> | undefined;
if (Array.isArray(snapshot.body_sections)) {
bodySections = snapshot.body_sections as BodySectionV2[];
} else if (typeof snapshot.body_sections === "object" && snapshot.body_sections !== null) {
bodySections = snapshot.body_sections as Record<string, unknown>;
}
return {
title: typeof snapshot.title === "string" ? snapshot.title : undefined,
summary: typeof snapshot.summary === "string" ? snapshot.summary : undefined,
@ -49,9 +60,10 @@ function snapshotToOverlay(snapshot: Record<string, unknown>) {
topic_tags: Array.isArray(snapshot.topic_tags)
? (snapshot.topic_tags as string[])
: undefined,
body_sections:
typeof snapshot.body_sections === "object" && snapshot.body_sections !== null
? (snapshot.body_sections as Record<string, unknown>)
body_sections: bodySections,
body_sections_format:
typeof snapshot.body_sections_format === "string"
? snapshot.body_sections_format
: undefined,
signal_chains: Array.isArray(snapshot.signal_chains)
? (snapshot.signal_chains as unknown[])
@ -206,6 +218,7 @@ export default function TechniquePage() {
const displayCategory = overlay?.topic_category ?? technique.topic_category;
const displayTags = overlay?.topic_tags ?? technique.topic_tags;
const displaySections = overlay?.body_sections ?? technique.body_sections;
const displayFormat = overlay?.body_sections_format ?? technique.body_sections_format ?? "v1";
const displayChains = overlay?.signal_chains ?? technique.signal_chains;
const displayPlugins = overlay?.plugins ?? technique.plugins;
const displayQuality = overlay?.source_quality ?? technique.source_quality;
@ -404,23 +417,52 @@ export default function TechniquePage() {
{/* Study guide prose — body_sections */}
{displaySections &&
Object.keys(displaySections).length > 0 && (
(Array.isArray(displaySections) ? displaySections.length > 0 : Object.keys(displaySections).length > 0) && (
<section className="technique-prose">
{Object.entries(displaySections).map(
([sectionTitle, content]: [string, unknown]) => (
<div key={sectionTitle} className="technique-prose__section">
<h2>{sectionTitle}</h2>
{typeof content === "string" ? (
<p>{content as string}</p>
) : typeof content === "object" && content !== null ? (
<pre className="technique-prose__json">
{JSON.stringify(content, null, 2)}
</pre>
) : (
<p>{String(content as string)}</p>
)}
</div>
),
{displayFormat === "v2" && Array.isArray(displaySections) ? (
<>
<TableOfContents sections={displaySections as BodySectionV2[]} />
{(displaySections as BodySectionV2[]).map((section) => {
const sectionSlug = slugify(section.heading);
return (
<div key={sectionSlug} className="technique-prose__section" id={sectionSlug}>
<h2>{section.heading}</h2>
{section.content && (
<p>{parseCitations(section.content, technique.key_moments)}</p>
)}
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
return (
<div key={subSlug} className="technique-prose__subsection" id={subSlug}>
<h3>{sub.heading}</h3>
{sub.content && (
<p>{parseCitations(sub.content, technique.key_moments)}</p>
)}
</div>
);
})}
</div>
);
})}
</>
) : (
/* V1 dict format — original rendering */
Object.entries(displaySections as Record<string, unknown>).map(
([sectionTitle, content]: [string, unknown]) => (
<div key={sectionTitle} className="technique-prose__section">
<h2>{sectionTitle}</h2>
{typeof content === "string" ? (
<p>{content as string}</p>
) : typeof content === "object" && content !== null ? (
<pre className="technique-prose__json">
{JSON.stringify(content, null, 2)}
</pre>
) : (
<p>{String(content as string)}</p>
)}
</div>
),
)
)}
</section>
)}

View file

@ -0,0 +1,76 @@
/**
* Parse [N] and [N,M] citation markers in text and replace with React anchor links.
*
* Citations are 1-based indices into the key_moments array.
* Each marker becomes a superscript link to #km-{momentId}.
*/
import React from "react";
import type { KeyMomentSummary } from "../api/public-client";
// Matches [1], [2,3], [1,2,3], etc.
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
/**
* Convert a text string containing [N] or [N,M] markers into an array of
* React nodes plain strings interleaved with citation anchor elements.
*/
export function parseCitations(
text: string,
keyMoments: KeyMomentSummary[],
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
// Push text before this match
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
// Parse the indices from the match group
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
// Citation indices are 1-based; key_moments array is 0-based
const moment = keyMoments[idx - 1];
if (moment) {
if (i > 0) links.push(", ");
links.push(
<a
key={`${matchStart}-${idx}`}
href={`#km-${moment.id}`}
className="citation-link"
title={moment.title}
>
{idx}
</a>,
);
} else {
// Invalid index — render as plain text
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className="citation-group">
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
// Push remaining text after the last match
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}