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>
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>
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>
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>
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>
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>
.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>
/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>
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>
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>
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>
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>
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>
- .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>
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>
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>
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>
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>
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>
- 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.
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>
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>
"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>
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>
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>
.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>
.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>
- .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>
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>
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>
- 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>
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>
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>
.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>
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>
- 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>
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>
- .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>
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>
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>
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>
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>