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
- 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.
- 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).
- Fail closed, explain once. Anything the user may not do shows one neutral message. Never confirm the existence of something they cannot see.
- Time and status, never judgement. Numbers about people (engagement, usage bands) show their confidence and never a ranking or a comparison with other people.
- 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)
| Token | Value | Use |
|---|---|---|
brand-500 / 600 | #f28500 / #ea7100 | primary actions, active navigation, unread state |
brand-50 / 100 | #fff7ed / #ffedd5 | active/selected backgrounds, copilot greeting |
navy-800 / 900 | #0b3954 / #03263b | avatars, code blocks, secondary emphasis |
ink / inkgray | #1a1a1a / #666666 | body text / secondary text |
gray-50…200 | Tailwind | page ground (gray-50), borders (gray-200), hover (gray-50/100) |
| Status | emerald ok · sky info · amber warning · red danger | badges, notices |
| Font | Rubik (--font-rubik), system fallback | everything |
| Radius | rounded-lg controls · rounded-xl cards · rounded-2xl hero cards · rounded-full pills | |
| Shadow | shadow-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 (menuicon,aria-expanded) at the top toggles it; the choice is remembered per browser inlocalStorage(eduspaze.sidebar) and published to the page as--sidebar-wbyShellProvider(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-wis 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 atitletooltip; 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) witharia-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-2xlon a detail line,max-w-3xlon a document), not the page. - Horizontal padding steps with the viewport:
1rem(< 640) ·1.5rem(≥ 640) ·2rem(≥ 1024). Left margin is--sidebar-wfrom 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-autocontainer; 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
| Portal | Base | Entries (in order) |
|---|---|---|
| School | /school | Dashboard, 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 | /startup | Dashboard, Lead Inbox, Market Insights, Challenge Statements, Matchmaking Trips, Profile & Pilot Offer, Integration & API |
| Platform admin | /admin | Dashboard, 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 | /parent | Your children (child pages carry their own Apps · Timeline · Activity tabs) |
| Pupil | /student | My 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:
- 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.
- Security — change password (email accounts) or change PIN (pupils); SSO identities when present; a line that account changes are audited.
- Support — Documentation, Contact support (
support_emailsetting), 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
| Pattern | Use | Shape |
|---|---|---|
| Overview | portal dashboards | PageHeader → 4-up StatCard row(s) → 2–3 cards of lists/charts → one call-to-action banner |
| List | schools, vendors, students, leads, articles | the standard list below: ListToolbar → Card with DataTable → Pagination |
| Detail | a lead, a pupil, an article | PageHeader with breadcrumb-style detail → two-column cards: facts left, actions/conversation right |
| Function page | anything that writes | forms inside cards; one primary SubmitButton; inline result text (emerald ok / red error) beside the button; the copilot steps aside |
| Settings | school settings, admin settings, integration | stacked cards, each a single concern with its own form and result line |
| Wizard | onboarding | steps 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:
ListToolbar— a GET form above the card: a search box with thesearchicon, 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.DataTable— uppercasetheadlabels,divide-yrows, numeric columns right-aligned, an empty-state row with one sentence, and anoverflow-x-autowrapper. The first column identifies the row (name infont-medium, one grey detail line).RowActions— the last column, right-aligned, icon-only controls (edit= manage,leads= email,key= reset password,power= enable/disable,vetting= vet) each witharia-labelandtitle; destructive ones turn red on hover. Never text buttons in rows; never more than four icons.Pagination— "41–57 of 57 · page 3 of 3", *Previous* / *Next* keeping search and filters, 20 rows per page (per5–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 withgroupBy/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:
hiddencarries 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.
- Route. Creating lives at
<base>/new; editing lives on the record's own detail page. A list page never holds a create form — itsPageHeaderaction is the primary New ‹thing› link (bg-brand-500), andNewRecordHeadergives the create page its title, one-line detail and a← All ‹things›back link. - Body. One column of
FormCards, one card per concern ("School", "Owner login", "Plan"), each with a title and a one-line detail. Fields sit inFormGrid(two columns fromsm); a wide field spans both withclassName="sm:col-span-2". - Fields. Always
Field+TextInput/Select/TextAreafromcomponents/form.tsx. The label is a plain noun,hintcarries the help text,requiredmarks what is required. Never a bare<input>. - Actions. One
FormActionsrow at the end: the primarySubmitButton(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. - Validation. A zod schema in
lib/validation/schemas.ts, parsed withparseForm. The first problem is shown in the actions row; the form keeps what the user typed. - 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 byFormResultwith "It will not be shown again". - 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
preblock 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 (
skyinfo,amberwarning,emeralddone,redfailed). - Session notice: a state that applies to the whole session (an EduSpaze support session) is passed to
PortalShellasnoticeand 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-labelandtitle; toggles exposearia-pressedoraria-expanded; the active nav entry hasaria-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).
| Width | Sidebar | Canvas | Copilot |
|---|---|---|---|
| ≥ 1024 px desktop | expanded unless the user collapsed it | --sidebar-w margin, 2rem padding, full remaining width | panel / docked / max as chosen |
| 768–1023 px tablet | starts collapsed (icons); user can expand | --sidebar-w margin, 1.5rem padding | panel / docked / max; docked still reserves 420 px |
| < 768 px phone | off-canvas drawer opened from the utility-bar burger; always shows labels; backdrop; closes on navigation / Esc | no 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 baregrid-cols-n. - Every
<table>sits in anoverflow-x-autowrapper; the page body never scrolls horizontally (the walk-through assertsscrollWidth === clientWidthon 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 fromlib/site-links.ts, the single source that also feedsapp/sitemap.ts. A test fails on a footer link that is not a real route or home-page anchor. - Search:
app/robots.tsallows the public pages and the parent / pupil sign-in pages and blocks every portal, API and the mock IdP;app/sitemap.tslists the public routes and the documentation pages. The root layout setsmetadataBase, 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 siblinglayout.tsx). - EduSpaze is described as a platform, never a marketplace, in titles and descriptions.
13. Adding a portal — checklist
- Layout:
PortalShellwithportal="<KEY>",sub,userName,userDetail,nav(every entry with a registry icon). Do not add a width cap or centring aroundchildren; the canvas is full width by standard. - Routes:
<base>/account/page.tsx(ProfilePagewith a context line) and<base>/notifications/page.tsx(NotificationsPage). - Registry: portal prefix, module rules (function pages vs information pages) in
lib/copilot/registry.ts;Portalenum for notifications; copilot config + articles. - Tests in
tests/shell.test.tsandtests/copilot.test.tspick 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.
RecordViewrenders the same card, the same field order and the same labels as the edit form;ViewFieldrenders 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
canEditfalse, 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
viewicon rather than Manage withedit; write-only row actions and New … buttons are absent withoutCREATE. - 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
sensitiveshow an amber sensitive chip. universalmodules 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-32onportal-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
closeSignaland 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 abusyLabelthat 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.
