Un-hard-wrap all Markdown documentation

Every prose paragraph and list item was manually wrapped at ~80-100
columns; joined each back into a single line. Headings, table rows,
and fenced code blocks are untouched -- tables already had one row per
line, and wrapping inside a code fence is the code's own formatting,
not something this applies to.

Also fixed two pre-existing typos this surfaced (both from wrapping
without leaving the space that was actually intended): a missing space
in "{ challenge_id, options }" and a stray "+ TypeScript" that had
accidentally been written as if it were a new line, in web/README.md
and README.md respectively.

Code comments are explicitly out of scope for this -- left exactly as
they were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 02:29:08 +01:00
co-authored by Claude Sonnet 5
parent f1309b4c10
commit 5c8f2dbf07
7 changed files with 90 additions and 445 deletions
+8 -27
View File
@@ -1,17 +1,10 @@
# PHP Project Manager # PHP Project Manager
A small project-management app: each user owns **projects**, and each A small project-management app: each user owns **projects**, and each project holds **cards** you can organise as a flat list or a drag-and-drop kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3 + TypeScript PWA frontend. There's no password — signing in is a link emailed to you, which creates your account the first time, and you can add a passkey afterwards for a quicker sign-in next time.
project holds **cards** you can organise as a flat list or a drag-and-drop
kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3
+ TypeScript PWA frontend. There's no password — signing in is a link
emailed to you, which creates your account the first time, and you can add
a passkey afterwards for a quicker sign-in next time.
## Documentation ## Documentation
This page covers running the app. For anything more technical — the REST This page covers running the app. For anything more technical — the REST API, configuration, running without Docker, project layout, and the development history — see [docs/](docs/).
API, configuration, running without Docker, project layout, and the
development history — see [docs/](docs/).
## Getting started ## Getting started
@@ -21,29 +14,17 @@ The only requirement is Docker with the Compose plugin.
docker compose up -d docker compose up -d
``` ```
Then open <http://localhost:8080>. A **[Mailpit](https://mailpit.axllent.org/)** Then open <http://localhost:8080>. A **[Mailpit](https://mailpit.axllent.org/)** mail-catcher also starts alongside the app, at <http://localhost:8025> — since a default local setup has nowhere else to send the sign-in emails.
mail-catcher also starts alongside the app, at <http://localhost:8025> —
since a default local setup has nowhere else to send the sign-in emails.
### First-time login ### First-time login
1. Enter any email address and submit. There's no separate sign-up step and 1. Enter any email address and submit. There's no separate sign-up step and no password to choose — this both creates your account and sends it a sign-in link.
no password to choose — this both creates your account and sends it a 2. Open <http://localhost:8025> (Mailpit) instead of a real inbox, and open the message that just arrived there.
sign-in link. 3. Click the link inside it. You're now signed in, on a new, already-verified account.
2. Open <http://localhost:8025> (Mailpit) instead of a real inbox, and open
the message that just arrived there.
3. Click the link inside it. You're now signed in, on a new,
already-verified account.
From then on, your profile page (top right, your email address) lets you add From then on, your profile page (top right, your email address) lets you add a **passkey** — your device's fingerprint, face, or PIN — so you don't need to wait on an email to sign in next time.
a **passkey** — your device's fingerprint, face, or PIN — so you don't need
to wait on an email to sign in next time.
Everything the app stores (your account, projects, cards) lives in a Docker Everything the app stores (your account, projects, cards) lives in a Docker volume, so it survives `docker compose restart` / `down` + `up`; `docker compose down -v` wipes it for a clean slate. See [docs/setup.md](docs/setup.md) for configuration, running without Docker, and running the test suite.
volume, so it survives `docker compose restart` / `down` + `up`;
`docker compose down -v` wipes it for a clean slate. See
[docs/setup.md](docs/setup.md) for configuration, running without Docker,
and running the test suite.
## Provenance ## Provenance
+5 -12
View File
@@ -1,15 +1,8 @@
# Documentation # Documentation
Technical documentation for the PHP Project Manager. Start with the root Technical documentation for the PHP Project Manager. Start with the root [README](../README.md) if you just want to run the app.
[README](../README.md) if you just want to run the app.
- **[API reference](api.md)** — every REST endpoint: auth (magic links, - **[API reference](api.md)** — every REST endpoint: auth (magic links, passkeys), projects, cards, statuses, error shapes, and a curl walkthrough.
passkeys), projects, cards, statuses, error shapes, and a curl walkthrough. - **[Architecture](architecture.md)** — backend layout and how the pieces fit together. Frontend architecture lives in [web/README.md](../web/README.md) instead, since it's a large enough topic on its own.
- **[Architecture](architecture.md)** — backend layout and how the pieces fit - **[Setup & configuration](setup.md)** — running without Docker, every environment variable, and the test suite.
together. Frontend architecture lives in - **[Development history](history.md)** — a stage-by-stage log of how the app got here.
[web/README.md](../web/README.md) instead, since it's a large enough topic
on its own.
- **[Setup & configuration](setup.md)** — running without Docker, every
environment variable, and the test suite.
- **[Development history](history.md)** — a stage-by-stage log of how the app
got here.
+25 -113
View File
@@ -1,7 +1,6 @@
# API reference # API reference
Base path: `/api`. All request and response bodies are JSON; send Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`.
`Content-Type: application/json`.
### `GET /api/health` ### `GET /api/health`
@@ -11,10 +10,7 @@ Base path: `/api`. All request and response bodies are JSON; send
## Auth ## Auth
There is no password and no separate registration endpoint. Entering an email There is no password and no separate registration endpoint. Entering an email address and opening the link sent to it is the entire flow, for a brand-new address and a returning one alike. A user can also register one or more [passkeys](#passkeys) and use one instead, once signed in at least once.
address and opening the link sent to it is the entire flow, for a brand-new
address and a returning one alike. A user can also register one or more
[passkeys](#passkeys) and use one instead, once signed in at least once.
| Method | Path | Auth | Purpose | | Method | Path | Auth | Purpose |
|--------|------|------|---------| |--------|------|------|---------|
@@ -29,14 +25,7 @@ address and a returning one alike. A user can also register one or more
Request: `{ "email": "ada@example.com" }`. Request: `{ "email": "ada@example.com" }`.
Emails a one-time sign-in link (`<APP_URL>/verify-email?token=…`, 15-minute Emails a one-time sign-in link (`<APP_URL>/verify-email?token=…`, 15-minute expiry) and always returns `202` with the same message. If the address has no account yet, one is created (unverified) right here — that's the only "sign up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown address is silently ignored (still `202`, nothing sent) and only an address that already has an account can sign in. A link is only actually (re-)sent if this address hasn't been emailed one in the last 60 seconds. `422` if the address is malformed.
expiry) and always returns `202` with the same message. If the address has no
account yet, one is created (unverified) right here — that's the only "sign
up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown
address is silently ignored (still `202`, nothing sent) and only an address
that already has an account can sign in. A link is only actually (re-)sent if
this address hasn't been emailed one in the last 60 seconds. `422` if the
address is malformed.
```json ```json
{ "message": "Check your email for a link to sign in." } { "message": "Check your email for a link to sign in." }
@@ -44,8 +33,7 @@ address is malformed.
### `POST /api/auth/verify-email` ### `POST /api/auth/verify-email`
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is `400` (distinct messages). Success signs the caller in:
`400` (distinct messages). Success signs the caller in:
```json ```json
{ {
@@ -63,24 +51,15 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
} }
``` ```
Opening a link is the only way to obtain a session, so an authenticated request Opening a link is the only way to obtain a session, so an authenticated request is always for a verified address — `email_verified` is `true` from the first token a user's browser ever holds.
is always for a verified address — `email_verified` is `true` from the first
token a user's browser ever holds.
### `GET /api/me` ### `GET /api/me`
Requires `Authorization: Bearer <jwt>`. `200 OK`: the same `user` object shown Requires `Authorization: Bearer <jwt>`. `200 OK`: the same `user` object shown above. `pending_email` is the address a still-valid email-change link is waiting on, or `null`. `401` if the header is missing, malformed, or the token is invalid/expired.
above. `pending_email` is the address a still-valid email-change link is
waiting on, or `null`. `401` if the header is missing, malformed, or the token
is invalid/expired.
### `POST /api/email/change` ### `POST /api/email/change`
Requires `Authorization: Bearer <jwt>`. Body: `{ "email": "new@example.com" }`. Requires `Authorization: Bearer <jwt>`. Body: `{ "email": "new@example.com" }`. The address must be free (`409`) and different from the current one (`422`). Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s resend window, per user) — `429` with `error.details.retry_after` when too soon. On success, `202` with `retry_after` and `pending_email`:
The address must be free (`409`) and different from the current one (`422`).
Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s
resend window, per user) — `429` with `error.details.retry_after` when too
soon. On success, `202` with `retry_after` and `pending_email`:
```json ```json
{ {
@@ -90,20 +69,11 @@ soon. On success, `202` with `retry_after` and `pending_email`:
} }
``` ```
The change is **not applied until** the magic link sent to the new address is The change is **not applied until** the magic link sent to the new address is opened — until then `GET /api/me` still shows the old address, with `pending_email` set. Opening that link both changes the address and re-verifies it, via the same `/api/auth/verify-email`.
opened — until then `GET /api/me` still shows the old address, with
`pending_email` set. Opening that link both changes the address and re-verifies
it, via the same `/api/auth/verify-email`.
## Passkeys ## Passkeys
WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey is always registered as a **discoverable, user-verified** credential, which is what makes login usernameless: the browser prompts the signed-in device for whichever passkey it has for this site, with no email typed first. There's no attestation/provenance check (`'none'` format) — this only confirms "the same device that registered", the standard trust model for a public site's own users, not a fleet of company-issued security keys.
is always registered as a **discoverable, user-verified** credential, which is
what makes login usernameless: the browser prompts the signed-in device for
whichever passkey it has for this site, with no email typed first. There's no
attestation/provenance check (`'none'` format) — this only confirms "the same
device that registered", the standard trust model for a public site's own
users, not a fleet of company-issued security keys.
| Method | Path | Auth | Purpose | | Method | Path | Auth | Purpose |
|--------|------|------|---------| |--------|------|------|---------|
@@ -114,35 +84,16 @@ users, not a fleet of company-issued security keys.
| `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) | | `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) |
| `POST` | `/api/auth/passkey/verify` | — | verify and sign in | | `POST` | `/api/auth/passkey/verify` | — | verify and sign in |
Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }``options.publicKey` is passed more or less directly to [`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) / [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) (binary fields travel as base64url strings; the frontend converts them — see [web/README.md](../web/README.md)). `challenge_id` identifies a **single-use** challenge, good for 5 minutes, and must be sent back with the browser's response:
`options.publicKey` is passed more or less directly to
[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)
/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)
(binary fields travel as base64url strings; the frontend converts them —
see [web/README.md](../web/README.md)). `challenge_id` identifies a **single-use**
challenge, good for 5 minutes, and must be sent back with the browser's
response:
- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. - `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. `credential` is `{ id, response: { clientDataJSON, attestationObject } }` (all base64url). `201` with the stored passkey (`{ id, label, created_at, last_used_at }` — never the credential id or public key) on success; `400` if the response doesn't check out, `409` if that credential is already registered.
`credential` is `{ id, response: { clientDataJSON, attestationObject } }` - `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, where `credential` also carries `authenticatorData`, `signature`, and `userHandle`. Success returns the same `{ user, token, expires_at }` envelope as `/api/auth/verify-email`. `401` if the credential isn't recognised or the signature doesn't check out.
(all base64url). `201` with the stored passkey
(`{ id, label, created_at, last_used_at }` — never the credential id or
public key) on success; `400` if the response doesn't check out, `409` if
that credential is already registered.
- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`,
where `credential` also carries `authenticatorData`, `signature`, and
`userHandle`. Success returns the same `{ user, token, expires_at }` envelope
as `/api/auth/verify-email`. `401` if the credential isn't recognised or the
signature doesn't check out.
`user.has_passkey` (on every user object) is `true` once at least one is `user.has_passkey` (on every user object) is `true` once at least one is registered — that's what the frontend's "add a passkey" notice keys off.
registered — that's what the frontend's "add a passkey" notice keys off.
## Projects ## Projects
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one All routes below require `Authorization: Bearer <jwt>`. A project belongs to one owner (the creator); another user's project — or a missing one — always responds `404`.
owner (the creator); another user's project — or a missing one — always responds
`404`.
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
@@ -152,9 +103,7 @@ owner (the creator); another user's project — or a missing one — always resp
| `PATCH` | `/api/projects/{id}` | rename the project (`title`) | | `PATCH` | `/api/projects/{id}` | rename the project (`title`) |
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | | `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
`GET /api/projects` is always ordered alphabetically (case-insensitive) by `GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most **100 projects** — creating one beyond that responds `409`.
title; there is no other sort option. A user may own at most **100 projects**
creating one beyond that responds `409`.
Create/update body: `title` (required, 1255 chars). Create/update body: `title` (required, 1255 chars).
@@ -176,17 +125,11 @@ Project representation:
`GET /api/projects` returns `{ "projects": [ … ] }`. `GET /api/projects` returns `{ "projects": [ … ] }`.
Creating a project also seeds it with three **statuses** — "To do", "Doing", Creating a project also seeds it with three **statuses** — "To do", "Doing", "Done" (see [Statuses](#statuses)).
"Done" (see [Statuses](#statuses)).
## Cards ## Cards
A card either sits in its owner's **inbox** (`project_id` and `status_id` both A card either sits in its owner's **inbox** (`project_id` and `status_id` both `null`) or belongs to exactly one of their projects with a status in it (both set) — enforced by a database CHECK constraint, never one without the other. The inbox is global to the user, not per-project. Because a card may have no project, single-card and ordering routes are addressed globally, by the card's own id, rather than nested under a project:
`null`) or belongs to exactly one of their projects with a status in it (both
set) — enforced by a database CHECK constraint, never one without the other.
The inbox is global to the user, not per-project. Because a card may have no
project, single-card and ordering routes are addressed globally, by the card's
own id, rather than nested under a project:
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
@@ -197,31 +140,15 @@ own id, rather than nested under a project:
| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) |
| `PUT` | `/api/cards/order` | set the order/contents of one column | | `PUT` | `/api/cards/order` | set the order/contents of one column |
Create body (either creation route): `text` (required, 11000 chars), Create body (either creation route): `text` (required, 11000 chars), `complete` (optional bool, default `false`). The project route also takes an optional `status_id`, appending the card to the end of that status (must belong to the project, else `422`) — omitted, it goes in the project's first status instead. `PATCH` accepts `text` and/or `complete` only — moving a card is done via the order route below, not PATCH.
`complete` (optional bool, default `false`). The project route also takes an
optional `status_id`, appending the card to the end of that status (must
belong to the project, else `422`) — omitted, it goes in the project's first
status instead. `PATCH` accepts `text` and/or `complete` only — moving a card
is done via the order route below, not PATCH.
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards **Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards that share an `(owner, project, status)`. The inbox is its own column, per owner. `PUT /api/cards/order` sets one column's contents and order:
that share an `(owner, project, status)`. The inbox is its own column, per
owner. `PUT /api/cards/order` sets one column's contents and order:
```json ```json
{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } { "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] }
``` ```
`project_id`/`status_id` are both `null` for the inbox, or both set to a `project_id`/`status_id` are both `null` for the inbox, or both set to a project owned by the caller and one of its statuses (`404`/`422` otherwise). `card_ids` must be distinct cards owned by the caller and must include every card already in the target column (`422` otherwise); it rewrites positions to `0..n-1`. Any card in the list that wasn't already in that column is re-parented into it — moving it from another project's status, or the inbox, or vice versa — and the column it left is re-packed, all in one transaction. Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card in the kanban board (or the sidebar's inbox) calls on every drop; moving a card from one project to another is just two calls, via the inbox in between.
project owned by the caller and one of its statuses (`404`/`422` otherwise).
`card_ids` must be distinct cards owned by the caller and must include every
card already in the target column (`422` otherwise); it rewrites positions to
`0..n-1`. Any card in the list that wasn't already in that column is
re-parented into it — moving it from another project's status, or the inbox,
or vice versa — and the column it left is re-packed, all in one transaction.
Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card
in the kanban board (or the sidebar's inbox) calls on every drop; moving a
card from one project to another is just two calls, via the inbox in between.
Card representation: Card representation:
@@ -241,17 +168,11 @@ Card representation:
} }
``` ```
`project_id` and `status_id` are `null` together for an inbox card. `status` is `project_id` and `status_id` are `null` together for an inbox card. `status` is the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` returns `{ "cards": [ … ] }`.
the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards`
returns `{ "cards": [ … ] }`.
## Statuses ## Statuses
Every project has an ordered set of card statuses, created with the project: Every project has an ordered set of card statuses, created with the project: "To do", "Doing", "Done". They are project-specific — each project owns its own rows, managed from the project's **configuration** view (create, reorder, delete). A project always keeps at least one status, since a project card must have one; deleting the last one is rejected (`409`).
"To do", "Doing", "Done". They are project-specific — each project owns its own
rows, managed from the project's **configuration** view (create, reorder,
delete). A project always keeps at least one status, since a project card must
have one; deleting the last one is rejected (`409`).
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
@@ -270,15 +191,9 @@ have one; deleting the last one is rejected (`409`).
} }
``` ```
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by the caller responds `404`.
the caller responds `404`.
**Deleting a status that still has cards** fails with `409` and **Deleting a status that still has cards** fails with `409` and `error.details.card_count` set, rather than silently orphaning them (a referenced status can't be deleted at the database level either — the FK is `ON DELETE RESTRICT`). Retry with `{ "reassign_to": <another status id> }` in the same project; those cards are moved there and the status deleted, in one transaction.
`error.details.card_count` set, rather than silently orphaning them (a
referenced status can't be deleted at the database level either — the FK is
`ON DELETE RESTRICT`). Retry with `{ "reassign_to": <another status id> }` in
the same project; those cards are moved there and the status deleted, in one
transaction.
## Error shape ## Error shape
@@ -292,10 +207,7 @@ Every error response looks like:
## Try it ## Try it
Signing in needs the link the API emails, so this pulls it back out of the Signing in needs the link the API emails, so this pulls it back out of the bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere). The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'` before grep (Mailpit's own JSON doesn't need that):
bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere).
The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'`
before grep (Mailpit's own JSON doesn't need that):
```bash ```bash
BASE=http://localhost:8080 BASE=http://localhost:8080
+4 -17
View File
@@ -1,8 +1,6 @@
# Architecture # Architecture
A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a passkey (see [docs/api.md](api.md)) — there's no session store or cookie.
TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a
passkey (see [docs/api.md](api.md)) — there's no session store or cookie.
## Backend layout ## Backend layout
@@ -29,23 +27,12 @@ docker/ Apache vhost (mod_rewrite, document root) + entrypo
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
``` ```
A `ProjectScopedController` base class centralizes "look up a project owned A `ProjectScopedController` base class centralizes "look up a project owned by the caller, or 404" for the controllers that need it (`Project`, `Card`, `CardStatus`). Every table a request can reach is scoped to the authenticated user one way or another — directly (`owner_id`/`user_id`) or via a project that is.
by the caller, or 404" for the controllers that need it (`Project`, `Card`,
`CardStatus`). Every table a request can reach is scoped to the
authenticated user one way or another — directly (`owner_id`/`user_id`) or
via a project that is.
## Frontend ## Frontend
The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern with its own conventions (routing, state, styling, drag-and-drop). See [web/README.md](../web/README.md) for all of that — this document only covers the backend.
with its own conventions (routing, state, styling, drag-and-drop). See
[web/README.md](../web/README.md) for all of that — this document only
covers the backend.
## Database ## Database
One SQLite file, migrated forward-only by `bin/migrate.php` from One SQLite file, migrated forward-only by `bin/migrate.php` from `migrations/*.sql` (each applied file is recorded in a `schema_migrations` table, so re-running is safe). See [docs/setup.md](setup.md) for how to run migrations, and [docs/history.md](history.md) for how the schema and the rest of the app got here.
`migrations/*.sql` (each applied file is recorded in a `schema_migrations`
table, so re-running is safe). See [docs/setup.md](setup.md) for how to run
migrations, and [docs/history.md](history.md) for how the schema and the
rest of the app got here.
+1 -5
View File
@@ -1,10 +1,6 @@
# Development history # Development history
A stage-by-stage log of major features, in the order they landed. Each row A stage-by-stage log of major features, in the order they landed. Each row describes the app as it was *at that point* — later stages sometimes superseded earlier ones (e.g. password login, added in stage 1, was removed again in stage 12); see [docs/api.md](api.md) and [web/README.md](../web/README.md) for how things work now.
describes the app as it was *at that point* — later stages sometimes
superseded earlier ones (e.g. password login, added in stage 1, was removed
again in stage 12); see [docs/api.md](api.md) and
[web/README.md](../web/README.md) for how things work now.
| Stage | Scope | State | | Stage | Scope | State |
|-------|-------|-------| |-------|-------|-------|
+12 -41
View File
@@ -1,8 +1,6 @@
# Setup & configuration # Setup & configuration
For the quick version — just running the app — see the root For the quick version — just running the app — see the root [README](../README.md#getting-started). This covers the rest: running without Docker, every configuration option, and the test suite.
[README](../README.md#getting-started). This covers the rest: running
without Docker, every configuration option, and the test suite.
## Run with Docker ## Run with Docker
@@ -12,35 +10,17 @@ The only requirement is Docker with the Compose plugin.
docker compose up -d docker compose up -d
``` ```
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than compiling PHP from source, which is most of why the image is ~90MB rather than several times that) bakes in the PHP source and the built SPA — applies migrations, and serves the whole app at <http://localhost:8080>: the SPA at `/` (assets and all) and the REST API under `/api` (e.g. `curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA shell for client-side routing.
PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than
compiling PHP from source, which is most of why the image is ~90MB rather
than several times that) bakes in the PHP source and the built SPA — applies
migrations, and serves the whole app at <http://localhost:8080>: the SPA at `/`
(assets and all) and the REST API under `/api` (e.g.
`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA
shell for client-side routing.
- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog - A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog successor — one ~15 MB Go binary, messages kept in memory) also starts. The API sends all email to it; read it at <http://localhost:8025>. Set `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API - The image is the artifact: PHP source and the compiled frontend are copied in at build time, not bind-mounted. Rebuild to pick up any code change: `docker compose up -d --build`. For iterating on the frontend, run the Vite dev server on the host instead (see [web/README.md](../web/README.md)).
sends all email to it; read it at <http://localhost:8025>. Set - The SQLite database and the generated JWT signing key live in the `storage` named volume, mounted at `/var/www/storage`, so they survive `docker compose restart` / `down` + `up`.
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real.
- The image is the artifact: PHP source and the compiled frontend are copied in
at build time, not bind-mounted. Rebuild to pick up any code change:
`docker compose up -d --build`. For iterating on the frontend, run the Vite
dev server on the host instead (see [web/README.md](../web/README.md)).
- The SQLite database and the generated JWT signing key live in the `storage`
named volume, mounted at `/var/www/storage`, so they survive
`docker compose restart` / `down` + `up`.
- `docker compose down -v` removes the volume and gives you a clean database. - `docker compose down -v` removes the volume and gives you a clean database.
- Override settings via the environment or a `.env` file in the repo root - Override settings via the environment or a `.env` file in the repo root (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see [docker-compose.yml](../docker-compose.yml)).
(Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see
[docker-compose.yml](../docker-compose.yml)).
## Run without Docker ## Run without Docker
Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus [Composer](https://getcomposer.org/). On Fedora:
[Composer](https://getcomposer.org/). On Fedora:
```bash ```bash
sudo dnf install php-cli php-pdo php-mbstring composer sudo dnf install php-cli php-pdo php-mbstring composer
@@ -60,18 +40,13 @@ composer migrate # creates storage/database.sqlite and its tables
composer serve # http://localhost:8080 (php -S localhost:8080 -t public) composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
``` ```
Any web server can serve the API as long as the document root is `public/` and Any web server can serve the API as long as the document root is `public/` and unknown paths fall through to `public/index.php`. `public/.htaccess` also serves a built frontend from `public/` (copy `web/dist/` there) and only falls back to `index.php` for `/api` and when no `index.html` is present.
unknown paths fall through to `public/index.php`. `public/.htaccess` also serves
a built frontend from `public/` (copy `web/dist/` there) and only falls back to
`index.php` for `/api` and when no `index.html` is present.
The frontend itself still needs its own toolchain — see The frontend itself still needs its own toolchain — see [web/README.md](../web/README.md).
[web/README.md](../web/README.md).
## Configuration ## Configuration
All settings are optional environment variables (read from `.env` or the real All settings are optional environment variables (read from `.env` or the real environment). See [.env.example](../.env.example).
environment). See [.env.example](../.env.example).
| Variable | Default | Purpose | | Variable | Default | Purpose |
|----------|---------|---------| |----------|---------|---------|
@@ -89,10 +64,7 @@ environment). See [.env.example](../.env.example).
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | | `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | | `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled Mailpit container (`mailpit:1025`, no auth/TLS); open <http://localhost:8025> to read what was "sent".
Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled
Mailpit container (`mailpit:1025`, no auth/TLS); open <http://localhost:8025> to
read what was "sent".
## Tests ## Tests
@@ -101,5 +73,4 @@ composer install # installs phpunit (require-dev)
vendor/bin/phpunit vendor/bin/phpunit
``` ```
Each test run applies every file in `migrations/*.sql` to a fresh SQLite Each test run applies every file in `migrations/*.sql` to a fresh SQLite database, so it always exercises the current schema from scratch.
database, so it always exercises the current schema from scratch.
+35 -230
View File
@@ -9,10 +9,7 @@ npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
The dev server proxies `/api` to `http://localhost:8080` (the Dockerised API — 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](.env.example)).
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](.env.example)).
## Build ## Build
@@ -21,10 +18,7 @@ npm run build # type-checks, then emits dist/
npm run preview npm run preview
``` ```
The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into 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 composebuild app` from the parent directory.
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 ## Layout
@@ -64,270 +58,81 @@ src/views/ DashboardView, LoginView, ProfileView, VerifyEmailView
ProjectConfigureView, CardView, CardConfigureView ProjectConfigureView, CardView, CardConfigureView
``` ```
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: 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.push`es to the chosen one, so it also works as a project switcher from anywhere. `App.vue`'s top-level `<RouterView>` is keyed so switching projects (or cards) always does a fresh load -- see [Project detail](#project-detail) for why that key isn't simply the path.
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.push`es to the chosen one, so
it also works as a project switcher from anywhere. `App.vue`'s top-level
`<RouterView>` is keyed so switching projects (or cards) always does a fresh
load -- see [Project detail](#project-detail) for why that key isn't simply
the path.
Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an 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.
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 `/` 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.
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 ## Styling
`style.css` is one global stylesheet (no scoped/component styles) with a `style.css` is one global stylesheet (no scoped/component styles) with a handful of conventions worth knowing before adding to it:
handful of conventions worth knowing before adding to it:
- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`, - **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`, `--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/`-border`), a border-radius scale (`--radius-sm` 6px compact controls, `--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl` 12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled` 0.6, `--opacity-ghost` 0.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."
`--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/ - **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used everywhere from the login form to the sidebar's project switcher. `.field--compact` is 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--autosize` adds the `resize:none; overflow:hidden` the dashboard's JS-driven auto-growing textarea needs. Crucially, **`.field` is applied directly to the control**, not to a wrapper (`.form input` no longer exists) -- see the next point for why that's load-bearing, not just style.
`-border`), a border-radius scale (`--radius-sm` 6px compact controls, - **Put component classes on the control itself, not a wrapping element**, for anything that needs a `:focus` state. `input:focus`/`textarea:focus`/`select:focus` (global, removes the default outline and colours the border with `--accent` instead) 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 keeps `var(--border)` forever, focused or not, no matter what `:focus` says. `.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 `:focus` state (`.card-row__text`, background swap included) writes `.card-row__text:focus` explicitly -- specificity `(0,0,2,0)`, genuinely higher, wins outright rather than by luck of ordering.
`--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl`
12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled`
0.6, `--opacity-ghost` 0.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."
- **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used
everywhere from the login form to the sidebar's project switcher.
`.field--compact` is 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--autosize` adds the `resize:none; overflow:hidden` the dashboard's
JS-driven auto-growing textarea needs. Crucially, **`.field` is applied
directly to the control**, not to a wrapper (`.form input` no 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 `:focus` state. `input:focus`/`textarea:focus`/
`select:focus` (global, removes the default outline and colours the border
with `--accent` instead) 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 keeps `var(--border)` forever,
focused or not, no matter what `:focus` says. `.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 `:focus` state (`.card-row__text`, background
swap included) writes `.card-row__text:focus` explicitly -- specificity
`(0,0,2,0)`, genuinely higher, wins outright rather than by luck of
ordering.
## Inbox ## Inbox
A card with no project lives in the caller's inbox (`useInboxStore`), rendered 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 a project's Kanban columns and Explore list (below), so a card can be dragged straight out of the sidebar into any status column of whichever project is currently open (or Explore can send one the other way, though not receive one -- see [Explore](#explore)). `AppSidebar`'s `onInboxChange` persists a drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a project's Explore or Kanban route is currently open, 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.
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 a project's Kanban columns and Explore list (below), so a card can
be dragged straight out of the sidebar into any status column of whichever
project is currently open (or Explore can send one the other way, though not
receive one -- see [Explore](#explore)). `AppSidebar`'s `onInboxChange`
persists a drop via `reorderColumn(null, null, ids)`, then reloads the inbox
and, if a project's Explore or Kanban route is currently open, 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 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).
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 ## Project detail
`/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a `/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>`.
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 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.
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 (`RouterLink`s styled as tabs, active one matched on `route.name`) The sub-nav (`RouterLink`s 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.
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 ### Explore
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards` The flat card list, **sorted by name (case-insensitive)** via a `sortedCards` ref (rebuilt by a `watch` on the store's `cards.cards` -- a plain computed can't be handed to `<draggable>`, which splices its bound list in place as the user drags). Each row links to the card's own view (`/cards/:id` — see [Card detail](#card-detail)); there is no manual order here, and no delete button either -- deleting lives on that view now.
ref (rebuilt by a `watch` on the store's `cards.cards` -- a plain computed
can't be handed to `<draggable>`, which splices its bound list in place as
the user drags). Each row links to the card's own view (`/cards/:id` — see
[Card detail](#card-detail)); there is no manual order here, and no delete
button either -- deleting lives on that view now.
The list is a `<draggable>` too, but one-directional: `group: { name: The list is a `<draggable>` too, but one-directional: `group: { name:'kanban', put: false }` and `sort: false` mean a card can be dragged *out* -- to the sidebar's inbox, unfiling it from the project -- but Explore can't receive a drop itself (there's no status to put an incoming card in), nor reorder on its own drag (it's sorted by name regardless). No `@change` handler is needed on this side: `<draggable>` already splices the card out of `sortedCards` locally, and the inbox's own handler (see above) persists the move and reloads this project's `cards`, which rebuilds the list from the authoritative result regardless of which side reacted to the drop.
'kanban', put: false }` and `sort: false` mean a card can be dragged *out* --
to the sidebar's inbox, unfiling it from the project -- but Explore can't
receive a drop itself (there's no status to put an incoming card in), nor
reorder on its own drag (it's sorted by name regardless). No `@change`
handler is needed on this side: `<draggable>` already splices the card out of
`sortedCards` locally, and the inbox's own handler (see above) persists the
move and reloads this project's `cards`, which rebuilds the list from the
authoritative result regardless of which side reacted to the drop.
### Kanban ### Kanban
One column per project status, in `position` order -- the inbox is *not* a 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 target as well as a source (unlike Explore, which can only send a card *to* the inbox, not receive one). 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.
column here; it's in the sidebar (see above), though it's still a valid drag
target as well as a source (unlike Explore, which can only send a card *to*
the inbox, not receive one). 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 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.
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 ## Project configuration
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's `/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).
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, 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.
`{ 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 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.
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 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`.
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 ## Card detail
A card's text is no longer inline-editable anywhere it's listed -- the 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.
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 `/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.
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 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.
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 ## Auth flow
There is no password and no separate sign-up — `LoginView` is an email field 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](#passkeys)) — that one *does* sign the caller in directly, no email round trip.
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](#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 - `/verify-email?token=…` is the target for every magic link (sign-in and email-change confirmation both). `VerifyEmailView` POSTs the token via `auth.verifyEmail()`, which returns a session — opening the link is what actually signs the caller in — then redirects to the dashboard.
email-change confirmation both). `VerifyEmailView` POSTs the token via - The token is kept in `localStorage` and sent as `Authorization: Bearer …`. On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
`auth.verifyEmail()`, which returns a session — opening the link is what - Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended path) when there is no authenticated user.
actually signs the caller in — then redirects to the dashboard. - 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_verified` is always `true` for a signed-in user — the frontend doesn't show any verification nagging or resend UI.
- The token is kept in `localStorage` and sent as `Authorization: Bearer …`.
On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
- Routes with `meta.requiresAuth` redirect 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_verified` is always `true` for a signed-in user — the frontend doesn't
show any verification nagging or resend UI.
## Passkeys ## Passkeys
`src/lib/webauthn.ts` wraps the two ceremonies. Both fetch a `{ challenge_id, `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 `ArrayBuffer`s, 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.
options }` pair from the API, decode `options.publicKey`'s base64url fields
(`challenge`, `user.id`, `*Credentials[].id`) into `ArrayBuffer`s, 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 - **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 from `navigator.userAgent`, e.g. "Mac") and a button calling `registerPasskey(label)`. On success it appends to the local list and calls `auth.fetchMe()` so `user.has_passkey` (and the notice below) updates immediately.
passkeys (`GET /api/passkeys`) with a **Remove** button each - **Login** (`LoginView`) — the passkey button calls `auth.loginWithPasskey()`, which adopts the returned session exactly like `verifyEmail()`, then redirects to `route.query.redirect` or `/`. A cancelled prompt (`DOMException` named `NotAllowedError`) shows "Cancelled." rather than a generic error.
(`DELETE /api/passkeys/{id}`), and an "Add a passkey" form: a label input - **`PasskeyNotice.vue`** (mounted in `App.vue`, between the header and the sidebar/main body — spans the full page width) shows when signed in with `user.has_passkey === false`. Dismissing it writes `localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days`; the banner stays hidden until that passes, and reappears immediately (no reload needed, since `has_passkey` is reactive on the shared `auth.user`) if every passkey is later removed.
(pre-filled with a guess from `navigator.userAgent`, e.g. "Mac") and a
button calling `registerPasskey(label)`. On success it appends to the local
list and calls `auth.fetchMe()` so `user.has_passkey` (and the notice below)
updates immediately.
- **Login** (`LoginView`) — the passkey button calls
`auth.loginWithPasskey()`, which adopts the returned session exactly like
`verifyEmail()`, then redirects to `route.query.redirect` or `/`. A
cancelled prompt (`DOMException` named `NotAllowedError`) shows "Cancelled."
rather than a generic error.
- **`PasskeyNotice.vue`** (mounted in `App.vue`, between the header and the
sidebar/main body — spans the full page width) shows when signed in with
`user.has_passkey === false`. Dismissing it writes
`localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days`; the
banner stays hidden until that passes, and reappears immediately (no reload
needed, since `has_passkey` is reactive on the shared `auth.user`) if every
passkey is later removed.
## Profile ## Profile
`/profile` (`ProfileView`) sets `meta.wide` like the dashboard/project views, `/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.
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.