From 9977dce4b1b4cb04c86858ead902699287bdb35b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 23:06:40 +0000 Subject: [PATCH] D619: the archetype card page's three sections, from the owner's canvas (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's #1006 canvas is a board, `Studio · Archetype card`, and the component its A24–A30 import, `Archetype Full Sections`. Both are committed in design/canvases/integrated/ (81 → 83), and A24–A30 are built on /studio/archetype: - Skills, "Your skills, two ways": one radar with the self shape over rated axes only and evidence as dots on the reply's corroborated_at scale; Not recorded said on the axis, never 0; the table and legend; the Eadwyn prompt when nothing is recorded. - History, "How your archetype has changed": one point per change of the displayed archetype, its trigger and date; "Current" only on the card's archetype. - Matches, "Who you match with": every type with the members who published it, D605's values line, the publish switch, and the plan lock. Each section reads its own source and retries on its own; a failed read is never an empty radar or list. A1–A23 (the compact card and the page top) are #1323 (D620). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jkNYLq29UXAvcqYxGCSB3 --- design/canvases/README.md | 14 +- .../Archetype Full Sections.dc.html | 142 +++++ .../Studio \302\267 Archetype card.dc.html" | 449 ++++++++++++++++ design/incoming/README.md | 2 +- documentation/architecture/PAGE_INVENTORY.md | 2 +- documentation/architecture/PROFILE_ROUTING.md | 9 +- documentation/architecture/ROUTE_MAP.md | 3 +- documentation/architecture/decisions/D619.md | 227 ++++++++ .../components/profile/ArchetypeSections.jsx | 507 ++++++++++++++++++ frontend/src/lib/archetypeCardV2.js | 198 ++++++- frontend/src/pages/ArchetypeCardPage.jsx | 127 ++++- .../test/archetype_sections_d619.test.mjs | 394 ++++++++++++++ frontend/test/profile_routing_fresh.test.mjs | 4 +- frontend/test/studio_archetype_card.test.mjs | 9 +- 14 files changed, 2058 insertions(+), 29 deletions(-) create mode 100644 design/canvases/integrated/Archetype Full Sections.dc.html create mode 100644 "design/canvases/integrated/Studio \302\267 Archetype card.dc.html" create mode 100644 documentation/architecture/decisions/D619.md create mode 100644 frontend/src/components/profile/ArchetypeSections.jsx create mode 100644 frontend/test/archetype_sections_d619.test.mjs diff --git a/design/canvases/README.md b/design/canvases/README.md index 21b54d432f..2186ac3729 100644 --- a/design/canvases/README.md +++ b/design/canvases/README.md @@ -5,7 +5,7 @@ The corpus the first integration pass worked from. Sorted by one question: | Folder | Count | Meaning | | --- | --- | --- | -| `integrated/` | 81 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | +| `integrated/` | 83 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | | `backlog/` | 28 | Graded `NEW` or `DEFERRED`. No route yet. | | `out-of-scope/` | 26 | Deliberately not being built. The `ROUTE_MAP.md` row says why. | | `shared/` | 6 | The dc-runtime bundle every canvas loads, plus two standalone pitch-deck exports. Generated — both code scanners ignore this tree. Every canvas asks for it as `src="./support.js"`, which resolves next to the canvas and **not** to this folder, so opening one straight from `integrated/` in a browser gets a blank page: copy `shared/support.js` beside it first. Nothing runs these at runtime, so the reference is nominal — it is a convention marking "the shared runtime, not an inlined 69KB copy", and `scripts/read-canvas.mjs` reproduces it when it decodes a new export. | @@ -96,9 +96,19 @@ are built in the same change: the Super Admin's review queue at `/hq/review` files went straight to `integrated/`, as `Help Ticket Screen` did: `integrated/` went **79 → 81**. Nothing left another folder. +**2026-10-06 (D619), two canvases arrived for a live route.** The owner's +archetype canvas for #1006 is a board, `Studio · Archetype card`, and the +component its A24–A30 import, `Archetype Full Sections`. The three sections +those artboards draw on `/studio/archetype` are built in the same change, so +both files went straight to `integrated/`: `integrated/` went **81 → 83**. +Nothing left another folder. The board sits beside `Studio · Archetype +preview` and does not replace it: its artboards are its own. The export left +out the script the component loads its sample data from (D619 names it), so +the component opens with its sections empty. + **The `107` in the title is the first audit's corpus, not today's total.** It is `54 + 26 + 27` — the sum as it stood when `integrated/` read 54. The three -folders hold `81 + 28 + 26 = 135` as of 2026-10-06, counted by listing them, +folders hold `83 + 28 + 26 = 137` as of 2026-10-06, counted by listing them, and the title keeps 107 because the paragraph below uses it for the audit it names. diff --git a/design/canvases/integrated/Archetype Full Sections.dc.html b/design/canvases/integrated/Archetype Full Sections.dc.html new file mode 100644 index 0000000000..7172fc1b00 --- /dev/null +++ b/design/canvases/integrated/Archetype Full Sections.dc.html @@ -0,0 +1,142 @@ + + + + + + + + + + + + + +
+ + +
+
Skills
+

Your skills, two ways

+
+
Your skills could not be read.Try again
+ +
+
+
+ + + + + + + + {{ l.t }} + Not recorded + Not recorded + {{ e.t }} +
+
Self, 0 to 5Evidence, platform activity
+
+
+
+
AxisStateSelfEvidence
+
{{ r.axis }}{{ r.st }}—{{ r.self }}—{{ r.ev }}
+
+
{{ l.t }}{{ l.d }}
+
Answer a few skill questions with Eadwyn to fill this in.Open Eadwyn
+
+
+
+

Evidence counts what you have done on the platform. It is not an assessment of how good you are.

+
+ + +
+
History
+

How your archetype has changed

+
+
Your history could not be read.Try again
+ +
+
+
+ +
+ +
{{ p.name }}{{ p.date }}{{ p.trigger }}Current
+
+
+
+

This is your first archetype. Changes appear here as your answers and activity build up.

+

Triggers: an answer, platform activity, a scheduled review, or an engine update.

+
+
+
+ + +
+
Matches
+

Who you match with

+
Matches are not included in your plan.
See plans
+
Matches could not be read.Try again
+ +
+ Only members who publish their archetype appear here. Publish yours so they can find you. +
{{ mt.pubState }}{{ mt.pubLabel }}
+
+
+ +
+
{{ t.name }}{{ t.why }}
+ +
{{ m.name }}{{ m.role }}{{ m.arch }}
{{ valuesChip }}coming soon
+
and {{ t.more }} more
+
+ +
No member has published this archetype yet.
+
+
+
+

You see a member's name, role and published archetype only. Scores, traits, skills, values and history stay private.

+
+
+
+
+ + diff --git "a/design/canvases/integrated/Studio \302\267 Archetype card.dc.html" "b/design/canvases/integrated/Studio \302\267 Archetype card.dc.html" new file mode 100644 index 0000000000..41562a20a1 --- /dev/null +++ "b/design/canvases/integrated/Studio \302\267 Archetype card.dc.html" @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + +
+
A1–A4 · A10Compact ARCHETYPE container on /studio — one hit target, six states/studio · Profile & Fit column · 400px
+
The whole card is the link. Pixel sprites stay; the only additions are a quiet "View full card →", a hover hairline in the archetype colour, a 2px lift and a violet focus ring. Empty, loading, error and locked are not clickable, and none of them shows a number it does not have.
+
+ +
+
{{ c.id }} · {{ c.state }}
+ + +
{{ c.title }}
+
+
+
+
Primary archetype
+
+ {{ c.name }} + {{ c.conf }} +
+
Core approach
+
{{ c.tagline }}
+
Description
+
{{ c.desc }}
+
+ View full card → + {{ c.sexNote }} +
+
+
+ +
+
{{ c.title }}
+
+
Primary archetype
+
Answer a few archetype questions in the advisor to reveal your archetype.
+
+
+
+ +
+
{{ c.title }}
+
+
+
+ +
+
{{ c.title }}
+
!Could not load your archetype. Retry.
+
+
+ +
+
{{ c.title }}
+
+ + Archetype card is part of the Studio plan. +
+
Primary archetype
+
Locked
+
Your answers are kept; the reading unlocks with the plan.
+
+
+
{{ c.note }}
+
+
+
+
+ + + +
+
{{ p.badge }}{{ p.title }}/studio/archetype · 1280
+
{{ p.blurb }}
+
+
+ ‹ Studio + Profile & Fit · Archetype +
+
+
+ +
+ +
+
+
+
{{ b.file }}
+
not yet supplied · {{ p.cloak }} cloak, white tri-spoke emblem · {{ p.staff }} · ¾ view, facing camera-right
+
+
+ + +
+
+
+
+ +
+
{{ p.licence }}Primary archetype
+

{{ p.name }}

+
+ {{ p.tagline }} + {{ p.confPill }} confidence +
+
+ +
+
+
+ +
+
+
Core approach
{{ p.tagline }}
+
Description
{{ p.desc }}
+
+
Strengths
{{ s }}
+
Blind spots
{{ s }}
+
+
+
+
+
Confidence
+
{{ p.conf }}{{ p.confNote }}
+
+
+
+
Complements
+
{{ c.name }}
+
Sibling archetypes in the same licence whose strengths sit where this one’s blind spots are.
+
+
+
How we read this
+
From your advisor conversation, not a quiz.
+
+
Illustration{{ p.sexLabel }}
+
+
+
+ ‹ Back to studio + {{ p.slug }} +
+
+
+
+ + +
+
A9Mobile 390 — compact in the Profile & Fit list, and the detailed page stacked/studio → /studio/archetype
+
Same card, same hit target, one column. The banner keeps 21:9 and the two-column editorial stacks to one; the back chevron is the only nav.
+
+
+
+
9:41▮▮▮ ◗
+
+
Profile & FitStudio
+
Skills
Product · GTM · Design lead
›
+ +
Founder archetype
+
+
+
+
Primary archetype
+
The Missionary82%
+
Mission first, built to last.
+
+
+
View full card →f
+
+
Values
Leans mission · leans speed
›
+
+
+
Compact in list context: the sprite drops to 96px beside the copy so the card stays under a thumb’s reach; description is deferred to the page.
+
+
+
+
9:41▮▮▮ ◗
+
+ ‹ Studio +
+
+
Founder · Primary archetype

The Missionary

+
+
+
Core approach
Mission first, built to last.
+
Description
Anchored to the why. Bias to durable, sustainable building and craft over raw speed.
+
Confidence82%
+
Strengths
· Anchors the team to a durable why· Earns deep trust from users and hires· Persistent through long feedback cycles
+
Blind spots
· May trade speed for conviction· Slow to pivot when the mission is challenged· Under-weights commercial urgency
+
Complements
The RocketeerThe Maverick
+
From your advisor conversation, not a quiz. · Illustration: f
+ ‹ Back to studio +
+
+
Detailed, stacked: banner, hairline, then the left column’s copy and the right column’s wells in reading order.
+
+
+
+ + +
+
A24Full page · below the banner · light/studio/archetype · 1280
+
Three sections under the existing banner, overlay well and strengths / blind spots: the skills radar with two layers and five axis states, the archetype timeline, and the published matches. Every count is a field; nothing renders as a 0.
+
+
+
+
A25Full page · below the banner · dark/studio/archetype · 1280
+
Same three sections in dark mode. Self stays a violet fill, evidence stays an outline of dots; the Not recorded gap is text on the axis, never a 0.
+
+
+
+
A26Phone 390 · light and dark/studio/archetype
+
The radar sits above its table; the timeline stacks oldest first; member rows wrap the values chip under the name. The dark phone shows the first-classification history and types with no published members.
+
+
+
+
+
+
+
A27Skills · every axis Not recorded/studio/archetype · 1280
+
No rating and no activity on any axis: the shape is absent, each axis reads Not recorded, and the prompt sends the member to Eadwyn. History shows the first classification; matches show types with no published members and the member unpublished.
+
+
+
+
A28Loading · skills and history/studio/archetype · 1280
+
Skeletons in the card wells. No figure is shown while a read is in flight.
+
+
+
+
A29Unreadable · all three sections/studio/archetype · 1280
+
"Your skills could not be read", "Your history could not be read", "Matches could not be read", each with Try again. A failed read never renders as an empty radar or an empty list.
+
+
+
+
A30Matches · locked by plan/studio/archetype · 1280
+
The member's plan does not include matches: a lock line and an upgrade action. Skills and history still render.
+
+
+
+ + diff --git a/design/incoming/README.md b/design/incoming/README.md index 2c48580154..0d497142aa 100644 --- a/design/incoming/README.md +++ b/design/incoming/README.md @@ -14,7 +14,7 @@ exists for each — not by how far each has been built. Counts as of 2026-10-06: | Folder | Meaning | | --- | --- | -| `canvases/integrated/` | 81 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. | +| `canvases/integrated/` | 83 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. | | `canvases/backlog/` | 28 — graded `NEW` or `DEFERRED`: no route yet. | | `canvases/out-of-scope/` | 26 — deliberately not being built. | diff --git a/documentation/architecture/PAGE_INVENTORY.md b/documentation/architecture/PAGE_INVENTORY.md index 394ae74e4e..0b258fb286 100644 --- a/documentation/architecture/PAGE_INVENTORY.md +++ b/documentation/architecture/PAGE_INVENTORY.md @@ -150,4 +150,4 @@ rather than merely absent. - branch_admin · Branch · Insights (`/branch/insights`) - branch_admin · Branch · Settings (`/branch/settings`) -**Canvas corpus:** 137. **Workspace assignments:** 179. +**Canvas corpus:** 138. **Workspace assignments:** 180. diff --git a/documentation/architecture/PROFILE_ROUTING.md b/documentation/architecture/PROFILE_ROUTING.md index c11bd2a989..56d451b38c 100644 --- a/documentation/architecture/PROFILE_ROUTING.md +++ b/documentation/architecture/PROFILE_ROUTING.md @@ -13,8 +13,8 @@ Nothing here is typed by hand, so nothing here can disagree with them. each canvas and why. This document answers a different question: *who sees it, where in their nav, and how do they get there.* -**Corpus:** 137 canonical canvases at `design/canvases/`, listed -179 times below — a canvas serving two workspaces appears under both. +**Corpus:** 138 canonical canvases at `design/canvases/`, listed +180 times below — a canvas serving two workspaces appears under both. ## Column meanings @@ -211,7 +211,7 @@ it, where in their nav, and how do they get there.* | PartnerRail | — | — (not routed) | Embedded rail | Not reachable yet | UPGRADE | Low | | System Sheet | — | — (not routed) | Reference (no surface) | Not reachable yet | NEW | Low | -## Shared (multi-workspace) — 19 canvases +## Shared (multi-workspace) — 20 canvases | Canvas | Route | Nav section | Surface | Entry point | Status | Confidence | | --- | --- | --- | --- | --- | --- | --- | @@ -230,6 +230,7 @@ it, where in their nav, and how do they get there.* | Perks & Products | `/perks` | — (no nav entry) | Full page | Deep link / in-page action | NEW | Medium | | Refer & Earn | `/referrals` · `/refer` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Send for Signature | `/esign/:token` | — (no nav entry) | Full page + 5-step wizard | Deep link / in-page action | NEW | Medium | +| Studio · Archetype card | `/studio/archetype` · `/studio` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Studio · Archetype preview | `/studio` · `/studio/archetype` | — (not routed) | Full page (proposed) | Not reachable yet | UPGRADE | Low | | Studio · Persona hubs | — | — (not routed) | Full page (proposed) | Not reachable yet | UPGRADE | Low | | Trust Center | `/trust` | — (no nav entry) | Full page | Deep link / in-page action | CURRENT | Medium | @@ -261,7 +262,7 @@ it, where in their nav, and how do they get there.* | Confidence | Rows | Meaning | | --- | ---: | --- | | High | 98 | Live route, reachable from that workspace’s sidebar. | -| Medium | 45 | Live route, but no sidebar row for that role — reached by deep link or an in-page action. | +| Medium | 46 | Live route, but no sidebar row for that role — reached by deep link or an in-page action. | | Low | 36 | Not routed. Proposed route only. | A Medium row is not a defect. Detail pages (`/deals/:id`), print artefacts diff --git a/documentation/architecture/ROUTE_MAP.md b/documentation/architecture/ROUTE_MAP.md index 6236992263..99112b81ef 100644 --- a/documentation/architecture/ROUTE_MAP.md +++ b/documentation/architecture/ROUTE_MAP.md @@ -129,7 +129,8 @@ the gap, not the intent. | Pages · Diligence file | founder, investor | `/research/diligence/:grant_uid/files/:file_uid` | `/research/diligence/:grantUid/files/:fileUid` → `DiligenceFile` (admin, investor — the same licences as the list; each room lists its open files as links) | `research.ts` `GET /diligence/:grantUid/files/:fileUid` (D310) through `data_room.ts`'s `activeGrant`/`ndaActive`; the download is `data_room.ts`'s own `POST /shared/:projectUid/files/:uid/download` | CURRENT | Arrived 2026-09-26 (D305) as artifact `96463a46`. Seven artboards (D1–D5, M1–M2), including the NDA refusal. **Built (D311):** name, size from bytes, type, the visibility pill, Download with its single-use note, the three tiles, the reader's own download history, the stated limit, and the NDA state with no name in the heading, crumb or shell title. **Differs from the canvas:** a history line says a link was *issued* — the log does not record whether it was followed, so "link used once, then expired" is not claimed; and an `nda` file reaches this page for an investor whose NDA is live (the canvas routes only `open` files here), with an "NDA · signed" pill. | | Pages · Diligence room | founder, investor | `/research/diligence/:grant_uid` | `/research/diligence/:grantUid` → `DiligenceRoom` (admin, investor; the list links each company and its Open the room →) | `research.ts` `GET /diligence/:grantUid` (D310) through `data_room.ts`'s `activeGrant`/`ndaActive`/`logAccess`; the memo is the `research/diligence` draft surface over `research_zone_drafts` (migration 221), scoped to the grant | CURRENT | Arrived 2026-09-26 (D305) as artifact `b6a5f992`. Eight artboards (D1–D6, M1–M2). **Built (D311):** header, the four tiles, the scope table, the open-file list with its three states, the NDA panel, Deal stage as Not recorded with the worker's reason, the reader's own activity, the ZONEDRAFT memo (Draft, Accept, Edit first, Discard) and the stated limit. **Retired:** the investor `SharedRooms` drawer on `/raise/data-room`; that route's investor branch redirects to `/research/diligence`. | | Pages · Company analysis | founder | `/research/companies/:id` | `/research/companies/:id` → `CompanyAnalysis` (admin, founder, advisor — the list's licences; the Companies zone links each saved analysis, and a new run opens here) | `competitors.ts`: `GET /:id`, `PATCH /:id` (title and output only — never the candidate set, whose bulk re-insert writes a blank relevance as 0), `PATCH /:id/candidates/:cid` for category, `POST /:id/candidates`, `DELETE /:id/candidates/:cid`, `POST /:id/rerun`, `POST /:id/refresh`, `GET /:id/export` | CURRENT | Arrived 2026-09-26 (D305) as artifact `90eb4cf2`. Eight artboards (D1–D6, M1–M2). **Built (D313):** editable title, mode and status pills, updated and edited-since-last-run; Save, Re-run, Refresh sources, JSON, Markdown; the inputs row with Not recorded blanks; the founder scope band; the running card, the failed-read card and the pipeline-error card with Re-run and Quick scan; tiles in three shapes (none on a failed read, Not recorded on a failed run, numbers on a finished one); the market summary; the seven-column competitors table with category select, origin, relevance (blank is Not recorded, never 0), source-kind chips and Remove; Add competitor with Crawl site; the feature grid (features by competitor, blank cells Not recorded) and the signal columns, each omitted when empty; gaps, wedge, next steps and notes; the local landscape-read ZONEDRAFT whose Accept appends to notes; the stated limit. **Differs:** "Recommended next actions" reads "Next steps the run listed" (the voice rule), and positioning and traction signals, which the run stores and the canvas does not draw, are kept as signal columns. **Retired:** `/build/competitors` for founder and admin redirects here with its `?id=`, or to the list; it stays for partner and investor, whom this route does not admit. **D593 (2026-10-06):** re-exported as "Pages · Founder Research Analysis" with a C0 board, and the canvas moved from `canvases/backlog/` to `canvases/integrated/`, where its live route had put it since D313. **Built:** a competitor row links "Directory profile →" when exactly one directory company has its site or, with no site match, its name — `directory_uid` on `GET /competitors/:id`, worked out on each read by `services/competitorDirectoryLink.ts` and never stored; ambiguity links nothing, and a hand-typed name with no profile stays plain text, as C0 draws. A failed directory read says "Directory profiles: Unreadable". The company page (`/research/companies/:analysisId/:candidateId`) carries the same link. **Differs:** the name keeps opening the competitor's own page (D313), which the C0 note predates; the directory link sits under it. | -| Studio · Archetype preview | shared (all five licences) | `/studio`, `/studio/archetype` | both live (`ProfileFitSection`, `ArchetypeCardPage`) | `assessment.ts` | UPGRADE | Arrived 2026-09-26 (D305) as artifact `ec6c3ada`. Fifteen artboards: S1–S13, S14 at 1280, S15 at 390. Session 3 builds it **UPDATE 2026-10-06 (D602, #1006; D605, #1296):** `/studio/archetype` reads the conversational archetype only (the assessment fallback is retired). `profile_history.ts` `GET /api/profile/published-members` lists the members who published a matching archetype (name, role, persona and archetype only), and each row carries `values_fit` (a band and a count, never a dimension or a score; shown only when the caller has also published). The radar, timeline and member matches are not drawn yet: they wait on a canvas (#1006, needs-design). | +| Studio · Archetype preview | shared (all five licences) | `/studio`, `/studio/archetype` | both live (`ProfileFitSection`, `ArchetypeCardPage`) | `assessment.ts` | UPGRADE | Arrived 2026-09-26 (D305) as artifact `ec6c3ada`. Fifteen artboards: S1–S13, S14 at 1280, S15 at 390. Session 3 builds it **UPDATE 2026-10-06 (D602, #1006; D605, #1296):** `/studio/archetype` reads the conversational archetype only (the assessment fallback is retired). `profile_history.ts` `GET /api/profile/published-members` lists the members who published a matching archetype (name, role, persona and archetype only), and each row carries `values_fit` (a band and a count, never a dimension or a score; shown only when the caller has also published). **UPDATE 2026-10-06 (D619, #1006):** the radar, the timeline and the member matches are drawn, from `Studio · Archetype card` (the next row). | +| Studio · Archetype card | shared (all five licences) | `/studio`, `/studio/archetype` | `/studio/archetype` → `ArchetypeCardPage`, its three sections in `components/profile/ArchetypeSections.jsx` (D619); `/studio` → `ProfileFitSection` | `skills.ts` `GET /api/skills/me/evidence` (D318); `profile_history.ts` `GET /api/profile/history` and `GET`/`PUT /api/profile/archetype-published` (D357), `GET /api/profile/published-members` (D602, D605); `best_fit.ts` `GET /api/best-fit/me` | UPGRADE | Arrived 2026-10-06 (D619, #1006) as artifact `6yTsUgRHkkiCwBpfZpwshh`: the board, and `Archetype Full Sections`, the component its A24–A30 import. The export left out the component's data script, `archetype-full.js`. A24–A30 are built: the two-layer skills radar, the evolution timeline and the matches with published members, in every state the canvas draws. A1–A23 draw the compact card and the page top differently from `main`; they are #1323 (D620). | | Studio · Persona hubs | shared (all five licences) | the five Studio homes | live (the five `*StudioHome` pages) | `assessment.ts` | UPGRADE | Arrived 2026-09-26 (D305) as artifact `69dc42f3`. Five hubs, S1–S5; S5 draws the File-ticket form (`/help/tickets`). Session 3 builds it. `backlog/Founder Studio` (DEFERRED) stays where it is | | Research · Main | founder, investor, advisor, partner | `/research` (the overview) | `/research` on all four licences: `ResearchOverviewPanel` inside `FounderResearchDesk` (founder), `InvestorResearchWorkspace` (investor; also at `/market-intel`), and above the `boards/research.js` board in `ResearchWorkspace` (advisor, partner) | `research_overview.ts` `GET /api/research/overview` (D536): `research_company_directory`, `research_market_directory`, `research_fund_directory` through the D533 crosswalk (`services/researchTaxonomy.ts`, migration 376), `signal_companies`, `market_intel_indexes` (live rows only, D538), `signals` (never `advisor_note`) | UPGRADE | Owner-approved 2026-10 (D546) for #1082, because A7 of the Founder Workspaces canvas draws none of these zones. Signed-in: the canvas's public top bar ("Sign in") and its search box are left out, and the panel sits inside each desk. Totals: companies, markets with the supplied-company sum, funds with the number of fund lists, countries, and the twelve indexed sectors with each one's last `computed_at` (not the raw labels). The split: private is the company directory, public is `signal_companies`, government reads Not recorded (no source; #1083 Q3 decides the wording). Competition with the "other" remainders and the no-label and no-country counts; money by crosswalked HQ country and focus market, euro size bands only where the currency is known (`EUR_SOURCE`), the full vintage range and every missing count; signals with why now, the build opportunity, region and confidence. Each section is ok, Not recorded or Unreadable on its own. | diff --git a/documentation/architecture/decisions/D619.md b/documentation/architecture/decisions/D619.md new file mode 100644 index 0000000000..e589e24cda --- /dev/null +++ b/documentation/architecture/decisions/D619.md @@ -0,0 +1,227 @@ +## D619 — The archetype card page's three sections: the two-layer skills radar, the evolution timeline, and the matches with published members + +**Issue #1006, slot S01, 2026-10-06.** Frontend only: no route, no +migration. D602 put the routes, the client methods and the data shapes on +`main`; this draws them. + +The owner sent the canvas for #1006's `needs-design` on 2026-10-06 +(`https://claude.ai/artifact/6yTsUgRHkkiCwBpfZpwshh`). It is a board and the +component its last seven artboards import. Both are committed in +`design/canvases/integrated/`, since the route runs on `main`: +- **`Studio · Archetype card.dc.html`**, the board: + - A1–A4 and A10: the compact `/studio` card; + - A5–A8, A8f and A11–A23: the top of `/studio/archetype` for each archetype; + - A9: the phone; + - A24–A30: the three new sections. +- **`Archetype Full Sections.dc.html`**, the component. Its props are the + theme and each section's state. + +The board is its own canvas, beside `Studio · Archetype preview` (S1–S15, +D305), not a newer export of it: its artboards and their numbering differ. + +**The export left out a data script.** The component loads its sample data +from `archetype-full.js` (`ARCH_FULL`), and the export did not include that +file. The committed component therefore opens with empty sections. Its markup, +copy and states are complete, and they are what this build follows. The data +is not reconstructed. + +This change builds A24–A30. #1006's prompt asked to keep the card's top as it +was, so A1–A23, which draw the compact card and the page top differently from +`main`, are #1323 (D620). + +| Board | Draws | Built here | +| --- | --- | --- | +| A24, A25 | the three sections, light and dark, at 1280 | yes | +| A26 | the same on a phone, light and dark | yes | +| A27 | every skill Not recorded, a first archetype, no published member, unpublished | yes | +| A28 | loading | yes | +| A29 | every read unreadable | yes | +| A30 | matches locked by plan | yes | +| A1–A23 | the compact card and the page top | no: #1323 | + +### What was true on main + +- **D602** built `GET /api/profile/published-members`, the client methods, + and the shapes `radarLayers`, `archetypeTimeline` and `matchGroups`. It + drew none of the three sections, for want of a canvas. +- **D605** gave each match a values band and `valuesFitLine`. The card did + not draw it either. +- **The page's last block** was the static "Who you match with" list: each + type and why, with no members. + +### The change + +**Skills: "Your skills, two ways"** (`GET /api/skills/me/evidence`, D318) +- **The radar has two layers on eight axes.** + - **Self** is the filled shape, out to the member's own 0–5 rating. It + joins consecutive rated axes only. An axis with no rating is a gap in the + shape, never a point at 0. A rated axis with no rated neighbour has + nothing to join, so it is a mark. + - **Evidence** is a dot on each axis with a rating and activity, out along + the axis by its weight. The outer ring is the reply's `corroborated_at`, + so a corroborated axis's dot sits on it. + - **Evidence only** shows its count of activities on the axis, and no dot, + so it never reads as a level. + - **Not recorded** is said on the axis. When every axis is Not recorded, + there is no shape and one label in the middle. +- **The table** names each axis, its state as a pill, the self rating + ("4 / 5") and the activity count. A missing value is "—", never 0. +- **The legend** says what the five states mean. +- **Every axis Not recorded** adds "Answer a few skill questions with Eadwyn + to fill this in." with Open Eadwyn, which goes to the Studio chat. +- **Under every state:** "Evidence counts what you have done on the + platform. It is not an assessment of how good you are." + +**History: "How your archetype has changed"** (`GET /api/profile/history`, D357) +- **One point per change of the displayed archetype,** oldest first, from the + first classification. Each shows the archetype, the date and the trigger: + an answer, platform activity, a scheduled review, or an engine update. +- **The timeline is the shown archetype's persona.** Someone with two + personas sees the one the card shows. +- **One point** adds "This is your first archetype. Changes appear here as + your answers and activity build up." +- **No archetype yet:** "Not recorded: Eadwyn has not classified you yet." +- **Horizontal from 672px,** stacked on a phone. + +**Matches: "Who you match with"** (the copy's types, `GET /api/profile/published-members`, `GET`/`PUT /api/profile/archetype-published`) +- **The publish banner** says only members who publish appear here. It shows + the member's own state, Published or Not published, and a switch whose + label is the action. A change re-reads the list, because D605 compares + values only for a member who has published. +- **Each match type always shows,** with its reason. + - Under it go the members who published that archetype: initials, name, + role, the archetype, and D605's values line. + - Three show, then "and N more", which shows the rest of that type. + - A type nobody has published reads "No member has published this + archetype yet." +- **No values line on any row** when the member has not published; the + reply's reason says why, once, above the list. +- **Past the route's 50,** a line says more members published than were + read. +- **Locked** (A30): "Matches are not included in your plan." with See plans. + +**On the page** (`pages/ArchetypeCardPage.jsx`) +- **Each section reads its own source and has its own Try again.** A + section loads as a skeleton with no figure. A failed read is its own + sentence, never an empty radar or an empty list. +- **The sections show once the archetype read has settled:** + - Skills always, since skills are never locked (D594). + - History and Matches with an archetype. + - The history's "not classified" line with none. + - The matches' lock when the plan withholds the archetype. +- **The article's static matching list is gone.** The Matches section + carries the same types and reasons. + +### Where the build departs from the canvas, and why + +- **The radar's labels are short** (Eng, GTM, Brand, Finance, Legal, + Capital), as on the platform's other radar (`SKILL_AXES_SHORT`), so eight + fit inside a 300px radar on a phone. The table names every axis in full. +- **The evidence scale is the reply's own.** The canvas's data did not come + with the export, so how far out a dot sits is this build's choice: the + outer ring is `corroborated_at`. +- **Evidence dots are amber-500 in dark mode.** The canvas keeps `#92400e`, + which on the dark card is about 2.5:1, under the 3:1 a graphic needs. + Amber-500 is about 8:1. +- **A failed read says what it does not claim**, as the repo's `Unreadable` + does. For example: "Your skills could not be read. This is not a claim that + none are recorded." +- **The values line is D605's.** The canvas predates D605 and draws a chip + reading "coming soon". With no comparison there is no chip, and the reason + shows once. +- **"and N more" is a button** that shows the rest. The canvas draws it as + text that goes nowhere. +- **The privacy footer names the values comparison:** "You see a member's + name, role and published archetype, and how your values compare overall. + Their scores, traits, skills, values and history stay private." The + canvas's line predates D605's band. +- **"Current" marks the last point only when it is the archetype the card + shows.** + - The card reads `GET /best-fit/me` (D602), whose archetype is the v1 + classifier. + - The timeline reads the profile snapshots, whose displayed archetype is + v2 and changes only after hysteresis. + - When the two differ, the point is unmarked, and a line says the card + shows another archetype and why. #1323 carries the question of moving + the card to v2. +- **A history cut at the route's 200 snapshots** does not call its oldest + point the first. It says "Changes before [date] are not shown." +- **States the canvas does not draw:** + - the matches loading (a skeleton); + - a history with no saved reading yet; + - a publish state that could not be read, or a change that did not save. +- **A locked archetype shows Skills and the matches' lock, and no History.** + The history's points are the archetype the plan withholds, and the route + removes them (D594). +- **No provenance per axis.** #1006's request asked for a few recent events + per axis. The canvas draws none, so the reply's `recent_events` and + `provenance` are not shown. + +### Tests + +**`frontend/test/archetype_sections_d619.test.mjs` (24):** +- **The radar:** + - the frame; + - the self shape over rated axes only, the lone mark, all rated, and none + rated; + - the evidence dots by weight, on the reply's scale, and none for evidence + only; + - the Not recorded chips, and the single middle label; + - the table's absent values; + - the short labels and their alignment; + - a state the client does not know is named, not called Not recorded. +- **The timeline:** + - order, de-duplication and persona; + - the Worker's four triggers; + - unparseable dates and triggers; + - "Current" only on the card's archetype; + - a history cut at 200; + - a date with no zone read as UTC in two time zones. +- **A member row** carries a name, role, archetype and values line, and + nothing else. +- **Every rendered state of the three sections and the publish banner.** +- **The page's wiring:** + - each source; + - each section's own retry; + - the persona; + - the re-read after a publish change; + - the lock; + - a reply without its list or flag reads as unreadable, never as an empty + section. +- **The committed canvas:** + - A24–A30 import the component; + - the copy the build uses; + - the committed serialisation; + - the missing data script, which this file records. + +**`frontend/test/studio_archetype_card.test.mjs`:** its matching assertions +now follow the list into the Matches section. + +**Mutation checks: 35 of 35 caught.** Each undid one behaviour, and every file +was restored and checked by sha256: +- **the radar:** + - an unrated axis drawn at 0, or a lone rated axis joined as a fan; + - evidence only given a dot; + - the scale fixed at 3, or a dot past the outer ring; + - eight chips when nothing is recorded; + - a missing self or activity read as 0; + - an unknown state called Not recorded; +- **the timeline:** + - "Current" on another archetype; + - a cut history's oldest point called the first; + - a date read in local time; + - two triggers' words swapped; +- **the rows:** a member row carrying the user id; +- **the rendered states:** + - no table, no Try again, no Eadwyn prompt; + - a failed read still drawing its radar or its types; + - no first-archetype note, no word on a disagreeing card; + - every member shown, a values chip with no comparison, no values reason; + - no way to the plans, a failed save left silent; +- **the page:** + - a retry click taken as the quiet flag, no re-read after publishing; + - matches listed on a locked plan; + - the timeline on the role's persona, not the archetype's; + - a locked archetype called unclassified; + - the scale not read from the reply; + - each malformed reply read as ready or empty. diff --git a/frontend/src/components/profile/ArchetypeSections.jsx b/frontend/src/components/profile/ArchetypeSections.jsx new file mode 100644 index 0000000000..1508d1aa48 --- /dev/null +++ b/frontend/src/components/profile/ArchetypeSections.jsx @@ -0,0 +1,507 @@ +/** + * D619 (#1006) — the archetype card page's three sections, from the owner's + * canvas `Studio · Archetype card` (A24–A30) and the component its artboards + * import, `Archetype Full Sections`. + * + * Skills — "Your skills, two ways": the self shape and the evidence dots + * on one radar, every axis in the table beside it. + * History — "How your archetype has changed": one point per change of the + * displayed archetype, oldest first. + * Matches — "Who you match with": each match type, the members who + * published it, and the caller's own publish switch. + * + * Presentational. The page reads each source and passes its state down, so a + * section's Try again re-reads its own source only. A read in flight draws a + * skeleton and a failed read draws its sentence, never an empty radar or an + * empty list (A28, A29). + */ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Lock } from 'lucide-react'; +import { + MATCH_MEMBERS_SHOWN, + RADAR, + SKILL_STATE_COPY, + SKILL_STATE_LEGEND, + memberRow, + radarRings, + radarSpokes, +} from '../../lib/archetypeCardV2'; +import { STUDIO_CHAT_ANCHOR } from '../advisor/interviewCompleteRow'; + +const CARD = 'rounded-[16px] border border-[#ececf1] dark:border-gray-700 bg-white dark:bg-gray-900'; +const WELL = 'bg-[#f4f3f7] dark:bg-gray-800'; +const MUTED = 'text-[#6b6577] dark:text-gray-400'; +const MONO = 'font-mono tabular-nums'; + +/** The state pill in the table and the legend: the state in form as well as words. */ +export const STATE_PILL = { + not_recorded: 'border border-dashed border-[#d4d1dc] dark:border-gray-600 text-[#6b6577] dark:text-gray-400', + self_rated_only: 'border border-[#ddd0fb] dark:border-violet-800 bg-[#f4f0fe] dark:bg-violet-950/40 text-[#6d28d9] dark:text-violet-300', + some_evidence: 'border border-[#fde68a] dark:border-amber-800 bg-[#fffbeb] dark:bg-amber-950/40 text-[#92400e] dark:text-amber-300', + corroborated: 'border border-[#bbf7d0] dark:border-green-800 bg-[#f0fdf4] dark:bg-green-950/40 text-[#15803d] dark:text-green-300', + evidence_only: 'border border-[#f59e0b] dark:border-amber-600 text-[#92400e] dark:text-amber-300', +}; + +function SectionHead({ eyebrow, title, id }) { + return ( +
+
{eyebrow}
+

{title}

+
+ ); +} + +/** A29 — a failed read: what could not be read, what that does not claim, and Try again. */ +function ReadFailed({ text, claim, onRetry, testId }) { + return ( +
+ + {text} {claim} + + {onRetry && ( + + )} +
+ ); +} + +const bar = (w) =>
; + +// ── 1 · Skills ─────────────────────────────────────────────────────────────── + +const LABEL_SHIFT = { start: 'translate(0,-50%)', end: 'translate(-100%,-50%)', middle: 'translate(-50%,-50%)' }; + +export function SkillsRadar({ model }) { + const rings = radarRings(model.labels.length || 8); + const spokes = radarSpokes(model.labels.length || 8); + return ( +
+ + {rings.map((r) => ( + + ))} + {spokes.map((s) => ( + + ))} + {model.selfPath && ( + + )} + {model.selfMarks.map((m) => ( + + ))} + {model.dots.map((d) => ( + + ))} + + {model.labels.map((l) => ( + + {l.text} + + ))} + {model.allNotRecorded && ( + + Not recorded + + )} + {model.gaps.map((g) => ( + + Not recorded + + ))} + {model.evidenceOnly.map((e) => ( + + {e.text} + + ))} +
+ ); +} + +const TABLE_ROW = 'grid grid-cols-[minmax(0,1fr)_120px] gap-x-[10px] gap-y-1 px-3 py-[9px] @2xl:grid-cols-[minmax(0,1.4fr)_150px_64px_96px] @2xl:gap-[10px] @2xl:px-[14px] @2xl:py-2'; + +export function SkillsTable({ rows }) { + return ( +
+ +
    + {rows.map((r) => ( +
  • + {r.label} + + {r.stateText} + + + Self: {r.self ?? '—'} + + + Evidence: {r.evidence ?? '—'} + +
  • + ))} +
+
+ ); +} + +/** + * state: 'loading' | 'unreadable' | 'ready'. `model` is `radarModel(...)`, + * required when ready. + */ +export function SkillsSection({ state, model, onRetry }) { + return ( +
+ + {state === 'loading' && ( +
+
+
{bar('60%')}{bar('85%')}{bar('40%')}
+
+ )} + {state === 'unreadable' && ( + + )} + {state === 'ready' && model && ( +
+
+ +
+ + + + +
+
+
+ +
+ {SKILL_STATE_LEGEND.map((l) => ( +
+
{SKILL_STATE_COPY[l.state]}
+
{l.text}
+
+ ))} +
+ {model.allNotRecorded && ( +
+ Answer a few skill questions with Eadwyn to fill this in. + + Open Eadwyn + +
+ )} +
+
+ )} +

+ Evidence counts what you have done on the platform. It is not an assessment of how good you are. +

+
+ ); +} + +// ── 2 · History ────────────────────────────────────────────────────────────── + +/** + * state: 'loading' | 'unreadable' | 'ready' | 'unclassified'. `view` is + * `timelineView(...)`; `cardLabel` names the archetype the card shows. + */ +export function HistorySection({ state, view, cardLabel, onRetry }) { + const points = view?.points || []; + return ( +
+ + {state === 'loading' && ( +
+
+
+
+
+ )} + {state === 'unreadable' && ( + + )} + {state === 'unclassified' && ( +
+ Not recorded: Eadwyn has not classified you yet. +
+ )} + {state === 'ready' && ( +
+ {points.length === 0 ? ( +

+ Not recorded yet. Your history starts with the first reading saved with your profile. +

+ ) : ( +
+
+ )} + {points.length === 1 && points[0].first && ( +

+ This is your first archetype. Changes appear here as your answers and activity build up. +

+ )} + {view?.capped && points.length > 0 && ( +

+ Changes before {points[0].date} are not shown. +

+ )} + {points.length > 0 && !view?.agrees && cardLabel && ( +

+ The card above shows {cardLabel}. This history follows the archetype saved with your profile, which changes only once a new reading has held for a while. +

+ )} +

+ Triggers: an answer, platform activity, a scheduled review, or an engine update. +

+
+ )} +
+ ); +} + +// ── 3 · Matches ────────────────────────────────────────────────────────────── + +/** + * The publish banner (A24, A27). `publish` is { state: 'loading' | 'unreadable' + * | 'ready', published, saving, saveFailed }. + */ +export function PublishBanner({ publish, onToggle, onRetry }) { + const ready = publish?.state === 'ready'; + const on = ready && publish.published === true; + return ( +
+ + Only members who publish their archetype appear here. Publish yours so they can find you. + + {publish?.state === 'loading' && } + {publish?.state === 'unreadable' && ( + + Whether yours is published could not be read. + + + )} + {ready && ( +
+ + {on ? 'Published' : 'Not published'} + + +
+ )} + {ready && publish.saveFailed && ( + + Your choice was not saved. Try again. + + )} +
+ ); +} + +function MatchType({ group }) { + const [open, setOpen] = useState(false); + const rows = group.members.map(memberRow); + const shown = open ? rows : rows.slice(0, MATCH_MEMBERS_SHOWN); + const more = rows.length - shown.length; + return ( +
  • +
    +

    {group.label}

    +

    {group.why}

    +
    + {rows.length === 0 ? ( +

    No member has published this archetype yet.

    + ) : ( +
      + {shown.map((m) => ( +
    • +
      + + {m.name} + {m.role && {m.role}} + {m.archetype && ( + + {m.archetype} + + )} +
      + {m.values && ( + + {m.values} + + )} +
    • + ))} + {more > 0 && ( +
    • + +
    • + )} +
    + )} +
  • + ); +} + +/** + * state: 'loading' | 'unreadable' | 'locked' | 'ready'. `groups` is + * `matchGroups(slug, reply)`; `reply` carries `more` and `values_fit_reason`. + */ +export function MatchesSection({ state, groups, reply, publish, onTogglePublish, onRetryPublish, onRetry }) { + return ( +
    + + {state === 'locked' && ( +
    +
    +
    + + See plans + +
    + )} + {state === 'unreadable' && ( + + )} + {state === 'loading' && ( +
    + {[0, 1].map((k) => ( +
    {bar('35%')}{bar('70%')}
    + ))} +
    + )} + {state === 'ready' && ( + <> + + {reply?.values_fit_reason && ( +

    {reply.values_fit_reason}

    + )} +
      + {(groups || []).map((g) => )} +
    + {reply?.more && ( +

    + More members published these archetypes than the 50 read here. +

    + )} +

    + You see a member's name, role and published archetype, and how your values compare overall. Their scores, traits, skills, values and history stay private. +

    + + )} +
    + ); +} diff --git a/frontend/src/lib/archetypeCardV2.js b/frontend/src/lib/archetypeCardV2.js index f7557773d5..6a7437963f 100644 --- a/frontend/src/lib/archetypeCardV2.js +++ b/frontend/src/lib/archetypeCardV2.js @@ -10,7 +10,7 @@ * conversational signal sees the empty state, never a gamified-assessment * result standing in for it. */ -import { ARCHETYPES } from './assessmentMeta.js'; +import { ARCHETYPES, SKILL_AXES, SKILL_AXES_SHORT, humanize } from './assessmentMeta.js'; /** The archetype the card shows, from `GET /best-fit/me` only, or null. */ export function cardArchetype(fit) { @@ -109,3 +109,199 @@ export function valuesFitLine(fit) { if (!fit || typeof fit !== 'object') return null; return VALUES_FIT_COPY[fit.band] ?? VALUES_FIT_COPY.none; } + +// ── D619 (#1006) — the three sections, drawn from the owner's canvas ───────── +// `Studio · Archetype card` A24–A30 and the component they import, +// `Archetype Full Sections`. Geometry and copy only; the page wires the reads. + +/** The radar's frame, in its 260-unit viewBox: rings out to `r`, labels at `labelAt`. */ +export const RADAR = { size: 260, cx: 130, cy: 130, r: 86, labelAt: 96, chipAt: 0.55 }; + +const round1 = (v) => Math.round(v * 10) / 10; +const angleOf = (i, n) => -Math.PI / 2 + (i * 2 * Math.PI) / n; +function radarPoint(i, n, radius) { + const a = angleOf(i, n); + return [round1(RADAR.cx + radius * Math.cos(a)), round1(RADAR.cy + radius * Math.sin(a))]; +} +const pctOf = (v) => `${round1((v / RADAR.size) * 100)}%`; + +/** The five rings (levels 1 to 5) as polygon `points`, product at the top. */ +export function radarRings(n = 8) { + return [1, 2, 3, 4, 5].map((k) => Array.from({ length: n }, (_, i) => radarPoint(i, n, (RADAR.r * k) / 5).join(',')).join(' ')); +} + +/** One spoke per axis, from the centre to the outer ring. */ +export function radarSpokes(n = 8) { + return Array.from({ length: n }, (_, i) => { + const [x2, y2] = radarPoint(i, n, RADAR.r); + return { x2, y2 }; + }); +} + +/** §5.2's five axis states, as the legend and the table name them. */ +export const SKILL_STATE_COPY = { + not_recorded: 'Not recorded', + self_rated_only: 'Self-rated only', + some_evidence: 'Some evidence', + corroborated: 'Corroborated', + evidence_only: 'Evidence only', +}; +export const SKILL_STATE_LEGEND = [ + { state: 'not_recorded', text: 'No rating and no activity yet. The axis says so, never 0.' }, + { state: 'self_rated_only', text: 'Your rating, with no activity on the platform yet.' }, + { state: 'some_evidence', text: 'Your rating, with some activity behind it.' }, + { state: 'corroborated', text: 'Your rating, with enough activity to back it.' }, + { state: 'evidence_only', text: 'Activity, but no rating. The axis shows how many, not a level.' }, +]; + +export function activityCount(n) { + const k = Number(n) || 0; + return `${k} ${k === 1 ? 'activity' : 'activities'}`; +} + +/** + * The radar and its table, from `radarLayers(report)`. + * - **Self** is the filled shape, and it joins consecutive rated axes only. An + * axis with no rating is a gap in it, never a point at 0. A rated axis with + * no rated neighbour has nothing to join, so it is a mark. + * - **Evidence** is a dot on each axis with a rating and activity, out along the + * axis by its weight. The outer ring is `corroboratedAt` (the reply's + * `corroborated_at`), so a corroborated axis's dot sits on it. Evidence only + * shows a count and no dot, so it never reads as a level. + * - **Not recorded** is a chip on the axis. When every axis is Not recorded, + * there is one chip in the middle and no shape. + */ +export function radarModel(layers, corroboratedAt = 3) { + const list = Array.isArray(layers) ? layers : []; + const n = list.length; + const full = Number(corroboratedAt) > 0 ? Number(corroboratedAt) : 3; + const rated = list.map((l) => l.self != null && Number.isFinite(Number(l.self))); + const vertex = (i) => radarPoint(i, n, (RADAR.r * Math.max(0, Math.min(5, Number(list[i].self)))) / 5); + let selfPath = ''; + const selfMarks = []; + if (n && rated.every(Boolean)) { + selfPath = `${list.map((_, i) => `${i ? 'L' : 'M'}${vertex(i).join(' ')}`).join(' ')} Z`; + } else if (rated.some(Boolean)) { + // Walk once round from the first unrated axis, so a run that wraps past + // the top stays one run. + const start = rated.indexOf(false); + const runs = []; + let run = []; + for (let k = 1; k <= n; k += 1) { + const i = (start + k) % n; + if (rated[i]) run.push(i); + else if (run.length) { runs.push(run); run = []; } + } + for (const r of runs) { + if (r.length === 1) { + const [cx, cy] = vertex(r[0]); + selfMarks.push({ axis: list[r[0]].axis, cx, cy }); + } else { + selfPath += `${selfPath ? ' ' : ''}M${RADAR.cx} ${RADAR.cy} ${r.map((i) => `L${vertex(i).join(' ')}`).join(' ')} Z`; + } + } + } + const dots = list.flatMap((l, i) => { + if (l.state !== 'some_evidence' && l.state !== 'corroborated') return []; + const w = Number(l.evidence); + if (!(w > 0)) return []; + const [cx, cy] = radarPoint(i, n, RADAR.r * Math.min(1, w / full)); + return [{ axis: l.axis, cx, cy }]; + }); + const allNotRecorded = n > 0 && list.every((l) => l.state === 'not_recorded'); + const chipAt = (i) => { + const [x, y] = radarPoint(i, n, RADAR.r * RADAR.chipAt); + return { left: pctOf(x), top: pctOf(y) }; + }; + const gaps = allNotRecorded ? [] : list.flatMap((l, i) => (l.state === 'not_recorded' ? [{ axis: l.axis, ...chipAt(i) }] : [])); + const evidenceOnly = list.flatMap((l, i) => ( + l.state === 'evidence_only' ? [{ axis: l.axis, text: activityCount(l.evidence_count), ...chipAt(i) }] : [] + )); + const labels = list.map((l, i) => { + const [x, y] = radarPoint(i, n, RADAR.labelAt); + const c = Math.cos(angleOf(i, n)); + return { + axis: l.axis, + text: SKILL_AXES_SHORT[l.axis] || l.label || l.axis, + left: pctOf(x), + top: pctOf(y), + align: c > 0.3 ? 'start' : c < -0.3 ? 'end' : 'middle', + }; + }); + const rows = list.map((l) => ({ + axis: l.axis, + label: l.label || SKILL_AXES[l.axis] || l.axis, + state: l.state, + stateText: SKILL_STATE_COPY[l.state] || (l.state ? humanize(l.state) : SKILL_STATE_COPY.not_recorded), + self: l.self == null ? null : `${l.self} / 5`, + evidence: Number(l.evidence) > 0 ? activityCount(l.evidence_count) : null, + })); + return { selfPath, selfMarks, dots, gaps, evidenceOnly, labels, rows, allNotRecorded }; +} + +/** §7.8's four triggers, as the timeline names them. */ +export const TRIGGER_COPY = { + answer: 'An answer', + evidence: 'Platform activity', + scheduled: 'A scheduled review', + engine_bump: 'An engine update', +}; +/** `GET /profile/history` returns at most this many snapshots, newest first (D357). */ +export const HISTORY_CAP = 200; + +/** "12 Mar 2026", read in UTC; null when the time does not parse. */ +export function dayLabel(at) { + if (!at) return null; + const s = String(at); + const iso = /^\d{4}-\d{2}-\d{2} \d/.test(s) ? `${s.replace(' ', 'T')}${/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? '' : 'Z'}` : s; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); +} + +/** + * The timeline as the section draws it, from `GET /profile/history`'s items. + * - The last point is marked current only when it is the archetype the card + * shows. The card reads `GET /best-fit/me` (D602) and the history reads the + * profile snapshots, which change their displayed archetype only after + * hysteresis, so the two can differ for a while. + * - When the reply holds the route's full 200, older snapshots were cut, so the + * oldest point shown is not called the first. + */ +export function timelineView(items, persona = null, cardSlug = null) { + const list = Array.isArray(items) ? items : []; + const capped = list.length >= HISTORY_CAP; + const pts = archetypeTimeline(list, persona); + const last = pts[pts.length - 1] || null; + const agrees = !!last && !!cardSlug && last.slug === cardSlug; + return { + capped, + agrees, + points: pts.map((p, i) => ({ + ...p, + date: dayLabel(p.at) || 'Date not recorded', + triggerText: TRIGGER_COPY[p.trigger] || 'Trigger not recorded', + first: p.first && !capped, + current: agrees && i === pts.length - 1, + })), + }; +} + +/** Members drawn under each match type before "and N more". */ +export const MATCH_MEMBERS_SHOWN = 3; + +/** One member row: who, and the values line (D605) when there is a comparison. */ +export function memberRow(m) { + const name = typeof m?.name === 'string' && m.name.trim() ? m.name.trim() : null; + const initials = name + ? name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') + : '?'; + return { + key: `${m?.user_id ?? ''}-${m?.persona ?? ''}`, + name: name || 'Name not recorded', + initials, + role: m?.role ? humanize(m.role) : null, + archetype: m?.label || ARCHETYPES[m?.slug]?.label || null, + values: valuesFitLine(m?.values_fit), + }; +} diff --git a/frontend/src/pages/ArchetypeCardPage.jsx b/frontend/src/pages/ArchetypeCardPage.jsx index ab06b65c12..c5f5670c0e 100644 --- a/frontend/src/pages/ArchetypeCardPage.jsx +++ b/frontend/src/pages/ArchetypeCardPage.jsx @@ -11,8 +11,13 @@ * card reads Eadwyn's conversational archetype (`GET /best-fit/me`) and * nothing else. A person with no conversational signal sees the empty state, * never a gamified-assessment result standing in for it. + * + * D619 (#1006) — below the card, the canvas's three sections (`Studio · + * Archetype card` A24–A30): the two-layer skills radar, the evolution + * timeline, and the matches with the members who published them. Each reads + * its own source and retries on its own (components/profile/ArchetypeSections). */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { ArrowLeft, Loader2, Lock } from 'lucide-react'; import { api, assessment } from '../lib/api'; @@ -21,7 +26,8 @@ import { Unreadable } from '../ui'; import { XpBar } from '../components/profile/xpBar'; import { openPaywall } from '../components/PaywallModal'; import { archetypeLock, lockedLine, lockedPlanName } from '../lib/archetypeLock'; -import { cardArchetype } from '../lib/archetypeCardV2'; +import { cardArchetype, matchGroups, matchSlugs, radarLayers, radarModel, timelineView } from '../lib/archetypeCardV2'; +import { HistorySection, MatchesSection, SkillsSection } from '../components/profile/ArchetypeSections'; import { archetypeAudienceFromRole, archetypeBanner, @@ -99,6 +105,85 @@ export default function ArchetypeCardPage({ activeRole }) { const title = latest?.label || meta?.label || latest?.slug; const accent = meta?.accent || '#7c3aed'; + // D619 — the skills radar reads its own source. Skills are never locked + // (D594), so it shows whatever the archetype read said. + const [skills, setSkills] = useState({ state: 'loading', data: null }); + const readSkills = useCallback(() => { + setSkills({ state: 'loading', data: null }); + api.skills.getMyEvidence() + .then((d) => setSkills(Array.isArray(d?.axes) && d.axes.length ? { state: 'ready', data: d } : { state: 'unreadable', data: null })) + .catch((e) => { reportError('ArchetypeCard:skills', e); setSkills({ state: 'unreadable', data: null }); }); + }, []); + useEffect(() => { readSkills(); }, [readSkills]); + const skillsModel = useMemo( + () => (skills.state === 'ready' ? radarModel(radarLayers(skills.data), skills.data.corroborated_at) : null), + [skills], + ); + + // The timeline is the shown archetype's persona: someone with two personas + // sees the one the card shows. + const slug = latest?.slug || null; + const persona = latest ? (fitData?.archetype?.persona || audience) : null; + const [history, setHistory] = useState({ state: 'loading', items: null }); + const readHistory = useCallback(() => { + if (!persona) return; + setHistory({ state: 'loading', items: null }); + api.profileHistory(persona) + .then((d) => setHistory(Array.isArray(d?.items) ? { state: 'ready', items: d.items } : { state: 'unreadable', items: null })) + .catch((e) => { reportError('ArchetypeCard:history', e); setHistory({ state: 'unreadable', items: null }); }); + }, [persona]); + useEffect(() => { readHistory(); }, [readHistory]); + const historyView = useMemo( + () => (history.state === 'ready' ? timelineView(history.items, persona, slug) : null), + [history, persona, slug], + ); + + // Matches: the types in the copy, and the members who published one of them. + const slugKey = useMemo(() => (slug ? matchSlugs(slug).join(',') : ''), [slug]); + const [members, setMembers] = useState({ state: 'loading', reply: null }); + const readMembers = useCallback((quiet = false) => { + if (!slug) return; + if (!slugKey) { setMembers({ state: 'ready', reply: { items: [], more: false } }); return; } + if (!quiet) setMembers({ state: 'loading', reply: null }); + api.profilePublishedMembers(slugKey.split(',')) + .then((d) => setMembers( + d?.locked ? { state: 'locked', reply: null } + : Array.isArray(d?.items) ? { state: 'ready', reply: d } + : { state: 'unreadable', reply: null }, + )) + .catch((e) => { reportError('ArchetypeCard:matches', e); setMembers({ state: 'unreadable', reply: null }); }); + }, [slug, slugKey]); + useEffect(() => { readMembers(); }, [readMembers]); + const groups = useMemo(() => (slug ? matchGroups(slug, members.reply) : []), [slug, members.reply]); + + const [publish, setPublish] = useState({ state: 'loading', published: null, saving: false, saveFailed: false }); + const readPublish = useCallback(() => { + setPublish({ state: 'loading', published: null, saving: false, saveFailed: false }); + api.archetypePublished() + .then((d) => setPublish(typeof d?.published === 'boolean' + ? { state: 'ready', published: d.published, saving: false, saveFailed: false } + : { state: 'unreadable', published: null, saving: false, saveFailed: false })) + .catch((e) => { + reportError('ArchetypeCard:published', e); + setPublish({ state: 'unreadable', published: null, saving: false, saveFailed: false }); + }); + }, []); + useEffect(() => { if (slug) readPublish(); }, [slug, readPublish]); + const togglePublish = useCallback((next) => { + setPublish((p) => ({ ...p, saving: true, saveFailed: false })); + api.setArchetypePublished(next) + .then((d) => { + setPublish({ state: 'ready', published: d?.published === true, saving: false, saveFailed: false }); + // D605 compares values only for a caller who has published, so the + // list is read again for its values lines. + readMembers(true); + }) + .catch((e) => { + reportError('ArchetypeCard:publish', e); + setPublish((p) => ({ ...p, saving: false, saveFailed: true })); + }); + }, [readMembers]); + return (