Commit Graph
85 Commits
Author SHA1 Message Date
aneurinandClaude Sonnet 5 fc1fbcc762 Make the per-owner project cap configurable via env
Self-hosters shouldn't be stuck with a hardcoded 100-project limit;
MAX_PROJECTS_PER_OWNER now controls it, defaulting to 0 (unlimited).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:15:06 +01:00
aneurinandClaude Sonnet 5 5c8f2dbf07 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>
2026-09-05 02:29:08 +01:00
aneurinandClaude Sonnet 5 f1309b4c10 Rebuild the Docker image on Alpine: ~735MB -> ~89MB
Stage 2 was php:8.3-apache (Debian), which compiles PHP from source
with --with-apxs2 for mod_php -- that base image alone is 719MB of
our 735MB, before any app code. Replaced with alpine:3.24 + apk's own
prebuilt php83/php83-apache2/apache2 packages: same architecture (one
process, mod_php, .htaccess-driven rewriting), no fpm/nginx rewrite
needed.

- docker/apache.conf: rewritten for Alpine's apache2 (mod_rewrite ships
  but isn't loaded by default; a different default document root/log
  paths). Logs redirected to stdout/stderr so `docker logs` still shows
  them -- Alpine's own defaults write to a real file under ServerRoot,
  unlike the official Debian image's symlinked paths.
- docker/entrypoint.sh: su-exec instead of su -- BusyBox's su doesn't
  take the same -c/user argument order as the GNU one the previous
  entrypoint relied on. Also moved earlier in the Dockerfile (with the
  other rarely-changing setup, before COPY . .) so it no longer re-runs
  on every build for a file that essentially never changes.
- Composer's binary is still borrowed from the official composer:2
  image via multi-stage COPY, not apk's own `composer` package, which
  turned out to pull in an entire second PHP interpreter (php85) as a
  dependency just to run itself.
