Role panels - the singleton ST port (mirror the Madara port for every role)
Status: SPEC + build (preprod
.stoka-preprod-wt, branchpreprod/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
/adminroutes 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 iffR < 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 (
rosterForinroles.ts, from the numericrankon 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, nome/nav, no runtimecreateElement-> 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 aretarget=_blankso a senior keeps their own hub open and multiplexes across roles. (hidanundefined +deidaraexternal have norank, so phantom roles never appear. Theme/navrank-guard is SEPARATE: it controls ACCESS to a panel, not the roster contents.)
- Gate rule (server,
- Surfaces are shared singletons.
SURFACE_REGISTRYhomes 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:
- Wrap in
.stx-shell(100vh,--accent,data-<role>-panel,data-home-rank,data-home-id). .stx-strip(38px) with role name +<AdminUserChip />..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..stx-rail:.stx-modebarcircular.stx-bubble[data-tab=<id>]per surface;.stx-rail-pagewith.stx-railslot[data-railslot=<id>]per surface (home -> RoleNav; surface with sub-views -> SurfaceNav; else simple pills).- The
activate(id)script toggles[data-surface]+[data-railslot]+bubble active +?surface=deep-link. - The rank guard reads
/api/admin/me/navand 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, portkamui.astropan/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 git4c76eca3: post grid + projects/status sidebar + bulk actions + AdminKonanBubble chat,/api/konan/*), Specs, Submissions, Newsletter, Supporters, Users. SPECIAL: Konan’s home stays/admin(thehome==='konan'no-auto-route case); the panel renders at/adminfor 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; buildKamuiSurface; wirepain.astro+tobi.astroto 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-entryroles.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
/adminfor 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 whatsrc/pages/admin/permits.astroalready 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/navreturns 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/meor the seats list). If own-wallet is not resolvable client-side, a tiny read-onlyGET /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.