diff --git a/documentation/architecture/PROFILE_ROUTING.md b/documentation/architecture/PROFILE_ROUTING.md index c11bd2a989..a5f5cee2dd 100644 --- a/documentation/architecture/PROFILE_ROUTING.md +++ b/documentation/architecture/PROFILE_ROUTING.md @@ -54,9 +54,9 @@ it, where in their nav, and how do they get there.* | Founder Workspaces Canvas | `/validate` · `/build` · `/raise` · `/grow` · `/network` · `/research` | Headerless | Full page | Sidebar → Headerless → Validate | UPGRADE | High | | Get Paid & Invoicing | `/payouts` · `/referrals` | — (no nav entry) | Full page | Deep link / in-page action | RESKIN | Medium | | Graduation Certificate | `/spinout-lab/certificate` · `/verify/:token` | Headerless | Full page + print stylesheet | Sidebar → Headerless → Spin-Out Lab | OUT OF SCOPE | High | -| Incorporate | `/spinout-lab/incorporate` · `/incorporate` · `/incorporate/success` · `/incorporate/83b` · `/incorporate/cofounder-agreement` | Headerless | Full page (Spin-Out Lab — frozen) | Sidebar → Headerless → Spin-Out Lab | OUT OF SCOPE | High | -| Legal & Capital Engine | `/legal-capital` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | -| Liquidity & Secondaries | `/liquidity` | Headerless | Full page | Sidebar → Headerless → Raise | UPGRADE | High | +| Incorporate | `/spinout-lab/incorporate` · `/incorporate` · `/incorporate/success` · `/incorporate/83b` · `/incorporate/cofounder-agreement` | Headerless | Full page (Spin-Out Lab — frozen) | Sidebar → Headerless → Raise | OUT OF SCOPE | High | +| Legal & Capital Engine | `/legal-capital` | Headerless | Full page | Sidebar → Headerless → Raise | UPGRADE | High | +| Liquidity & Secondaries | `/liquidity` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Metrics | `/build/metrics` | Headerless | Full page | Sidebar → Headerless → Build | UPGRADE | High | | Office Hours | `/spinout-lab/office-hours` | Headerless | Full page (Spin-Out Lab — frozen) | Sidebar → Headerless → Spin-Out Lab | OUT OF SCOPE | High | | Ops Page · Advisor practice (A1–A5, D573) | `/practice/deliverables` · `/practice/session-types` · `/practice/booking-links` · `/practice/access-log` · `/advisors/:uid` · `/b/*` | Headerless | Full page (proposed) | Sidebar → Headerless → Grow | CURRENT | Low | @@ -68,7 +68,7 @@ it, where in their nav, and how do they get there.* | Pages · Founder Build | `/build` · `/build/board` · `/build/roadmap` · `/build/kpi` | Headerless | Full page (proposed) | Sidebar → Headerless → Build | UPGRADE | Low | | Pages · Founder Grow | `/grow` · `/grow/focus` | Headerless | Full page (proposed) | Sidebar → Headerless → Grow | UPGRADE | Low | | Pages · Founder Network | `/network` | Headerless | Full page | Sidebar → Headerless → Network | RESKIN | High | -| Pages · Founder Raise | `/raise/pitch` · `/raise/capital` · `/raise/legal-engine` · `/liquidity` | Headerless | Full page (proposed) | Sidebar → Headerless → Raise | UPGRADE | Low | +| Pages · Founder Raise | `/raise/pitch` · `/raise/capital` · `/raise/legal-engine` · `/liquidity` · `/raise/liquidity?mode=workspace` | Headerless | Full page (proposed) | Sidebar → Headerless → Raise | UPGRADE | Low | | Pages · Founder Research | `/research/ask` · `/research/library` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | | Pages · Founder Validate | `/validate` · `/validate/{interviews` | Headerless | Full page (proposed) | Sidebar → Headerless → Validate | CURRENT | Low | | Pages · Fund dossier | `/research/funds/:uid` · `/research/funds` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | @@ -130,7 +130,7 @@ it, where in their nav, and how do they get there.* | Fund Brief One-Pager | `/spinout-lab` · `/spinout-lab/investor-workspace` | Headerless | Full page + print stylesheet | Sidebar → Headerless → Spin-Out Lab | CURRENT | High | | Investor LP Canvas | `/studio` · `/deals` · `/portfolio` · `/funds` · `/network` · `/research` | Headerless | Full page | Sidebar → Headerless → Studio | UPGRADE | High | | InvRail | — | — (not routed) | Embedded rail | Not reachable yet | NEW | Low | -| Liquidity & Secondaries | `/liquidity` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | +| Liquidity & Secondaries | `/liquidity` | Headerless | Full page | Sidebar → Headerless → Portfolio | UPGRADE | High | | LP Investor Workspace | `/spinout-lab/investor-workspace` · `/lp-portal` · `/funds/lp-workspace` | Headerless | Full page | Sidebar → Headerless → Axal VC Fund | UPGRADE | High | | Pages · Diligence file | `/research/diligence/:grantUid/files/:fileUid` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | | Pages · Diligence room | `/research/diligence/:grantUid` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | @@ -146,7 +146,7 @@ it, where in their nav, and how do they get there.* | Research · Main | `/research` · `/market-intel` | Headerless | Full page | Sidebar → Headerless → Research | UPGRADE | High | | Research · Markets | `/research/markets` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | | Research · Sector | `/research/markets/sector/:slug` | Headerless | Full page | Sidebar → Headerless → Research | CURRENT | High | -| Scoring Engine v2 | `/scoring` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | +| Scoring Engine v2 | `/scoring` | Headerless | Full page | Sidebar → Headerless → Deals | UPGRADE | High | ## Partner / Operator — 12 canvases @@ -260,8 +260,8 @@ 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. | +| High | 100 | Live route, reachable from that workspace’s sidebar. | +| Medium | 43 | 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..7196d5b7af 100644 --- a/documentation/architecture/ROUTE_MAP.md +++ b/documentation/architecture/ROUTE_MAP.md @@ -161,7 +161,7 @@ does not fill it. | Pages · Founder Build | founder | `/build`, `/build/board`, `/build/roadmap`, `/build/kpi` | `FounderBuildDesk`, `FounderBuildBoard`, `FounderBuildRoadmap`, `FounderBuildKPI` | `pipeline.ts`, `founder_board.ts`, `progress.ts` | UPGRADE | D548 (#1141): Review counts and cards read the stored `review` status with the same anatomy as other tasks. Movement history remains Not recorded (D510). The legacy execution URLs keep their existing editor/redirect behavior. | | Pages · Founder Grow | founder | `/grow`, `/grow/focus` and the six other Grow zones | `FounderGrowDesk`, `FounderGrowFocus` and existing zone pages | `progress.ts` (metric targets/snapshots), existing customer, team, brand and launch routes | UPGRADE | D548 (#1141), migration 384: an explicit nullable focus on a metric target, at most one per project, with actor-recorded set/clear writes. The desk and Focus panel display that target or No focus metric chosen with a picker; failed reads say Unreadable. No experiment effects or card movements are inferred. | | Pages · Founder Network | founder | `/advisor/network/{relationships,introductions,organizations}` (existing) | those three routes now redirect to `/network` (D10) | `contacts.ts`, `introductions.ts`, `partnernet.ts` | RESKIN | Same dead/mock wiring as the Network row above. Detail-layer additions specific to this canvas: a **relationship book with strength, notes, reminders and an explicit going-cold flag**; an **introductions ledger** recording every ask and offer, who carried it and how it ended — including the ones that quietly died; **organizations treated as a lens over the book** rather than a second address book. **CORRECTION 2026-08-29 — this row was stale and said the opposite of the code.** `AdvisorNetworkWorkspace` and `AdvisorResearchWorkspace` no longer exist: the mock stack was DELETED under DECISIONS.md D10, and `/advisor/network{,/introductions,/relationships,/organizations}` and `/advisor/research{,/market}` now `` onto the working surfaces (`/network`, `/market-intel` — the latter making ~32 live calls). `frontend/test/network_consolidated.test.mjs` fails the build if the dead files reappear or a redirect is removed. Read as written, this row would have sent someone to re-wire a component that was deliberately removed. The detail-layer ideas below (relationship strength and going-cold flags, an introductions ledger recording the asks that quietly died, organizations as a lens rather than a second address book) are still unbuilt and still worth building — but against `/network`, not against a workspace that is gone. | -| Pages · Founder Raise | founder | `/raise/pitch`, `/raise/capital`, `/raise/legal-engine`, `/liquidity` (existing) | `PitchWorkspacePage`, `CapitalWorkspacePage`, `LegalEnginePage`, `LiquidityPage` | `decks.ts`, `deck_share_actions.ts`, `captable.ts`, `financials.ts`, `contacts.ts` (raise-prospects/closes/pro-rata), `legal.ts`, `legalcap.ts`, `esign.ts`, `compliance.ts`, `liquidity.ts`, `files.ts`, `dd.ts` | UPGRADE | Six zones; two have no live route. **Status** (`/raise/status`) is a raise war-room — every blocker with what it holds up, investor list by stage, pace-vs-close timeline — nothing like it ships. **Data room** (`/raise/data-room`) is a founder-side **permission matrix** (artifact × investor, grant state, view analytics, stage gap analysis); **UPDATE 2026-09-07:** `GET /deals/:id/data-room` is still the only export bundle, but the page is no longer only that. `/raise/data-room` mounts `AdvisorGrantSection` (`DataRoomPage.jsx:257`), a real per-scope grant control writing through `/api/advisor-grants` over migration 218 — a founder names an advisor and ticks which of three scopes they may read. That is the permission matrix this row asked for, for advisors rather than for investors. See D50 and D55. **Pitch** adds per-investor share analytics incl. which slide people stop on (`/decks/:id/engagement` gives per-token views + read_seconds only, no per-slide dwell). **Capital** adds scenario modelling, the 409A record and an exit waterfall. **Legal** adds an agreement library + signature archive. | +| Pages · Founder Raise | founder | `/raise/pitch`, `/raise/capital`, `/raise/legal-engine`, `/liquidity` (existing; a founder there is redirected to `/raise/liquidity?mode=workspace`, D563) | `PitchWorkspacePage`, `CapitalWorkspacePage`, `LegalEnginePage`, `LiquidityPage` | `decks.ts`, `deck_share_actions.ts`, `captable.ts`, `financials.ts`, `contacts.ts` (raise-prospects/closes/pro-rata), `legal.ts`, `legalcap.ts`, `esign.ts`, `compliance.ts`, `liquidity.ts`, `files.ts`, `dd.ts` | UPGRADE | Six zones; two have no live route. **Status** (`/raise/status`) is a raise war-room — every blocker with what it holds up, investor list by stage, pace-vs-close timeline — nothing like it ships. **Data room** (`/raise/data-room`) is a founder-side **permission matrix** (artifact × investor, grant state, view analytics, stage gap analysis); **UPDATE 2026-09-07:** `GET /deals/:id/data-room` is still the only export bundle, but the page is no longer only that. `/raise/data-room` mounts `AdvisorGrantSection` (`DataRoomPage.jsx:257`), a real per-scope grant control writing through `/api/advisor-grants` over migration 218 — a founder names an advisor and ticks which of three scopes they may read. That is the permission matrix this row asked for, for advisors rather than for investors. See D50 and D55. **Pitch** adds per-investor share analytics incl. which slide people stop on (`/decks/:id/engagement` gives per-token views + read_seconds only, no per-slide dwell). **Capital** adds scenario modelling, the 409A record and an exit waterfall. **Legal** adds an agreement library + signature archive. | | Pages · Founder Research | founder | `/research/{ask,markets,companies,funds,library}` | `/research/ask` → `ResearchWorkspace` → `AskZone`; `/research/library` → `LibraryZone` | `research.ts` owned Ask/Library routes (migrations 213, 221, 385); `/research/collections*` → `research_collections.ts` (385); current Markets/Companies/Funds directory routes | CURRENT | D549: Ask keeps saved/discarded disposition and an optional what-you-did note through the existing answer PATCH; Discarded filters the caller’s whole history. New citations freeze an owned Library document UID after a D1 ownership check; old citations remain plain text. Library uploads accept Report, Primary and Legal, reads/edits title, kind and nullable source year (Not recorded when absent), and counts Primary from its kind. Owned named collections list/create/rename/delete and add/remove owned documents; collection filters and the bound New collection op are live. Shared client files stay read-only, outside collections and outside Ask’s namespace. Empty and Unreadable states are distinct. Cited in deck draws nothing; Clear history remains refused. A source year alone does not establish stale status; the Stale chip remains disabled with that reason. No per-document Q&A count or cached-cost fixture is invented. | | Pages · Investor Deals | investor-LP | `/deals`, `/pipeline/screening`, `/pipeline/commit`, `/pipeline/transactions` (existing) | `/deals` → `InvestorDealsRoutes` for investors (the ID1–ID4 zone pages plus the bucket board), `DealsPage` for partner/admin; `/pipeline` keeps `PipelineWorkspace` for founders; `/pipeline/screening\|commit\|transactions` redirect to `/deals/screening\|commit\|closing` (D119) | `deals.ts`, `pipeline.ts`, `scoring.ts`, `ic.ts`, `votes.ts`, `dd.ts`, `esign.ts`, `legalcap.ts` | UPGRADE | **Pipeline** adds configurable stages, saved views, velocity, an SLA stale/overdue flag, unassigned-owner surfacing, deal source (incl. Spin-Out Lab) and a passed-deal ledger with a reason taxonomy. **Screening** adds batch runs, rubric configuration and red-flag rules (today `scoring.ts` scores one project at a time with no editable rubric). **Commit** adds quorum arithmetic, conflict/recusal declarations, allocation math and minutes — `ic.ts` records only vote + rationale. **Closing** adds checklist templates, a wire trail and a signature archive. Note the canvas's `/deals/pipeline` etc. would collide with the live `/deals/:dealId` param route. **UPDATE 2026-09-10 (Task #149, canvas ID1):** `/deals/pipeline` is the artboard now, in its own file (`pages/investor/deals/PipelineZone.jsx`) registered in `ZONES` in `InvestorDealsRoutes.jsx`; ID2–ID4 join it there as they land, and a slug with no file still falls through to `InvestorDealsWorkspace`. The route collision the row above warned about did not happen — `/deals/pipeline` and `/deals/:dealId` are both registered and React Router prefers the literal. **What replaced what:** five columns of two-fact cards became the artboard's six-column instrument — Company · Sector · Stage · Ask · In stage · Owner — because the two facts the cards could not carry, how long a deal has sat and who owns it, are the two the artboard's note is entirely about. The strip is four-up over the record: live deals with the total ask, deals past the SLA, unassigned, and on-platform. **The stage vocabulary moved to `lib/dealFlow.js`** (`DEAL_STAGES`, `dealStage`, `dealMoneyExact`) so ID2–ID4 cannot disagree with ID1 about where a deal is; `deals.status` is a CHECKed set of five and the bucket's five stages are not the same five, and `rejected` maps to NO stage at all — a passed deal used to fall through to Commit and sit in the funnel as live. The SLA bands are `slaPreset('standard')`, which is the artboard's own 14/30, read rather than retyped so the strip, the chip row and the AI band cannot drift apart. **Two figures are deliberately not the artboard's.** Its fourth tile counts deals `From the Lab`, and no sourcing channel is stored on a deal — `deals` has no `source` column and the Lab's tables attach to people and applications, so the tile counts on-platform deals instead and the gap is stated in the zone's limits (D56: a tile reading Not recorded because the PRODUCT has no store is design commentary on a customer's screen). And the total ask SKIPS a deal with no recorded ask rather than summing it as zero, and says how many it excluded — arithmetically identical, and the difference between a board-wide figure and one that quietly covers four deals of six. **The AI band is real**: `deals/pipeline` is allow-listed in `routes/research.ts` and gathers deals past the same 14 days, instructed to give two options and no third — assign an owner, or pass and record the reason — and to say first when a deal has no owner at all. Guard: `frontend/test/investor_deals_id1.test.mjs`, which drives `dealStage`/`slaBand` as behaviour and reads the artboard's columns, grid and thresholds out of the canvas. **UPDATE 2026-09-10 (Task #149, canvas ID2):** `/deals/screening` is the artboard now (`pages/investor/deals/ScreeningZone.jsx`), over a new `GET /api/deals/screening` registered before `/:id` and gated on a privileged role. **The finding that shaped it: two of this zone's `unbuilt` reasons were false.** `investorZoneActions` said "no scoring run is stored" and "no rubric is stored"; `score_snapshots` carries SIX dimensions per snapshot — market, team, product, capital, fit, distribution — each with sub-scores and a total beside `total_score` and `tier`, which is exactly the artboard's "Rubric · 6 dims", plus `admin_review_status` and `anomaly_flags`, which is a red-flag store under another name. `POST /api/scoring/score` writes one and locks it for the week. Both reasons are corrected to the narrower true ones: a run is started against ONE project and nothing batches it, and the WEIGHTS are fixed in `services/scoring.ts` with no per-firm store to edit. An unbuilt reason that overstates the gap is as misleading as a control that does nothing — it tells the next reader not to look, which is why the zone had rendered "No deals are currently in screening or diligence" over a live store. **`deals/screening` also left the `excluded` list in `profile_zone_filters.test.mjs`**, which had deferred it for exactly this body work; all four of its chips are live keys (Scored, Rubric, Red flags, Pass reasons) and the investor profile is 17 zones. **A SANDBOX SNAPSHOT IS NOT A SCREENING RESULT** — `is_sandbox` marks a practice run and `official_week` is NULL for those, so they are excluded from every count and the exclusion is printed rather than silent; a rehearsal that moved the desk's numbers would make the numbers untrustworthy. A deal with no snapshot renders unrecorded, never zero. The instrument is the artboard's own — "Pass reasons — the fund's memory" over `GET /api/deals/pass-analytics`, read as a SEPARATE call so one store failing costs its own zone; exactly two of the five taxonomy reasons carry a re-entry condition and the note counts them rather than asserting a number. **The AI band** (`deals/screening`, allow-listed in `research.ts`) drafts a memo against the six dimensions and is instructed never to score a silent dimension zero — a zero is a verdict nobody gave — and never to cite a page or slide, since nothing records which part of a deck a sub-score came from. **Still not built, and stated:** batching, editable weights, and red-flag RULES a person can write (`detectAnomalies` produces flags from fixed heuristics). Guard: `frontend/test/investor_deals_id2.test.mjs`, which verifies the six-dimension claim against the baseline DDL rather than repeating it. **UPDATE 2026-09-10 (Task #149, canvas ID3):** `/deals/commit` is the artboard now (`pages/investor/deals/CommitZone.jsx`), over a new `GET /api/ic/commit-room` registered ahead of `/:uid` so the literal is not read as a uid. **The finding, and it is the same defect ID2 found one zone earlier:** `investorZoneActions` marked `Close vote` unbuilt because "no vote is opened here, so none can be closed", and the file it was written about disproves it in two lines — `POST /api/ic/:uid/vote` moves a decision `draft` → `voting` on the first vote cast, and `PUT /api/ic/:uid` with a `decision` forces `decided` and stamps `decided_at`. Both transitions are stored and served; what is missing is a SCREEN, which is the narrower true claim and the idiom this table already used four rows down ("served by the API; no screen offers the form yet"). The panel it replaced was three DEAL columns — status, total committed, target — under a heading reading "Commit room", and never read `ic_decisions` at all. **RECUSAL IS THE LINE THE PAGE WILL NOT CROSS, and this row's own summary above is what made it tempting:** it says `ic.ts` records only vote + rationale, and that is exactly right — `ic_votes.vote` is `yes | no | abstain`. The ID3 artboard's instrument note is entirely about a partner who RECUSED and was therefore excluded from the denominator, and an abstention is not that: an abstention is a vote cast, the voter counted and declined; a recusal is a declared conflict. Mapping one onto the other would put a false statement about a conflict of interest on a fund's screen, so no vote renders as recused, no denominator is reduced, and the AI instruction forbids the word outright — that surface is the one most likely to reach for it, because every IC memo ever written says it. Conditions, quorum and minutes are absent for the same kind of reason and are returned as explicit unavailable-with-reason rather than omitted. `ic_meetings`/`ic_meeting_attendees` ARE live and give an invited roster with an RSVP joined to a deal, so the artboard's `Eligible voters` tile becomes `In the room` — an invitation is not a voting entitlement, and labelling it as one would invent a governance rule the product does not enforce. **The rationale tile checks the artboard rather than repeating it:** the artboard says `Rationale required`; the column is nullable and the vote endpoint accepts a vote without one, so the tile counts how many votes carry a reason and says the requirement is not enforced. **One scoped read, and only one.** `routes/ic.ts` exports `scopedDecisions`, and both readers — the commit-room route and the `deals/commit` AI gather — go through it, so the predicate migration 219 added exists in one place. That is also why the route lives in `ic.ts` rather than `deals.ts`: a commit-room read in a file that does not import `icDecisionScope` is how that hole gets reopened. `UNSCOPED_ROLES` is `{'admin'}`, so a hard-coded `role: 'admin'` in a scope call turns the predicate into `1=1` and puts every firm's deliberations into one caller's prompt — pinned by the guard. **`deals/commit` also left the `excluded` list in `profile_zone_filters.test.mjs`** (two live keys, two honest `unbuilt` reasons; the investor profile is 18 zones), and the stale line in that deferral saying `api.icList` runs unscoped is corrected in the same pass. Guard: `frontend/test/investor_deals_id3.test.mjs`, 41 mutants. **UPDATE 2026-09-10 (Task #149, canvas ID4 — the last of the four):** `/deals/closing` is the artboard now (`pages/investor/deals/ClosingZone.jsx`), and it added NO endpoint. That is the deliberate part: ID3 ended on the rule that a second copy of a scoped query is how a tenancy hole reopens, and everything this zone needs was already served and already scoped — `api.listDeals` plus `api.esignList`, whose route (`esign.get('/')`) opens its WHERE clause with `esignEnvelopeScope` unconditionally. The zone composes the two and joins on `deal_id` in the browser; the guard pins its API surface at exactly those two calls so a third fails the build. **The finding, which makes three of the four zones:** `Apply template` was marked unbuilt because "no closing templates are stored". `legal_templates` seeds 21 rows with `merge_fields`, `version` and `is_active`, three of them the closing paper this artboard names by name — `safe` (SAFE Agreement), `spa` (Stock Purchase Agreement), `subscription` (Subscription Agreement). The gap is one layer ABOVE the templates: nothing stores a closing CHECKLIST for one to be applied to. `Record wire` was true but imprecise — `capital_calls` carries an amount and a `paid_date`, but that is an LP paying INTO the fund, not the fund wiring OUT to a company, and the reason now says which direction is missing. **THE INSTRUMENT IS DELIBERATELY NOT THE ARTBOARD'S.** ID4 draws a Closing checklist (Item / State / Owner / Note) and no store holds one. `dd_checklist_items` and `diligence_checklists` both belong to DILIGENCE; drawing them here would relabel diligence work as closing work on a document a fund hands to counsel, and D56 forbids drawing the table as rows of "Not recorded". So the instrument is the collection the stage really has — every envelope raised against a deal at closing, with its signature state — and the checklist's absence is stated. **Executed means `status = 'completed'` and nothing else:** a signature count reaching its recipient count is a different fact, and an envelope with NO recipients satisfies that ratio trivially, so it renders unrecorded rather than complete. **Blocking is unbuilt on both sides:** the artboard's note says its blocking item "arrived from the Commit vote", and ID3 established no condition is stored, so no row is drawn as gating a transfer. **The AI band** is instructed never to name a condition (an invented legal impediment in a note to counsel is worse than an omission) and to say plainly that the platform records the movement of money rather than moving it. **THE BUCKET IS COMPLETE, and the workspace is nearly empty.** All four decision panels are gone from `InvestorDealsWorkspace.jsx`, which now draws only the deal-invitation queue; the shaping it did for them — a stage ladder, an SLA band, a money format — went with the last consumer, since the deals array is now read for its LENGTH alone. What it knew lives in `lib/dealFlow.js`, in the version that fixed the defect the old ladder had. `deals/closing` also left the `excluded` list in `profile_zone_filters.test.mjs`, **which is now EMPTY** — every route on all five investor canvases has a filter row, 19 zones. Guard: `frontend/test/investor_deals_id4.test.mjs`, 35 mutants. | | Pages · Investor Fund | investor-LP | `/funds`, `/funds/calls`, `/funds/accounting`, `/funds/ledger`, `/lp-reports` | `FundOpsWorkspace`, `InvestorFundAccounting`, `LPReportingPage` | `funds.ts`, `fundRollup.ts`, `lp_reports.ts`, `capital.ts`, `kyc.ts` | PARTIAL | D550: GP/admin-scoped analytics reads capital-call lines and migration 312 receipts independently, returning called, collected and outstanding per fund and LP. Accounting shows actual collected receipts and derives dry powder from collected less deployed. Missing receipts are Not recorded with a reason; failed ledger reads are Unreadable. Calls remain D371. NAV, RVPI, TVPI, IRR, fees, journal and close remain unrecorded pending #1138. | @@ -504,6 +504,46 @@ Those bodies take the row as a RENDER PROP called with the rows they loaded handed and learns nothing about licences. `/signals`, `/build/competitors`, `/relationships` and `/network` for an operator pass nothing and get nothing. +## The Workspaces launcher's 28 pages, placed in the profile sidebars (D563) + +The admin shells' Workspaces launcher (D284) is gone. Each of its 28 pages +keeps its route and now lights the profile sidebar row that owns it, and that +row's overview links it under "Also in". The mapping is `PAGE_HOMES` in +`frontend/src/lib/adminPlacement.js`; `frontend/test/workspaces_retired_d563.test.mjs` +checks each line against the route's guard and the row's `match`. An admin +reaches them through View As and the command palette. + +| Page | Guard (App.jsx) | Home | How | +| --- | --- | --- | --- | +| `/pipeline` | admin, founder, partner, investor | partner Pipeline | the row itself | +| `/scoring` | Lab + admin, partner, investor | investor Deals | `match` + Also in | +| `/portfolio/risk-matrix` | admin, partner, investor | investor Portfolio | already under `/portfolio`; Also in | +| `/market-intel` | Lab + admin, partner, investor, advisor | partner and advisor Research | `match` + Also in | +| `/signals` | admin, founder, partner, investor, advisor | partner and advisor Research | `match` + Also in | +| `/advisory` | admin, founder | founder Validate | `match` + Also in | +| `/deals` | admin, partner, investor | investor Deals | the row itself | +| `/capital` | admin, investor | investor Portfolio | `match` + Also in | +| `/liquidity` | admin, founder, partner, investor | investor Portfolio | `match` + Also in. **A founder is redirected** to `/raise/liquidity?mode=workspace`, the same `LiquidityPage` inside Raise | +| `/portfolio/health` | admin, founder, partner, investor | investor Portfolio | the overview's alias | +| `/portfolio/coverage` | admin, partner | partner Delivery | `match` + Also in | +| `/portfolio/reserves` | admin, investor | investor Portfolio | already under `/portfolio`; Also in | +| `/portfolio/waterfall` | admin, investor | investor Portfolio | already under `/portfolio`; Also in | +| `/watchlist` | admin, partner, investor | investor Portfolio | `match` + Also in | +| `/legal-capital` | admin, founder, partner, investor | founder Raise | `match` + Also in | +| `/incorporate` | Lab + admin, founder, partner, investor | founder Raise | `match` + Also in | +| `/compliance` | Lab + admin, founder, partner | founder Raise | `match` + Also in | +| `/network` | admin, founder, partner, investor, advisor | every profile's Network | the row itself | +| `/network-effects` | admin, founder, partner, investor | founder Grow | `match` + Also in | +| `/my/jobs` | admin, founder, partner, investor, advisor; a founder is redirected (unchanged) | partner and advisor Network | `match` + Also in | +| `/services` | admin, founder, partner, investor; a founder is redirected (unchanged) | partner Offers | `match` + Also in | +| `/needs` | admin, founder, partner, investor; a founder is redirected (unchanged) | partner Pipeline | `match` + Also in | +| `/partner/insights` | admin, partner, investor | partner Pipeline | `match` + Also in | +| `/partner/office-hours` | admin, partner | partner Offers | `match` + Also in | +| `/comarketing` | admin, partner, founder, investor | partner Offers; founder Grow | `match` + Also in | +| `/incorporate/cofounder-agreement` | Lab + admin, founder, partner | founder Raise | under `/incorporate`; Also in | +| `/spinout-lab/83b` | Lab + admin | founder Spin-Out Lab | `match`; the Lab's arsenal links it | +| `/perks` | admin, founder, partner, investor, advisor, exploring | founder Grow | `match` + Also in; a partner is redirected to `/offers/perk-deals` (unchanged) | + ## The four profiles, totalled | Profile | Zones | Actions | Exports | Links | Stated gaps | diff --git a/documentation/architecture/decisions/D563.md b/documentation/architecture/decisions/D563.md new file mode 100644 index 0000000000..568769a75c --- /dev/null +++ b/documentation/architecture/decisions/D563.md @@ -0,0 +1,106 @@ +## D563 — The Workspaces launcher is retired; its 28 pages light the profile sidebar rows that own them + +**Issue:** #1164. **Decided by:** S1, option A, at the owner's request +(2026-10-06). + +### What was decided + +The Workspaces launcher (D284, "S23 · H37"), a top-bar button that only +the HQ and Admin shells drew, is removed. It was the only way into most +of its 28 pages: 24 of them were in no profile's sidebar. + +The profile sidebars keep the rows their approved canvases draw. No row +is added, and no row or zone count moves. Instead: + +- **Each kept page joins the `match` of the row that owns it**, so it + lights that row. +- **The row's overview links the page** in an "Also in " list + (`workspaces/AlsoIn.jsx`). +- **On an admin shell, the command palette still indexes all 28.** + Admins reach the profile pages through View As and the palette; their + own sidebar stays the admin consoles. +- **Every page keeps its route.** + +The mapping is data: `PAGE_HOMES` in `lib/adminPlacement.js`, one line +per page. Each line names `[role, row, door]`: + +- `row`: the page is the row's own destination (`/pipeline`, `/deals`, + `/network`), or its overview alias (`/portfolio/health`). +- `also`: the row's `match` lists the page, and its overview draws the + "Also in" link. +- `lab`: `/spinout-lab/83b`. The founder's Spin-Out Lab row matches it, + and the Lab's own arsenal (`lib/spinoutLabArsenal.js`) links it. + +The full table, with each route's guard, is in `ROUTE_MAP.md`, in the +section "The Workspaces launcher's 28 pages". + +### Where the code disagreed with the starting table + +- **`/scoring`** does not admit founders. Its guard is + `labRoles(['admin', 'partner', 'investor'])`. It lives on investor + Deals (diligence), not on founder Validate. +- **`/advisory` keeps its route.** It is an 800-line page (advisor + directory, financial planner, diligence checker) that founder Validate + already tabs and links, so redirecting it would have removed working + features, which is outside this issue's navigation-only scope. The + voice-rule problem was the launcher's label, "AI Advisory Suite". The + palette now labels the page with its own heading, "Advisory". +- **`/spinout-lab/83b` is already in the Lab's tree.** The arsenal links + it and `/incorporate/83b` redirects to it, so there is nothing to + redirect it to. It now lights the founder's Spin-Out Lab row. +- **`/compliance` is not a second filings calendar.** It is the founder's + company compliance calendar. D417 (#1002, PR #1127) stores fund-entity + obligations, and the only UI it has is HQ · Funds' deadline flag. The + page stays, under founder Raise ("stay legal"). +- **`/network-effects` stays on founder Grow**, which already matched and + tabbed it. Admin Insights (#1155) is S08's page. + +### The one redirect + +A founder on `/liquidity` is sent to `/raise/liquidity?mode=workspace`. +That address renders the same `LiquidityPage` inside the Raise shell, +and it is the Raise · Liquidity zone that `shellConfig.js` records as +`/liquidity`'s successor. + +Partners and investors still land on `/liquidity`. For investors it +lights Portfolio. + +To match, founder Raise drops `/liquidity` from its `match`, because no +founder renders that path any more (D505). The Raise tab bar lights +Liquidity on both addresses. `FounderWorkspaceTabs` now passes a tab's +`match` through to `WorkspaceTabs`; before this change it dropped it. + +### Also changed + +- **`adminPlacement.js`:** + - the tier and form `launcher` are now `palette`; + - `WORKSPACE_GROUPS`, which only the launcher read, is removed; + - `WORKSPACES` stays for the palette. +- **The overviews that draw the list:** + - `BucketBoard` and `BucketOverview`, for every bucket root; + - `FounderValidatePage`, `FounderRaiseDesk` and `FounderGrowDesk`; + - `InvestorPortfolioCanvas`. + +### Not touched + +- The rail frame on these pages belongs to #1156 (S09). +- The admin pages' content belongs to #1155 (S08). +- Pages that redirect a founder (`/my/jobs`, `/services`, `/needs`) keep + their redirects. +- No migration. + +### Verified + +`frontend/test/workspaces_retired_d563.test.mjs` checks: + +- the 28 pages, each with a home; +- each home is a role the route's guard admits; +- each kept page lights its row, by that row's `match`; +- no page lights two rows of one profile; +- each "also" page is linked from its row's overview; +- the Lab page is in the arsenal; +- the founder redirect; +- that no launcher remains. + +Each of 24 mutations makes it fail. The D284 tests are updated to the +palette-only state. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f20d8a640a..ea1ed1dd5b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -52,11 +52,10 @@ import BranchNotDeployedBar from './components/BranchNotDeployedBar'; import MobileTabBar from './components/MobileTabBar'; import ImpersonationBar from './components/ImpersonationBar'; import useBranchDeployment from './hooks/useBranchDeployment'; -import WorkspacesLauncher from './components/WorkspacesLauncher'; import { LensProvider, useLensLocation } from './workspaces/lens'; import HqSubNavStrip, { StripLink } from './components/HqSubNavStrip'; import { RailStripSlot } from './ui/RailFrame'; -import { ADMIN_SHELLS, MESSAGES_ROLES } from './lib/paletteIndex'; +import { MESSAGES_ROLES } from './lib/paletteIndex'; import { stripFor } from './lib/hqStrips'; import { clearSupportSession } from './lib/supportSession'; import { disablePush } from './lib/pwa'; @@ -1262,11 +1261,10 @@ function ProtectedLayout({ children, user, onLogout, viewMode, onViewModeChange, Messages )} - {/* S23 · H37 — the 29 working pages, one launcher on the HQ and - Admin shells. Not on the branch shell: S7–S19 draw none, and - the pages are the studio's own working surfaces, not a - branch's consoles. */} - {ADMIN_SHELLS.includes(shellRole) && } + {/* D563 — no Workspaces launcher. Its 28 pages each light the + profile sidebar row that owns them and are linked from that + row's overview ("Also in"); an admin reaches them through + View As and the command palette. */} }> @@ -3001,7 +2999,10 @@ function AppInner() { ))} /> )} /> )} /> - ))} /> + {/* D563: a founder's /liquidity was the same LiquidityPage that + /raise/liquidity?mode=workspace renders inside the Raise shell, so + the founder is sent there; a partner or investor still lands here. */} + : )} /> : )} /> } /> } /> diff --git a/frontend/src/components/WorkspacesLauncher.jsx b/frontend/src/components/WorkspacesLauncher.jsx deleted file mode 100644 index 3a2417b6c6..0000000000 --- a/frontend/src/components/WorkspacesLauncher.jsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * The Workspaces launcher — S23 · H37 (D284). - * - * One top-bar button on the HQ and Admin shells opening the 29 working pages - * in four groups. They are not admin consoles, and they no longer sit in the - * sidebar: a workspace page lights no row. The list is `WORKSPACE_GROUPS`, - * shared with the command palette; the count is computed from it. - * - * Three of the pages redirect a FOUNDER to a founder page (App.jsx: /my/jobs, - * /services, /needs, each `user?.role === 'founder' ? `). The - * canvas notes "Opens a founder page" on them; an admin is not redirected, so - * the note is not drawn here — it would be false for the only people who see - * this launcher. - */ -import React, { useEffect, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { LayoutGrid, ChevronDown } from 'lucide-react'; -import { WORKSPACES, WORKSPACE_GROUPS } from '../lib/adminPlacement.js'; - -export default function WorkspacesLauncher() { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; - const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; - document.addEventListener('mousedown', onDoc); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDoc); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - - return ( -
- - {open && ( -
-
-
Workspaces
-
- {WORKSPACES.length} pages · not admin consoles -
-
-
- {WORKSPACE_GROUPS.map((g) => ( -
-
- {g.group} · {g.count} -
-
- {g.items.map((w) => ( - setOpen(false)} - className="block px-2 py-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800" - > -
- {w.label} - {w.route} -
-
{w.description}
- - ))} -
-
- ))} -
-
- )} -
- ); -} diff --git a/frontend/src/lib/README.md b/frontend/src/lib/README.md index 8099e56a40..bc63f22c08 100644 --- a/frontend/src/lib/README.md +++ b/frontend/src/lib/README.md @@ -5,7 +5,7 @@ The API client, formatters and helpers. Nothing here renders. | File | What it is | | --- | --- | | `absence.js` | `NOT_RECORDED`, `text`, `titleCase` — how a string says the store has nothing. The string-level half of `../ui/Honesty.jsx`'s rule. `titleCase` deliberately takes no fallback: one that did re-cased its own sentence and shipped "Stage Not Recorded". | -| `adminPlacement.js` | `ADMIN_PLACEMENT`, `UNPLACED`, `placedOn`, `routePath` — the H35 placement map as data (D283): one entry per row of the old 50-row admin sidebar, per Admin Console tab and for the parked X row, each with its tier (HQ · Admin · launcher · parked), its row, how it appears there and whether App.jsx wraps it in `hqOnly(`. Wellbeing is on Admin · Community and Trash is a literal link on the Admin Console, both by decision; the Admin Console itself is the one recorded exception. Pure data — it imports nothing — so `../../test/admin_placement_h35.test.mjs` can hold it to App.jsx and to `ADMIN_SECTIONS` directly. | +| `adminPlacement.js` | `ADMIN_PLACEMENT`, `UNPLACED`, `placedOn`, `routePath` — the H35 placement map as data (D283): one entry per row of the old 50-row admin sidebar, per Admin Console tab and for the parked X row, each with its tier (HQ · Admin · palette · parked; the 28 `palette` pages were the Workspaces launcher's until D563), its row, how it appears there and whether App.jsx wraps it in `hqOnly(`. Wellbeing is on Admin · Community and Trash is a literal link on the Admin Console, both by decision; the Admin Console itself is the one recorded exception. Pure data — it imports nothing — so `../../test/admin_placement_h35.test.mjs` can hold it to App.jsx and to `ADMIN_SECTIONS` directly. It also holds `PAGE_HOMES` and `alsoInFor`: the profile sidebar row each of the 28 pages lights and is linked from (D563). | | `branchNotDeployed.js` | `notDeployedStrip`, `brandOf`, `NOT_DEPLOYED_STEPS`, `NOT_DEPLOYED_RULES`, `NOT_DEPLOYED_SUBLINE`, `NO_STEP_TIME_REASON`, `UNREADABLE` — what S21's strip says for a licence administrator whose branch is not deployed yet (D287), decided from the `deployment` field the HQ arm of `/api/licence/mine` answers. Re-derives nothing the worker decided: `requested` is whether a row exists, `live` is the worker's one rule, `readable` is whether the read succeeded; a failed read renders "Unreadable", never "not requested". No time is shown, because nothing records a step's time; the caption says why. | | `impersonationBar.js` | `REASON_KEY`, `NOT_RECORDED`, `SUPPORT_SESSION_MINUTES`, `EXTEND_LABEL`, `END_LABEL`, `storeReason`, `readStoredReason`, `clearStoredReason`, `whyValue`, `impersonationFields` — what the operator's impersonation bar says (D290, canvas H25): Who · As · Why · Limit in the artboard's order, and the one owner of the stored reason. The worker echoes the reason it stored on `impersonation_sessions` (or null when that write failed); `api.adminImpersonate` keeps the echo beside the expiry, `App.jsx` purges it on sign-out, End session and the hand-back, and a null one reads "Not recorded" — never a stand-in. `../components/ImpersonationBar.jsx` draws it above `PortalSwitcher`. | | `viewAsScope.js` | `VIEW_AS_SCOPE`, `viewAsScopeFor`, `viewAsSentence`, `NARROWED_CLAIM`, `declinesSentence`, `outOfScopeSentence` — which HQ routes the view-as scope actually reaches (D289): `/hq` and `/admin/accounts` narrow to the branch, `/admin/analytics` and `/admin/platform/topology` say they do not, and every other route gets the out-of-scope sentence with no per-page edit. `HqViewingAsBar` draws the sentence by pathname; the guard holds the registry equal, both ways, to the files that read `useViewAsBranch`. | diff --git a/frontend/src/lib/adminPlacement.js b/frontend/src/lib/adminPlacement.js index ea80bf7419..a3d53afee8 100644 --- a/frontend/src/lib/adminPlacement.js +++ b/frontend/src/lib/adminPlacement.js @@ -3,7 +3,7 @@ * * One entry per row of the old 50-row admin sidebar, per tab of the Admin * Console at /admin, and for the parked X row. Each says which tier and row - * it lives on and how it appears there. The launcher and the command palette + * it lives on and how it appears there. The command palette * (D284) and the HQ sub-navigation strips (D285) read this list; nothing else * decides where a console is reached from. * @@ -21,12 +21,13 @@ * Pure data: it imports nothing, so a Node test can import it directly. */ -export const TIERS = ['HQ', 'Admin', 'launcher', 'parked']; +export const TIERS = ['HQ', 'Admin', 'palette', 'parked']; // H35's three forms plus the three the canvas also draws: a lane on Approvals, -// a top-bar button, and the launcher. `row` is for the one entry that IS a +// a top-bar button, and the palette (D563 retired the launcher that was the +// sixth). `row` is for the one entry that IS a // shell row rather than something placed inside one. -export const FORMS = ['row', 'sub-nav', 'card link', 'embedded', 'lane', 'top bar', 'launcher']; +export const FORMS = ['row', 'sub-nav', 'card link', 'embedded', 'lane', 'top bar', 'palette']; // H35's note, verbatim: "Rows: exactly eleven, unchanged." export const HQ_ROWS = ['Home', 'Licences', 'Funds', 'Contracts', 'Team', 'Revenue', 'Content', 'Platform', 'Support', 'Security', 'Settings']; @@ -46,9 +47,9 @@ const row = (label, route, place, hqOnly, extra = {}) => ({ key: route, kind: 'r // An Admin Console tab. `key` is `tab:`; the route is the deep link. // /admin is `guard(['admin'])` with no elevation, so no tab is hqOnly. const tab = (value, label, place, extra = {}) => ({ key: `tab:${value}`, kind: 'tab', tabValue: value, label, route: `/admin?tab=${value}`, hqOnly: false, ...place, ...extra }); -// One of the 29 working pages: S23/H37's launcher, with the canvas's own +// One of the 28 working pages S23/H37 drew in a launcher, with the canvas's own // one-line description of what the page is. -const page = (group, label, route, what) => ({ key: route, kind: 'page', group, label, route, what, hqOnly: false, tier: 'launcher', row: null, form: 'launcher' }); +const page = (group, label, route, what) => ({ key: route, kind: 'page', group, label, route, what, hqOnly: false, tier: 'palette', row: null, form: 'palette' }); export const ADMIN_PLACEMENT = [ // ── Home group ────────────────────────────────────────────────────────── @@ -116,16 +117,20 @@ export const ADMIN_PLACEMENT = [ tab('network-profiles', 'Advisors & Partners', hq('Content', 'sub-nav'), { how: 'Sub-navigation item on Content (the deck roster); card link on Community.', also: [admin('Community', 'card link')] }), - // ── The 29 working pages → the Workspaces launcher (S23 · H37) ────────── - // "AI Advisory Suite" keeps its shipped label. H37 flags it — the voice - // rule forbids calling the AI an advisor — and the rename is the owner's - // call (D284); the launcher draws the label and no flag. + // ── The 28 working pages (S23 · H37) → the profile sidebars (D563) ────── + // H37 drew them in a Workspaces launcher on the admin shells; D563 retired + // it. Each now lights the profile row in `PAGE_HOMES` below and is linked + // from that row's overview; on an admin shell the palette indexes them. + // "/advisory" is labelled by its page's own heading, "Advisory": the + // launcher's "AI Advisory Suite" called the AI an advisor, which the voice + // rule forbids (H37 flagged it, D284 left it to the owner, D563 drops it + // with the launcher). page('Studio', 'Pipeline Board', '/pipeline', 'Deals by stage on one board.'), page('Studio', 'Scoring Engine', '/scoring', 'Venture-readiness scores and the evidence behind them.'), page('Studio', 'Risk Matrix', '/portfolio/risk-matrix', 'Portfolio companies by risk dimension.'), page('Studio', 'Market Intelligence', '/market-intel', 'Market readings with their sources.'), page('Studio', 'Signals', '/signals', 'External signals, grouped by theme.'), - page('Studio', 'AI Advisory Suite', '/advisory', 'AI-assisted analysis tools.'), + page('Studio', 'Advisory', '/advisory', 'Advisors, financial planning and diligence checks.'), page('Studio', 'Deal Flow', '/deals', 'Incoming deals and their status.'), page('Capital & Legal', 'Capital & Investment', '/capital', 'Rounds, commitments and instruments.'), page('Capital & Legal', 'Liquidity & Exits', '/liquidity', 'Secondaries and recorded exits.'), @@ -150,22 +155,74 @@ export const ADMIN_PLACEMENT = [ page('More', 'Perks', '/perks', 'Partner perks and claims.'), ]; -// S23 / H37's launcher groups, in the canvas's order. +// S23 / H37's groups, in the canvas's order. The palette's hint names them. export const WORKSPACE_GROUP_ORDER = ['Studio', 'Capital & Legal', 'Network & Growth', 'More']; /** - * The 29 working pages, the one list the Workspaces launcher and the command - * palette both read (D284). Derived from the map, never typed twice. + * The 28 working pages, the list the command palette reads for an admin shell + * (D284). Derived from the map, never typed twice. */ export const WORKSPACES = ADMIN_PLACEMENT - .filter((e) => e.tier === 'launcher') + .filter((e) => e.tier === 'palette') .map(({ group, label, route, what }) => ({ group, label, route, description: what })); -/** The same 29, grouped for rendering. `count` is computed, never typed. */ -export const WORKSPACE_GROUPS = WORKSPACE_GROUP_ORDER.map((group) => { - const items = WORKSPACES.filter((w) => w.group === group); - return { group, items, count: items.length }; -}); +/** + * Where each of the 28 lives in the profile shells (D563). One entry per page: + * `[role, row, door]`, where `row` is the profile sidebar row's label and + * `door` says how the page is reached from that row: + * + * 'row' — the page IS the row's own destination (or its overview alias); + * 'also' — the row's `match` lists it, and the row's overview draws it in + * its "Also in" list (`workspaces/AlsoIn.jsx`); + * 'lab' — a Spin-Out Lab page: the row's `match` lists it and the Lab's + * own arsenal (`lib/spinoutLabArsenal.js`) links it. + * + * Only roles the page's route guard admits are named, and a page has at most + * one home per role, which is the sidebar's own rule ("items must not appear + * in more than one group within a role"). `frontend/test/ + * workspaces_retired_d563.test.mjs` checks every line against App.jsx and + * `sidebarConfig.js`. + * + * `/liquidity` is the one page that redirects, and only a founder: its + * founder page is the Raise · Liquidity zone (`/raise/liquidity`, the same + * `LiquidityPage` in workspace mode). A partner or investor still lands on it. + */ +export const PAGE_HOMES = { + '/pipeline': [['partner', 'Pipeline', 'row']], + '/scoring': [['investor', 'Deals', 'also']], + '/portfolio/risk-matrix': [['investor', 'Portfolio', 'also']], + '/market-intel': [['partner', 'Research', 'also'], ['advisor', 'Research', 'also']], + '/signals': [['partner', 'Research', 'also'], ['advisor', 'Research', 'also']], + '/advisory': [['founder', 'Validate', 'also']], + '/deals': [['investor', 'Deals', 'row']], + '/capital': [['investor', 'Portfolio', 'also']], + '/liquidity': [['investor', 'Portfolio', 'also']], + '/portfolio/health': [['investor', 'Portfolio', 'row']], + '/portfolio/coverage': [['partner', 'Delivery', 'also']], + '/portfolio/reserves': [['investor', 'Portfolio', 'also']], + '/portfolio/waterfall': [['investor', 'Portfolio', 'also']], + '/watchlist': [['investor', 'Portfolio', 'also']], + '/legal-capital': [['founder', 'Raise', 'also']], + '/incorporate': [['founder', 'Raise', 'also']], + '/compliance': [['founder', 'Raise', 'also']], + '/network': [['founder', 'Network', 'row'], ['investor', 'Network', 'row'], ['partner', 'Network', 'row'], ['advisor', 'Network', 'row']], + '/network-effects': [['founder', 'Grow', 'also']], + '/my/jobs': [['partner', 'Network', 'also'], ['advisor', 'Network', 'also']], + '/services': [['partner', 'Offers', 'also']], + '/needs': [['partner', 'Pipeline', 'also']], + '/partner/insights': [['partner', 'Pipeline', 'also']], + '/partner/office-hours': [['partner', 'Offers', 'also']], + '/comarketing': [['partner', 'Offers', 'also'], ['founder', 'Grow', 'also']], + '/incorporate/cofounder-agreement': [['founder', 'Raise', 'also']], + '/spinout-lab/83b': [['founder', 'Spin-Out Lab', 'lab']], + '/perks': [['founder', 'Grow', 'also']], +}; + +/** The pages a profile row's overview lists under "Also in", in map order. */ +export function alsoInFor(role, row) { + return WORKSPACES.filter((w) => (PAGE_HOMES[w.route] || []) + .some(([r, label, door]) => r === role && label === row && door === 'also')); +} // Legacy entries with no placement of their own, each with its reason. The // guard pins this list by value, so it cannot grow quietly. diff --git a/frontend/src/lib/paletteIndex.js b/frontend/src/lib/paletteIndex.js index a5e1303d7a..a3df56ce49 100644 --- a/frontend/src/lib/paletteIndex.js +++ b/frontend/src/lib/paletteIndex.js @@ -2,7 +2,7 @@ * What the command palette indexes, decided in one pure place (D284). * * For an admin shell — `admin` or `super_admin` — the palette indexes the - * shell's own rows, the consoles the H35 map places, and the 29 working + * shell's own rows, the consoles the H35 map places, and the 28 working * pages. It never offers an `hqOnly` route to someone without the elevation, * never indexes the parked X, and every entry carries its real route. The * other shells keep indexing their own sidebar, as they did before. diff --git a/frontend/src/pages/founder/FounderGrowDesk.jsx b/frontend/src/pages/founder/FounderGrowDesk.jsx index 4e87afb3b7..8c013ba18f 100644 --- a/frontend/src/pages/founder/FounderGrowDesk.jsx +++ b/frontend/src/pages/founder/FounderGrowDesk.jsx @@ -11,6 +11,7 @@ import { ASSIST_SURFACES } from '../../ui/eadwynConfig'; import { zonePillClass } from './deskZoneNav'; import './founderGrowDesk.css'; import { LensNavLink } from '../../workspaces/lens'; +import AlsoIn from '../../workspaces/AlsoIn'; // Seven labels, seven routes — A5's `anchG`, in its order. This was a // seven-deep ternary chain ending in an `` fallback that no @@ -223,6 +224,7 @@ export default function FounderGrowDesk() { {error &&
{error}
} + ` fallback no label could @@ -138,6 +139,7 @@ export default function FounderRaiseDesk() { {(projectError || Object.keys(errors).length > 0) &&
{projectError || 'Some selected-project records are unavailable.'}
} + {state === 'error' &&
{error}
} setReloadKey((value) => value + 1)} featured={featured} interviews={interviews} evidence={evidence} signals={signals} dateFormat={dateFormat} stageLinks={stageLinks} workspaceNavigationState={workspaceNavigationState} bar={board?.bar} /> + - {tabs.length > 1 && ({ to, label, icon }))} />} + {tabs.length > 1 && ({ to, label, icon, match }))} />} {children} ); diff --git a/frontend/src/pages/investor/InvestorPortfolioCanvas.jsx b/frontend/src/pages/investor/InvestorPortfolioCanvas.jsx index 6be7ee8047..e3f533eb71 100644 --- a/frontend/src/pages/investor/InvestorPortfolioCanvas.jsx +++ b/frontend/src/pages/investor/InvestorPortfolioCanvas.jsx @@ -4,6 +4,7 @@ import { Activity, ArrowUpRight, Inbox, RefreshCw, TrendingDown, TrendingUp, Use import { api } from '../../lib/api'; import { WorkerRail } from '../../ui'; import ZoneNav from '../../workspaces/ZoneNav'; +import AlsoIn from '../../workspaces/AlsoIn'; import { bucketForPath } from '../../workspaces/shellConfig'; import './investorPortfolioCanvas.css'; import { titleCase } from '../../lib/absence'; @@ -155,6 +156,7 @@ export default function InvestorPortfolioCanvas() { {state.analytics.mark_coverage != null ? ` · ${Math.round(state.analytics.mark_coverage * 100)}% mark coverage` : ''}. )} +
Investor workspace. Data shown is governed by your existing access and permissions.
{/* The zone row above covers Positions, Updates and Value-add. diff --git a/frontend/src/sidebarConfig.js b/frontend/src/sidebarConfig.js index 2d2fca0d8e..f8c235bb50 100644 --- a/frontend/src/sidebarConfig.js +++ b/frontend/src/sidebarConfig.js @@ -29,6 +29,13 @@ import { // resolve extensionless relative specifiers the way Vite does. import { allZoneRoutes, bucketsFor } from './workspaces/shellConfig.js'; +// D563 — the Lab page the retired Workspaces launcher listed, lit on the +// founder's Spin-Out Lab row (the Lab's members are founders). The row's `to` +// still matches only `/spinout-lab` itself, because a `match` list is the +// row's complete claim (SidebarNav), so no other Lab subtree changes light. +// The Lab's arsenal (`lib/spinoutLabArsenal.js`) is what links the page. +const SPINOUT_LAB_PAGES = ['/spinout-lab/83b']; + // Task #6 — Real subscription-tier check. Bypass roles // (admin/partner/investor/advisor) always pass; founders are gated by their // `subscription_tier` column. Mirrors the worker's `userMeetsTier` helper. @@ -306,7 +313,7 @@ export const SIDEBAR_GROUPS = { { key: 'home', label: '', items: [ { to: '/studio', icon: LayoutDashboard, label: 'Studio' }, // Spin-Out Lab keeps its own tree, untouched by the shell migration. - { to: '/spinout-lab', icon: Rocket, label: 'Spin-Out Lab' }, + { to: '/spinout-lab', icon: Rocket, label: 'Spin-Out Lab', match: SPINOUT_LAB_PAGES }, // Each row points at the workspace ROOT, which renders that workspace's // overview. Pointing a row at a section page is how the overviews were // lost: a rebuild took the desk's slot, the desk was re-mounted on @@ -316,7 +323,9 @@ export const SIDEBAR_GROUPS = { match: ['/validate', '/build/discovery', '/build/marketplace', '/needs', '/services', '/advisory'] }, { to: '/build', icon: Briefcase, label: 'Build', match: ['/build/this-week', '/build/board', '/build/roadmap', '/build/cadence', '/build/kpi', '/build/metrics', '/execution'] }, - { to: '/raise', icon: Sparkles, label: 'Raise', match: ['/raise', '/liquidity'] }, + // D563: the launcher's founder legal pages light Raise, and /liquidity + // left the list: a founder there is redirected to /raise/liquidity. + { to: '/raise', icon: Sparkles, label: 'Raise', match: ['/raise', '/legal-capital', '/incorporate', '/compliance'] }, { to: '/grow', icon: TrendingUp, label: 'Grow', match: ['/grow', '/build/team', '/advisors', '/cofounder', '/my/jobs', '/jobs', '/my/applications', '/build/brand', '/comarketing', '/perks', '/network-effects'] }, // Points at /network, not at the first zone: /network is the one route @@ -445,12 +454,12 @@ export const SIDEBAR_GROUPS = { // a `match` entry would light Delivery beside the footer's own row, two // rows for one page. The other successors sit under this row's root. { to: '/delivery', icon: Briefcase, label: 'Delivery', - match: ['/delivery', '/delivery/health'] }, + match: ['/delivery', '/delivery/health', '/portfolio/coverage'] }, { to: '/offers', icon: Package, label: 'Offers', match: ['/offers', '/services', '/perks', '/comarketing', '/partner/office-hours', '/offers/catalog'] }, { to: '/network', icon: Users, label: 'Network', - match: ['/network', '/relationships', '/contacts'] }, + match: ['/network', '/relationships', '/contacts', '/my/jobs'] }, { to: '/research', icon: Radar, label: 'Research', match: ['/research', '/signals', '/market-intel'] }, ]}, ], @@ -472,14 +481,15 @@ export const SIDEBAR_GROUPS = { // renders the sections. A row aimed at a zone skips the overview, which // is precisely how the founder overviews were lost the first time. { to: '/deals', icon: Handshake, label: 'Deals', - match: ['/deals', '/pipeline', '/raise/data-room'] }, + match: ['/deals', '/pipeline', '/raise/data-room', '/scoring'] }, // Positions is the canvas's word for the holdings view. /portfolio is the // overview; /portfolio/positions is the book; /portfolio/health is the // legacy alias that still renders the overview, and the generic tab bar // labels the book "Cap Table" — three names across two pages, and // settling that vocabulary is a content decision. What the row owes is // the root. - { to: '/portfolio', icon: Briefcase, label: 'Portfolio', match: ['/portfolio'] }, + // D563: the launcher's investor pages outside /portfolio light it too. + { to: '/portfolio', icon: Briefcase, label: 'Portfolio', match: ['/portfolio', '/watchlist', '/capital', '/liquidity'] }, { to: '/spinout-lab/investor-workspace', icon: Landmark, label: 'Axal VC Fund' }, { to: '/funds', icon: Wallet, label: 'Fund', match: ['/funds', '/lp-reports'], requiredInvestorTier: 'institutional' }, { to: '/network', icon: Handshake, label: 'Network', match: ['/network', '/relationships', '/contacts'] }, @@ -524,7 +534,7 @@ export const SIDEBAR_GROUPS = { { to: '/expertise', icon: UserCircle, label: 'Expertise', match: ['/expertise', '/advisors'] }, { to: '/network', icon: Users, label: 'Network', - match: ['/network', '/relationships', '/contacts', '/advisor/network'] }, + match: ['/network', '/relationships', '/contacts', '/advisor/network', '/my/jobs'] }, { to: '/research', icon: Radar, label: 'Research', match: ['/research', '/signals', '/market-intel'] }, // NO Trust row (it belongs to the user dropdown) and NO Practice Settings diff --git a/frontend/src/workspaces/AlsoIn.jsx b/frontend/src/workspaces/AlsoIn.jsx new file mode 100644 index 0000000000..286e43ff25 --- /dev/null +++ b/frontend/src/workspaces/AlsoIn.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { alsoInFor } from '../lib/adminPlacement.js'; + +/** + * "Also in " — the working pages a profile row owns beyond its zones + * (D563). The Workspaces launcher that listed them is retired; each now + * lights the row in `PAGE_HOMES` and is linked here, on that row's overview, + * so it is one click from the sidebar. The list is `alsoInFor(role, row)`, + * read from the map, never typed on the page. + * + * Renders nothing when the row owns no such page. + */ +export default function AlsoIn({ role, row, className = '' }) { + const pages = alsoInFor(role, row); + if (!pages.length) return null; + return ( + + ); +} diff --git a/frontend/src/workspaces/BucketBoard.jsx b/frontend/src/workspaces/BucketBoard.jsx index 8dd2b25ce3..5de5cb904e 100644 --- a/frontend/src/workspaces/BucketBoard.jsx +++ b/frontend/src/workspaces/BucketBoard.jsx @@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom'; import { Card, Skeleton } from '../ui'; import { accentLinkClass, zonePath } from './shellConfig'; import NoStoreYet from './NoStoreYet'; +import AlsoIn from './AlsoIn'; import useBucketSources from './useBucketSources'; /** @@ -79,7 +80,10 @@ export default function BucketBoard({ bucket, role = 'founder', board, className if (!bucket?.zones?.length || !board?.sections?.length) return null; const zoneBySlug = new Map(bucket.zones.map((z) => [z.slug, z])); + // D563: the row's working pages outside its zones sit under the board, as + // they do under BucketOverview's grid. return ( + <>
{board.sections.map((section) => { const zone = zoneBySlug.get(section.slug); @@ -103,6 +107,8 @@ export default function BucketBoard({ bucket, role = 'founder', board, className ); })}
+ + ); } diff --git a/frontend/src/workspaces/BucketOverview.jsx b/frontend/src/workspaces/BucketOverview.jsx index dd2508c97d..e41dedc0c6 100644 --- a/frontend/src/workspaces/BucketOverview.jsx +++ b/frontend/src/workspaces/BucketOverview.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { ACCENT, zonePath } from './shellConfig'; +import AlsoIn from './AlsoIn'; import './bucketOverview.css'; /** @@ -58,7 +59,9 @@ export default function BucketOverview({ }) { if (!bucket?.zones?.length) return null; const accent = ACCENT[role] || ACCENT.founder; + // D563: the row's working pages outside its zones sit under the grid. return ( + <>
+ + ); } diff --git a/frontend/src/workspaces/README.md b/frontend/src/workspaces/README.md index b902922022..02d0852ca3 100644 --- a/frontend/src/workspaces/README.md +++ b/frontend/src/workspaces/README.md @@ -24,6 +24,7 @@ modules that mount bodies into it. The pages themselves live in | `zoneFiltersByRole.js` | The dispatcher, mirroring `zoneActionsByRole.js`. An unknown role gets an empty list, never a default profile's: a founder's `Investors / Advisors` segmentation shown to an advisor would be a claim about how their relationships are classified, in the product's voice, over rows classified otherwise. | | `BucketOverview.jsx` | The body a bucket root renders when the canvas corpus draws **no root artboard** for it: the bucket's zones as cards. Advisor `/cohorts` and every founder and investor bucket. | | `BucketBoard.jsx` | The body a bucket root renders when the corpus **does** draw one: an h1, then one section per zone, each with a real count, a short table of real rows and a footnote. Partner ×5, advisor ×4. | +| `AlsoIn.jsx` | The "Also in " link list under a bucket root's board or cards, and on the founder Validate, Raise and Grow desks and the investor Portfolio canvas: the pages the row owns beyond its zones, read from `alsoInFor(role, row)` in `../lib/adminPlacement.js` (D563). Renders nothing for a row that owns none. | | `useBucketSources.js` | A board's parallel fetch, one state per source. It reads the endpoints the **zone pages already read**, so the overview's number is the zone's number by construction — and so the honest `*_note` beside a null figure travels with it instead of being re-copied. | | `NoStoreYet.jsx` | The honest state for a zone with no store: what it would hold, what would fill it, and the nearest live surface. Shared by the three route modules and by a gapped board section. | | `noStoreCopy.js` | The gap statements themselves, as plain data so `bucket_board.test.mjs` can load them in Node. A board section's `gap` is **imported from here**, never written inline — the same object the zone page renders — so a board can never be gentler about an absence than the page behind it. `BucketOverview` derives its "Not built" line from the same objects. | diff --git a/frontend/test/admin_placement_h35.test.mjs b/frontend/test/admin_placement_h35.test.mjs index 6886e9d958..01d5876d89 100644 --- a/frontend/test/admin_placement_h35.test.mjs +++ b/frontend/test/admin_placement_h35.test.mjs @@ -184,11 +184,12 @@ test('every placement names a real tier, a real row of that tier, and a real for assert.ok(TIERS.includes(p.tier), `${key}: tier ${p.tier}`); if (p.tier === 'HQ' || p.tier === 'Admin') { assert.ok(rowsOf[p.tier].includes(p.row), `${key}: ${p.tier} has no row ${p.row}`); - assert.ok(FORMS.includes(p.form) && p.form !== 'launcher', `${key}: form ${p.form} on a shell row`); + assert.ok(FORMS.includes(p.form) && p.form !== 'palette', `${key}: form ${p.form} on a shell row`); if (p.form === 'top bar') assert.equal(p.row, TOP_BAR, `${key}: a top-bar placement sits on the top bar, not a row`); else assert.notEqual(p.row, TOP_BAR, `${key}: the top bar takes buttons, not a ${p.form}`); } - if (p.tier === 'launcher') assert.deepEqual([p.row, p.form], [null, 'launcher'], `${key}: a launcher entry has no row`); + // D563 retired the launcher; its pages are indexed by the palette only. + if (p.tier === 'palette') assert.deepEqual([p.row, p.form], [null, 'palette'], `${key}: a palette-only entry has no row`); if (p.tier === 'parked') assert.deepEqual([p.row, p.form], [null, null], `${key}: a parked entry is nowhere`); }; for (const e of ADMIN_PLACEMENT) { @@ -197,10 +198,10 @@ test('every placement names a real tier, a real row of that tier, and a real for check(`${e.key} (also)`, a); assert.ok(a.tier === 'HQ' || a.tier === 'Admin', `${e.key}: a second placement is on a shell`); } - if (e.tier === 'launcher') assert.ok(e.group, `${e.key}: a launcher entry names its group`); + if (e.tier === 'palette') assert.ok(e.group, `${e.key}: a palette-only entry names its group`); } // S23 and H37 launched 29 pages; AI Matches (/matches) was deleted since. - assert.equal(ADMIN_PLACEMENT.filter((e) => e.tier === 'launcher').length, 28, 'S23 and H37 launch 28 pages once AI Matches is gone'); + assert.equal(ADMIN_PLACEMENT.filter((e) => e.tier === 'palette').length, 28, 'S23 and H37 drew 28 pages once AI Matches is gone'); }); test('the three decided placements hold: Wellbeing, X, Trash', () => { diff --git a/frontend/test/founder_shell.test.mjs b/frontend/test/founder_shell.test.mjs index 48c4f1747a..2e3feb2046 100644 --- a/frontend/test/founder_shell.test.mjs +++ b/frontend/test/founder_shell.test.mjs @@ -311,8 +311,9 @@ test('Home is /studio, no role root invented', () => { assert.ok(!targets.includes('/founder'), 'no bare /founder root'); }); -test('Spin-Out Lab keeps its row, untouched', () => { - assert.match(founder, /\{ to: '\/spinout-lab', icon: Rocket, label: 'Spin-Out Lab' \}/); +test('Spin-Out Lab keeps its row; its match adds only the 83(b) tracker (D563)', () => { + assert.match(founder, /\{ to: '\/spinout-lab', icon: Rocket, label: 'Spin-Out Lab', match: SPINOUT_LAB_PAGES \}/); + assert.match(src, /const SPINOUT_LAB_PAGES = \['\/spinout-lab\/83b'\];/); }); test('the Research row lands somewhere every founder can actually open', () => { diff --git a/frontend/test/investor_shell.test.mjs b/frontend/test/investor_shell.test.mjs index 32ca6c0b73..97fdb5ba07 100644 --- a/frontend/test/investor_shell.test.mjs +++ b/frontend/test/investor_shell.test.mjs @@ -103,7 +103,7 @@ test('legacy investor deep links still highlight their owning workspace', () => assert.ok(researchMatch, 'the Research row has no match array'); assert.ok(researchMatch[1].includes("'/market-intel'"), '/market-intel no longer highlights the Research row'); - assert.match(investor, /label: 'Portfolio', match: \['\/portfolio'\]/); + assert.match(investor, /label: 'Portfolio', match: \['\/portfolio', '\/watchlist', '\/capital', '\/liquidity'\]/); assert.match(investor, /label: 'Fund', match: \['\/funds', '\/lp-reports'\]/); assert.match(investor, /label: 'Network', match: \['\/network', '\/relationships', '\/contacts'\]/); }); diff --git a/frontend/test/workspaces_launcher_d284.test.mjs b/frontend/test/workspaces_launcher_d284.test.mjs index c8a7a5f143..49f984fa6a 100644 --- a/frontend/test/workspaces_launcher_d284.test.mjs +++ b/frontend/test/workspaces_launcher_d284.test.mjs @@ -1,38 +1,33 @@ /** - * One launcher, one Messages button, and a palette that indexes what the map - * places (D284, tasks 420 and 408). + * One Messages button, and a palette that indexes what the map places (D284, + * tasks 420 and 408), with the Workspaces launcher retired (D563). * - * The 29 working pages left `SIDEBAR_GROUPS.admin` for a top-bar launcher; - * Messages left it for a top-bar button on both admin shells; and ⌘K for an - * admin shell now indexes the shell's own rows, the consoles H35 places and - * the 29 — never the old 50-row sidebar, never an `hqOnly` route for someone - * without the elevation, never the parked X. The list the launcher and the - * palette both read is `WORKSPACES` in `lib/adminPlacement.js`; the palette's - * index is `lib/paletteIndex.js`, pure, so this file puts a holder, a plain - * admin and a founder through it rather than reading the component. - * - * CI runs no browser. A Chromium look at the launcher is a recorded - * verification in D284; the gate is here. + * The 29 working pages (28 since AI Matches went) left `SIDEBAR_GROUPS.admin` + * for a top-bar launcher (D284); D563 retired that launcher, and each page now + * lights a profile sidebar row (`workspaces_retired_d563.test.mjs`). Messages + * left the admin sidebar for a top-bar button on both admin shells; and ⌘K for + * an admin shell indexes the shell's own rows, the consoles H35 places and the + * 28 — never the old 50-row sidebar, never an `hqOnly` route for someone + * without the elevation, never the parked X. The list the palette reads is + * `WORKSPACES` in `lib/adminPlacement.js`; the palette's index is + * `lib/paletteIndex.js`, pure, so this file puts a holder, a plain admin and a + * founder through it rather than reading the component. */ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SIDEBAR_GROUPS } from '../src/sidebarConfig.js'; -import { WORKSPACES, WORKSPACE_GROUPS, WORKSPACE_GROUP_ORDER, ADMIN_PLACEMENT } from '../src/lib/adminPlacement.js'; +import { WORKSPACES, WORKSPACE_GROUP_ORDER, ADMIN_PLACEMENT } from '../src/lib/adminPlacement.js'; import { pageItemsFor, sidebarPageItems, groupByKind, KIND_ORDER, ADMIN_SHELLS, MESSAGES_ROLES, } from '../src/lib/paletteIndex.js'; -import { codeOnly } from './_codeOnly.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const read = (rel) => readFileSync(resolve(root, rel), 'utf8'); const APP = read('frontend/src/App.jsx'); -// The component's CODE, not its header comment, which is allowed to say -// "29" and to quote the canvas note it explains the absence of. -const LAUNCHER = codeOnly(read('frontend/src/components/WorkspacesLauncher.jsx')); const PALETTE = read('frontend/src/components/CommandPalette.jsx'); /** path → hqOnly, read off every line. */ @@ -52,12 +47,13 @@ const CHROME = APP.slice(0, APP.indexOf(' { +test('the palette list is the canvas list less AI Matches: 28 pages in four groups, every one a registered route', () => { assert.deepEqual(WORKSPACES.map((w) => [w.group, w.label, w.route]), CANVAS); assert.deepEqual(WORKSPACE_GROUP_ORDER, ['Studio', 'Capital & Legal', 'Network & Growth', 'More']); - assert.deepEqual(WORKSPACE_GROUPS.map((g) => [g.group, g.count]), + assert.deepEqual(WORKSPACE_GROUP_ORDER.map((g) => [g, WORKSPACES.filter((w) => w.group === g).length]), [['Studio', 7], ['Capital & Legal', 10], ['Network & Growth', 8], ['More', 3]]); - for (const g of WORKSPACE_GROUPS) assert.equal(g.count, g.items.length, `${g.group}'s count is not its length`); for (const w of WORKSPACES) { assert.ok(w.description && w.description.endsWith('.'), `${w.route} has no description`); - assert.ok(registered(w.route), `launcher route ${w.route} is not registered in App.jsx`); + assert.ok(registered(w.route), `palette route ${w.route} is not registered in App.jsx`); } - // One list: the launcher entries are the map's launcher tier, nothing else. - assert.equal(ADMIN_PLACEMENT.filter((e) => e.tier === 'launcher').length, WORKSPACES.length); -}); - -test('the count is computed from the list, never typed', () => { - assert.match(LAUNCHER, /WORKSPACES\.length/, 'the launcher no longer reads its count off the list'); - assert.doesNotMatch(LAUNCHER, /\b29\b/, 'the launcher types the count'); - assert.match(LAUNCHER, /pages · not admin consoles/); - assert.match(LAUNCHER, /\{g\.group\} · \{g\.count\}/, 'a group heading no longer reads "{group} · {count}"'); + // One list: the entries are the map's palette tier, nothing else. + assert.equal(ADMIN_PLACEMENT.filter((e) => e.tier === 'palette').length, WORKSPACES.length); }); -test('AI Advisory Suite keeps its shipped label and draws no flag; the founder note is not drawn for an admin', () => { - assert.equal(WORKSPACES.find((w) => w.route === '/advisory')?.label, 'AI Advisory Suite'); - assert.doesNotMatch(LAUNCHER, /Rename to be decided|Shipped label/, 'the launcher draws H37\'s flag'); - // The three routes redirect ONLY a founder; an admin lands on the page, so - // "Opens a founder page" would be false for everyone who sees this launcher. +test('no page is labelled as an AI advisor, and the three founder redirects still apply only to a founder', () => { + // The voice rule: the model is Eadwyn, never an advisor. H37's "AI Advisory + // Suite" went with the launcher (D563). + assert.ok(!WORKSPACES.some((w) => /\bAI Advis/i.test(w.label)), 'a page is labelled as an AI advisor again'); for (const path of ['/my/jobs', '/services', '/needs']) { const line = APP.split('\n').find((l) => l.includes(`path="${path}"`)); assert.ok(line, `${path} is not routed`); assert.match(line, /user\?\.role === 'founder' \? { - assert.deepEqual(ADMIN_SHELLS, ['admin', 'super_admin'], 'the branch shell draws no launcher (S7–S19 draw none)'); - assert.match(CHROME, /ADMIN_SHELLS\.includes\(shellRole\) && /, - 'App.jsx no longer gates the launcher on the admin shells'); +test('no Workspaces launcher is mounted (D563), and Messages is offered to exactly the roles /messages admits', () => { + assert.deepEqual(ADMIN_SHELLS, ['admin', 'super_admin']); + assert.doesNotMatch(CHROME, /WorkspacesLauncher/, 'App.jsx mounts the Workspaces launcher again'); + assert.doesNotMatch(APP, /from '\.\/components\/WorkspacesLauncher'/, 'App.jsx imports the launcher again'); + assert.ok(!existsSync(resolve(root, 'frontend/src/components/WorkspacesLauncher.jsx')), 'the launcher component is back'); assert.match(CHROME, /MESSAGES_ROLES\.includes\(activeRole\) && \(\s* l.includes('path="/messages"')); @@ -180,7 +167,7 @@ test('the other shells index their own sidebar, as before', () => { const items = pageItemsFor({ role: 'founder', shellRole: 'founder', user: founder }); assert.deepEqual(items, sidebarPageItems('founder', founder)); assert.deepEqual(items.map((i) => i.to), SIDEBAR_GROUPS.founder.flatMap((g) => g.items.map((it) => it.to))); - assert.ok(!items.some((i) => i.hint.startsWith('Workspaces')), 'a founder was given the admin launcher'); + assert.ok(!items.some((i) => i.hint.startsWith('Workspaces')), 'a founder was given the admin shells\' page list'); }); test('an article result renders: every indexed kind has a bucket, in order', () => { diff --git a/frontend/test/workspaces_retired_d563.test.mjs b/frontend/test/workspaces_retired_d563.test.mjs new file mode 100644 index 0000000000..e6da81d369 --- /dev/null +++ b/frontend/test/workspaces_retired_d563.test.mjs @@ -0,0 +1,176 @@ +/** + * The Workspaces launcher is retired; its 28 pages live in the profile + * sidebars (D563, #1164). + * + * S1's decision A: the profile sidebars keep the rows their approved canvases + * draw. Each kept page joins the `match` of the row that owns it, so it lights + * that row, and the row's overview links it in an "Also in" list. The command + * palette still indexes all 28 on an admin shell, and every page keeps its + * route. One page redirects, and only a founder: `/liquidity`, which is the + * same `LiquidityPage` that `/raise/liquidity?mode=workspace` renders. + * + * `PAGE_HOMES` in `lib/adminPlacement.js` is the mapping. This file checks + * every line of it against the three things that make it true: the route's + * guard in App.jsx, the row's `match` in `sidebarConfig.js` (read the way + * SidebarNav reads it), and the overview that draws the link. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { SIDEBAR_GROUPS } from '../src/sidebarConfig.js'; +import { WORKSPACES, PAGE_HOMES, alsoInFor } from '../src/lib/adminPlacement.js'; +import { bucketsFor } from '../src/workspaces/shellConfig.js'; +import { routeBlock } from './_routes.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (rel) => readFileSync(resolve(root, rel), 'utf8'); +const APP = read('frontend/src/App.jsx'); + +const PROFILES = ['founder', 'investor', 'partner', 'advisor']; +const DOORS = ['row', 'also', 'lab']; + +/** The roles a route's guard names, and whether `labRoles(...)` widens it. */ +function guard(path) { + const block = routeBlock(APP, path); + assert.ok(block, `${path} is not routed in App.jsx`); + const m = /guard\((labRoles\()?\[([^\]]*)\]/.exec(block); + assert.ok(m, `${path} has no guard list`); + return { roles: m[2].match(/'([^']+)'/g).map((s) => s.slice(1, -1)), lab: Boolean(m[1]) }; +} +const guardRoles = (path) => guard(path).roles; + +/** Every sidebar row of a profile. */ +const rowsOf = (role) => SIDEBAR_GROUPS[role].flatMap((g) => g.items || []); + +/** SidebarNav's rule: a `match` list is the row's whole claim; no list means `to` exactly. */ +function lights(row, path) { + if (path === row.to) return true; + if (!Array.isArray(row.match)) return false; + return row.match.some((p) => path === p || path.startsWith(`${p}/`)); +} + +/** Where each "also" row's overview draws its list. */ +const DESK = { + 'founder|Validate': 'frontend/src/pages/founder/FounderValidatePage.jsx', + 'founder|Raise': 'frontend/src/pages/founder/FounderRaiseDesk.jsx', + 'founder|Grow': 'frontend/src/pages/founder/FounderGrowDesk.jsx', + 'investor|Portfolio': 'frontend/src/pages/investor/InvestorPortfolioCanvas.jsx', +}; +// Every other row is a bucket root, which renders BucketBoard or, with no +// board registered, BucketOverview. Both draw the list for their own bucket. +const BUCKET_BODIES = ['frontend/src/workspaces/BucketBoard.jsx', 'frontend/src/workspaces/BucketOverview.jsx']; + +test('the mapping covers exactly the 28 pages, and every line names a profile, a row and a door', () => { + assert.deepEqual(Object.keys(PAGE_HOMES).sort(), WORKSPACES.map((w) => w.route).sort()); + assert.equal(WORKSPACES.length, 28); + for (const [route, homes] of Object.entries(PAGE_HOMES)) { + assert.ok(homes.length > 0, `${route} has no home`); + for (const [role, row, door] of homes) { + assert.ok(PROFILES.includes(role), `${route}: ${role} is not a profile`); + assert.ok(DOORS.includes(door), `${route}: door ${door}`); + assert.ok(rowsOf(role).some((r) => r.label === row), `${route}: ${role} has no ${row} row`); + } + } +}); + +test('every home is a role the route\'s guard admits', () => { + for (const [route, homes] of Object.entries(PAGE_HOMES)) { + const { roles, lab } = guard(route); + for (const [role, row, door] of homes) { + // `labRoles` adds the viewer's own role when their Lab is active, so a + // Lab page admits a Lab member of any profile, and only the Lab door + // may rely on that. + const admitted = roles.includes(role) || (door === 'lab' && lab); + assert.ok(admitted, `${route} is homed on ${role} · ${row}, but its guard admits ${roles.join(', ')}${lab ? ' and Lab members' : ''}`); + } + } +}); + +test('every kept page lights its sidebar row', () => { + for (const [route, homes] of Object.entries(PAGE_HOMES)) { + for (const [role, label, door] of homes) { + const row = rowsOf(role).find((r) => r.label === label); + assert.ok(lights(row, route), `${route} does not light ${role} · ${label}`); + if (door === 'also' || door === 'lab') { + assert.ok(Array.isArray(row.match) && row.match.some((p) => route === p || route.startsWith(`${p}/`)), + `${route} lights ${role} · ${label} only by accident, not by its match list`); + } + } + } +}); + +test('one home per page per role: no route lights two rows of the same profile', () => { + for (const role of PROFILES) { + for (const { route } of WORKSPACES) { + const lit = rowsOf(role).filter((r) => lights(r, route)).map((r) => r.label); + assert.ok(lit.length <= 1, `${route} lights ${lit.join(' and ')} for ${role}`); + } + } +}); + +test('every "also" page is linked from its row\'s overview', () => { + for (const [route, homes] of Object.entries(PAGE_HOMES)) { + for (const [role, row, door] of homes) { + if (door !== 'also') continue; + assert.ok(alsoInFor(role, row).some((w) => w.route === route), `${route} is missing from ${role} · ${row}'s Also in list`); + const desk = DESK[`${role}|${row}`]; + if (desk) { + assert.match(read(desk), new RegExp(``), `${desk} does not draw ${row}'s Also in list`); + } else { + assert.ok(bucketsFor(role).some((b) => b.label === row), `${role} · ${row} is neither a desk nor a bucket root`); + } + } + } + for (const f of BUCKET_BODIES) { + assert.match(read(f), //, `${f} no longer draws the bucket's Also in list`); + } +}); + +test('the Also in list draws exactly the "also" pages, and a row with none draws nothing', () => { + assert.deepEqual(alsoInFor('investor', 'Portfolio').map((w) => w.route), + ['/portfolio/risk-matrix', '/capital', '/liquidity', '/portfolio/reserves', '/portfolio/waterfall', '/watchlist']); + assert.deepEqual(alsoInFor('founder', 'Raise').map((w) => w.route), + ['/legal-capital', '/incorporate', '/compliance', '/incorporate/cofounder-agreement']); + // `row` doors are the row itself; they are not listed under it. + assert.ok(!alsoInFor('partner', 'Pipeline').some((w) => w.route === '/pipeline')); + assert.deepEqual(alsoInFor('investor', 'Trust'), []); + const comp = read('frontend/src/workspaces/AlsoIn.jsx'); + assert.match(comp, /if \(!pages\.length\) return null;/); + assert.match(comp, / { + const arsenal = read('frontend/src/lib/spinoutLabArsenal.js'); + for (const [route, homes] of Object.entries(PAGE_HOMES)) { + if (!homes.some(([, , door]) => door === 'lab')) continue; + assert.match(arsenal, new RegExp(`route: '${route}'`), `${route} is not in the Lab's arsenal`); + } +}); + +test('the one retired page redirects a founder to the zone that replaced it, and no one else', () => { + const block = routeBlock(APP, '/liquidity'); + assert.match(block, /effectiveRole === 'founder' \? /); + assert.match(block, /: <\/FounderWorkspaceTabs>/, + 'a partner or investor no longer lands on /liquidity'); + assert.ok(guardRoles('/raise/liquidity').includes('founder'), 'the redirect lands on a route the founder cannot open'); + assert.match(routeBlock(APP, '/raise/liquidity'), /get\('mode'\) === 'workspace'\s*\?[^:]* r.label === 'Raise'); + assert.ok(!raise.match.includes('/liquidity'), 'founder Raise still matches /liquidity'); + assert.ok(lights(raise, '/raise/liquidity'), 'founder Raise does not light the redirect target'); + // The Raise tab bar lights Liquidity on the address the founder lands on. + const tabs = read('frontend/src/pages/founder/FounderWorkspaceTabs.jsx'); + assert.match(tabs, /to: '\/liquidity', label: 'Liquidity', icon: TrendingUp, match: \['\/liquidity', '\/raise\/liquidity'\]/); + assert.match(tabs, /tabs\.map\(\(\{ to, label, icon, match \}\) => \(\{ to, label, icon, match \}\)\)/, + 'FounderWorkspaceTabs drops a tab\'s match before WorkspaceTabs sees it'); +}); + +test('no launcher: the component is gone and nothing in the header renders it', () => { + assert.ok(!existsSync(resolve(root, 'frontend/src/components/WorkspacesLauncher.jsx'))); + assert.doesNotMatch(APP, /WorkspacesLauncher/); + assert.doesNotMatch(APP, /data-testid="workspaces-launcher"/); +});