- ext-iconv needed adding explicitly (symfony/polyfill-mbstring depends
  on it; the official Debian image bundles it by default, apk doesn't).

Verified against the real compose stack, not just that it builds: apk
install; composer install; migrations on startup; PHPUnit 88/88 (runs
on the host, but confirms nothing else broke); and by hand, all
through the actual container -- health check, SPA fallback for unknown
routes, static assets served directly, the API's 401 guard, and a full
magic-link -> verify -> JWT -> authenticated project create/list round
trip via the real Mailpit catcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:15:39 +01:00
aneurinandClaude Sonnet 5 82e8ee6c36 Trim .dockerignore to everything the build context doesn't need
Verified against the Dockerfile's actual COPY instructions -- src/,
bin/, migrations/, public/, docker/, composer.json/lock, and web/'s
own build inputs are the only things either stage touches. Everything
else that COPY . . would otherwise sweep in now excluded: tests/,
phpunit.xml, docker-compose.yml, .env.example, and web/'s own
non-build files (README.md, .env.example, .gitignore, .dockerignore).

web/.dockerignore itself is dead either way -- the build context is
the repo root (docker-compose.yml's `build: .`), not web/, so it was
never actually consulted by anything -- but there's no harm leaving it
for a hypothetical standalone frontend build.

Verified: rebuilt the image, confirmed the excluded paths are genuinely
absent from it, app still serves and 401s correctly unauthenticated,
and PHPUnit (run from the host, unaffected either way) still 88/88.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:54:41 +01:00
aneurinandClaude Sonnet 5 ef9669dc57 Catch up web/README.md and two stale comments to current code
web/README.md's Layout section hadn't been touched since early in the
session: it was missing ManageMenu.vue, CardManageMenu.vue,
StatusManager.vue, useDialog.ts, and four of the ten views
(ProjectExploreView, ProjectKanbanView, CardView, CardConfigureView)
entirely, still described CardRow as inline-editable-with-a-delete-
button, and still claimed the top-level <RouterView> was keyed by
plain route.path (true before this session's Explore/Kanban route
split, wrong after -- see App.vue's routeKey). The Inbox section also
still gated the post-drag cards refresh on route.name === 'project'
alone, from before Explore itself could send a card there.

Also fixed two lingering "all tasks" references (the tab's name before
it became "Explore") in a stores/cards.ts comment and a style.css one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:48:14 +01:00
aneurinandClaude Sonnet 5 fb67b57807 Reorganize docs: simple README + organized docs/
Removed docs/stage-1-auth-api.md -- an early planning doc, badly out
of date (predates passwordless auth, statuses, the inbox, and
everything after).

README.md is now just: what this is, a pointer to docs/, an end-user
getting-started guide (Docker up, first-time login via the bundled
Mailpit catcher, adding a passkey), and provenance -- everything else
it used to carry moved out:

- docs/api.md -- the full REST API reference (auth, passkeys,
  projects, cards, statuses, error shape) + the curl walkthrough.
- docs/setup.md -- running without Docker, every environment
  variable, the test suite.
- docs/architecture.md -- backend file layout; points to
  web/README.md for the frontend, which already documented itself in
  enough depth to stand alone.
- docs/history.md -- the stage-by-stage feature log, with a new row
  for this session's refactoring work (which hadn't been logged yet).
- docs/README.md -- an index tying the above together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:41:55 +01:00
aneurinandClaude Sonnet 5 b00ec7addd Squash all migrations into one clean initial schema
Replaces 001-011 (create/alter/rebuild/backfill, in the order features
landed) with a single 001_initial_schema.sql that creates every table
in its final shape directly -- no password_hash (added then dropped),
no project description (added then dropped), cards already shaped as
the global-inbox-with-a-CHECK-constraint design rather than rebuilt
into it, no data-migration/backfill statements (nothing to backfill
against a schema created fresh).

This is a pre-release project with no data worth preserving, so the
dev database (and the storage volume's generated JWT key with it) was
wiped rather than migrated -- confirmed the fresh schema matches
exactly (same tables/columns as before, minus the two dropped columns)
and the app works end to end against it. PHPUnit's in every run
already builds its database from migrations/*.sql from scratch, so the
suite needed no changes and is unaffected either way: 88/88.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:32:27 +01:00
aneurinandClaude Sonnet 5 fb59a54938 Drop the unused project description field entirely
It never got a UI home on the frontend -- removed from ProjectView a
few sessions back and never restored anywhere -- so it was a fully
live field (validated, stored, returned by the API, covered by tests)
with no consumer.

- Migration 011: ALTER TABLE projects DROP COLUMN description.
- ProjectRepository: description dropped from ProjectRow, SELECT,
  create() and update() -- update() is now just a rename (string, not
  a $fields array; there was never more than one editable field once
  this left).
- ProjectController: store()/update() no longer accept or validate
  it; update() drops the "provide at least one of title, description"
  branch, since title is unconditionally the only field now.
- Frontend Project type, root README's API docs and curl example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:25:53 +01:00
aneurinandClaude Sonnet 5 367a98308a Extract a shared ManageMenu; add notFoundOr for the repeated 404 pattern
ManageMenu.vue holds what ProjectManageMenu and CardManageMenu had
copy-pasted between them almost verbatim: the dropdown, the confirm
modal, both useDialog wirings, menuOpen/confirmingDelete/deleting
state. Each is now a thin wrapper supplying only what's genuinely
entity-specific -- the Configure route, the delete labels, and (as
confirmDelete) what deleting actually does, since that differs more
than the UI around it (which stores to refresh, where to navigate).
The confirmation body text comes through the default slot, since a
project's names its card count and a card's doesn't.

notFoundOr(e, notFoundMessage, fallbackMessage) in lib/api.ts replaces
the identical "404 -> fixed message, else -> the error's own message,
else -> a fallback" block that ProjectView, ProjectConfigureView,
CardView, and CardConfigureView had each written out by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:25:37 +01:00
aneurinandClaude Sonnet 5 6c7b78ad4b Explore: cards can be dragged out to the inbox
One-directional drag support, joining the shared "kanban" group:
put: false and sort: false mean a card can leave Explore's list (to
unfile it via the sidebar's inbox) but the list can't receive a drop
itself (there's no status to assign an incoming card) or be reordered
by dragging (it's sorted by name regardless).

sortedCards moves from a computed to a ref rebuilt by a watch --
<draggable> splices its bound list in place as the user drags, which a
plain computed would just discard on its next recomputation. No local
@change handler is needed: the splice already happens locally, and the
inbox's own handler (AppSidebar) persists the move and reloads this
project's cards regardless of which side of the drag it's reacting to.

AppSidebar: widened the "is a project open" check for the post-drag
cards refresh back to both project routes (Explore's list is now also
a place a card can leave from), and reused the same route-name set
already defined for the sidebar's project switcher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:04:50 +01:00
aneurinandClaude Sonnet 5 9505d20e0d Add-passkey form: one line, "Passkey label" placeholder, shorter label
Switches from the stacked .form pattern to .field-row, same as the
other single-line forms (input + fit-width button); the visible
"Label" span becomes a placeholder/aria-label instead, and the button
reads "Add passkey" rather than "Add a passkey".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:57:47 +01:00
aneurinandClaude Sonnet 5 d1de9f50f5 Remove the per-row delete button from Explore's cards
Matches KanbanCard, which never had one -- deleting a card lives on
its own view now (CardManageMenu's "Delete card"), reachable by
clicking through from either list.

Cleaned up what that leaves unused: the cards store's remove() (its
only caller), and .card-row__delete's styling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:54:36 +01:00
aneurinandClaude Sonnet 5 80ff31748b Explore card rows: match the kanban card's hover style
.card-row now highlights its border-color on hover, same as
.kanban-card, instead of tinting .card-row__link's background --
a card should look the same hovered whichever view shows it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:51:24 +01:00
aneurinandClaude Sonnet 5 8641c3d013 Make Explore and Kanban real routes, not tab state
/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>
2026-09-05 00:46:17 +01:00
aneurinandClaude Sonnet 5 81900f4d06 Rename the "All tasks" tab to "Explore"
Also refreshed web/README.md while touching this section: fixed the
project/project-configure header descriptions to describe the current
inline .title-back arrow (they still described the old separate "Back
to project" button), and added a "Card detail" section documenting
CardView/CardConfigureView/CardManageMenu, which had none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:36:38 +01:00
aneurinandClaude Sonnet 5 4cd59234e2 All tasks tab: match the new-card form layout used everywhere else
Was the stacked .form pattern (visible "New card" label above a
full-width input). Now .kanban__new, same as the kanban columns and
sidebar inbox: compact field + placeholder/aria-label in place of the
visible label, on one line with the icon button. Kept form--new-card
for the border-top separator above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:32:50 +01:00
aneurinandClaude Sonnet 5 e55641bcba Card configure view: "Edit text"/"Save" -> "Rename card"/"Rename"
Matches the project rename form's wording ("Renaming…"/"Rename").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:30:26 +01:00
aneurinandClaude Sonnet 5 55fa9fef56 Split card editing/delete out into a CardConfigureView, mirroring projects
CardView is now just the read view -- header (inline back arrow +
card text) and its status badge, no tabs (there's nothing to tab
between). New CardConfigureView (/cards/:id/configure) holds the
"Edit text" form that used to live inline in CardView.

New CardManageMenu, mirroring ProjectManageMenu: a "Manage" dropdown
with "Configure" (hidden while already on the configure view) and
"Delete card" (with its own confirmation modal). Both CardView and
CardConfigureView use it in their header actions, same as the project
view and its own configure view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:29:06 +01:00
aneurinandClaude Sonnet 5 8a0befa1c7 Stretch the add-icon buttons to their row's height; bigger glyph
Dropped the hardcoded 2rem height -- .field-row/.kanban__new are flex
rows with the default align-items: stretch, so the button now fills
to match its compact field's actual height instead of a guessed value
that had to be kept in sync with it. Also bumped the "+" from 1.1rem
to 1.4rem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:23:53 +01:00
aneurinandClaude Sonnet 5 ca3be38f0c Give the add-icon buttons a border matching the compact field
A borderless icon next to a bordered input looked adrift. Same
1px solid var(--border) as .field, turning var(--accent) on hover
(alongside the icon itself, as before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:22:00 +01:00
aneurinandClaude Sonnet 5 f5cc82a2f4 Ghost-style the add-card/status icon buttons; convert the last one
- .btn-icon: transparent background, muted icon that turns accent on
  hover -- matching .title-back's treatment exactly, instead of a
  solid accent-filled square.
- The "all tasks" tab's "Add card" button is now the same .btn-icon
  as the kanban/inbox/status ones, so every "add" action in the app
  looks and behaves the same.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:20:32 +01:00
aneurinandClaude Sonnet 5 7ce0f156f0 Replace the compact "Add" buttons with a plus icon
The kanban column, sidebar inbox, and add-status forms all had their
own "Add" submit button; with a column-per-status now, that's a lot of
identical labels on screen at once. Replaced with a small square "+"
icon button (.btn-icon), each keeping a descriptive title + aria-label
(dynamic, so it reads "Adding…" while the request is in flight) since
the visible glyph alone isn't an accessible name.

.btn-icon is excluded (":not(.btn-icon)") from the generic submit
button rule and the .field-row/.kanban__new compact-button overrides,
rather than out-specificity'd -- same reasoning as .field's low
specificity against input:focus: it shouldn't depend on source order
which one wins. .kanban__new's own override is now dead (both of its
two buttons are icons) and removed outright.

Left the "Add card" button on the "All tasks" tab as text -- it's a
lone, full-width primary action in a larger form, not one of several
compact icon-sized ones crowded together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:16:38 +01:00
aneurinandClaude Sonnet 5 19ccb8f881 Add a "new card" form to each kanban column
Each column gets its own form at the bottom (styled like the sidebar
inbox's), creating the card directly in that status, appended after
its existing cards.

- API: POST /projects/{id}/cards takes an optional status_id, which
  must belong to the project (422 otherwise); omitted, it still
  defaults to the project's first status as before. Position is
  already "end of that status" for free -- createInProject() already
  ranks by (owner, project, status).
- cards store: add() takes an optional statusId, forwarded as
  status_id when given.
- ProjectView: one draft string per status (keyed by status id) so
  typing in one column doesn't touch another's, mirroring the
  per-status independence the columns already have for reordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:10:52 +01:00
aneurinandClaude Sonnet 5 e598d4ef57 Change-email input: "New email" -> "New email address"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:06:20 +01:00
aneurinandClaude Sonnet 5 b04935ada9 Make the change-email form a single line, like the rename forms
Switches from the stacked .form pattern to .field-row: the input and
its submit button now sit side by side, the button sized to fit its
text rather than stretched full width -- matching the project rename
and add-status forms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:05:06 +01:00
aneurinandClaude Sonnet 5 4947ebdf38 Use the card view's inline back arrow on every other heading
Generalized .card-view__back into .title-back and applied it to:

- ProjectView: arrow back to the dashboard, inline before the title
  (previously no back link at all here -- the app bar's brand already
  covers it, but this is more discoverable and matches the others).
- ProjectConfigureView: arrow back to the project, replacing the
  separate "Back to project" button that used to sit in the actions
  corner next to Manage.
- ProfileView: arrow back to the dashboard, replacing the plain-text
  link removed in an earlier commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:02:09 +01:00
aneurinandClaude Sonnet 5 fb329bf693 Add a dedicated card view; make card text no longer inline-editable
Clicking a card -- in the "all tasks" list, a kanban column, or the
inbox -- now opens /cards/:id instead of editing the text in place:

- CardRow's text is now plain (its RouterLink wraps the text + status
  badge; the delete button stays a sibling so it isn't nested inside
  the link). KanbanCard is now itself a RouterLink.
- New CardView.vue: header follows the project view's layout, but the
  back link sits inline inside the title (before the card's text)
  rather than off in the actions corner, since it isn't paired with a
  manage menu here. Inbox cards have no project to link back to, so
  they go to the dashboard instead. Below the header, an "Edit text"
  section (styled like the project rename form) replaces the inline
  editing that used to live in the list row, and a delete button in
  the header actions replaces CardRow's per-row delete for cards
  reached via kanban/inbox (which never had one).
- No backend changes: GET/PATCH/DELETE /cards/{id} already existed.
- Renamed .card-row__status to the more general .status-badge, now
  shared by the list row and the card view.
- cards store: dropped setText, now unused now that editing goes
  through a direct PATCH + store refresh in CardView instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:51:06 +01:00
aneurinandClaude Sonnet 5 c2c988d928 Remove the redundant back-to-dashboard link on the profile view
The app bar's brand/logo already goes to the dashboard, same as the
other app pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:42:48 +01:00
aneurin 83471e55d4 Extract StatusManager, dedupe ColumnChange type, add useDialog composable
- Pull the status-management feature (drag reorder, add, delete with
  reassignment) out of ProjectConfigureView into its own StatusManager
  component. It fetches its own status list independently, so the view
  is left with just page chrome and the rename form (316 -> 106 lines).
- Define ColumnChange once in lib/cardOrder.ts instead of duplicating
  the same type in AppSidebar.vue and ProjectView.vue.
- Add composables/useDialog.ts for the Escape-to-close + focus-on-open
  behaviour shared by every confirm/reassign modal (and, without a
  focus target, plain dropdown menus). Used by ProjectManageMenu's
  delete-confirmation modal + its own menu, and StatusManager's
  reassignment modal.
2026-09-04 23:35:33 +01:00
aneurinandClaude Sonnet 5 a3462af005 Fix the broken input focus outline, unify input styling, tokenize scales
The root cause of 'some inputs show a focus outline, most don't': the
global input:focus/textarea:focus/select:focus rule (outline:none,
border-color: accent) has specificity (0,0,1,1). Every per-component
rule shaped '.wrapper input { border: 1px solid var(--border) }' (.form
input, .field-row input, .kanban__new input, .sidebar__select select,
.modal__field select) ties it exactly, and -- being unconditional --
silently won that tie by simply appearing later in the file, so the
input's border stayed var(--border) forever regardless of focus. Only
two controls escaped: .project-card__name-input (a class applied
directly to the element, (0,0,1,0), too low to ever win the tie) and
.card-row__text (has its own .card-row__text:focus, (0,0,2,0),
genuinely higher). Confirmed with a live computed-style test against
the real stylesheet before and after.

Fix: one canonical .field class (plus .field--compact for inline 'add'
rows and the sidebar, .field--autosize for the dashboard's JS-grown
textarea), applied directly to the <input>/<textarea>/<select> itself
in every view -- structurally immune to the same tie, since a bare
class can never out-specificity input:focus. This also collapses five
near-duplicate rules (each with its own padding/radius/font-size) into
one definition, and fixes .kanban__new input's stray
background: var(--surface) (every other field uses var(--bg); this one
nearly matched its own var(--surface) sidebar panel).

Also: .btn-danger hardcoded #b3261e/#fff instead of the already-
existing var(--error)/var(--accent-text) tokens, so danger buttons
didn't adapt in dark mode like every other error-coloured element;
now they do. Added .field:disabled styling (opacity/cursor), matching
buttons -- latent until now since no input bound :disabled yet.

Second pass, promoting repeated-but-consistent raw values to tokens:
- border-radius: 6/7/8/10/12/999px -- the stray 7px (.sidebar__link)
  folded into the 6px tier -- become --radius-sm/md/lg/xl/pill.
- disabled/ghost opacity: 0.6 was used for every disabled button
  except .status-row__delete's 0.4 (now unified) and drag-ghost states'
  0.5 (semantically different, kept separate) -- --opacity-disabled/
  --opacity-ghost.
Font-size and spacing values also repeat but don't reduce to a clean
scale without arbitrary judgement calls either way -- left alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:07:35 +01:00
aneurinandClaude Sonnet 5 a85bb182c7 De-duplicate controller boilerplate: owned-project lookup, reorder validation
requireOwnedProjectId() was copy-pasted identically in CardController
and CardStatusController; ProjectController's own
requireOwnedProject() was the same lookup, just returning the full
row instead of the id. New abstract ProjectScopedController (extends
Controller) holds one copy of both, and all three controllers now
extend it instead of Controller directly, forwarding their
ProjectRepository to its constructor.

Separately, CardController::reorder() (card_ids) and
CardStatusController::reorder() (status_ids) each had the same inline
'must be an array of ids, no duplicates' check. Both now call a new
Validator::intIdArray(), which does the same shape check once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:45:17 +01:00
aneurinandClaude Sonnet 5 accc6a273c Centralize the current-timestamp SQL expression
"strftime('%Y-%m-%dT%H:%M:%SZ', 'now')" was a private nowExpr()
method copy-pasted identically in CardRepository, CardStatusRepository
and ProjectRepository, and inlined as a raw literal directly in
PasskeyRepository, WebAuthnChallengeRepository, UserRepository and
EmailVerificationRepository -- 7 files, ~12 occurrences of the same
string. Now one Database::nowExpr() static method (Support/Database
already being the natural home for SQLite-specific concerns), used
everywhere a repository sets a timestamp explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:41:25 +01:00
aneurinandClaude Sonnet 5 029c05c46f Fix the mobile project-head layout with Grid instead of flex
The previous flex fix (column-reverse + stretch + justify-content) was
silently a no-op: its media query was placed earlier in the file than
.project-head's own base rule, so at equal specificity the later base
rule (align-items: flex-start, no flex-direction override) always won
the cascade regardless of viewport -- the buttons never actually
moved or stretched on mobile.

Replaced with grid-template-areas: 'title actions' on desktop
becomes a single column, 'actions' over 'title', below 768px, with
.project-head__actions given justify-self: end so it (and both the
Back/Manage buttons or the lone Manage button inside it) sits flush
right once it's the full row width. Verified with real screenshots at
375px on both ProjectView and ProjectConfigureView -- buttons stay on
one line top-right, the long title wraps beneath at full width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:30:49 +01:00
aneurinandClaude Sonnet 5 91c0e8d6af Make the magic-link resend cooldown configurable
EmailVerifier::RESEND_INTERVAL_SECONDS was a hardcoded class constant
shared (via a copy-of-a-constant) by AuthController and
EmailVerificationController. It's now a constructor param
(resendIntervalSeconds, default 60, same as before) sourced from
Config -- new MAGIC_LINK_RESEND_SECONDS env var, default unchanged.

Docker Compose sets it to 0, so magic links resend immediately during
local development instead of waiting out the throttle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:30:41 +01:00
aneurinandClaude Sonnet 5 4a9717caa5 Keep the header buttons right-aligned once the title wraps below
.project-head__actions stretches full width on mobile along with the
title (align-items: stretch), but its own content was left-aligned by
default. Add justify-content: flex-end there under the same
breakpoint, and wrap ProjectView's ProjectManageMenu in a
.project-head__actions div (it wasn't before) so both views share one
selector for this instead of ProjectView needing its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:18:26 +01:00
aneurinandClaude Sonnet 5 ba84c0e81b Mobile: stack project-head instead of squeezing it, wrap headings cleanly
.project-head (title + action buttons, shared by ProjectView and
ProjectConfigureView) was a plain flex row at every width, so on a
narrow screen the title and the Manage/Back buttons fought over space
instead of wrapping. Below 768px it's now flex-direction:
column-reverse -- the buttons (last in the DOM in both views) render
on top on one line, and the title wraps to as many lines as it needs
underneath, full width.

Also add a global h1/h2/h3 overflow-wrap: break-word so a long,
unbroken heading (a project title with no spaces, say) wraps instead
of overflowing its container, without breaking ordinary multi-word
headings mid-word. Drops .project-head__title's now-redundant own
word-break rule, since it's an <h1> and the global rule covers it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:15:47 +01:00
aneurinandClaude Sonnet 5 d4420f7fe0 Relabel the project name button 'Rename'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:11:54 +01:00
aneurinandClaude Sonnet 5 038322e975 Relabel the configuration view's name section 'Rename project'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:10:57 +01:00
aneurinandClaude Sonnet 5 eb1f6055f0 Document that Profile now renders wide
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:09:47 +01:00
aneurinandClaude Sonnet 5 f8c52f8103 Drop two more unneeded width caps
- .tabs__panel--narrow (42rem, the project view's 'All tasks' tab):
  removed the class and its rule, same reasoning as the config
  sections -- the page around it is already full-width.
- Profile now sets meta.wide, so it uses .app__main--wide instead of
  the default .app__main's 32rem cap, matching the dashboard/project/
  configure views instead of sitting narrow like the login form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:09:31 +01:00
aneurinandClaude Sonnet 5 b51d70b447 Let config sections fill the container width
Drop .config-section's 32rem cap -- the project name and status forms
now stretch to whatever width .card.project actually has instead of
sitting narrower than the header above them for no reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:06:25 +01:00
aneurinandClaude Sonnet 5 0eaa100d97 Relabel the project name button 'Update'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:04:42 +01:00
aneurinandClaude Sonnet 5 ed07ea442d Put the Save name button beside the input, fit to its width
Matches the status 'Add' form immediately below it: renamed the
shared row layout from .status-list__new to .field-row (it now backs
both forms) and switched the project name form to it -- input grows,
button sits to its right sized to its own label instead of stretching
full width underneath. Dropped the visible 'Name' label in favour of
a placeholder + aria-label, mirroring the status form exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:03:57 +01:00
aneurinandClaude Sonnet 5 ea169ebed0 Move project renaming from the project view to configuration
- ProjectView: title is now a plain <h1>, no longer inline-editable
  (dropped titleDraft/saveTitle/patchProject and the input markup --
  nothing else used patchProject).
- ProjectConfigureView: new 'Project name' section above 'Statuses',
  a small PATCH /api/projects/:id form. On success it also calls the
  projects store's fetchProjects(), since the dashboard grid and the
  sidebar's project dropdown read from that store and otherwise
  wouldn't pick up the new name (or the project's new alphabetical
  position) until some unrelated reload.
- style.css: dropped the now-dead .project-head__title input rules
  (both views render a plain heading now); .project-head__title gains
  word-break so a long static title still wraps instead of overflowing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:01:27 +01:00
aneurinandClaude Sonnet 5 c5ffdae2cd Group Back-to-project and Manage together, styled alike
Moves the back link from the far left to sit immediately left of the
Manage button (both now top right, in a new .project-head__actions
row), and switches it from .btn-secondary to .menu__toggle so the two
are visually identical rather than just similar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:58:13 +01:00
aneurinandClaude Sonnet 5 89a95f1885 Sidebar becomes a mobile drawer below 768px
The sidebar was flex:0 0 15rem inside .app__body unconditionally, so
on a narrow viewport it and the main content just fought over space.
Below 768px it now leaves the flex flow entirely (position: fixed,
translated off-canvas by default) and slides in as a drawer instead,
leaving .app__main the full width.

- App.vue: a ☰ button in the header (hidden above the breakpoint)
  opens it; a backdrop tap, the drawer's own close button, or any
  navigation (route.fullPath watcher) closes it. State lives in
  App.vue since the toggle button is in the header, not the sidebar.
- AppSidebar.vue: a close button (✕), only visible in the drawer,
  emitting 'close'.
- Desktop (>=768px) layout and behaviour is untouched -- the drawer
  CSS and the toggle button both live behind the same media query.

Drag-and-drop is untouched, as asked -- this only affects layout, and
the sidebar's inbox draggable works the same as before either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:54:18 +01:00
aneurinandClaude Sonnet 5 acfc35af4c Show a dashed drop-area in empty kanban columns and the inbox
.kanban__cards:empty (shared by every status column and the sidebar's
inbox list) gets a dashed border, transparent background, and a
'Drop cards here' hint, echoing the dashboard's 'Create a project'
tile. Pure CSS: both draggables are already always-rendered even when
empty, so an empty one is a genuinely childless element and :empty
just tracks that -- it steps aside on its own once Sortable inserts a
drag-over ghost, and needs no JS empty-state flag.

Drops the sidebar's separate 'Nothing in the inbox.' message, now
redundant with the box itself. Verified the container really is
childless when empty via a DOM dump against a running instance (no
screenshot).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:47:17 +01:00
aneurinandClaude Sonnet 5 dd7d217e8e Project configuration view: manage a project's statuses
New /projects/:id/configure view, linked from a new 'Configure' item on
the project view's Manage menu.

Backend:
- CardStatusRepository/CardStatusController gain full CRUD: create
  (appended at the end), reorder (dense positions, like card
  ordering), and delete.
- Deleting a status with cards attached is rejected with 409 and
  error.details.card_count, rather than hitting the existing FK
  RESTRICT constraint -- retrying with { reassign_to: <status id> }
  moves those cards to that status first (CardRepository::
  reassignStatus, appended after the destination's existing cards)
  and deletes in one transaction (CardStatusRepository::transaction,
  shared PDO connection across repositories).
- The last status in a project can't be deleted, since a project card
  is required to have one.
- Routes: POST/DELETE .../statuses(/:id), PUT .../statuses/order.
- 14 new CardStatusTest cases covering all of the above.

Frontend:
- ProjectConfigureView.vue: header (title, back-to-project link, the
  shared Manage menu) + a vuedraggable status list (reorder persists
  the whole new order) with a delete button per row and an add-status
  form. A row's plain delete either succeeds immediately or, on 409,
  opens a modal to choose a different status before retrying the
  delete with reassign_to.
- Extracted ProjectManageMenu.vue (the Manage dropdown + delete-project
  modal) out of ProjectView so both views share it; it now also has a
  Configure link (hidden on the configure page itself).
- ApiError gains a cardCount getter (details.card_count), mirroring
  the existing retryAfter getter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:31:10 +01:00
aneurinandClaude Sonnet 5 21e148aa4d Simplify the project view header: drop the back link and description
- Remove the '<- All projects' link at the top -- the sidebar's
  Dashboard link already covers that.
- Remove the description textarea and all of its state/save logic
  (descriptionDraft, saveDescription, patchProject's description
  field); the backend field and API are untouched.
- Drop .project__chrome's 42rem max-width, which existed only to keep
  the now-gone description prose readable. The header (title + Manage
  menu) now spans the full wide-layout width, so Manage sits at the
  true top right instead of a narrower centred column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:14:03 +01:00
aneurinandClaude Sonnet 5 374d607ce1 Drop the browser focus ring on form controls, keep the border highlight
A global input:focus/textarea:focus/select:focus rule removes the
default outline and sets border-color to the accent colour instead --
matching the pattern a few components (.card-row__text,
.project-head__title input, .project-head__desc) already used with
their own higher-specificity :focus rules, which still take precedence
for their extra focus styling (background swap, etc.). This now also
covers plain form inputs, the dashboard's project-name textarea, the
sidebar's project select, and the inbox/kanban 'new card' inputs,
which previously fell back to the browser's default outline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:10:12 +01:00
aneurinandClaude Sonnet 5 baef3078da Add a 'Projects' heading to the dashboard
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:08:34 +01:00
aneurinandClaude Sonnet 5 3988ea8404 Balance the new-project tile's spacing; equalize grid row heights
- .project-card--new gets its own gap (0.75rem vs the default 0.4rem)
  so the Name field and Create project button aren't cramped together.
- .dashboard__grid switches align-items from start to stretch, so
  every tile in a row shares the row's height; .project-card gets an
  explicit justify-content: flex-start so its contents stay pinned to
  the top of the now-taller box instead of spreading out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:07:49 +01:00
aneurinandClaude Sonnet 5 49c10673d2 Auto-growing name box for the new-project tile
Replace the plain <input> with a rows=1 <textarea> that grows to fit
its content on input, so a long or wrapped name stays fully visible
instead of scrolling. Enter still submits rather than adding a
newline, and the box resets to one row after a successful create.

Also make the tile's background transparent (dashed border stays) and
drop its 'Create a project' title, keeping just the Name field --
accessible label moved to aria-label since the placeholder now stands
alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:03:18 +01:00
aneurinandClaude Sonnet 5 47429daaa6 Move the new-project form into the dashboard grid as a tile
It now sits last in .dashboard__grid, styled like a project-card (dashed
border to read as an action rather than a project) instead of as a
separate form below the grid. Relabel it 'Create a project' and add a
'Name' placeholder to the input.

Also drop the now-redundant 'no projects yet' empty-state message --
the grid always renders since the create tile lives inside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:59:03 +01:00
aneurinandClaude Sonnet 5 9d51ecc367 Move passkey login below the email sign-in form
The divider now sits between the email form and the passkey button,
rather than between the passkey button and the form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:54:37 +01:00
aneurinandClaude Sonnet 5 25c2e69afe Style the passkey login button to match the email sign-in button
Introduce a .btn-primary class carrying the same look as
button[type="submit"] (accent fill, full width, centered), and apply
it to the passkey button on LoginView -- previously unstyled since it
is type="button", not type="submit".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:53:25 +01:00
aneurinandClaude Sonnet 5 afdd53ec4c Add passkeys (WebAuthn): register from the profile, log in without email
New library dependency: lbuchs/webauthn (^2.2, MIT, zero transitive deps
beyond PHP+OpenSSL+Mbstring, both already required). 'none' attestation --
this only confirms "the same device that registered", not hardware
provenance, the standard trust model for a public site's own passkey login.

Backend
- migrations/010: `passkeys` (one row per registered credential: owner,
  credential_id, public_key, sign_count, label) and `webauthn_challenges`
  (short-lived, single-use, bridging each ceremony's "options" and "verify"
  calls -- user_id set for a registration, null for a login since who's
  signing in isn't known until the credential comes back).
- Config: WEBAUTHN_RP_ID (defaults to APP_URL's host) and WEBAUTHN_RP_NAME.
- PasskeyRepository, WebAuthnChallengeRepository, PasskeyController:
  GET/POST /api/passkeys, POST /api/passkeys/options, DELETE
  /api/passkeys/{id} (all auth), plus the public POST /api/auth/passkey/
  options and /verify for login. Registration always asks for a
  discoverable, user-verified credential -- what makes login usernameless:
  the browser offers whatever passkeys it has for the site, no email first.
- SessionPayload now also exposes `has_passkey` on every user object
  (PasskeyRepository::countForUser() > 0), reused by both the profile page
  and the dismissible notice.
- PasskeyTest: auth guards, options response shape, challenge single-use/
  expiry/purpose/cross-user rules, malformed-input handling, list/remove
  CRUD (seeded rows) -- everything short of a real signature, which isn't
  practical from PHPUnit. 73 tests pass.

Frontend
- lib/webauthn.ts: base64url <-> ArrayBuffer conversion and the two
  ceremonies (registerPasskey, loginWithPasskey), matching the API's wire
  format exactly.
- ProfileView: a Passkeys section -- list with Remove buttons, an "Add a
  passkey" form (label pre-filled from a UA guess).
- LoginView: a "Log in with a passkey" button above the email form, shown
  only when the browser supports WebAuthn.
- PasskeyNotice.vue: dismissible banner across the top of the page
  (`user.has_passkey === false`); dismissal is a week-long localStorage
  timestamp.

Verified against the rebuilt container using a Chrome DevTools Protocol
*virtual authenticator* (real ECDSA signing, no human interaction) end to
end: notice shown -> register a passkey -> notice gone (same page and after
navigating) -> log out -> "Log in with a passkey" with no email typed ->
correct account, notice still gone -> remove the passkey -> notice back ->
dismiss -> stays hidden for ~7 days across pages. Along the way, caught and
fixed a real bug: AuthenticatorData::getCredentialId() returns a raw binary
string, not a ByteBuffer like most of this library's other binary fields --
bin2hex() it directly rather than calling ->getHex().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 19:34:53 +01:00
aneurinandClaude Sonnet 5 7da881bb78 Add a config toggle to turn off new user registration
APP_ALLOW_REGISTRATION (default true) gates the only "sign up" this app has --
the account-creation side effect of POST /api/auth/magic-link. When false, an
unknown address is silently ignored (find-only, no findOrCreateByEmail) while
an existing address still gets its sign-in link as normal; the response is
identical either way (202, same message), so there's still no enumeration
signal.

- Config::allowRegistration, read from APP_ALLOW_REGISTRATION.
- AuthController::requestLoginLink takes the flag; only looks up (doesn't
  create) when it's off.
- docker-compose.yml / .env.example / README document the new var.
- ApiTestCase::reconfigure() rebuilds the app against changed env (same
  database) for tests that need a non-default Config; two new AuthTest
  cases cover both halves (blocks a new address, doesn't block an existing
  one). 59 tests pass.

Verified against the rebuilt container: with the flag on (default), a new
address gets a link and an account; switched off via the same env var, a
brand-new address gets the same 202 but no email and no user row, while an
address that already had an account still receives its link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:19:48 +01:00
aneurinandClaude Sonnet 5 7504bd826d Inset the sidebar and size it to the screen
The sidebar sat flush against the window edge and the header, with the main
content floating with its own margin -- visually unbalanced. .app__body now
carries the outer spacing (1.5rem top/bottom, 1.25rem sides, 1.5rem gap
between sidebar and main) that .app__main used to own alone, so both columns
start at the same position and read as a matched pair. The sidebar is now a
floating panel to match (full border + border-radius, not just a right edge).

The sidebar's height is set explicitly -- calc(100vh - 6.0625rem), tuned to
the app's actual ~3.0625rem header plus a 1.5rem gap top and bottom -- rather
than capped, so it fills the screen height even when its own content (project
dropdown, inbox) is short, with room to spare at the bottom instead of
overflowing past the fold. Its sticky `top` matches the outer top gap so that
gap holds once it starts sticking during a scroll.

Verified via headless Chrome at a couple of viewport heights and on both a
narrow (Profile) and wide (Dashboard) page: sidebar and main content start
level, the sidebar is inset ~1.25rem from the window edge, and its box ends
with a ~1.5rem gap above the viewport bottom rather than touching or
overflowing it. Confirmed the sticky behavior while scrolling a long project
page holds the same 1.5rem gap once pinned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:41:27 +01:00
aneurinandClaude Sonnet 5 d8a4183e4d Move new-project form to the dashboard; sidebar project list becomes a switcher; Kanban is the default project tab
- DashboardView: a "New project title" form now sits directly below the
  project grid (creating one stays on the dashboard -- the grid and the
  sidebar dropdown pick it up via the shared projects store, no navigation).
- AppSidebar: the per-project RouterLink list is replaced by a <select>. It's
  a v-model-bound writable computed (selectedProjectId): the getter tracks
  route.params.id so it reflects whichever project is open, the setter
  router.pushes to the chosen one -- so it doubles as a project switcher from
  anywhere, not just a picker from the dashboard. Removed the now-unused
  .sidebar__link--project/.sidebar__projects/.sidebar__form CSS; added
  .sidebar__select, and .form--new-project back for the dashboard form.
- ProjectView: activeTab now initialises to 'kanban' instead of 'all'. The
  tabs array (and so the tab bar's DOM order, All tasks first) is untouched --
  only the default selection changed.

Verified via headless Chrome: sidebar has no per-project links (a <select>
listing both projects instead); the new-project form follows the grid and
creating from it stays on /dashboard while the grid and dropdown both update;
selecting a project in the dropdown navigates there and the dropdown reflects
it once there; a project opens on Kanban with the tab bar still reading "All
tasks, Kanban" in that order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:31:30 +01:00
aneurinandClaude Sonnet 5 4ee0f24078 Make the inbox global instead of per-project
A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:22:33 +01:00
aneurinandClaude Sonnet 5 c82bdbbf0e Passwordless-only auth: drop registration and passwords entirely
There is now one way in: POST /api/auth/magic-link with an email address. It
creates the account (unverified) if the address is new -- that's the only
"sign up" -- and emails a sign-in link either way, subject to the existing
60s-per-user resend throttle. Opening the link (POST /api/auth/verify-email,
unchanged) is what actually creates the session, and marks the address
verified the first time. Since a session can now only ever come from an
opened link, "authenticated" implies "verified" -- there's no more
authenticated-but-unverified state, so the resend-verification endpoint and
all the "verify your email" nagging UI are gone too.

Backend
- migrations/008: ALTER TABLE users DROP COLUMN password_hash.
- UserRepository: create() takes only an email; new findOrCreateByEmail()
  (race-safe) backs the magic-link endpoint.
- AuthController: register()/login() removed; requestLoginLink() now
  find-or-creates before sending.
- EmailVerificationController: resend() removed (dead -- you can't be
  authenticated and unverified); requestChange() drops the password check,
  now just { email }.
- EmailVerifier: sendVerification() removed (unused once register() and
  resend() are gone); sendLoginLink() is the one email people get.
- Routes: POST /auth/register, POST /auth/login, POST /email/verification
  all gone.

Frontend
- LoginView: email field + "Send sign-in link" button, nothing else.
  RegisterView and the /register route are gone.
- auth store: register()/login()/resendVerification() removed;
  requestEmailChange() drops the password param.
- ProfileView: password field and the "verify your email" section removed,
  leaving just the change-email form.
- App.vue: the "verify email" header badge is gone; DashboardView's
  unverified-address notice is gone.
- Now-dead .badge/.badge--warn/a.badge CSS removed.

Tests: AuthTest and EmailVerificationTest rewritten for the new flow (52
tests total, down from 58 -- consolidated, not reduced coverage).
ApiTestCase::authHeader() signs in via the real magic-link -> verify flow.

Verified end-to-end against the rebuilt container and the dev server: a brand
new address gets an account + session from one link; /auth/register,
/auth/login and /email/verification all 404; the UI shows no password field
anywhere and no verification nagging. Also fixed the README's "Try it" curl
snippets, which had been silently broken since JSON_PRETTY_PRINT was added
(grep patterns didn't tolerate the space after ':').

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:47:46 +01:00
aneurinandClaude Sonnet 5 8f8ad8593d Make the dashboard a grid of projects with their inbox
DashboardView drops the placeholder for a full-width grid of project cards.
Each card links to the project and lists its inbox cards (status_id === null)
under a "New" heading, or "Nothing new." when empty. Cards are fetched per
project (one GET /projects/{id}/cards each) after the project list resolves.

projects store: fetchProjects() now shares one in-flight request between
concurrent callers (the sidebar and the dashboard mount together).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:02:47 +01:00
aneurinandClaude Sonnet 5 cd54b41ab7 Add a persistent left sidebar to the signed-in layout
App.vue now renders a left AppSidebar beside the routed view for any
requiresAuth page, staying mounted as you move between the dashboard and
projects. The sidebar has a Dashboard link (icon), a divider, the project list
(each an icon link, current page highlighted via RouterLink active-class), and a
compact new-project form that jumps to the created project.

- New /dashboard route + DashboardView ("under construction"); / and unknown
  paths redirect there. HomeView removed -- its project list and form moved into
  the sidebar.
- <RouterView :key="route.path"> so navigating project -> project via the
  sidebar remounts and reloads instead of reusing the instance.
- Signed-out routes (login/register/verify-email) render without the sidebar.

Icons are inline SVG -- no new dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:55:31 +01:00
aneurinandClaude Sonnet 5 9c1768ddb2 Tint the kanban inbox column lighter than the status columns
New --inbox-bg token (light and dark) applied via .kanban__col--inbox so the
inbox reads as distinct from "To do" / "Doing" / "Done".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:43:57 +01:00
aneurinandClaude Sonnet 5 c47c800d01 Add per-project card statuses and a kanban board
Statuses
- Migration 006: card_statuses table (project-scoped) and cards.status_id, a
  nullable FK with ON DELETE SET NULL. Every new project is seeded with
  "To do" / "Doing" / "Done"; GET /api/projects/{id}/statuses lists them.
- New cards have no status -- they sit in an "inbox" until moved.

Project view
- Full-width and tabbed: "All tasks" (a flat list, sorted by name
  case-insensitively) and "Kanban" (Inbox plus one column per status).
- Drag a card within or between columns to reorder / restatus; the Inbox
  column has its own name + Add form.

Ordering
- Migration 007: `position` is now a dense 0..n-1 rank within a
  (project_id, status_id) column, not a project-wide order. New composite
  index idx_cards_project_status_position; existing rows re-ranked.
- PUT /api/projects/{id}/cards/order takes { status_id, card_ids } and sets one
  column's contents and order, re-parenting moved-in cards and re-packing their
  source column in a single transaction. PATCH status_id appends the card to the
  end of the destination column.

58 phpunit tests pass; the frontend type-checks and builds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:37:17 +01:00
aneurinandClaude Sonnet 5 d9db4a3a30 Serve the built SPA from the PHP image; drop the web container
The Dockerfile is now multi-stage: a Node stage runs `npm run build`, and the
PHP/Apache stage copies the result into public/. Apache + public/.htaccess route
/api* to the Slim front controller, serve real files, and fall back to
index.html for client-side routes.

docker-compose.yml loses the `web` service, its volume, and the source
bind-mount -- the image is the artifact now (rebuild to pick up changes).
Frontend dev moves to `npm run dev` on the host; APP_URL defaults to :8080 since
the one container serves both halves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:37:07 +01:00
aneurinandClaude Sonnet 5 49acc301ec Pin the Compose project name to php-project-manager
Was derived from the directory name (php-todo-list). Containers, volumes and
network are now prefixed php-project-manager_ regardless of the directory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:02:22 +01:00
aneurinandClaude Sonnet 5 ebfa20982b Rename package to project-manager
- composer.json: aneurin/php-todo-list -> aneurin/php-project-manager
- web/package.json (+ lockfile): "web" -> "project-manager-web"
- docker-compose.yml image tags: php-todo-list -> php-project-manager,
  php-todo-web -> php-project-manager-web

The directory, the Compose project prefix (derived from the directory name),
and the git remote still carry the old name and are left as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:00:16 +01:00
aneurinandClaude Sonnet 5 c66e5ceb9b Rename lists -> projects and items -> cards throughout
Project scope shifts from a todo list to a project-management app. This is a
straight terminology rename across code, comments, migrations, tests, and
docs — no behaviour change.

- DB: table todo_lists -> projects, todo_items -> cards, column
  todo_items.list_id -> cards.project_id, indexes renamed. Migrations 003/004
  rewritten in place (destructive; recreate the volume with `down -v`).
- API: /api/lists -> /api/projects, nested /items -> /cards, reorder body
  item_ids -> card_ids, JSON keys list/lists/item/items -> project/projects/
  card/cards, item_count -> card_count, list_id -> project_id, and the
  matching error messages.
- PHP: TodoList/TodoItem Repository + Controller -> Project/Card; shared SQL
  aliases l/i -> p/c.
- Frontend: stores lists.ts/items.ts -> projects.ts/cards.ts (useProjectsStore
  / useCardsStore, MAX_PROJECTS), ListView -> ProjectView, TodoItemRow ->
  CardRow, route /lists/:id -> /projects/:id (name "project"), types TodoList/
  TodoItem -> Project/Card, and all UI copy. CSS .lists*/.list-head* ->
  .projects*/.project-head*, .item* -> .card-row* (kept the generic .card
  panel class), .items -> .cards.
- Product name in the header, PWA manifest, index.html title and package
  descriptions -> "Project Manager" / "Projects".

Backend suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:28:59 +01:00
aneurinandClaude Sonnet 5 be592f38fc Add stage 8: passwordless magic-link login
Backend
- POST /api/auth/magic-link (public): emails a one-time login link for an
  address. Always 202 with the same body so accounts can't be enumerated; a
  link is sent only when the account exists and wasn't emailed in the last
  60s. Opening it (existing verify-email endpoint) returns a session and, as a
  side effect, verifies the address. New EmailVerifier::sendLoginLink; the
  60s interval is now EmailVerifier::RESEND_INTERVAL_SECONDS, shared.

Frontend
- LoginView defaults to magic-link mode: email only, "Log in with email". A
  "Log in with password" link reveals the password field, changes the button
  to "Log in", and itself becomes "Get a magic link" to switch back.
- VerifyEmailView copy is now login-neutral ("Signing you in").

Tests: 5 new (magic-link login, implicit verification, enumeration-safety,
throttle, validation). Suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:29:13 +01:00
aneurinandClaude Sonnet 5 64ba21795b Bundle a Mailpit catcher and default Compose to SMTP
Adds a mailpit service (axllent/mailpit, ~15 MB, in-memory) to
docker-compose.yml — chosen over the unmaintained MailHog for the same
footprint. It has no profile, so `docker compose up -d` starts it alongside
the API; the app defaults to MAIL_TRANSPORT=smtp -> mailpit:1025 (no
auth/TLS) and mail is read at http://localhost:8025.

Also change the default MAIL_FROM to no-reply@todo.test: PHPMailer v7 rejects
the dotless no-reply@localhost as an invalid address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 08:40:36 +01:00
aneurinandClaude Sonnet 5 f9b65cc4a7 Add stage 7: email verification magic links and a profile page
Backend
- New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail()
  (the default fallback), and log-to-file transports, selected by
  MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token
  and sends the link (APP_URL/verify-email?token=...).
- Migration 005: email_verifications table + users.verification_email_sent_at.
- Registration now emails a verification link (best effort — a send failure
  doesn't fail registration).
- POST /api/auth/verify-email consumes a token and returns a session, so
  opening the link verifies the address (or applies a pending email change)
  and logs the user in. Single-use; distinct 400s for invalid/used/expired.
- POST /api/email/verification resends; POST /api/email/change requests a
  deferred change (current password required; link goes to the new address;
  users.email only updates when that link is opened). Both throttled to once
  per 60s, returning 429 + retry_after.
- GET /api/me and every session payload now include pending_email. Shared
  SessionPayload builds the user/session JSON for all entry points.

Frontend
- /verify-email view: posts the token, adopts the returned session, redirects.
- /profile view: shows address + status, a resend button with a live cooldown
  (driven by retry_after / 429), and a change-email form (new address +
  current password) that surfaces the pending change.
- Header shows a "verify email" badge linking to the profile.

Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle,
and deferred change; AuthTest folded into ApiTestCase, which now routes mail
to a per-test log. Suite: 32 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:04:49 +01:00
aneurinandClaude Sonnet 5 c4c947896e Drop the description from the lists index
The all-lists view now shows only the title and item count per list; the
description lives on the list detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:13:19 +01:00
aneurinandClaude Sonnet 5 3bf70504cc Add inline title/description editing and list delete to the list view
ListView: the title and description are now inline-editable fields saved on
blur via PATCH /api/lists/:id; an empty description shows an "Add a
description" placeholder. A "Manage" menu in the top right (click-outside and
Esc to close) holds a "Delete list" action that opens a confirmation modal;
confirming calls DELETE and routes back to the all-lists view. No backend
change — the existing PATCH/DELETE endpoints cover it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:10:31 +01:00
aneurinandClaude Sonnet 5 8d0e0165bb Drop the redundant empty-items line on the list view
The summary line already shows "No items yet." when a list has no items;
remove the second one below the (empty) item list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:01:35 +01:00
aneurinandClaude Sonnet 5 b506b83b1e Add stage 5: list detail page with items UI and drag reorder
API: new PUT /api/lists/{id}/items/order takes the full ordered id set and
rewrites positions 0..n-1 in a transaction (422 unless the set matches the
list exactly). TodoItemRepository gains idsForList() and reorder().

Frontend: lists on the home page are now links to /lists/:id (ListView).
ListView shows the list title, a "M of N done" summary, and each item as a
drag handle + checkbox + inline-editable text (saved on blur) + delete
button, with a create-item form at the bottom. Drag-and-drop uses
vuedraggable; on drop the whole order is persisted via the new endpoint and
the response replaces local state, with a resync-on-error fallback. New
items store; items store is also reset on logout.

Tests: reorder happy path, incomplete-set rejection, owner scoping. Backend
suite: 23 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:59:51 +01:00
aneurinandClaude Sonnet 5 dd2ab5b7d3 Rework the lists view: form below list, drop description, add count
HomeView now shows a "You have N lists." summary above the list, moves the
new-list form beneath the list, and drops the description input (title only).
lists store createList() loses its description parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:48:48 +01:00
aneurinandClaude Sonnet 5 bc1142b929 Add stage 4: frontend lists view + enforce 100-list cap
API: GET /api/lists is now ordered alphabetically (COLLATE NOCASE) by title
with no other option, and TodoListController rejects a create past 100 lists
per owner with 409. New TodoListRepository::countForOwner.

Frontend: HomeView replaces the placeholder with the user's lists (rendered in
API order) and a create form (title + optional description). New Pinia lists
store fetches and creates, re-fetching after a create so the new list sorts
into place; it is reset on logout. Form disables and explains at 100 lists;
create errors surface inline. Neutral .badge with a .badge--warn variant;
dropped the unused .facts styles.

Tests: alphabetical ordering and the 100-list cap. Suite: 17 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:42:15 +01:00
aneurinandClaude Sonnet 5 5a0d29a308 Add stage 3: todo list and item CRUD API
Two migrations add todo_lists (owner_id FK to users, title, description) and
todo_items (list_id FK, text, complete, position), both with ON DELETE
CASCADE.

New endpoints under /api/lists, all behind AuthMiddleware:
  - lists: index / store / show / update (PATCH) / destroy
  - items: nested under a list, same five verbs
Lists are owner-scoped — another user's or a missing list responds 404, never
403. New items append after the highest position unless one is given; the
list carries item_count / completed_count. Item PATCH is partial and never
renumbers siblings.

Adds App\Support\Validator for request-body checks, TodoList/TodoItem
repositories, and body()/user() helpers on the Controller base. Feature tests
move their shared harness into tests/ApiTestCase; TodoTest covers CRUD,
ownership isolation, ordering, completion counts, validation and cascade
delete. Full suite: 15 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:30:52 +01:00
aneurinandClaude Sonnet 5 5e3b8dbd7e Add stage 2: Vue/TypeScript PWA shell with auth-gated routing
Backend: new migration adds users.email_verified_at (null = unverified);
registration leaves it null, and the register/login/me payloads now expose
email_verified and email_verified_at.

Frontend (web/): Vite + Vue 3 + TypeScript PWA (vite-plugin-pwa). Pinia auth
store keeps the token in localStorage and validates it via GET /api/me on
load. vue-router guards redirect unauthenticated visitors to /login,
preserving the intended path; /register creates an account and signs in
immediately (with the email unverified). Placeholder home page, minimal
styling, generated icons. Dev server proxies /api to the API.

docker-compose.yml gains an optional "web" service (profile: frontend) so
`docker compose --profile frontend up -d` runs the dev server alongside the
API; `docker compose up -d` still starts the API alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 18:12:31 +01:00
aneurinandClaude Sonnet 5 e91fc89e23 Bind-mount the project into the container for live edits
docker-compose.yml now mounts the working directory at /var/www/html so
PHP changes take effect without an image rebuild. To avoid a mount nested
inside that bind mount, the storage directory moves out to /var/www/storage
(still a named volume). Config gains a STORAGE_PATH env var driving both the
SQLite database and the JWT signing-key location; the entrypoint chowns that
directory. The Dockerfile is unchanged and still builds a self-contained
image (STORAGE_PATH defaults back to ./storage when unset).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:51:56 +01:00
aneurinandClaude Sonnet 5 15145865d4 Add Docker Compose setup for local runs
PHP 8.3 + Apache image serving public/ on port 8080, with pdo_sqlite and
mbstring built in. The container entrypoint applies migrations as www-data
before starting Apache; the SQLite database and generated JWT signing key
persist in a named "storage" volume. `docker compose up -d` is now the
primary way to run the project without a local PHP toolchain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:45:35 +01:00
aneurinandClaude Sonnet 5 0938e1a59b Note in README that the project is generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:38:22 +01:00
aneurinandClaude Sonnet 5 7faef6fbff Add stage 1: authentication REST API
Slim 4 + SQLite todo-list API providing email/password registration,
login, and an authenticated GET /me endpoint. Stateless HS256 JWTs,
bcrypt password hashing, uniform JSON error envelope, and a SQL
migration runner. Includes PHPUnit feature tests and stage-1 docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:35:10 +01:00