Commit Graph
15 Commits
Author SHA1 Message Date
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 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 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
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 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 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 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 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 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 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 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