← All specs

Role panels - the singleton ST port (mirror the Madara port for every role)

> Status: SPEC + build (preprod `.stoka-preprod-wt`, branch `preprod/redesign`). Prod untouched. > Authored 2026-06-27 (portal engineer), grounded by 3 read-only mappers (Madara port recipe; Pain/T…

Role panels - the singleton ST port (mirror the Madara port for every role)

Status: SPEC + build (preprod .stoka-preprod-wt, branch preprod/redesign). Prod untouched. Authored 2026-06-27 (portal engineer), grounded by 3 read-only mappers (Madara port recipe; Pain/Tobi+shared surfaces; Konan/Kakuzu/Juzo functionality). Madara (Edward) ratified the architecture 2026-06-27 (“yes you understand go”).

0. The architecture (ratified)

  • Each role = ONE singleton panel, keyed to the role (admin.role_seats, unique active seat per role), shared by whoever holds the seat. /admin/<role> IS the singleton. Identity overlaid via AdminUserChip; substance is the role’s.
  • Two doors, one room: a seated user clicking general /admin routes STRAIGHT to their role home (me/nav.home -> /admin/<role>); Madara/seniors reach the same singletons via the roster. Both land on the one instance.
  • The cross-role roster tab is the apex-style “role-level tab” (Madara’s flame tab + RoleNav). It appears on a panel ONLY when the VIEWER’S roster is non-empty (Madara set “no if empty” 2026-06-27).
    • Gate rule (server, _compute_nav_for_wallet): a viewer of rank R sees role tab X iff R < X.rank OR X==own. So:
      • Madara (0): roster = {pain,tobi,konan,kakuzu,juzo} (full).
      • Pain/Tobi (1): roster = {konan,kakuzu,juzo} (NON-empty -> they DO get a roster tab). NOT each other (peer), NOT madara.
      • Konan/Kakuzu/Juzo (2): roster = {} -> NO roster tab; their panel is just their functionality.
    • The roster/home tab is INTRINSIC TO THE PANEL ROLE, not the viewer (REVISED 2026-06-27, Madara: a panel must show the role’s OWN hub, the same the holder gets). A panel for role R shows the roster of the roles R commands == real roles ranked strictly below R (rosterFor in roles.ts, from the numeric rank on real seats). Madara -> {pain,tobi,konan,kakuzu,juzo}; Pain/Tobi -> {konan,kakuzu,juzo}; rank-2 (konan/kakuzu/juzo) -> none. This holds REGARDLESS of who is viewing: a senior opening a subordinate’s singleton sees that role’s own hub. RoleNav renders the roster at SSR (static, no me/nav, no runtime createElement -> scoped CSS valid); RolePanel shows the home/roster tab iff the role commands a roster (it counts the SSR buttons), else prunes to the pure single-surface view. Roster buttons are target=_blank so a senior keeps their own hub open and multiplexes across roles. (hidan undefined + deidara external have no rank, so phantom roles never appear. The me/nav rank-guard is SEPARATE: it controls ACCESS to a panel, not the roster contents.)
  • Surfaces are shared singletons. SURFACE_REGISTRY homes a surface to multiple roles (rinnegan -> madara/pain/tobi; kamui -> madara/tobi). The SAME instance + endpoints; components are REUSED across panels, never copied.

1. The port recipe (old AdminRoleHome -> ST shell), extracted from the Madara port

OLD (AdminRoleHome.astro): 248px left sidebar (identity + surface links) + main launcher cards; clicking a surface NAVIGATES to /admin/<surface>?home=<role>. NEW (MadaraPanel.astro, commit 3c52195b): ST shell, surfaces become circular modebar tabs that swap [data-surface] main + [data-railslot] rail in place (no nav), per-tab sidebar in the rail, me/nav rank guard, AdminUserChip in the strip. Recipe to port any role home:

  1. Wrap in .stx-shell (100vh, --accent, data-<role>-panel, data-home-rank, data-home-id).
  2. .stx-strip (38px) with role name + <AdminUserChip />.
  3. .stx-main: one <section class="stx-surface" data-surface=<id>> per surface (first visible, rest hidden). Home section = identity + launcher cards (data-go-surface). Surface sections = the surface component.
  4. .stx-rail: .stx-modebar circular .stx-bubble[data-tab=<id>] per surface; .stx-rail-page with .stx-railslot[data-railslot=<id>] per surface (home -> RoleNav; surface with sub-views -> SurfaceNav; else simple pills).
  5. The activate(id) script toggles [data-surface]+[data-railslot]+bubble active + ?surface= deep-link.
  6. The rank guard reads /api/admin/me/nav and bounces disallowed viewers.

2. Generalize the shell -> RolePanel.astro (codified, config-driven)

MadaraPanel is Madara-specific. Generalize the shell skeleton + modebar + activate() + rank guard + AdminUserChip + the conditional-roster-home-tab into ONE src/components/RolePanel.astro driven by a per-role config. Each role page renders <RolePanel role="<id>" />. The config (extend src/lib/surfaces.ts or a sibling src/lib/rolePanels.ts) declares, per role: accent, the ordered surface tabs (id, label, glyph, which component to mount, optional sub-views), and hasRoster is computed at runtime (prune if empty). HARD: refactoring Madara onto RolePanel MUST leave Madara’s rendered output + behavior identical (regression-gated by Playwright before/after).

3. Per-role tab spec (what each panel shows)

  • Madara (0): [Home/roster=full] White Zetsu, Black Zetsu, Rinnegan. (Already built; refactor onto RolePanel, identical output. SURFACE_REGISTRY also homes Kamui to madara -- optional add.)
  • Pain (1): [Home/roster={konan,kakuzu,juzo}] + Rinnegan (reuse RinneganSurface.astro). Accent rose #fb7185.
  • Tobi (1): [Home/roster={konan,kakuzu,juzo}] + Kamui (new KamuiSurface.astro, port kamui.astro pan/zoom asset-vault canvas + bank modal + /api/admin/kamui/*) + Rinnegan (reuse). Accent orange #f97316.
  • Kakuzu (2): NO roster. Bounties/Outreach (port kakuzu.astro: segment table + compose modal + recent sends, /api/admin/outreach/*), Treasury (/api/admin/treasury), Stripe (/api/admin/stripe/events), Crypto-pending (/api/admin/crypto-pending). Accent teal #2dd4bf.
  • Konan (2, path /admin): NO roster. Blog (port the old blog admin from git 4c76eca3: post grid + projects/status sidebar + bulk actions + AdminKonanBubble chat, /api/konan/*), Specs, Submissions, Newsletter, Supporters, Users. SPECIAL: Konan’s home stays /admin (the home==='konan' no-auto-route case); the panel renders at /admin for a seated Konan. Accent green #4ade80.
  • Juzo (2): NO roster. Deidara -- a launch/embed surface for the EXTERNAL app https://deidara.phillyshell.cloud (external; iframe or a clear launch card, tailnet-only). Accent violet #a78bfa.

4. Build phasing

  • Workflow 1 (foundation + surface-homing roles): generalize RolePanel + refactor Madara (identical-output gate) + role-panel config + the conditional roster tab; build KamuiSurface; wire pain.astro + tobi.astro to RolePanel (Pain=[Rinnegan], Tobi=[Kamui,Rinnegan]); build + Playwright verify (Madara regression + Pain + Tobi). Reuses RinneganSurface.
  • Workflow 2 (function-role ports): Kakuzu (outreach + finance tabs), Konan (blog + content tabs, path /admin), Juzo (Deidara embed) on the proven RolePanel. Heaviest ports (kakuzu.astro ~751 lines; the old Konan blog admin + AdminKonanBubble ~750 lines) -> faithful ports, xhigh effort, each its own agent.

5. Decisions / open

  • Pain/Tobi roster: confirmed they DO get a roster tab listing {konan,kakuzu,juzo} (non-empty). If Madara wants the cross-role roster to be apex-ONLY, drop it from Pain/Tobi too (one-line change). FLAG for confirm.
  • Juzo/Deidara: iframe-embed the external dashboard inside the tab, or a launch card that opens it in a new tab? (External app, tailnet-only.) Default: a clear launch card + optional iframe.
  • roles.ts vs backend drift (from the prior pass): RoleNav/roster should be driven by me/nav.tabs (the 6 real backend roles), not the static 8-entry roles.ts; Hidan (undefined, not in backend) and Deidara (a surface/external app, not a seat) should not appear as roster role-buttons. roles.ts stays the glyph/label/accent lookup. Reconcile during WF1 (roster source) / WF2 (Deidara placement).
  • Konan path: the panel must render at /admin for a seated Konan (not /admin/konan) to preserve the home===’konan’ stay-on-/admin special case.

6. Juzo = the INTERN role + the Pain/Tobi control surface (Madara 2026-06-27)

Juzo is REDEFINED from “Deidara operator” to an intern: by default its panel shows NOTHING; it is granted very granular individual tabs at will, and Pain + Tobi get a complementary control surface to grant/revoke them.

Backend EXISTS + ready (grounded 2026-06-27) -- NO backend change for either side:

  • Grant: POST /api/admin/access/grants {wallet, tab_id, notes?}; Revoke: DELETE /api/admin/access/grants/{wallet}/{tab_id} -> admin.tab_grants. Gate = verify_admin_actor (is_admin). Pain/Tobi PASS (a seated role = is_admin), so they can grant today. Grants target a WALLET.
  • Read: GET /api/admin/access/seats -> role seats (find the juzo seat wallet); GET /api/admin/access/wallets/{wallet} -> the per-tab matrix with .granted. (This is exactly what src/pages/admin/permits.astro already drives.)
  • Grantable units = the 15 functional non-role tabs: kamui, specs, supporters, crypto-pending, users, permits, stripe, treasury, newsletter, submissions, tracker, nash, monologue, staged-hero, white-zetsu.
  • GAP: /api/me/nav returns ROLE tabs only, NOT grants. So Juzo’s panel sources its granted tabs from the access API for its OWN wallet (GET /api/admin/access/wallets/{ownWallet}; ownWallet from /api/account/me or the seats list). If own-wallet is not resolvable client-side, a tiny read-only GET /api/me/functional (wraps the existing _compute_functional_tabs_for_wallet) is the clean self-endpoint = a small deploy-dep; FE degrades to empty meanwhile.

SHARED-DB CAVEAT (HARD): a grant writes admin.tab_grants = REAL prod access (no staging). The FE build does NOT grant; the Playwright verify MUST MOCK the grant endpoints (zero real DB write). Using the surface = real grants (a Pain/Tobi/Madara action, never an automated one).

Build:

  • InternGrantsSurface.astro (the control surface, mounted on Pain + Tobi, optionally Madara): GET seats -> juzo wallet; GET wallets/{juzoWallet} -> matrix of the 15 grantable tabs with .granted; per-row toggle = POST/DELETE grants (with a confirm, since it is live prod access). Mirrors the permits matrix, scoped to the intern + only the grantable functional tabs.
  • Juzo intern panel: the ST shell (reuse RolePanel’s CSS + activate pattern) with surfaces built CLIENT-SIDE from the viewer’s granted tabs (per-viewer, runtime). Default = a calm empty state (“No tabs granted yet. Your lead grants access.”). Each granted tab opens/embeds its functional surface (same-origin iframe of /admin/<tab> in-shell, with an “open full” fallback; first cut, can be replaced by a ported surface later). NO roster tab (rank 2, empty roster).
  • rolePanels.ts: juzo = intern mode (grant-driven, dynamic surfaces); add an “intern” control surface tab to pain + tobi.