System logic

UI/UX specification — every EduSpaze portal

UI/UX specification — every EduSpaze portal

The standard that every portal follows: platform admin, school, vendor (startup), parent and pupil. Anything that touches the frame, navigation, notifications, account pages or the copilot is defined here once and implemented once (components/portal-shell.tsx and components/shell/*). A page that needs something this document does not cover extends the standard here first. Last updated 2026-09-03 (content canvas + responsive shell).

1. Principles

  1. One shell, five portals. The frame, its controls and its positions never vary by portal. Only the navigation entries, the copilot configuration and the account context line differ.
  2. Plain language, sentence case, active voice. Menu names are nouns; buttons are verbs that keep their name through the whole flow ("Add staff member" on the button, "Mrs Tan added as teacher" in the confirmation).
  3. Fail closed, explain once. Anything the user may not do shows one neutral message. Never confirm the existence of something they cannot see.
  4. Time and status, never judgement. Numbers about people (engagement, usage bands) show their confidence and never a ranking or a comparison with other people.
  5. Children's surfaces are simpler. The pupil launcher uses the same shell but no copilot, larger touch targets and the Children's Code notice.

2. Design tokens (app/globals.css)

TokenValueUse
brand-500 / 600#f28500 / #ea7100primary actions, active navigation, unread state
brand-50 / 100#fff7ed / #ffedd5active/selected backgrounds, copilot greeting
navy-800 / 900#0b3954 / #03263bavatars, code blocks, secondary emphasis
ink / inkgray#1a1a1a / #666666body text / secondary text
gray-50…200Tailwindpage ground (gray-50), borders (gray-200), hover (gray-50/100)
Statusemerald ok · sky info · amber warning · red dangerbadges, notices
FontRubik (--font-rubik), system fallbackeverything
Radiusrounded-lg controls · rounded-xl cards · rounded-2xl hero cards · rounded-full pills
Shadowshadow-sm cards · shadow-xl floating panels

Brand mark: the corporate EduSpaze logo supplied by EduSpaze lives in public/brand/ (eduspaze-logo.png for light grounds, eduspaze-logo-white.png for dark grounds, eduspaze-mark*.png = leaves + "Edu" for the collapsed sidebar; app/icon.png is the favicon). components/logo.tsx is the only place that renders it; no page draws its own. EduSpaze is presented as a platform, never as a "marketplace": the school section that lists vendors is called *Solutions*.

Type scale: page title text-2xl font-bold; card title font-semibold; body text-sm; meta text-xs text-gray-500; stat text-2xl font-bold; labels text-xs uppercase tracking-wide text-gray-500.

3. The shell (components/portal-shell.tsx)

┌──────────────┬──────────────────────────────────────────────┬────────────┐
│ ☰  Logo      │                     utility bar        🔔    │            │
│              │  Page title                                  │  Copilot   │
│  ▣ Dashboard │  detail line                                 │  (panel /  │
│  ★ Recomm…   │                                              │   docked / │
│  …           │  content                                     │   max)     │
│              │                                              │            │
│ (JT) Jane T. │                                              │            │
│  school admin⏻                                               │            │
└──────────────┴──────────────────────────────────────────────┴────────────┘

3.1 Sidebar (components/shell/sidebar.tsx)

  • Fixed left, full height, white, border-r. Expanded 15rem (w-60) or collapsed 4rem (w-16, icons only). The burger (menu icon, aria-expanded) at the top toggles it; the choice is remembered per browser in localStorage (eduspaze.sidebar) and published to the page as --sidebar-w by ShellProvider (components/shell/shell-context.tsx), so content re-flows. With no stored choice, tablets (< 1024 px) start collapsed.
  • On phones (< 768 px) the sidebar is an overlay drawer: off-canvas by default, --sidebar-w is 0, and the burger in the utility bar opens it (always with labels, over a backdrop). It closes on navigation, Esc, backdrop click or its own burger.
  • Header: logo with portal word (sub); the collapsed state shows the mark only.
  • Every entry has an icon from lib/ui/icons.tsx (24-grid, 1.75 stroke). Active entry: bg-brand-50 text-brand-700, aria-current="page". Collapsed entries show the icon with a title tooltip; a count badge becomes a brand dot.
  • Footer: profile (initials avatar, name, detail line) links to <portal>/account; sign out is an icon button (logout) with aria-label.
  • Order of entries per portal is fixed by the layout file. Entries may carry a group; the heading is drawn once above the first entry that has it, and becomes a divider line when the sidebar is collapsed. Group by what the user is doing, not by data model — the admin portal runs Demand · Supply · Pipeline · Programmes · Insight · Platform, so the menu reads from who is on the platform, through what they are doing, to how the platform is run.
  • New entries go inside the right group, and Settings stays last.

Icon map (nav → registry name): Dashboard dashboard · Recommendations recommendations · Marketplace marketplace · Bundles & Credits bundles · My Pipeline pipeline · Lead Inbox leads · Challenge Statements / Challenges challenges · Matchmaking Trips trips · Benchmarking benchmark · Needs Survey survey · Parent Questions parent-questions · Students students · Classes & Groups classes · Engagement engagement · Staff & Access staff · School Settings / Settings settings · Audit Log / Audit audit · Market Insights insights · Profile & Pilot Offer profile · Integration & API integration · Vetting Queue vetting · CRM crm · Schools schools · Credits & Conversions credits · Grant Referrals grants · Reports & Insights reports · Copilot & Knowledge Base copilot · Your children children · My apps apps · What can my parents see? privacy · Vendors vendors. A test fails on a nav entry whose icon is not in the registry.

3.2 Utility bar

The first row of the content column, above the page header. Right-aligned on tablets and desktops; on phones it is a two-ended bar with the navigation burger on the left. Contains the notification bell (docs/notifications.md §3). Future global controls (search, help) go here, right-aligned, in this order: search · help · bell.

3.3 Content canvas (main.portal-main, rules in app/globals.css)

  • Left-aligned and full width. The canvas starts at the sidebar's edge and stretches to the viewport's right edge (minus a docked copilot, --copilot-dock). There is no centred column and no maximum width: grids and tables use the room available. Long-form prose caps its own line length (max-w-2xl on a detail line, max-w-3xl on a document), not the page.
  • Horizontal padding steps with the viewport: 1rem (< 640) · 1.5rem (≥ 640) · 2rem (≥ 1024). Left margin is --sidebar-w from 768 px, zero below it.
  • Starts with the utility bar, then PageHeader (title, one-line detail, optional action on the right; wraps under the title when there is no room).
  • Nothing on the canvas may widen the page: tables and code blocks live inside an overflow-x-auto container; card grids collapse to one column below their breakpoint.

3.4 Copilot

Bottom-right pill → slide-out panel → dock / maximise / revert, per docs/copilot.md §2. Not mounted on the pupil launcher.

3.2 Navigation groups

Entries carrying the same group sit under one heading, and the heading is a button: clicking it folds the group away, and the choice is remembered per portal in localStorage.

  • The group holding the current page is always open, whatever was remembered. Navigation that can hide the page you are on is a trap, not a preference.
  • Folding applies only to the expanded sidebar. In icons-only mode a group is a divider with nothing to click, so nothing folds — there would be no way to get it back.
  • A portal with no groups renders exactly as before; grouping is opt-in per portal.

Every portal is grouped: platform admin by function (Demand, Supply, Pipeline, Programmes, Insight, Platform), school by what a school is doing (Find, Engage, In use, People, Administration), vendor by the same logic (Pipeline, Market, My company). Within a group the entry somebody opens most often goes first — Messages above Pipeline for a school, Lead Inbox above Messages for a vendor.

4. Navigation maps

PortalBaseEntries (in order)
School/schoolDashboard, Recommendations, Marketplace, Bundles & Credits, My Pipeline, Challenge Statements, Matchmaking Trips, Benchmarking, Needs Survey, Parent Questions*, Students†, Classes & Groups†, Engagement†, Staff & Access‡, School Settings‡, Audit Log‡
Vendor/startupDashboard, Lead Inbox, Market Insights, Challenge Statements, Matchmaking Trips, Profile & Pilot Offer, Integration & API
Platform admin/adminDashboard, Vetting Queue (badge), CRM, Schools, Vendors, Bundle Curation, Credits & Conversions, GTM Trips, Challenges, Reports & Insights, Settings, Audit, Copilot & Knowledge Base (Grant Referrals is hidden from the menu; the page remains at /admin/grants)
Parent/parentYour children (child pages carry their own Apps · Timeline · Activity tabs)
Pupil/studentMy apps, What can my parents see?

\* when parent access is on · † teacher and above · ‡ school admin only.

Every portal also has, outside the menu: <base>/account (footer profile) and <base>/notifications (bell → View all).

5. Account & settings (components/shell/profile-page.tsx)

Reached from the sidebar footer on every portal. Three cards, always in this order:

  1. Account — signed-in handle, role badge, member since, one portal context line (school + school role · company · guardian id · school + class), display-name form. The handle is read-only and the copy says who manages it.
  2. Security — change password (email accounts) or change PIN (pupils); SSO identities when present; a line that account changes are audited.
  3. Support — Documentation, Contact support (support_email setting), Notifications; data-access / erasure contact line.

Every change here is a domain event on the audit trail and, for password changes, an in-app notification to the account.

6. Notifications

Specification in docs/notifications.md. In one line: unread items light the bell with a count, the dropdown shows the eight most recent with read/unread state and *Mark all read*, *View all* opens <portal>/notifications with search, status, type and date filters.

7. Page patterns

PatternUseShape
Overviewportal dashboardsPageHeader → 4-up StatCard row(s) → 2–3 cards of lists/charts → one call-to-action banner
Listschools, vendors, students, leads, articlesthe standard list below: ListToolbarCard with DataTablePagination
Detaila lead, a pupil, an articlePageHeader with breadcrumb-style detail → two-column cards: facts left, actions/conversation right
Function pageanything that writesforms inside cards; one primary SubmitButton; inline result text (emerald ok / red error) beside the button; the copilot steps aside
Settingsschool settings, admin settings, integrationstacked cards, each a single concern with its own form and result line
Wizardonboardingsteps on the left, one step at a time, embedded copilot beside it (the only non-shell copilot)

7.1 Standard list (components/list.tsx, lib/list.ts)

Every listing page in every portal is built from the same four pieces, so search, filters, pagination and row actions look and behave identically:

  1. ListToolbar — a GET form above the card: a search box with the search icon, one <select> per column filter (label above, "All" first), *Apply*, *Clear* (only when something is active) and a live result count on the right. The URL is the state (?q=&<filter>=&page=&per=), so a filtered list can be bookmarked or shared.
  2. DataTable — uppercase thead labels, divide-y rows, numeric columns right-aligned, an empty-state row with one sentence, and an overflow-x-auto wrapper. The first column identifies the row (name in font-medium, one grey detail line).
  3. RowActions — the last column, right-aligned, icon-only controls (edit = manage, leads = email, key = reset password, power = enable/disable, vetting = vet) each with aria-label and title; destructive ones turn red on hover. Never text buttons in rows; never more than four icons.
  4. Pagination — "41–57 of 57 · page 3 of 3", *Previous* / *Next* keeping search and filters, 20 rows per page (per 5–100 honoured from the URL).

parseListQuery reads the URL once, pageInfo / pageArgs turn it into Prisma skip / take, and the page builds the where from a pure function (lib/admin/lists.ts, lib/admin/manage.ts) so the filters are unit-tested. Inline editing inside a list row is not part of the standard: rows link to a detail (function) page where the forms live. The one allowed inline control is a single-step transition that needs one value (logging a trial conversion's contract value); everything else is an icon action or lives on the detail page.

Rules that go with every list page:

  • Stat tiles first. A list page opens with three to five StatCards for the key measures of its subject (deals by stage, trials in each checkpoint, meetings logged, articles published …) computed with groupBy / aggregate, never by loading the whole table.
  • Card lists (challenges, bundles) keep their card layout when a row is read as a whole; the toolbar, result count and pagination are still the standard ones (10 per page).
  • Text and date filters use FilterDef.type (text | date) instead of options.
  • Cursor-paged sources (the audit platform) use CursorPagination: *Newest* / *Older*, count on this page, no page numbers.
  • Toolbar keeps context: hidden carries the active tab or portal through Apply / Clear.

7.4 Create and edit forms (components/form-page.tsx)

Every "New …" page in every portal has the same shape, so a form is predictable wherever it appears. Whenever Claude Code adds a way to create a record, it follows this.

  1. Route. Creating lives at <base>/new; editing lives on the record's own detail page. A list page never holds a create form — its PageHeader action is the primary New ‹thing› link (bg-brand-500), and NewRecordHeader gives the create page its title, one-line detail and a ← All ‹things› back link.
  2. Body. One column of FormCards, one card per concern ("School", "Owner login", "Plan"), each with a title and a one-line detail. Fields sit in FormGrid (two columns from sm); a wide field spans both with className="sm:col-span-2".
  3. Fields. Always Field + TextInput / Select / TextArea from components/form.tsx. The label is a plain noun, hint carries the help text, required marks what is required. Never a bare <input>.
  4. Actions. One FormActions row at the end: the primary SubmitButton (a verb naming the outcome, "Create school", not "Submit"), then Cancel back to the list, then the result message in that same fixed place — never a modal, never a toast.
  5. Validation. A zod schema in lib/validation/schemas.ts, parsed with parseForm. The first problem is shown in the actions row; the form keeps what the user typed.
  6. Success. The action redirects to the new record's detail page (or the list when there is none) with ?created=1, and that page shows one green confirmation notice. Anything shown once, such as a temporary password, is rendered by FormResult with "It will not be shown again".
  7. Audit. Creates go through the audited client like every other write, so the new row is captured with the admin as actor. Anything that gives a person an account also sends them an in-app notification.

7.5 Toggle rows (components/toggle-row.tsx)

Anything that is simply on or off — a platform module, an opt-in — is a full-width box: name and description on the left, a switch (role="switch", aria-checked) on the right. The switch is a server-action form, so it works without JavaScript and each flip is an audited write. When the thing is on and has settings of its own, they appear below a divider inside the same box; when it is off they are hidden, because they cannot apply. Never a checkbox in a list of unrelated checkboxes, and never a toggle that silently does nothing until a separate Save.

7.7 Version history

Anything the platform team edits that people receive — an email template today — keeps its history. Every save writes the wording it replaced, the list shows subject, body, who and when, and restoring is a save rather than a rewind, so history only grows. Present it as a collapsed Section under the editor, newest first, with the current version badged.

7.6 Section sub-navigation (components/sub-nav.tsx)

A section with several pages of its own (Settings) keeps one entry in the portal sidebar and carries its pages in a second rail inside the content canvas: a left rail from lg up, a scrolling row above the content below that. Each item has an icon, a label and a one-line detail; the active item is derived from the URL (aria-current="page"). Use it when a section has four or more pages; fewer than that, use tabs.

7.2 Tabs (components/tabs.tsx)

When one section holds two or more peer views of the same subject (CRM: Deals · Vendor compliance; Vetting: All applications · Scoring queue; Copilot: one tab per portal), use Tabs: link-driven (?tab= / ?view= / ?portal=), underline style directly under the stat tiles, aria-current="page" on the active tab, optional count badge. Never buttons that toggle client state — a tab must be bookmarkable and work without JavaScript.

7.3 Collapsible sections (components/section.tsx)

A page that stacks several concerns (copilot settings · knowledge base · recent questions; a trip's schools · vendors · meetings; a "New trip" form above a list) wraps each in Section: the whole header is the toggle (chevron, title, optional count and detail line, optional action on the right), aria-expanded / aria-controls, open state remembered per browser by id. The primary section defaults open; helper forms and secondary lists default closed. A Card is for a single concern that never collapses.

Tables scroll inside their card (overflow-x-auto); the page never scrolls horizontally. Empty states use EmptyState (dashed card, title, one-line hint). Destructive or irreversible actions require a typed confirmation word (e.g. ANONYMISE).

8. Components (components/ui.tsx, components/form.tsx)

Card, PageHeader, StatCard, Badge (+ VerifiedBadge, IntegrationBadge, StageBadge), CompanyAvatar, EmptyState, AiNote; Field, TextInput, Select, TextArea, CheckboxGroup, RadioGroup, SubmitButton; Icon. New shared elements are added to these files, never copied into a page.

Badges: status colours as in §2; text lower-case for states (published, active), Title Case for names. Buttons: primary bg-brand-500 / hover 600; secondary white with border-gray-300; destructive text-only text-red-600 unless it is the page's purpose.

9. States and feedback

  • Loading: server-rendered pages; client widgets show a one-word placeholder ("Loading…", "Thinking…"). No spinners over content.
  • Success / error from a server action: one sentence beside the submit button, in the same place every time; never a modal.
  • Once-only secrets (temporary passwords, API keys) render in a pre block with select-all and the sentence "it will not be shown again".
  • Notices at the top of a page use the same tinted card as the copilot notice bar (sky info, amber warning, emerald done, red failed).
  • Session notice: a state that applies to the whole session (an EduSpaze support session) is passed to PortalShell as notice and renders above every page, amber, with the one action that ends the state (components/shell/support-banner.tsx).

10. Accessibility

  • Every icon-only control has aria-label and title; toggles expose aria-pressed or aria-expanded; the active nav entry has aria-current.
  • Live regions announce copilot state changes and notification updates.
  • Focus rings on all inputs (focus:ring-2 focus:ring-brand-200); Esc closes any floating panel; every dropdown closes on outside click.
  • Colour is never the only signal: unread items also get a dot and bold text; estimated engagement blocks also get a dashed outline.
  • Target size on the pupil launcher ≥ 44 px.

11. Responsive behaviour

One layout, three widths. Breakpoints are Tailwind's (sm 640 · md 768 · lg 1024).

WidthSidebarCanvasCopilot
≥ 1024 px desktopexpanded unless the user collapsed it--sidebar-w margin, 2rem padding, full remaining widthpanel / docked / max as chosen
768–1023 px tabletstarts collapsed (icons); user can expand--sidebar-w margin, 1.5rem paddingpanel / docked / max; docked still reserves 420 px
< 768 px phoneoff-canvas drawer opened from the utility-bar burger; always shows labels; backdrop; closes on navigation / Escno margin, 1rem padding (1.5rem from 640)any open mode renders maximised; dock and maximise controls hidden; launcher pill stays

Rules that make pages hold up at every width:

  • Stat rows and card grids declare their columns with a breakpoint (sm:grid-cols-2, md:grid-cols-4), never a bare grid-cols-n.
  • Every <table> sits in an overflow-x-auto wrapper; the page body never scrolls horizontally (the walk-through asserts scrollWidth === clientWidth on phone width).
  • Floating panels (bell dropdown, copilot) never exceed 100vw.
  • Touch targets ≥ 44 px on the pupil launcher; icon buttons ≥ 36 px elsewhere.

12. Public site (home, report, docs, register, sign-in)

  • Every public page ends with the shared site footer (components/site-footer.tsx): a brand column and four link groups (For schools · For edtech companies · Parents and pupils · Insights and resources) drawn from lib/site-links.ts, the single source that also feeds app/sitemap.ts. A test fails on a footer link that is not a real route or home-page anchor.
  • Search: app/robots.ts allows the public pages and the parent / pupil sign-in pages and blocks every portal, API and the mock IdP; app/sitemap.ts lists the public routes and the documentation pages. The root layout sets metadataBase, a title template, Open Graph and Twitter cards; each public page sets its own title, description and canonical URL (client pages do so from a sibling layout.tsx).
  • EduSpaze is described as a platform, never a marketplace, in titles and descriptions.

13. Adding a portal — checklist

  1. Layout: PortalShell with portal="<KEY>", sub, userName, userDetail, nav (every entry with a registry icon). Do not add a width cap or centring around children; the canvas is full width by standard.
  2. Routes: <base>/account/page.tsx (ProfilePage with a context line) and <base>/notifications/page.tsx (NotificationsPage).
  3. Registry: portal prefix, module rules (function pages vs information pages) in lib/copilot/registry.ts; Portal enum for notifications; copilot config + articles.
  4. Tests in tests/shell.test.ts and tests/copilot.test.ts pick the new portal up from the layout and page files.

7.8 Read-only record view (components/record-view.tsx)

The twin of the standard form, for someone with VIEW but not EDIT on the module (docs/rbac-spec.md §6.2). Never a greyed-out form — a person who cannot edit should not be invited to type.

  • RecordView renders the same card, the same field order and the same labels as the edit form; ViewField renders one field, with in grey where a value is empty.
  • An Edit button appears in the header only when the permission is held (editHref + canEdit).
  • With canEdit false, a closing line states plainly that the record can be read but not changed, and who to ask.
  • In a standard list the row action reads View with the view icon rather than Manage with edit; write-only row actions and New … buttons are absent without CREATE.
  • Panels inside a record follow their own module, so reading a school does not imply touching its plan, its credits or its staff accounts.

7.9 Permission matrix (components/admin/role-forms.tsx)

How a role is edited. One row per module, one column per verb, grouped exactly as lib/rbac/modules.ts groups them.

  • A checkbox appears only where the verb applies; everything else is a grey .
  • Each row carries the module's own name and description — the words the admin reads, not developer shorthand.
  • Modules marked sensitive show an amber sensitive chip.
  • universal modules are ticked and disabled: your own account is never revocable.
  • Ticking Create, Edit or Delete ticks View with it, and the server applies the same rule, so a hand-made POST cannot produce an incoherent role.

7.10 Conversation thread (components/messaging/thread.tsx)

One thread, in every portal (docs/messaging.md §7).

  • The header names the thread and chips every participant — dashed border and grey text for anyone whose identity is hidden from the reader.
  • Every message line carries its author's label and an identity hidden badge where it applies. Once at the top is not enough: a label can be scrolled past.
  • A system line (somebody joined, somebody chose to be identified) is centred, small and never attributed to a person.
  • A body the reader may not see renders as •••• and is not in the page's data.
  • Who can see whose identity is a collapsible table under the thread: a row per person, a chip per side saying "sees them" or "cannot see them".

7.11 The dock (components/shell/dock.tsx)

The copilot established this shape; messaging uses the same one. Two panels that behave differently in the same corner of the screen is a worse product than one behaviour learned once.

  • Modes: closed (a launcher pill), panel (420px over the page), docked (beside the page, reserving --copilot-dock), max (full viewport). Maximise remembers where it came from; Esc reverts, then closes.
  • The mode is remembered per portal per panel. On phones every open mode is maximised and nothing is docked.
  • Only one panel is open at a time — opening messaging closes the copilot and the other way round, so the page is never squeezed by two docks.
  • An occupant is handed the current mode and lays itself out to fit: messaging is master–detail at 420px and a list-plus-thread split when maximised.
  • The utility bar carries a launcher for each: the message icon with its unread count sits next to the notification bell.
  • The page reserves room beneath itself for the closed launchers (pb-32 on portal-main). They are fixed to the bottom-right of the *viewport*, so without that reserve the last row of any scrolled-to-bottom page — a Restore button, a pagination *Next* — sits under a pill and swallows the click, which reads as a control that does not work. Anything else fixed over the page owes the same clearance.

7.12 Modal dialog (components/modal.tsx)

For a short piece of work that interrupts a page — changing a password, enrolling a second factor. Not for anything long enough that somebody would want a URL for it.

  • A native <dialog>: the browser supplies the focus trap, the backdrop, Escape, and inertness of the page behind. A hand-rolled overlay gets all four subtly wrong.
  • One trigger, one dialog. Closed by Escape, the backdrop, the ✕, or by the work inside finishing — pass closeSignal and the dialog closes when it changes.
  • The card behind the trigger keeps the standing facts, so the page is still worth reading when the dialog is shut: *when the password last changed*, *whether two-factor is on*.
  • The body only mounts while open, so a form inside starts clean each time.

7.13 Two-factor enrolment (components/shell/account-forms.tsx)

On every portal's account page, for every kind of user — not only EduSpaze staff.

  • The card states the position first: Two-factor: On / Not set up, and when it was last used. The action is a button that opens §7.12.
  • Inside, the key is offered both ways: a QR to scan and the same secret in typed blocks. One of those always works when the other does not — a locked-down phone camera, a desktop password manager, a person reading it aloud.
  • The QR is rendered to SVG on the server: the secret is in the URI and must not be handed to anything else to draw.
  • The secret is shown once. Confirming a code from the app is what completes enrolment.

7.14 AI action button (components/admin/ai-action-button.tsx)

Any button whose press calls a model — *Draft summary*, *Generate suggested bundle*, *Draft insight summary*. See docs/ai-prompts.md §5.

  • These calls take several seconds. A plain server-action form shows nothing while it waits, and a button that does nothing visible when pressed reads as broken: this is exactly how *Draft insight summary* came to be reported as unclickable.
  • So while pending: disabled, aria-busy, a spinner, and the label swapped for a busyLabel that says what is happening and that it takes a moment.
  • The state comes from useFormStatus, so the button must be *inside* the <form> whose action it submits — that is what makes it work without any state plumbing on the page.
  • Never a second confirmation step. The work is cheap to redo and the result is a draft somebody reviews, so the press is the confirmation.

Source: docs/ui-ux-spec.md in the repository. Last updated with the code it describes.

UI/UX specification — EduSpaze