The role EDITOR page — `/roles/new` and `/roles/[roleKey]/edit`, the base picker, the permission grid, the pinned bar, and the two doors into it
The surface the story is named for: an admin names a role, picks one to start from, ticks and unticks permissions, and saves. It is drawn in full — panel 3 of roles-permissions.mock.html — including why it is a page rather than a dialog: a revision that used a modal measured 2165px tall in a 1200×900 viewport, 2.4× the height it had to fit in, and 1675px of that was the permission list.
The shape, from the asset
- Two routes, one component.
/settings/project/roles/newand/settings/project/roles/[roleKey]/edit— "editing a custom role is this same page with the values filled in". The[roleKey]segment matches the shipped detail route's slug exactly, and the staticnewsegment resolves ahead of the dynamic one. - Name, then something to start from. The
Start frompicker offers the three built-ins and nothing else, and it SEEDS THE GRID AND IS NOT STORED (Yue, 2026-08-09). It exists so an author does not face 28 blank checkboxes — a quiz rather than freedom — and for no other reason: nothing records which built-in was picked, so on the edit route there is no base to show and the picker is ABSENT. A saved role is its name and its set. - One layout for one catalog. The permission list is the detail screen's list with its marks swapped for checkboxes — same
permissionsByDomainorder, same 15 domain headings, same descriptions on one line. A permission is held or not held — ONE checked state, since nothing records where a tick came from — and each names itself in its accessible label (Held / Not held), so the state never rests on colour. - A pinned action bar carrying the running count and
Cancel/Create role, held at the bottom of the viewport for the whole scroll, so the commit is never 1500px from the tick that changed the answer.
⚠️ The pinned bar has a trap, and the asset paid for it
position: sticky; bottom: 0 pins against the nearest scrolling ancestor. In the shipped app that is AppLayout's <main> (min-h-0 overflow-y-auto inside an h-dvh overflow-hidden column) and app/(authed)/settings/project/layout.tsx is a pass-through, so it pins correctly there. Any ancestor between <main> and the bar that sets overflow to anything but visible kills it silently — the element keeps position: sticky in its computed style and simply never pins. An earlier revision of the asset itself declared a sticky bar that did not stick. So: do not wrap this page in a clipping container, do not add overflow-hidden to a wrapper for rounded corners, and assert the pinning in a test rather than trusting the declaration.
The two doors
The card that builds a surface builds its entrances, so this one also adds:
Create roleon the role list, for an actor holdingproject:manage_access— and disabled with its explanation when the project is atMAX_CUSTOM_ROLES_PER_PROJECT, read fromlib/permissions/limits.tsand the catalog's own custom-role count, never a literal. That is the in-place-control treatmentdesign-notes.md§ Gating affordances (6.4.6) prescribes.Editon a custom role's detail screen, for the same actor. A built-in's screen keeps its lock and no control.
The delete card owns Delete and its dialog and adds neither of these; the two cards touch the same two files in different places.
Scope boundary
In: both routes and the editor component, the base picker, the grid, the pinned bar, the running count, the save and its refusal handling, the two doors with the cap state, and the en + zh strings. Out: Delete and the reassign dialog; anything the read card owns about how a role is DISPLAYED; the permission catalog itself; assigning a role to a member. A permission the server would refuse — one outside the role-gated enforced set — renders disabled rather than absent, and today that set is empty, so no row is disabled in practice; the code must derive it from the constants so the next one is.
Acceptance criteria
/settings/project/roles/newand/settings/project/roles/[roleKey]/editboth render the editor; the edit route pre-fills the role's name and set, and an unknownroleKeyor a built-in key 404s rather than offering an editor.- Both routes refuse an actor without
project:manage_accesswith the shipped no-access state, and the server refuses the write independently — the page's gate is presentation, never protection. - The
Start frompicker offers exactlyAdmin/Member/Vieweron the new route; choosing one pre-ticks that role's grants, and switching replaces the pre-ticked set while keeping anything the author added. It is ABSENT on the edit route — nothing was stored, so there is nothing to show and nothing that could be changed. The request body carries{ name, permissions }only; abasedOnsent by an old client is ignored, not stored. - Each permission row renders the
Checkboxprimitive in the right state, with an accessible name naming it — Held / Not held — and rows are grouped under the same domain headings, inpermissionsByDomainorder, as the detail screen. - The pinned bar shows the running count of held permissions against the role-gated total, updates as boxes are ticked, and a test scrolls the real scroll container and asserts the bar's bottom edge is still at the container's bottom edge — the declaration alone is not the criterion.
- Save calls the API once (
POSTon new,PATCHon edit) and, on success, lands on that role's detail screen showing what was just saved. That screen shows the role's name, itsCustomchip and its set — no provenance chip, because none is recorded. - Each refusal has a drawn outcome: a taken name and a cap reached surface on the form with their own message and leave the author's input intact; a lost permission surfaces as the no-access state. None is a silent no-op.
Create rolerenders for an admin, is absent for everyone else, and is disabled with its explanation at the cap — with the cap and the count both read from the shared constant and the catalog, so a test at the cap boundary needs no hardcoded number.Editrenders on a custom role's detail screen for an admin only, and never on a built-in's.- No wrapper between
<main>and the bar setsoverflowto anything butvisible; every string is inmessages/en.jsonandmessages/zh.json; no Tier-0--color-*and no rawrounded-*/p-*/h-*; both themes render.
Context refs
design/projects/roles-permissions.mock.htmlpanel 3 ·design/projects/design-notes.md§§ Creating a role is a PAGE, not a dialog, The pinned bar: the mechanism, and the trap that bit this mock, Primitives composed, and the measured contrast table.components/ui/AppLayout.tsx(lines 56 and 80 — the real scrolling ancestor) ·app/(authed)/settings/project/layout.tsx(the pass-through).app/(authed)/settings/project/roles/page.tsx·[roleKey]/page.tsx·_components/RoleList.tsx·RoleDetail.tsx·PermissionGroups.tsx— the screens this extends and the list grammar it mirrors.lib/permissions/catalog.ts—permissionsByDomain,PERMISSION_DOMAINS;lib/permissions/builtinRoles.ts—ROLE_GATED_PERMISSIONS,BUILTIN_ROLE_PERMISSIONS.app/(authed)/settings/project/fields/_components/FieldsSettingsEditor.tsx— a shipped settings editor's save, refusal and cap-state handling.- The
Checkboxcard · the API card · the read card · the design amendment, which decides whether a domain heading carries a bulk toggle.