Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2483Done

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/new and /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 static new segment resolves ahead of the dynamic one.
  • Name, then something to start from. The Start from picker 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 permissionsByDomain order, 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 role on the role list, for an actor holding project:manage_access — and disabled with its explanation when the project is at MAX_CUSTOM_ROLES_PER_PROJECT, read from lib/permissions/limits.ts and the catalog's own custom-role count, never a literal. That is the in-place-control treatment design-notes.md § Gating affordances (6.4.6) prescribes.
  • Edit on 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/new and /settings/project/roles/[roleKey]/edit both render the editor; the edit route pre-fills the role's name and set, and an unknown roleKey or a built-in key 404s rather than offering an editor.
  • Both routes refuse an actor without project:manage_access with the shipped no-access state, and the server refuses the write independently — the page's gate is presentation, never protection.
  • The Start from picker offers exactly Admin / Member / Viewer on 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; a basedOn sent by an old client is ignored, not stored.
  • Each permission row renders the Checkbox primitive in the right state, with an accessible name naming it — Held / Not held — and rows are grouped under the same domain headings, in permissionsByDomain order, 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 (POST on new, PATCH on edit) and, on success, lands on that role's detail screen showing what was just saved. That screen shows the role's name, its Custom chip 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 role renders 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.
  • Edit renders 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 sets overflow to anything but visible; every string is in messages/en.json and messages/zh.json; no Tier-0 --color-* and no raw rounded-* / p-* / h-*; both themes render.

Context refs

  • design/projects/roles-permissions.mock.html panel 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.tspermissionsByDomain, PERMISSION_DOMAINS; lib/permissions/builtinRoles.tsROLE_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 Checkbox card · the API card · the read card · the design amendment, which decides whether a domain heading carries a bulk toggle.