/projects/:id (name "project") is now Explore; /projects/:id/kanban (name "project-kanban") is Kanban. ProjectView.vue becomes a layout: header + sub-nav, loading the project and its cards (both children need the cards list) and rendering the active one via <RouterView>. ProjectExploreView.vue and ProjectKanbanView.vue hold what used to be each tab's own template/logic; Kanban additionally loads its own statuses, since Explore has no use for them. The sub-nav is now RouterLinks (active state matched on route.name), not buttons toggling local state. App.vue: the top-level <RouterView> was keyed by the full route path to force a fresh instance per project/card id -- with Explore/Kanban now separate paths under one layout, that would also remount the layout (and re-fetch the project) on every tab switch. Keyed by the matched route's top-level path + params instead, which is the same value for both of a project's child routes. AppSidebar: the project switcher and the inbox-drag refresh both used to check route.name === 'project' for "this project is open" -- fixed to cover both routes for the switcher, and narrowed to 'project-kanban' specifically for the inbox-drag refresh, since Kanban is the only route with a draggable list a card could have moved to/from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Project Manager — web
Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.
Develop
npm install
npm run dev # http://localhost:5173
The dev server proxies /api to http://localhost:8080 (the Dockerised API —
run docker compose up -d in the parent directory first). Override the target
with VITE_PROXY_TARGET, or point the app at a different API entirely with
VITE_API_BASE_URL (see .env.example).
Build
npm run build # type-checks, then emits dist/
npm run preview
The parent Dockerfile runs this build in a Node stage and copies dist/ into
the PHP image's public/, so the app container serves the compiled SPA at /.
There is no separate frontend container — a production image is docker compose build app from the parent directory.
Layout
src/main.ts App bootstrap; resolves the stored session before mount
src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts Pinia store: the user's projects (fetch + create)
src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/lib/webauthn.ts base64url <-> ArrayBuffer + the register/login passkey ceremonies
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/components/ProjectManageMenu.vue "Manage" dropdown + delete-project modal, shared by ProjectView and ProjectConfigureView
src/views/ DashboardView, ProjectView, ProjectConfigureView, LoginView,
ProfileView, VerifyEmailView
Signed-in "app" routes (meta.requiresAuth) render inside a persistent shell:
the top bar, then a left sidebar (AppSidebar.vue) beside the routed view.
The sidebar stays mounted across navigation — it holds a Dashboard link, a
divider, a project <select>, another divider, then the Inbox (see below).
The dropdown is a v-model-bound writable computed (selectedProjectId):
its getter reads the open project from route.params.id, so it tracks
whichever project is current; its setter router.pushes to the chosen one, so
it also works as a project switcher from anywhere. <RouterView :key="route.path"> remounts the view on every path change so switching
projects always does a fresh load.
Below 768px (style.css's one layout breakpoint) the sidebar becomes an
off-canvas drawer instead of sitting beside the page: position: fixed,
translated out of view by default, slid in via a .sidebar--open class.
App.vue owns the drawerOpen state -- a ☰ button in the header (hidden
above the breakpoint) opens it; a backdrop tap, the drawer's own ✕ (which
AppSidebar emits close for), or any navigation (a route.fullPath
watcher) closes it. Above the breakpoint drawerOpen just goes unused, since
nothing renders the button that would set it.
/ redirects to /dashboard (DashboardView.vue): a full-width grid linking
to each project (title + card count), with a "Create a project" tile
styled to match sitting last in the same grid (creating one stays on the
dashboard; the grid and the sidebar dropdown both pick it up via the shared
projects store). Signed-out routes
(/login, /verify-email) render without the sidebar.
Styling
style.css is one global stylesheet (no scoped/component styles) with a
handful of conventions worth knowing before adding to it:
- Tokens (
:rootcustom properties): colours (--bg,--surface,--border,--text,--muted,--accent(-text),--error,--warn-bg/-border), a border-radius scale (--radius-sm6px compact controls,--radius-md8px buttons/inputs,--radius-lg10px tiles,--radius-xl12px panels,--radius-pill), and two opacity values (--opacity-disabled0.6,--opacity-ghost0.5 for a dragged item's placeholder). Reach for one of these before hand-writing a value that's really just "the same grey border again" or "the same rounding as every other button." .fieldis the one themed<input>/<textarea>/<select>look, used everywhere from the login form to the sidebar's project switcher..field--compactis the same thing smaller, for a control that's a flex child beside a button (an inline "add" row) or squeezed into the sidebar..field--autosizeadds theresize:none; overflow:hiddenthe dashboard's JS-driven auto-growing textarea needs. Crucially,.fieldis applied directly to the control, not to a wrapper (.form inputno longer exists) -- see the next point for why that's load-bearing, not just style.- Put component classes on the control itself, not a wrapping element,
for anything that needs a
:focusstate.input:focus/textarea:focus/select:focus(global, removes the default outline and colours the border with--accentinstead) has specificity(0,0,1,1)-- one pseudo-class, one element. A rule shaped.wrapper input { border-color: var(--border) }ties it exactly, and being unconditional, silently wins that tie by simply appearing later in the file -- the input keepsvar(--border)forever, focused or not, no matter what:focussays..field, applied straight to the element, is(0,0,1,0)-- strictly lower, so it can never win that tie regardless of source order. (This bit a real shipped version of the app: half the inputs had a working focus ring and half silently didn't, purely from which pattern each one happened to use.) A component that needs its own more elaborate:focusstate (.card-row__text, background swap included) writes.card-row__text:focusexplicitly -- specificity(0,0,2,0), genuinely higher, wins outright rather than by luck of ordering.
Inbox
A card with no project lives in the caller's inbox (useInboxStore), rendered
in the sidebar under the project list -- not per-project, and not tied to
whatever page is open. It's a vuedraggable list in the same "kanban" drag
group as every project's kanban columns (below), so a card can be dragged
straight out of the sidebar into any status column of whichever project is
currently open, or the other way. AppSidebar's onInboxChange persists a
drop via reorderColumn(null, null, ids), then reloads the inbox and, if a
project view is currently mounted (route.name === 'project'), that project's
cards too -- either side of a drag could have been the inbox. A small form
under the list adds a card straight to the inbox.
Empty (no cards) is shown as a subtle dashed drop-area rather than blank
space -- .kanban__cards:empty in style.css, so it's pure CSS keyed off the
real DOM child count. Since a card's kanban column/inbox <draggable> is
always rendered even with nothing in it (see above), that container is
genuinely childless when empty, so the rule applies with no extra markup or
JS state; it steps aside automatically once Sortable inserts its drag-over
ghost. The same rule covers every kanban status column too (below).
Project detail
/projects/:id shows one project. ProjectView.vue is a layout, not a
page of its own: it renders on a full-width layout (the parent route sets
meta.wide, inherited by its children, which widens .app__main in
App.vue), loads the project and its cards, and renders the header + a small
sub-nav — its two children (below) render into its <RouterView>.
The header: a plain title heading, with an inline .title-back arrow to the
dashboard right before the text -- renaming lives on the configuration view
(below) -- and ProjectManageMenu.vue top right, with a Configure link
(to that view) and a Delete project action that opens a confirmation
modal; confirming calls DELETE /api/projects/:id and returns to the
dashboard.
The sub-nav (RouterLinks styled as tabs, active one matched on route.name)
is real navigation, not client-side tab state -- Explore is the project's
own route (/projects/:id, name project), Kanban a child beneath it
(/projects/:id/kanban, name project-kanban). Both read the cards store
the layout already loaded; App.vue's top-level <RouterView> key is derived
from the matched route's top-level path plus params rather than the full
path, so switching between them doesn't remount the layout (and re-fetch the
project) the way switching to a different project's id still does.
Explore
The flat card list, sorted by name (case-insensitive) via a sortedCards
computed — there is no manual order here. Each row links to the card's own
view (/cards/:id — see Card detail) and shows its status
chip (card.status.name or "No status") next to the text; a delete button
sits outside that link.
Kanban
One column per project status, in position order -- the inbox is not a
column here; it's in the sidebar (see above), though it's still a valid drag
source/target (the only view where that's true -- Explore has no draggable
list of its own, which the sidebar accounts for when deciding whether to
refresh a project's cards after an inbox drag). Unlike the cards, statuses
are this route's own fetch (GET /api/projects/:id/statuses) -- Explore has
no use for them. board is derived from cards.cards + those statuses and
rebuilt by a watch whenever either changes.
Every drop — whether reordering within a column (moved) or dragging in from
another column or the sidebar's inbox (added) — calls
reorderColumn(projectId, column.statusId, ids) from lib/cardOrder.ts (shared
with the sidebar) → PUT /api/cards/order. The server re-parents any moved-in
card and re-packs whatever column it left; afterwards the view always reloads
both inbox and this project's cards, since either could have been the other
side of the move.
Project configuration
/projects/:id/configure (ProjectConfigureView.vue) manages a project's
statuses. The header mirrors the project view's — an inline .title-back
arrow before the title, this time back to the project, and
ProjectManageMenu top right (its own Configure link is hidden here, since
it would just point at the current page).
A Rename project section (a plain PATCH /api/projects/:id form,
{ title }) sits above Statuses. On success it also calls the
projects store's fetchProjects() -- the local project ref (and this
view's own header) update from the PATCH response directly, but the
dashboard grid and the sidebar's project dropdown read from that store, so
without the extra fetch the new name (and alphabetical position -- projects
are API-ordered by title) wouldn't show up there until some other reload.
The status list is a vuedraggable list (its own list, no shared drag group
with the kanban board) bound directly to a local statuses ref; dragging
mutates it in place, and @change persists the whole new order via
PUT /api/projects/:id/statuses/order, reverting to the server's copy on
failure. A small form below it adds a status
(POST /api/projects/:id/statuses) at the end of the list.
Each row has a delete button. A status with no cards deletes immediately; one
still holding cards gets 409 back from DELETE .../statuses/:statusId with
error.details.card_count (surfaced as ApiError#cardCount) -- that opens a
modal asking which other status to move its cards to, then resubmits the same
delete with { reassign_to }, which reassigns and deletes in one request. The
last remaining status can't be deleted (a project card always needs one); its
row's delete button is disabled once statuses.length <= 1.
Card detail
A card's text is no longer inline-editable anywhere it's listed -- the
Explore row, a kanban column, and the sidebar inbox all just link to
/cards/:id (CardView.vue) instead. Its header follows the same pattern as
every other view now: an inline .title-back arrow before the title text,
Manage in the actions corner. The arrow goes to the card's project, or the
dashboard for an inbox card (there's no standalone view of the inbox to
return to). Below the header sits just the status badge -- no tabs, since
there's nothing else to show for a single card.
/cards/:id/configure (CardConfigureView.vue) mirrors the project
configuration view once more: same header (its back arrow instead returns to
the card view), with a Rename card form below it (PATCH /api/cards/:id,
{ text }). CardManageMenu.vue -- the card equivalent of
ProjectManageMenu.vue -- provides both views' Manage menu: a
Configure link (hidden on the configure view itself) and a Delete
card action behind a confirmation modal.
Saving a rename or confirming a delete refreshes whichever store holds the
card -- the cards store (for a project card) or the inbox store -- so the
board or sidebar it came from picks up the change; deleting also navigates
back to wherever its back arrow points.
Auth flow
There is no password and no separate sign-up — LoginView is an email field
and a "Send sign-in link" button (POST /api/auth/magic-link), for a new
address or a returning one alike. On success it shows a "check your email"
message; it does not sign the caller in itself. If the browser supports
WebAuthn, a "Log in with a passkey" button sits below the form, past a
divider (see Passkeys) — that one does sign the caller in
directly, no email round trip.
/verify-email?token=…is the target for every magic link (sign-in and email-change confirmation both).VerifyEmailViewPOSTs the token viaauth.verifyEmail(), which returns a session — opening the link is what actually signs the caller in — then redirects to the dashboard.- The token is kept in
localStorageand sent asAuthorization: Bearer …. On load,fetchMe()validates it viaGET /api/me; a failure clears it. - Routes with
meta.requiresAuthredirect to/login(preserving the intended path) when there is no authenticated user. - Because the only way to get a session is opening a link or using a passkey
(which itself requires a prior link-based sign-in to register),
user. email_verifiedis alwaystruefor a signed-in user — the frontend doesn't show any verification nagging or resend UI.
Passkeys
src/lib/webauthn.ts wraps the two ceremonies. Both fetch a { challenge_id, options } pair from the API, decode options.publicKey's base64url fields
(challenge, user.id, *Credentials[].id) into ArrayBuffers, call
navigator.credentials.create() / .get(), then base64url-encode the
resulting PublicKeyCredential's response back into JSON for the API
({ id, response: { clientDataJSON, ... } }). passkeysSupported() is a
one-line window.PublicKeyCredential check gating the UI everywhere below.
- Register (
ProfileView, "Passkeys" section) — lists the caller's passkeys (GET /api/passkeys) with a Remove button each (DELETE /api/passkeys/{id}), and an "Add a passkey" form: a label input (pre-filled with a guess fromnavigator.userAgent, e.g. "Mac") and a button callingregisterPasskey(label). On success it appends to the local list and callsauth.fetchMe()souser.has_passkey(and the notice below) updates immediately. - Login (
LoginView) — the passkey button callsauth.loginWithPasskey(), which adopts the returned session exactly likeverifyEmail(), then redirects toroute.query.redirector/. A cancelled prompt (DOMExceptionnamedNotAllowedError) shows "Cancelled." rather than a generic error. PasskeyNotice.vue(mounted inApp.vue, between the header and the sidebar/main body — spans the full page width) shows when signed in withuser.has_passkey === false. Dismissing it writeslocalStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days; the banner stays hidden until that passes, and reappears immediately (no reload needed, sincehas_passkeyis reactive on the sharedauth.user) if every passkey is later removed.
Profile
/profile (ProfileView) sets meta.wide like the dashboard/project views,
rather than sitting in the default narrow column (that's now just the login
form). It shows the current address, the Passkeys section
described above, and a Change email form (new address only, no password).
On success the API has emailed a confirmation link to the new address and
set user.pending_email (shown as a notice until it's opened); the change
only lands once that link is opened. The button shows a live countdown driven
by retry_after and by 429 responses.