Commit Graph

224 Commits

Author SHA1 Message Date
xarmian 2f58193f22 chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install
links were placeholders pointing at GitHub README anchors while
TASK-863's docs page didn't exist yet. That page is now live at
getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the
three URLs to the real docs:

- "Other install options →" → https://getpad.dev/docs#installation
  (broader install matrix: Homebrew + Binary + Docker + Source)
- "Documentation"          → https://getpad.dev/docs/connect-workspace
- "Troubleshooting"        → https://getpad.dev/docs/connect-workspace#troubleshooting

Updated the in-source comment to reflect that the URLs are now the
canonical ones, not placeholders.

This closes out PLAN-859 (web-first onboarding on-ramp): a user who
creates a workspace in the web UI now has a complete in-app + docs
path to connecting that workspace to their local project.
2026-04-29 10:26:15 -04:00
xarmian e5eae5e94e feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)

Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.

Server:

- New store method WorkspaceHasCLISource(workspaceID) — backed by
  EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
  cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
  HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
  one cli item flips it on, soft-delete flips it back off, and
  cross-workspace isolation.

Web:

- New <ConnectBanner> Svelte 5 component
  (web/src/lib/components/ConnectBanner.svelte). Self-contained:
  reads dismissed state from localStorage, fetches has_cli_source
  itself, mounts <ConnectWorkspaceModal> internally. Two split
  $effect blocks per CONVE-606 — one for the localStorage sync, one
  for the dashboard fetch — so a workspace change doesn't entangle
  the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
  flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
  onboarding-dismissed pattern. Per-browser only; TODO comment in
  source about backing it with a workspace_user_state row if cross-
  device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
  above {@render children()} so it appears on every workspace page
  (dashboard, collection lists, item detail, search, activity, etc.)
  and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
  has_cli_source: boolean.

Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).

Test plan:
- go build ./... && go test ./... — all green (incl. new
  TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)

Two findings from Codex review on PR #284:

1. Stale-response race: rapid workspace switches could let a slow
   dashboard fetch from workspace A overwrite hasCliSource for
   workspace B after the user navigated. Capture the requested slug
   at fetch time, ignore the response if wsSlug has changed since.

2. Auto-hide didn't work in-session: if a user opened the banner
   modal, copied the command, ran it elsewhere, and closed the modal,
   the banner stayed visible because hasCliSource was stale. Refetch
   when the modal transitions from open → closed (the natural moment
   the user has just connected). Uses $effect.pre with a tracked
   previous value, matching the transition pattern in ShareDialog.

The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.

* fix(server/items): persist source from auth context on create (Codex round 2)

Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.

Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).

Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
  session token in the header since the auth middleware validates
  token format and rejects fake values with 401 before the handler
  runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')

* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)

Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.

Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.

* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)

Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.

Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.

* fix(store): visibility-filter has_cli_source query (Codex round 5)

Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.

Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.

Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2026-04-29 10:05:34 -04:00
xarmian a28767d323 feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861)

Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
Gives a user who created a workspace via the web UI a one-line copy-paste
to connect that workspace to their local repo, exposed in the two
zero-state surfaces where they'd look for it.

Changes:

- New `<ConnectWorkspaceModal>` Svelte 5 component
  (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no
  host-page coupling. Matches ShareDialog's modal pattern (overlay +
  centered modal, native, open = $bindable(), Escape closes). Props:
  serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed
  install — macOS/Linux/Windows/Docker, default tab from detected
  platform) and Step 2 (pad init --url ... --workspace ... snippet
  with a copy button on the full snippet). Footer links to docs +
  troubleshooting.
- New web/src/lib/utils/platform.ts — tiny dependency-free OS detection
  helper. SSR-safe (defaults to "macos" with no navigator).
- Mounted in the workspace landing page as a "Connect your local
  project" card directly under <OnboardingChecklist> in the empty-
  workspace .onboarding-wrapper. Modal itself is mounted unconditionally
  at the page root so it survives re-renders of the conditional empty
  state.
- Mounted in TopBar.svelte's user menu (both desktop and mobile
  branches): "Connect a project..." entry between Theme/Cloud-support
  links and the Sign-out divider. Modal lives outside the dropdown so
  it doesn't unmount when the dropdown closes. Both gated on
  workspaceStore.current?.slug since the modal needs a workspace to
  interpolate.

Docs URLs in the modal footer (getpad.dev/docs/install,
getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in
PLAN-859 will publish those pages and we'll wire the final URLs then.

Test plan:
- go build ./... && go test ./... clean
- cd web && npm run build clean
- make install clean, server restarted
- Svelte MCP autofixer ran on all four touched files — no findings

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1)

Two findings from Codex review on PR #283:

1. macOS install command was `brew install xarmian/pad/pad`, but the
   actual tap is `PerpetualSoftware/tap/pad` (per README.md and
   skills/INSTALL.md). Users would have hit a failing install.

2. Footer links pointed at `getpad.dev/docs/install` and
   `getpad.dev/docs/connect-local-project` — pages TASK-863 will
   publish but don't exist yet. Until they do, point at the GitHub
   README's #installation and #getting-started anchors so clicks at
   least land somewhere useful instead of 404.

The TASK-863 follow-up will swap these back to the dedicated docs URLs
once the pages ship.

* fix(web/connect-modal): use real install commands from README (Codex round 2)

Round 2 caught that Linux/Windows/Docker commands were fabricated:
- Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist
- Docker used wrong volume mount (/root/.pad vs the image's /data) and
  didn't publish ports

All four tabs now mirror the README's Installation section exactly:
- macOS + Linux: brew install PerpetualSoftware/tap/pad
- Windows: pointer to the GitHub releases page (no first-party one-liner)
- Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
2026-04-29 09:20:29 -04:00
xarmian 86a2f3c55b fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) (#281)
* fix(web/editor): copy from table puts plain text only on clipboard (TASK-858)

ProseMirror's default copy serialization for selections inside a table
included the wrapping <table>...</table> in the text/html clipboard
payload. Pasting into rich-text apps (or anywhere that prefers HTML over
plain text) reproduced the table styling when the user just wanted the
cell text.

Add a tableCopyPlugin mirroring the existing codeBlockCopyPlugin
pattern: when the selection lives entirely inside a table, write a
plain-text representation to text/plain and clear text/html. Cut also
deletes the range, same as the code-block plugin.

Behavior:
- Text selection inside a single cell: cell text on text/plain.
- CellSelection (multi-cell drag): tab between cells, newline between
  rows. Pastes correctly into Excel/Sheets/Numbers.
- Selection that spans into/out of the table: falls through to default.

Trade-off (accepted): re-pasting a multi-cell copy into our own editor
yields TSV text, not a reconstructed table. Matches Linear/Notion/Slack.

Fixes BUG-855.

* fix(web/editor): preserve parent Table plugins + selection-aware cut per Codex review (round 1)

Two findings from Codex review of PR #281:

1. Table.extend's addProseMirrorPlugins was returning only [tableCopyPlugin],
   replacing the parent extension's plugins and silently dropping
   columnResizing (negating resizable: true) and tableEditing (cell
   selection / table editing). Now spreads ...(this.parent?.() ?? []) and
   appends tableCopyPlugin.

2. Cut path used tr.delete(from, to) which is unsafe for CellSelection —
   a contiguous document range can include unrelated cells (or row
   structure) between the rectangular cell-selection's endpoints. Switched
   to tr.deleteSelection(), which routes through prosemirror-tables'
   CellSelection.replace override and clears each selected cell's content.
   Still correct for the TextSelection-inside-one-cell case (deletes the
   text range as before).

The codeBlockCopyPlugin's tr.delete(from, to) is intentionally left alone —
that path validates the selection sits inside a single code_block, where
from/to is a flat text range and no structural risk exists.
2026-04-29 00:57:14 -04:00
xarmian cc4f1c16b6 feat(web): let users switch collection inside the Quick Add modal (TASK-857) (#280)
The Quick Add modal previously locked users into the collection they
launched it from. Replace the static `{icon} New {Singular}` header with
a clickable pill that opens a small popover listing every regular
collection in the workspace; selecting one swaps the target collection
without losing the typed title.

Behavior preserved:
- Default collection still comes from the launch entry point (sidebar
  `+`, dashboard buttons, Cmd-N).
- Picker excludes agent collections (conventions, playbooks) via the
  existing `regularCollections` filter.
- If only one regular collection exists, the pill renders as a non-
  interactive label (no caret, no popover).
- `submitQuickAdd` already re-derives default fields and content
  template from the current `quickAddCollection`, so swapping mid-flow
  Just Works.

Keyboard:
- Enter / Space / ArrowDown on the pill opens the picker.
- ArrowUp/Down/Home/End navigate; Enter selects; Esc closes the picker
  only (textarea Esc still closes the modal).

The outside-click handler is kept as its own `$effect` per CONVE-606
(don't combine reactive triggers in a single effect).

Implements IDEA-749.
2026-04-29 00:06:49 -04:00
xarmian eaae76f667 feat(auth): link to /console from CLI auth success state (TASK-856) (#279)
After approving a CLI session at /auth/cli/{code}, the success state
previously dead-ended with "you can close this tab" and no link out.
Adds a primary "Go to your workspaces" CTA linking to /console — the
same destination that / redirects to and that pad-cloud's OAuth flow
lands users on post-login. Universal across self-hosted, Docker, Remote,
and Pad Cloud (which proxies /auth/cli/ to the upstream pad backend
via nginx, no pad-cloud change needed).

The existing "you can close this tab" message stays — some users
(CI runs, headless approvals, teammate's laptop) genuinely just want
to close the tab.

Source: IDEA-848.
Parent: PLAN-833.
2026-04-28 23:33:01 -04:00
xarmian 43b2565afe fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849)

The custom link renderer called marked.parseInline on the raw text of a
link's child tokens to render the visible text. For autolinks (bare URLs
that GFM auto-detects as links) the raw text *is* the URL, so the
recursive parseInline re-tokenized it as another autolink and re-entered
the same renderer — stack overflow, browser console spammed with
"Please report this to https://github.com/markedjs/marked", and the
item page rendered as fallback text.

Triggered on any item whose content or comments contained a bare URL,
e.g. HT-786 had a comment with https://manage.maileroo.app.

Use marked's intended API: this.parser.parseInline(tokens) renders the
already-parsed inline tokens directly, no re-tokenization. Required:
- regular function (not arrow) so `this` binds to the Renderer instance
  (marked invokes overrides via override.apply(rendererInstance, args))
- import Renderer for the `this: Renderer` annotation
- escape the title attribute via escapeHtml() at the source instead of
  relying on DOMPurify after the fact

* fix(web): encode href in markdown link renderer (defense-in-depth)

Mirror marked's internal cleanUrl() so the custom link renderer produces
well-formed HTML even when href contains spaces, quotes, or other
URL-unsafe characters — and degrades gracefully to plain text when
encodeURI throws (lone surrogates).

Before: an href like `http://x" onclick="alert(1)` (reachable via marked's
`[x](<...>)` URL-with-spaces syntax) would land in the attribute
verbatim, producing malformed HTML the sanitizer then had to repair.
After: encodeURI turns the quotes into %22, so the intermediate HTML is
already well-formed before DOMPurify runs. The %25 → % round-trip avoids
double-encoding hrefs that already contain percent-encoded bytes
(e.g. %20).

DOMPurify is still the URL-safety authority — javascript:/data: schemes
are stripped by sanitizeMarkdownHtml's ALLOWED_URI_REGEXP. This change
is defense-in-depth plus correctness for the intermediate HTML, matching
the behavior of marked's default renderer.

Flagged in Codex review of #274.
2026-04-28 13:52:43 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian 0ab6d3ed10 feat(web): signed-in account chip on CLI auth approval + switch-accounts (TASK-836) (#271)
* feat(web): show signed-in account chip on CLI auth approval page (TASK-836)

The CLI auth approval page (/auth/cli/{code}) previously showed only an
"Approve" button with no indication of WHICH account was about to grant
the CLI access. For OAuth users on Pad Cloud — most of whom have
multiple GitHub/Google accounts — wrong-account approval was a silent
footgun, recoverable only by revoking the CLI token after the fact.

This change renders an account chip above the Approve button when the
session is pending, showing:

- The user's avatar (when avatar_url is present)
- Display name (or username fallback if name is empty)
- Email

Below the chip, a "I'm not <Name> — switch accounts" link button calls
api.auth.logout() and navigates to /login?redirect=/auth/cli/{code}, so
after re-login the user lands back on this same approval page (the
login page already validates relative-only redirects to prevent open
redirects).

Graceful degradation: api.auth.me() is wrapped in its own try/catch.
If it fails, currentUser stays null and the chip simply doesn't
render — the Approve flow still works. The Approve button is also
disabled while a switch-accounts call is in flight to avoid
double-action races.

Works for both email/password (self-hosted) and OAuth (Cloud)
sessions because api.auth.me() and api.auth.logout() operate on the
unified pad session regardless of how it was established.

Parent: PLAN-833. Source: IDEA-831 issue #3.

* fix(web): plumb redirect through OAuth login + surface logout failures

Codex round-1 findings on TASK-836:

- MEDIUM: The login page already preserved ?redirect= for password and
  2FA login but the GitHub/Google OAuth buttons were plain anchors with
  hardcoded hrefs. A user clicking "Switch accounts" on the CLI auth
  approval page and then signing in via OAuth would land at /console
  instead of back at /auth/cli/{code}. Added a $derived oauthRedirectQuery
  rune that reuses the existing getRedirectTarget() validation and
  appends ?redirect=<encoded> to both OAuth links when the redirect is
  non-default. Whether pad-cloud's /auth/github and /auth/google handlers
  honor the redirect param is an out-of-tree concern and tracked
  separately if needed; the client side now consistently passes it.

- LOW: handleSwitchAccount silently swallowed logout failures and then
  navigated to /login. If the server didn't actually invalidate the
  session cookie (network/CSRF), login's onMount would see an
  authenticated session and bounce the user right back to the approval
  page — making "switch accounts" appear to be a no-op. The handler now
  surfaces the error in the page error slot and stays on the approval
  page, giving the user a clear next step (retry or close the tab) and
  also resets switchingAccount so the UI isn't stuck in a "Switching..."
  state.

A defensive code check was also added to handleSwitchAccount to mirror
handleApprove's "Missing CLI session code" guard, even though the button
only renders when status === 'pending'.

Parent: PLAN-833.

* fix(web): tighten redirect validation + cover OAuth banner buttons

Codex round-2 findings on TASK-836:

- MEDIUM: getRedirectTarget() accepted protocol-relative URLs (`//host`
  and `/\host`) because the bare `startsWith('/')` check passes for
  both. Browsers and most server-side redirect handlers treat those as
  cross-origin destinations, so a crafted `?redirect=//evil.example`
  could become an open redirect once forwarded through the OAuth
  handler. Now also rejects strings that start with `//` or `/\`. This
  was a pre-existing bug in the password/2FA redirect path; the OAuth
  link change made the surface area worth tightening.
- LOW: The "Use a different GitHub/Google account" banner buttons that
  appear on `oauth_provider_not_linked` errors hardcoded `?force=1` and
  dropped the redirect target. Added a sibling `oauthRedirectAmpQuery`
  derived value (`&redirect=...`) so those links compose properly with
  `?force=1`. When the redirect is the default `/console` it stays
  empty so we don't add redundant query noise.

Both changes live in cmd/pad/... no, in web/src/routes/login/+page.svelte
and don't affect the password / 2FA paths beyond the validation
tightening (which they were already passing through silently).

Parent: PLAN-833.
2026-04-27 23:11:35 -04:00
xarmian 8ae009fa40 feat(admin): add Billing tab and dashboard page (TASK-828) (#267)
* feat(admin): add Billing tab and dashboard page (TASK-828)

Surfaces the Pad Cloud billing metrics in the admin console as a new
tab between "Audit Log" and "Settings". Final piece of PLAN-825.

The page calls GET /api/v1/admin/billing-stats (TASK-827) and renders
six metric cards in a responsive auto-fit grid:

1. MRR              (Stripe-derived; greyed when unavailable)
2. ARR              (Stripe-derived; greyed when unavailable)
3. Active Subs      (Stripe-derived; greyed when unavailable)
4. Customers/Plan   (LOCAL — always real; e.g. "Free: 42 · Pro: 7")
5. New Signups 30d  (LOCAL — always real)
6. Churn 30d        (Stripe-derived; greyed when unavailable;
                     subtitle shows cancelled count)

Two banners drive the degraded-state UX:
- cloud_unreachable=true  → amber warning ("sidecar unreachable, showing
                            local data only")
- stripe_configured=false → blue info banner explaining that Stripe
                            metrics will be zero until STRIPE_SECRET_KEY
                            is set on pad-cloud (the expected pre-launch
                            steady state)

Header carries a Refresh button (re-fetches without unmounting the page)
and an "Open in Stripe Dashboard ↗" external anchor (rel=noopener).
A subtle footer renders "Updated just now" or "Updated N min ago" from
the cache_age_seconds field.

The Billing tab is hidden from the layout's tab list when
adminStore.stats.cloud_mode is false — self-host operators won't see a
tab that always 404s on click. Used $derived(...) for the tabs array so
the tab list reacts to the cloud_mode flag flipping after stats load.

Svelte 5: runes throughout ($state, $derived, $props), single onMount
for the initial fetch, no combined effect-on-effect chains (CONVE-606).
Visual idiom mirrors the existing /console/admin stats-bar (.stat
cards, --bg-secondary background, --radius-lg, value/label sizing).

Validated with the svelte MCP autofixer (clean) and `npm run build`
(clean, page emitted to entries/pages/console/admin/billing).

Closes PLAN-825's UI work.

* fix(admin): add role=status / aria-live=polite to Stripe info banner

Codex round 1 LOW: the warning banner already carries role=alert because
its message is urgent (sidecar unreachable), but the "Stripe not
configured" info banner appears asynchronously after load with no live-
region semantics, so screen readers never announce that the page is in
a degraded state. Add role=status + aria-live=polite so the announcement
is non-interrupting but happens.
2026-04-27 14:52:13 -04:00
xarmian 8e067c19db feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)

New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').

Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
  place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
  /admin/metrics/billing with the X-Cloud-Secret header (the same secret
  pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
  package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
  the account-delete tests still satisfy the extended interface.

Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
  to render: cloud_unreachable=true (sidecar errored or unwired) and
  stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
  non-admin gets 403.

Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.

Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
  X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
  non-200 → SidecarError, transport error stays bare, malformed JSON,
  nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
  (merges local + remote correctly, handles plan="" → "free", filters
  new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
  degrades to local-only, transport error degrades, sidecar 5xx degrades,
  stripe_configured=false propagates verbatim with cloud_unreachable=false.

Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).

* fix(admin): address Codex review (round 1) on billing-stats proxy

- Replace handler-side ListUsers walk with store.CountBillingAggregates
  (two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
  for new pro signups). Removes the per-row TOTP decrypt overhead that
  ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
  healthy" requires cloud_unreachable=false AND stripe_configured=true,
  not "both flags false" as previously stated.

Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.

* fix(store): GROUP BY normalised plan expression in CountBillingAggregates

Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.

Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
2026-04-27 14:42:53 -04:00
xarmian 29f720c996 docs: add real README screenshots (dashboard + board views) (#257)
The README had two TODO placeholders for screenshots that have been
sitting commented-out since the project started. With the launch
imminent, fill them in.

Captures:
- docs/screenshots/dashboard.png — workspace dashboard with Active
  Work cards, Active Plans (v0.2 — Collaboration with progress),
  collection summaries, recent activity.
- docs/screenshots/board.png — tasks board view, four columns
  (Open / In-Progress / Done / Cancelled) with realistic task cards.
- docs/screenshots/list.png — list view (not currently referenced
  from the README, but kept as part of the reproducible asset set).

Reproducibility:

web/e2e/screenshots.spec.ts is a gated Playwright spec (skipped
unless PAD_SCREENSHOTS=1) that uses the existing e2e fixture
infrastructure to:
1. Spin up a fresh pad binary against a clean data dir.
2. Bootstrap an admin + workspace seeded with the startup template.
3. Add a realistic demo dataset (1 active plan, 7 tasks across
   open/in-progress/done with mixed priorities, 2 ideas).
4. Navigate + capture three views at 1440x900.

To regenerate:

  make build
  cd web && PAD_SCREENSHOTS=1 PAD_E2E_PORT=17801 \\
    npx playwright test screenshots --project=desktop-chromium

Notes:

- Table view (?view=table) was originally in scope but the URL
  parser only accepts list/board today; setting via toggle would
  require localStorage manipulation. Three screenshots already
  cover the README's needs; revisit if/when table view becomes
  URL-reachable.
- Dark/light variants were also in scope but the web UI is dark-
  mode-only at present, so the captures are dark-only.

Refs: TASK-673
2026-04-26 20:12:40 -04:00
xarmian a1bbfabf67 fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow
menu shipped in IDEA-758 / TASK-759:

- Layout collapse: `.workspace-list` had no `flex: 1`, so
  ResizeObserver fed the shrinking content width back into the
  fitting calc and ratcheted down to "active pill only". Wrap pills,
  trigger, and add button in a centered `.workspace-row` that owns
  `flex: 1`; the row's full width now drives the split.
- Trigger position + menu anchoring: trigger now sits next to the
  last visible pill, and the menu opens directly under the trigger
  via a `position: relative` `.overflow-anchor` wrapper.
- Overflow zone not registering as a drop target: switched from
  `pointer-events: none` / `transform: scale(0)` to
  `visibility: hidden` for the closed state. svelte-dnd-action's
  hit-test uses bounding-rect math (not `elementsFromPoint`), and
  `scale(0)` confuses its transform-undoing on percentage origins.
- Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone
  is registered before drag starts (mid-drag mount isn't picked up).
- Post-drop snap-back: set `dropCooldown = true` synchronously in
  finalize handlers, before flipping `isDragging`, so the resync
  effect doesn't clobber the post-drag zones before the persist
  microtask runs.
- Click-after-drop navigation: `dropClickGuard` swallows the
  synthetic click that fires on the dragged `<a>` after mouseup,
  preventing `goto()` from firing on every drop.
- Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s
  synchronous `workspaces.find(...)` was leaking a reactive dep on
  `workspaceStore.workspaces` into both the workspace `+layout`
  effect and the dashboard `+page` load effect. Wrap both in
  `untrack(...)` so they only re-run on `wsSlug` change.
- Active-pin reject cleanup: rejection paths now call
  `clearCooldownAfterRejection()` so a stuck `dropCooldown` from
  the source-zone finalize doesn't gate sync effects forever.
- A11y: `aria-expanded` on the trigger now uses a `menuVisible`
  derived (`overflowOpen || isDragging || dragArmed`) so it matches
  the visual open state.
- Replace `CHROME_RESERVATION = 72` magic number with named parts
  derived from the actual CSS box model (= 68, was off by 4).
2026-04-25 19:14:58 -04:00
xarmian f58290272f fix(web): persistent low-opacity expand tabs for hidden sidebar/topbar (TASK-762) (#246)
Implements IDEA-757.

⌘\ toggles BOTH the sidebar and the topbar at once. When they go hidden,
the only on-screen affordances to bring them back are the .topbar-expand-btn
and .sidebar-expand-btn tabs, which were styled `opacity: 0` at idle and
only became visible on `:hover` of the parent container. A user who hits the
shortcut accidentally and stares at a now-mostly-empty screen sees no
affordance at all.

Bump idle opacity to 0.5 on both expand tabs so the affordance is always
faintly visible. Hover amplification to 1 (existing) is unchanged. The
tooltips on the tabs ("Show workspace bar (⌘\)" / "Open sidebar (⌘\)") now
become discoverable, teaching the shortcut on first encounter.

CSS-only change.
2026-04-25 09:57:55 -04:00
xarmian 441f624584 feat(web): mobile navbar workspace switcher always present, preserve sidebar state on switch (TASK-761) (#245)
* feat(web): mobile workspace switcher always present, preserve sidebar state on switch (TASK-761)

Implements IDEA-760.

- web/src/routes/+layout.svelte: replace the mobile-header workspace-name link
  with <WorkspaceSwitcher mobile /> so the switcher is reachable from both
  sidebar states. Add `.mobile-switcher-slot` to flex-fill the gap next to the
  hamburger; drop the now-unused `.mobile-title` rules.
- web/src/lib/components/layout/WorkspaceSwitcher.svelte: drop uiStore.onNavigate()
  from select() so workspace switching no longer collapses the mobile sidebar —
  the user's sidebar state carries over to the new workspace per IDEA-760. Add
  same-workspace dashboard parity (mirrors TopBar.handleWsClick) so tapping the
  current workspace still gives a one-tap path back to the dashboard.

openCreateModal() retains its uiStore.onNavigate() — separate modal-overlay UX.

* fix(web): tighten WorkspaceSwitcher dashboard URL + a11y on switcher trigger

Codex P2 + nit follow-up to TASK-761:

- WorkspaceSwitcher.select(): same-workspace dashboard branch now reads
  owner_username from workspaceStore.current rather than ws.owner_username
  (which is typed optional). When isCurrent is true `current` is non-null and
  shares the slug, so its owner_username is guaranteed present. Avoids the
  edge case where a caller passing a workspace without owner_username would
  produce `//slug` (scheme-relative URL) instead of an in-app path.
- WorkspaceSwitcher trigger: add aria-haspopup="menu", aria-expanded={open},
  and aria-hidden on the chevron glyph so screen readers get the menu
  semantics + open/closed state on the new primary mobile navbar control.

* fix(web): aria-haspopup type matches actual popup (dialog mobile, menu desktop)

Codex follow-up nit on TASK-761: the WorkspaceSwitcher trigger advertised
aria-haspopup="menu" unconditionally, but on mobile the popup is a
role="dialog" BottomSheet and on desktop it's a dropdown of buttons.
Make the hint match the actual surface by deriving from isMobile.

* fix(web): drop aria-haspopup on desktop WorkspaceSwitcher popup

Codex follow-up nit on TASK-761: the desktop popup is a plain dropdown
<div> of buttons without role=menu/menuitem or arrow-key keyboard nav,
so aria-haspopup="menu" overstated the semantics. Mobile keeps
aria-haspopup="dialog" because that branch genuinely renders a
role="dialog" BottomSheet. Desktop falls back to aria-expanded alone,
which is sufficient for "button toggles a popup" without claiming
specific popup type semantics that aren't backed by roles.
2026-04-25 09:57:28 -04:00
xarmian 8346f9348e feat(web): replace desktop navbar scroll with overflow menu (TASK-759) (#244)
* feat(web): replace desktop navbar scroll with overflow menu (TASK-759)

The desktop top bar's workspace list previously used `overflow-x: auto`
with a hidden scrollbar — workspaces past the visible edge were reachable
only by horizontal scroll, with no visual cue that anything was hidden.
Mobile already solved this via BottomSheet (TASK-637); desktop never got
the equivalent.

This change implements a "priority+" overflow pattern in TopBar.svelte:

- Pills are measured in a hidden ghost row keyed by slug.
- A ResizeObserver tracks the visible container's width.
- Pills that don't fit move into a `…` overflow menu anchored under the
  trigger. The active workspace is pinned to the visible row regardless
  of fit position so the "you are here" cue is never hidden.
- The trigger is always rendered (with `visibility: hidden` when empty)
  to prevent layout oscillation as workspaces are added or removed.

Drag-and-drop works to and from the overflow menu on day one. Three
dndzones share `type: 'topbar-workspace'`: the visible row, the menu,
and the trigger as a single-slot drop target. A 400 ms spring-loaded
auto-open lets the user drag onto the trigger and place the dropped
item at a precise position inside the menu. Dropping on the trigger
without waiting appends to overflow. Active is rejected from overflow
finalize and snapped back to visible.

Persistence reuses the existing `api.workspaces.reorder()` path. Both
zones' finalize events are coalesced into a single persist via
queueMicrotask. A 1s `dropCooldown` prevents store→local sync from
fighting the just-written order, mirroring BoardView's pattern.

Mobile (≤640px) is unchanged — still uses WorkspaceSwitcher BottomSheet.

Spec: IDEA-758.

* fix(web): address Codex review round 1 (TASK-759)

Per Codex review on PR #244, round 1:

HIGH — Drop active onto `…` trigger silently dropped active from the
persisted order. handleTriggerFinalize stripped active from droppedSafe
without restoring it to visibleZone, so persistGlobalOrder rebuilt
fullOrder = visibleZone + overflowZone with active missing from both.
Now both rejection paths (overflow zone and trigger zone) reset all
zones from the un-mutated propVisible/propOverflow derived split and
cancel the queued persist via cancelPersist().

MEDIUM — Active-pin rejection in the overflow zone snapped active to
the END of visible instead of restoring its original position. Same fix
as above — reset from the derived split, which preserves sort order.

MEDIUM — Failure rollback was hidden by dropCooldown for ~1s. The catch
block now also clears the cooldown timer, immediately resyncs zones
from the restored derived split, and unblocks the sync effect.

MEDIUM — dropCooldown setTimeouts stacked. Track a single cooldownTimer,
clearTimeout it on each new write, and cancel on rollback.

MEDIUM — A single long active-workspace name could blow past the bar
because active is pinned visible. Cap `.workspace-name` at max-width
200px with ellipsis inside `.workspace-list` and `.workspace-ghost`
(not in the overflow menu — full names read better there).

LOW — Lost the "click current workspace → workspace dashboard"
override during the click-handler refactor. The pre-PR onclick branched
on `ws.slug === currentSlug`. Restored.

LOW — Pending springLoadTimer / cooldownTimer would survive component
destroy. Added an $effect cleanup that cancels both on unmount.

* fix(web): address Codex review round 2 (TASK-759)

HIGH — Active-pin rejection only worked when the target zone's finalize
fired AFTER the source's. svelte-dnd-action does not guarantee the
order, so when handleVisibleFinalize ran AFTER handleOverflow/Trigger
finalize, it overwrote the freshly-restored visibleZone with its own
post-drag items (which excluded active). Added a `dragRejected` flag:
target-zone rejection sets it, handleVisibleFinalize early-returns if
set so the reset isn't clobbered. Cleared at the start of every
consider event so it doesn't bleed across drags.

MEDIUM — Cooldown timer race: a prior persist's pending timer was only
cleared AFTER awaiting the new persist's reorder/load, so it could fire
mid-request and flip dropCooldown false while a newer write was still
in flight. Cleared the prior timer at the start of persistGlobalOrder
(before the await) instead.

* fix(web): address Codex review round 3 (TASK-759)

MEDIUM — persistCancelled could leak past a rejected active-pin drag.
On pointer DnD svelte-dnd-action finalizes the target zone BEFORE the
source. In that order, cancelPersist() runs in the rejection handler
when no microtask was queued (the source's schedulePersist hadn't
fired yet), then handleVisibleFinalize early-returns on dragRejected
without scheduling. The flag was left set, so the next legitimate
reorder was silently dropped.

Fixed by clearing persistCancelled at the start of schedulePersist —
each new schedule begins from a clean slate, regardless of what stale
state a prior rejection may have left.
2026-04-25 01:17:17 -04:00
xarmian 2c59bfa925 fix(web): wire desktop topbar workspace switching through last-route restore (#243)
* fix(web): wire desktop topbar workspace switching through last-route restore (TASK-754 follow-up)

The TASK-754 restore logic only fired from WorkspaceSwitcher.svelte
(used on mobile). On DESKTOP, the workspace switcher is the topbar's
horizontal workspace icon list, which used plain `<a href>` links to
`/{owner}/{slug}` — bypassing restore entirely and silently
overwriting the workspace's saved deep route on every left-click.

Symptom (reported by user): "navigate to a deep page → storage updates
to that page → navigate to another workspace → saved value sticks →
click back via topbar → lands on dashboard, and the saved value gets
overwritten back to dashboard."

Fix:
- Extract the validation+pickup logic into a pure helper at
  `web/src/lib/utils/workspace-route.ts` (`workspaceRestoreTarget`).
- WorkspaceSwitcher.svelte's `select()` now delegates to the helper
  (no behavior change on mobile).
- TopBar.svelte intercepts plain left-click on each workspace `<a>` to
  goto the restore target. `href=` stays pointed at the dashboard so
  modifier-clicks (cmd/ctrl/shift/alt) and middle-click still open a
  fresh dashboard in a new tab.

Other workspace nav surfaces are left alone on purpose:
- Sidebar Dashboard nav item, mobile-header workspace name, and
  /console workspace cards are not "switchers" — semantically they're
  Home/breadcrumb/picker navigation that should always land on the
  dashboard.

Parent: IDEA-753.

* fix(web): clicking current workspace in topbar goes to dashboard

When the user clicks the workspace they're already in, override the
last-route restore and go straight to the dashboard. Gives users a
way back to the workspace home from any nested route. Clicking a
different workspace still restores its last-visited route.
2026-04-24 23:30:23 -04:00
xarmian 1ee3e5d725 feat(web): persist + restore page scroll on collection re-entry (TASK-755) (#241)
* feat(web): persist + restore page scroll on collection re-entry (TASK-755)

Page-level scroll position is now persisted (debounced 200ms) on the
collection list/board/table view, keyed by
'pad-last-scroll-{wsSlug}-{pathname+search}', so the workspace switcher
(TASK-754) brings the user back not just to the same URL but to the
same scroll offset.

Restore semantics:
- Triggers once after data hydrates (loading=false, items present).
- Gate is keyed by pathname (NOT pathname+search), so in-page filter
  toggles via replaceState do not re-restore — that would teleport the
  user away from where they're currently scrolling. Sidebar nav to a
  different collection and back DOES re-restore.
- Top-of-page (scrollY=0) clears the entry to keep storage tidy.
- Two RAFs before scrollTo so layout settles after items render;
  behavior is 'instant' (this is a positional restore, not a UX jump).

Out of scope:
- Board view's internal '.board-view' horizontal scroll and per-column
  '.column-cards' vertical scroll. BoardView would need to expose
  scroll refs; deferred. Page-level vertical scroll still applies and
  covers list and table (the dominant views).

Implements IDEA-753.

Parent: IDEA-753.

* fix(web): scroll save race + restore-gate stuck state per Codex review (round 1)

Round 1 Codex findings (TASK-755):

- HIGH: scheduleScrollSave() captured scrollKey at timer fire time, not
  scroll-event time. If the user scrolled on URL A then changed
  filters/view (replaceState) within the 200ms debounce window, the
  pending timer would write A's scroll-y under B's URL key. SvelteKit's
  auto-scroll-to-top on real navigations could also clobber a stored
  entry by writing y=0 before the restore effect ran.
  Fix: capture `key` and `y` synchronously inside scheduleScrollSave
  before setTimeout, gate saves on `scrollRestoredFor === scrollGateKey`
  (no save until restore has had its window), and clearTimeout the
  pending save in onDestroy so a debounced write can't fire post-unmount.

- MEDIUM: The once-per-pathname gate only advanced when a real restore
  attempt was made (filteredItems.length > 0). Visiting an empty/error
  collection between two visits to A left scrollRestoredFor stuck on
  A's gateKey, so re-entry to A would skip restore.
  Fix: separate $effect that resets scrollRestoredFor whenever
  scrollGateKey changes (CONVE-606 — kept its own clean dep list).

Parent: IDEA-753.

* fix(web): cross-key flush + RAF restore guard per Codex review (round 2)

Round 2 Codex findings (TASK-755):

- LOW: A single shared debounce timer with cross-key cancellation lost
  the user's last position on collection A when they navigated to and
  scrolled on collection B within the 200ms debounce window — the new
  scheduleScrollSave() cleared A's timer to start B's, so A never
  flushed. Note: [collection] param changes reuse the same +page.svelte
  instance, so onDestroy doesn't fire between them.
  Fix: track pending (key, y) explicitly. When scheduleScrollSave is
  called with a key different from the pending one, FLUSH the prior
  pending save before reseating the timer. Same flush also runs from
  onDestroy so the final position survives unmount.

- LOW: The restore effect's queued requestAnimationFrame had no
  still-on-the-same-gate check before calling window.scrollTo. A fast
  follow-up navigation between effect-run and RAF-fire could scroll the
  NEW page to the OLD saved offset (visible jump, even though the save
  gate now prevents persistence).
  Fix: capture expectedGate = scrollGateKey in the closure; verify
  scrollGateKey === expectedGate inside the inner RAF before scrolling.

Parent: IDEA-753.

* fix(web): cancel queued restore RAF on unmount per Codex review (round 3)

Round 3 Codex finding (TASK-755):

- LOW: The expectedGate guard at the inner restore RAF only catches
  same-instance gate changes. Once the component is destroyed (e.g.
  fast cross-route nav), scrollGateKey settles at its last computed
  value inside the closure, so the check passes and window.scrollTo
  fires on the next page.
  Fix: track the RAF id (scrollRestoreRAF) and cancelAnimationFrame on
  onDestroy. Cleared inside the inner RAF too so a successful run
  doesn't leave a stale id around.

Parent: IDEA-753.

* fix(web): include showArchived in scroll key per Codex review (round 4)

Round 4 Codex finding (TASK-755):

- LOW: showArchived changes the fetched dataset but isn't synced to the
  URL — saving a scroll position while archived view was on would later
  be reapplied to the non-archived view, landing the user at an
  unrelated/clamped offset.
  Fix: append '|archived' to scrollKey when showArchived is true so the
  archived and non-archived views maintain separate scroll entries.
  showArchived is not added to scrollGateKey on purpose: toggling
  archive within a page is a filter-like action, and re-restoring on
  every toggle would teleport the user (same rationale as not gating
  on pathname+search).

Parent: IDEA-753.

* fix(web): early gate-mark + RAF re-validate scrollKey per Codex review (round 5)

Round 5 Codex findings (TASK-755):

- LOW: The restore effect bailed on filteredItems.length === 0 BEFORE
  marking scrollRestoredFor. If the user landed on an empty collection
  / over-restrictive filter and items later appeared on the same
  pathname (e.g. user creates an item, or a filter toggle that produces
  items but doesn't change the gate-key), the restore would fire as a
  surprise teleport.
  Fix: set scrollRestoredFor = scrollGateKey BEFORE the empty-items
  short-circuit. Empty-state visits still 'consume' the gate so later
  items don't re-trigger restore.

- LOW: The queued RAF re-checked scrollGateKey but not scrollKey. A
  filter/archive toggle changes scrollKey without changing scrollGateKey
  (filters share the same pathname-only gate), so a queued restore
  could scroll to the previous filter combo's offset on the new view.
  Fix: also re-check scrollKey === expectedKey inside the inner RAF
  before scrollTo.

Parent: IDEA-753.
2026-04-24 22:16:33 -04:00
xarmian b999a7aaee feat(web): restore last-visited route on workspace switch (TASK-754) (#240)
* feat(web): restore last-visited route on workspace switch (TASK-754)

The workspace switcher previously always landed on the dashboard. Now
the workspace +layout writes the current pathname to localStorage on
every navigation (keyed by `pad-last-route-{wsSlug}`), and the
switcher reads that key on click and routes there instead — falling
back to the dashboard on miss, storage error, or any saved path that
doesn't belong to the target workspace (guards username changes,
corrupt entries, cross-workspace bleed).

Storage layer:
- Per CONVE-606, the persistence is its own $effect with a clean
  dependency list (wsSlug + pathname) — combining with the title
  sync above would re-run on async workspace-name resolution.
- Storage failures (private mode, disabled storage) swallowed; the
  feature degrades to the previous dashboard-only behavior.

UX:
- Direct Dashboard navigation (sidebar + mobile header use plain
  `<a href>` to the workspace root) is unaffected — only the
  switcher takes the last-route path.
- Initial page load is unchanged (URL-driven).
- Stale targets (deleted item) take the user to the existing 404
  surface; subsequent navs overwrite the bad entry.

Implements IDEA-753.

Parent: IDEA-753.

* fix(web): persist query string + clear cache on item-fetch error per Codex review (round 1)

Round 1 Codex findings (TASK-754):

- MEDIUM: Storing only `pathname` dropped URL-carried collection state
  (?view, ?sort, ?group-by, filters, ?q). Now persist
  `pathname + search`. Switcher splits on '?' before validating the
  path-portion against the target workspace prefix.

- LOW: A restored route to a since-deleted item became a sticky
  re-entry target — the leaf page renders an inline error and the
  +layout effect re-saves the same dead URL on every visit. Now the
  item-detail catch path clears `pad-last-route-{wsSlug}` so the next
  switcher click falls back to the dashboard. The cache repopulates
  on the user's next nav.

Parent: IDEA-753.

* fix(web): stale-request guard + path canonicalization per Codex review (round 2)

Round 2 Codex findings (TASK-754):

- LOW: The item-page catch path cleared 'pad-last-route-{wsSlug}' with
  no stale-request guard. If the user opened a deleted item then
  navigated away in the same workspace before the fetch rejected, the
  +layout effect would save the new valid route first, then the old
  rejected catch would clobber it. Now we capture (username, wsSlug,
  collSlug, itemSlug) at loadData entry and only clear the cache if
  its current value still points at THAT failed URL. Comparison
  strips ?query / #hash before checking.

- LOW: WorkspaceSwitcher's split-on-'?' prefix check could be bypassed
  by encoded traversal (e.g. /owner/ws/%2e%2e/other?q=1) — passes
  startsWith(fallback + '/') textually but goto() normalizes outside
  the workspace path. Now we canonicalize via URL(saved, origin) and
  require: same origin, workspace prefix on the normalized pathname,
  and no '/..' / '/./' / '//' / percent-encoded chars in the path
  (the app never generates any of those).

Parent: IDEA-753.
2026-04-24 21:39:14 -04:00
xarmian fe4ff887a0 fix(web): truncate long parent titles on item cards (BUG-630) (#238)
`item.parent_title` is populated by `enrichItemForResponse()` (via
`GetParentForItem()`) for both `parent` AND `implements` link types
— see `childLinkTypes` in `internal/store/items.go:17`. So when a task
implements an idea (a common pattern via the Implements relationship),
the idea's title becomes the task's `parent_title` and renders in the
`.meta-parent` chip on the item card.

That chip had `white-space: nowrap` and no width cap, so a long idea
title (e.g. an idea recorded as a full sentence — "we should add a
'pad info' cli command that provides information about the local
instance" is 89 chars) pushed the card past its column bounds on
Board view.

Fix:
- `.meta-parent`: add `overflow: hidden; text-overflow: ellipsis;
  max-width: 100%; min-width: 0;` alongside the existing `nowrap`,
  so the chip truncates with an ellipsis at the card-content edge.
- `.card-meta`: add `min-width: 0` so flex children with intrinsic
  content wider than the card can actually shrink instead of forcing
  the parent to grow.
- Template: bind a single `parentLabel` `@const` and pass it through
  to a `title={parentLabel}` attribute on the chip so the full label
  is still accessible via hover tooltip after truncation.

Affects both Board and List views (ItemCard is shared); the original
report focused on Board where columns are narrowest.

Verified manually on the running server with the known offending
item (`add-pad-server-info-for-local-and-remote-connection-status`
in docapp/tasks, parent IDEA-322, 89-char title): card now stays
within its column on Board view, chip truncates with ellipsis,
tooltip shows full text on hover.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 19:53:18 -04:00
xarmian fd0ace48ff fix(web): long-press delay on mobile status-header drag (BUG-641) (#237)
ListView's outer dndzone for status groups was missing `delayTouchStart`,
so any touch on a group header was immediately interpreted as the start
of a group-reorder drag. On mobile this meant trying to scroll the page
by touching a header instead grabbed the header and dragged it with the
finger — the page wouldn't scroll and the user couldn't reach content
below the visible status bands.

Mirror the inner item dndzone's `delayTouchStart: touchDragDelayMs`
(500ms) on the outer group dndzone so the same long-press gesture is
required to start a group reorder. Quick taps (collapse toggle) and
short touch-drags (page scroll) now pass through unmolested; the
existing drag-to-reorder behaviour is preserved behind the long-press,
matching what already works for items inside a group.

The `touchDragDelayMs` constant (line 46) was already in scope and
already used for the inner dndzone, so this is a one-line addition.

Verified manually on iOS at the running server: status headers no
longer hijack scroll; long-press still reorders groups; tap-to-collapse
unaffected.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 19:38:31 -04:00
xarmian 190d589afe fix(web): render markdown in timeline comments via .prose class (BUG-748) (#235)
* fix(web): render markdown in timeline comments via .prose class (BUG-748)

TimelineCommentCard tagged comment + reply bodies with `markdown-body`,
a class with no rules anywhere in the codebase. The global
`* { margin: 0; padding: 0 }` reset in app.css then stripped list
padding, heading margins, code-block backgrounds, blockquote borders,
and table styling — so any comment containing markdown (bullet lists,
headings, fenced code, quotes) rendered as run-on text without its
visual structure.

Switch both bodies to the existing `.prose` class (same one used by
the item-detail content view), and override `max-width: none` in the
scoped style so comments still fill the timeline column instead of
shrinking to the 960px content width that .prose pins for long-form
item bodies.

Comments are sanitized through DOMPurify in renderMarkdown (TASK-647);
this change is purely styling.

Verified: web/npm run build clean, go test ./... green.

* fix(web): explicit font-family + table overflow on comment-body (Codex round 1)

Address two LOW findings from Codex review of #235:

1. `.prose` pins `font-family: var(--font-content)`. The scoped
   `.comment-body, .reply-body` rule didn't override font-family, so
   comments inherited the .prose font. Currently identical to --font-ui,
   but make the relationship explicit (`font-family: inherit`) so a
   future divergence between --font-ui and --font-content doesn't
   silently change comment typography.

2. `.prose table { width: 100% }` plus padded cells can produce a wider-
   than-column table inside the indented `.reply-card` (which sits
   inside `.replies` with an extra padding-left + border-left, so its
   inner width is significantly narrower than a top-level comment).
   Add `overflow-x: auto` to .comment-body/.reply-body so wide tables
   scroll horizontally instead of overflowing the card.

Verified: web/npm run build clean, go test ./... green.
2026-04-24 18:25:24 -04:00
xarmian 7478d013cb feat(auth): surface ?error= and ?linked= on login + settings (TASK-741) (#234)
* feat(auth): surface ?error= and ?linked= on login + settings (TASK-741)

Before this change, pad-cloud's OAuth redirects with ?error=... and
?linked=... query params were silently ignored. A user who unlinked
GitHub and then hit "Sign in with GitHub" would land on a clean form
with no explanation of why their OAuth didn't work — classic silent
failure.

### Login page (/login)

- readOAuthErrorFromQuery() parses ?error= and the optional ?provider=
  hint on mount.
- Five recognised codes map to actionable banners:
  * oauth_provider_not_linked — the core recovery path: "That
    <Provider> account isn't linked to a Pad account. Sign in with
    your password below, or retry with a different account." with
    "Use a different GitHub account" / "Use a different Google
    account" CTAs wired to /auth/{github,google}?force=1 (shipped in
    pad-cloud PR #21). If ?provider is not present, both CTAs render
    so the user picks.
  * oauth_failed — generic retry prompt.
  * no_email — "verify your email with the provider" guidance.
  * too_many_attempts — rate-limit language (no client-side Retry-After
    countdown; the pad-cloud redirect doesn't carry that info).
  * account_disabled — "contact an administrator", no retry.
- Unknown codes fall back to a safe generic message so a future code
  never breaks the page.
- After rendering, ?error / ?provider stripped via
  history.replaceState so refresh / back-button doesn't re-show.
- Dismiss button on the banner for users who want to clear it
  before retrying.

### Settings page (/console/settings)

- readOAuthQueryStatus() on mount handles the three link-flow error
  codes and the two success flags:
  * ?linked=github / ?linked=google → providerMsg success toast
  * ?error=not_logged_in → session-expired guidance
  * ?error=email_mismatch → identity-mismatch fix-up
  * ?error=link_failed → generic retry prompt
  * Unknown → generic fallback
- Same history.replaceState cleanup.

### Why this is a beta blocker

A legit user who unlinks a provider can become silently un-loginable
with no UI path back. Shipping PLAN-645 to beta operators without
this makes every provider-unlink a support ticket.

Parent: PLAN-645. Depends on TASK-742 (?force=1, already merged) for
the "Use a different account" CTAs to actually work. Optional
?provider= hint will be a small pad-cloud follow-up (handler today
emits ?error= only).

* fix(settings): make provider msg/error live regions for screen readers (Codex round 1)

Addresses PR #234 Codex MEDIUM. The settings page's provider-section
banners (providerMsg / providerError) were plain <p> elements, so the
readOAuthQueryStatus() result on mount was silent to screen-reader
users — unlike the login page's oauth-banner which already had
role/aria-live. Added role='status' + aria-live='polite' to the
success element and role='alert' + aria-live='assertive' to the
error element so both get announced on mount and on subsequent
unlink/link form actions.
2026-04-24 14:53:10 -04:00
xarmian 3f58e0badc feat(billing): plan comparison matrix on /console/billing (TASK-712) (#233)
* feat(billing): plan comparison matrix on /console/billing (TASK-712)

Replaces the single-column Usage section with a side-by-side Free vs
Pro comparison table. Before: users saw their own plan's limits but
had no visible reason to upgrade — the Upgrade CTA linked to checkout
without any explanation of what Pro actually changes. Now: every
field from PlanLimits is rendered for both tiers in one table, the
current plan's column is highlighted, and a secondary Upgrade CTA
lives directly beneath the comparison for Free users.

Changes on /console/billing:

- PlanLimits interface extended with webhooks + automated_backups so
  the UI renders every field the server advertises (DefaultFreeLimits
  and DefaultProLimits in internal/store/limits.go both expose them).
- New formatBytes helper — renders storage_bytes in the natural unit
  (500 MB for Free, 10 GB for Pro) rather than raw byte counts.
- New formatCompareCell helper — 0 → "—" (reads as "not included" for
  Webhooks / Automated backups on the Free tier); -1 → "Unlimited";
  undefined → "…" while limits are loading; storage → formatBytes;
  anything else → locale-formatted integer.
- Comparison table component: scoped <th> headers for accessibility,
  a "Current" tag next to the user's plan column, subtle accent-blue
  wash on every cell in the current plan's column. Rows driven by a
  static COMPARE_ROWS array keyed on LimitKey so TypeScript enforces
  that every column references a real PlanLimits field.
- Mobile-friendly padding at the 480px breakpoint.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 4 —
the last bullet of the umbrella. TASK-712 can close after this lands.

* fix(billing): match server's negative→unlimited, don't collapse 0, fix formatBytes boundary + badge contrast (Codex round 1)

Addresses PR #233 round 1 findings:

1. formatCompareCell collapsed every 0 to "—" to read as "not included".
   Admin-configured plan limits are arbitrary integers (see
   /console/admin/settings), so a legitimate zero — "storage_bytes = 0",
   "workspaces = 0", "api_tokens = 0" — misrepresented as a placeholder.
   Removed the 0-case; zero now renders as the literal "0". "—" readability
   on the Free tier's 0-valued webhooks/automated_backups is a small loss
   compared to the correctness win.

2. formatCompareCell only treated exactly -1 as "Unlimited", but
   internal/store/limits.go enforces ANY negative value as unlimited
   (checkLimit returns Allowed=true for limit < 0). A stored -2 would
   behave unlimited server-side while the billing table showed "-2 B".
   Changed the check to "value < 0" to match server semantics.

3. formatBytes rounded at each unit tier, so values just below a unit
   boundary (1,048,575 bytes → "1024 KB", 1,073,741,823 → "1024 MB")
   overflowed the displayed value. Rewrote to use "bump" thresholds
   (bumpMB = MB - KB/2, bumpGB = GB - MB/2): a value that would
   round-display as 1024 of the smaller unit is instead shown as "1.0"
   of the next unit. Extracted the value/unit rendering into
   formatUnit() so the tier thresholds stay readable.

4. .current-tag on the comparison table header used accent-blue text on
   an 18%-alpha accent-blue wash, landing around 3.5-3.9:1 in either
   theme — below the 4.5:1 target for 0.7rem text. Switched to solid
   accent-blue background with #fff text, which stays comfortably above
   4.5:1 across both themes.
2026-04-24 14:24:05 -04:00
xarmian 119e2d8aa2 feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2)

Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping
next) to give paying users a chance to update their card before dunning
exhausts and the subscription cancels. pad owns the Maileroo integration
and the user→email mapping; the sidecar forwards the invoice metadata
here.

Changes:

- email.Sender.SendPaymentFailed — new template (HTML + plain). Subject
  "Your Pad payment couldn't be processed"; body names the amount +
  next retry date when provided, falls back to generic copy when Stripe
  omits them, and CTAs to the billing portal so the user can update
  their card. Transactional (no unsubscribe link) — users who want the
  emails to stop either fix their card or cancel the subscription.

- POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint
  (handlers_cloud.go). Accepts stripe_customer_id + optional pre-
  formatted amount_display + next_retry_display. Looks up the user,
  sends the email, logs a payment_failed_email_sent audit entry.
  Returns 200 + email_sent=false with a reason string for every
  non-error skip (unknown customer, no email on file, Maileroo not
  configured) so the sidecar never rolls back the Stripe webhook over
  an email failure. Returns 200 + email_sent=false + reason=send_failed
  when Maileroo itself errors — still no rollback.

- Registered the path in cloudAdminPaths, the server router, and the
  CloudAdmin rate limiter so the sidecar's calls share the same rate
  bucket as /plan + /stripe-customer-id.

- ActionPaymentFailedEmailSent audit constant for the new entry.

- Three focused tests: cus_ prefix validation, unknown-customer 200,
  and email-not-configured 200. Added an entry to the cloud-mode gate
  table-driven test to confirm /admin/payment-failed also 404s when
  cloud mode is off.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad
side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR.

* fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1)

Addresses PR #232 round 1 findings:

MEDIUM — payment-failed handler only wrote an audit row on the actual
send attempt, so no_customer / no_email_address / email_not_configured
skip paths left no durable trail. Consolidated the audit + response
into a single auditAndRespond closure called from every outcome
branch, so operators can always reconstruct whether (and why) a
customer was notified during dunning reconciliation.

MEDIUM — audit UserID was set to actorID, which is empty for sidecar
calls. /audit-log?user=<target-user-id> would never surface these
events. Now set UserID to targetUser.ID whenever we have one; the
no_customer branch still writes a row but with empty UserID (filtered
only by action + stripe_customer_id metadata). Moved actor identity
into an actor_is_admin metadata field instead.

LOW — test coverage was thin: no assertion on the most important
contract ("return 200 with reason=send_failed and still record the
attempt"), no test of the happy send path, no audit-log assertions.
Added email.Sender.SetEndpoint (exported, test-only — comment says
so) so tests can point the Sender at a mock Maileroo server, plus
three new tests:
  - TestPaymentFailed_HappyPath_SendsAndAudits
  - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits
  - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID
The first two verify audit metadata per outcome; the third proves
unknown-customer cases still leave a findable audit row.

Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint
outside the sender's RWMutex — fine before the mutable SetEndpoint
existed, now a data race. Pulled the endpoint read into the same
RLock scope as fromAddr/fromName.

* fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2)

Addresses PR #232 round 2 findings:

MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not
which admin. For manual operator-triggered calls, that meant the audit
trail could not answer "who sent the dunning email?" when multiple
admins touched the endpoint. Added admin_actor_id to the metadata
whenever the authenticated caller has role=admin. Sidecar calls with
no authenticated user still have no admin_actor_id, which correctly
distinguishes them from manual admin operations.

LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back
to "first 3 metadata keys" when no formatter exists for an action,
which could hide the important reason/sent fields. Added a dedicated
case for payment_failed_email_sent that renders either "sent (cus_...)"
or "skipped: <reason> (cus_...)" depending on the outcome, matching
the terse display style of the other switch cases.

* fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3)

Addresses PR #232 round 3 LOWs:

- The formatter lumped every sent=false outcome under 'skipped', which
  conflates a genuine Maileroo delivery failure with a pre-send skip.
  Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other
  reasons → 'skipped (<reason>) (...)'.
- admin_actor_id was recorded in metadata but invisible in the UI: the
  User column shows the target user via a.user_id. Appended
  'by admin:<id>' to the formatted string whenever admin_actor_id is
  present, so manual operator calls are attributable at a glance.
  Sidecar calls have no admin_actor_id and render without the suffix.

* fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4)

The backend emits payment_failed_email_sent and the custom formatter
knows how to render it, but the audit-log page's ACTION_TYPES /
ACTION_LABELS registry omitted the action, so admins couldn't filter
for these events from the dropdown — undercutting the dunning
reconciliation workflow this PR is adding. Added 'payment_failed_email_sent'
to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS.
2026-04-24 01:50:08 -04:00
xarmian 69e0b2017a feat(billing): confirm-upgrade polling on /console/billing (TASK-712) (#231)
* feat(billing): confirm-upgrade polling on /console/billing (TASK-712)

Stripe Checkout redirects back to /console/billing?checkout=success the
moment the user finishes paying, but pad-cloud's checkout.session.completed
webhook is asynchronous — it needs a beat to land, authenticate against
pad's /admin/plan endpoint, and flip the user's plan to "pro". Before this
change, the returning user saw the Free plan with the "Upgrade to Pro"
button and had to refresh manually before the app caught up.

Changes on /console/billing:

- Detects ?checkout=success on mount. Runs a single fresh authStore.load()
  first — if the webhook is already in, skip straight to the confirmed
  state. Otherwise start polling authStore.load() every 2s for up to 30s.
- Four states: idle (default), checking (spinner + "Confirming your
  upgrade…"), confirmed (green check + "welcome to Pro!"), timeout
  (yellow, payment went through + support contact).
- On confirm, clears the ?checkout=success query via history.replaceState
  so a page reload does not re-enter the polling branch.
- onDestroy stops the interval — no dangling timers after navigation.
- Reduced-motion users see a static spinner frame per prefers-reduced-motion.
- Banner has role="status" aria-live="polite" so screen readers announce
  state changes.

Reuses authStore's existing inflight-coalescing + generation guard (shipped
with PR #229), so concurrent polls share a single /auth/session fetch and
a post-logout navigation cannot resurrect a stale plan value.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 2.
Bullet 3 (failed-payment email) ships next; bullet 4 (plan matrix) later.

* fix(billing): destroyed guard, parallel tasks, plan-reconcile banner (Codex round 1)

Addresses PR #231 review findings:

HIGH — onMount's awaits could race with onDestroy: a late authStore.load()
or plan-limits fetch finishing after the user navigated away would still
mutate upgradeStatus/limits, and startUpgradeConfirmation could even
install a setInterval on a destroyed component. Added a 'destroyed' flag
set in onDestroy and checked after every await; stopPolling also runs on
teardown and inside pollForUpgrade's post-await guard for belt-and-
braces.

MEDIUM — startUpgradeConfirmation was sequenced behind the plan-limits
fetch. A slow /plan-limits request would delay the 'checking' banner and
the first authStore.load() refresh, defeating the purpose of the PR.
Split them: onMount is now synchronous, kicks off startUpgradeConfirmation
and loadPlanLimits in parallel as fire-and-forget promises, each with
its own destroyed-guarded error handling.

LOW — upgradeStatus latched 'confirmed' independently of the current plan
value. If plan transitioned away from 'pro' for any reason after the
banner appeared, it would stay stuck showing the success message. Render
the confirmed banner only while upgradeStatus === 'confirmed' AND isPro
so the banner fades out automatically if the plan reconciles down.
2026-04-24 00:52:02 -04:00
xarmian cf00eeba84 feat(web): add Support + Status links on auth pages and user menu (TASK-713) (#230)
Implements TASK-713 bullets 1+2 for Pad Cloud: a support@getpad.dev
mailto and a https://status.getpad.dev link users can reach before
signing in and from inside the app. Discord link deferred — spawn an
HT follow-up once the server URL is known.

Changes:
- New SupportFooter.svelte — Support · Status row, gated on cloudMode,
  styled consistently with LegalFooter (underlined, focus-visible
  outline). Rendered below LegalFooter on login, register, and
  forgot-password.
- TopBar user dropdown (desktop + mobile branches): Support and Status
  entries between the theme toggle and Sign out, grouped by a divider,
  gated on cloudMode so self-hosted installs do not advertise links
  that are not theirs to offer.

Bullets 3+4 of TASK-713 (admin impersonation, MRR/churn dashboard)
remain out of scope for Chunk 1 per the PLAN-645 audit decision; they
will be tracked in a follow-up plan after beta launch.

Parent: PLAN-645 (Pad Cloud Beta Readiness). Second PR in Chunk 1.
2026-04-24 00:09:03 -04:00
xarmian cf3e64caf4 feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229)
* feat(web): add legal footer + consent notice on auth pages (TASK-714)

Pad Cloud (cloudMode=true) needs visible Terms / Privacy / Sub-processors
links so Stripe Live-mode compliance is defensible and users know what
they're agreeing to. Self-hosted installs (cloudMode=false) don't need
these — the legal docs at getpad.dev are Perpetual Software LLC's TOS
for the hosted service, not the user's own instance.

Changes:
- New LegalFooter.svelte component: renders Terms · Privacy · Sub-processors
  links to https://getpad.dev/{terms,privacy,subprocessors}. Gated on a
  cloudMode prop so self-hosted installs see nothing.
- login, register, forgot-password pages: render <LegalFooter> below the
  card. Each page already fetches session; register/forgot-password now
  persist session.cloud_mode into a local $state so the footer can be
  reused consistently.
- register page adds a "By creating an account, you agree to Terms and
  Privacy Policy" consent notice directly under the Create account button
  (only when cloudMode=true). This is the signup touchpoint for
  Stripe Live mode.
- Auth-page containers switched to flex-direction: column so the card
  and footer stack cleanly centered.

Cookie banner intentionally deferred: the privacy policy already asserts
"strictly-necessary cookies only, no banner required" and app.getpad.dev
only sets first-party session/CSRF cookies. Stripe Checkout runs on
billing.stripe.com (separate origin) so its cookies don't apply here.

Parent: PLAN-645 (Pad Cloud Beta Readiness). This covers the main
launch-blocking legal bullet (Stripe Live mode compliance); the pad-web
marketing site already hosts the actual legal content.

* fix(web): read cloudMode from authStore; add link affordance (Codex round 1)

Addresses PR #229 review findings:

MEDIUM — register and forgot-password pages were fetching session via
api.auth.session() specifically to derive cloudMode, duplicating the
root layout's authStore.load() and adding a silent failure path (if the
extra request failed, the legal footer/consent would vanish even on
Pad Cloud). Switched to authStore.cloudMode in both pages. register
still calls api.auth.session() for its pre-existing setup_required +
authenticated checks, but no longer reads cloud_mode from that call.
forgot-password no longer needs onMount at all — its only addition
was the cloudMode fetch.

LOW — legal footer links had no affordance until hover: muted color,
no underline, nothing for keyboard users. Added persistent subtle
underline (1px, 2px offset) and :focus-visible outline so the links
are discoverable for touch and keyboard users.

Also: login page intentionally left untouched. It has a pre-existing
local cloudMode state used for OAuth buttons + sign-up link + legal
footer; unifying it with authStore is a separate refactor and outside
this PR's scope.

* fix(auth): add authStore.ensureLoaded for post-logout auth nav (Codex round 2)

Addresses PR #229 round 2 finding:

MEDIUM — After logout, authStore.session is cleared and the root layout
doesn't re-run onMount on SPA navigation, so subsequent visits to
/register or /forgot-password would read authStore.cloudMode=false and
silently hide the legal footer/consent on Pad Cloud.

Fix: add authStore.ensureLoaded() which returns the cached session when
present or fetches it otherwise. register's onMount now routes its
pre-existing session fetch through ensureLoaded (so the same call
populates authStore for downstream components like LegalFooter).
forgot-password calls ensureLoaded() on mount — cheap no-op when the
store is already populated, one fetch when it's been cleared.

This keeps the single-source-of-truth benefit from round 1 while
handling the logout-then-navigate case Codex flagged.

* fix(auth): coalesce concurrent session loads (Codex round 3)

Addresses PR #229 round 3 finding:

MEDIUM — authStore.ensureLoaded() did a bare 'if (session)' check, so a
hard page load that fired both the root layout's authStore.load() and
the page's ensureLoaded() could issue two /auth/session requests. If
one succeeded and the other failed, the later catch path (session =
null) would overwrite the good session, leaving cloudMode=false after
a successful fetch.

Fix: add an inflight Promise in auth.svelte.ts. load() returns the
inflight promise when one exists; the then/catch/finally chain runs
exactly once per fetch and clears inflight in finally. ensureLoaded()
keeps its cached-session short-circuit and otherwise delegates to
load(), so concurrent callers all await the same underlying request.

* fix(auth): guard stale session fetches with generation counter (Codex round 4)

Addresses PR #229 round 4 findings:

MEDIUM — clear() did not invalidate a pending inflight load, so a
pre-logout /auth/session call could resolve after logout and resurrect
the logged-out user's session. Subsequent ensureLoaded() calls would
also attach to that stale promise.

LOW — inflight was only cleared in the promise's finally, so a
permanently-hanging fetch wedged loading=true and every subsequent
load()/ensureLoaded() returned the same never-settling promise.

Fix: introduce a 'generation' counter that bumps on clear(). load()
captures the current generation at fetch start and only writes session
/ clears loading / clears inflight when the generation is still
current. clear() now also drops the inflight reference and resets
loading=false, so the next ensureLoaded() fires a fresh request and
the UI is not left hanging. Late callbacks from pre-logout fetches
still resolve in the background but cannot mutate authStore state.
2026-04-23 23:33:42 -04:00
xarmian c8601a2031 test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) (#225)
* test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689)

Option A of TASK-689: land the test infrastructure and a minimal smoke
test on both mobile and desktop viewports. Broader flow coverage (board
view drag, item detail, comments, mobile hamburger, BottomSheet
regression guard) is tracked as TASK-733.

Infrastructure
--------------
- web/playwright.config.ts: two projects (desktop-chromium, mobile-
  chromium via Pixel 7), reporter list+html, trace/video/screenshot
  retained on failure, webServer that wipes + recreates the data dir
  then runs the pad binary. Paths anchored to the config file's
  directory so runs are cwd-invariant.
- web/e2e/global-setup.ts: bootstraps admin via POST /auth/bootstrap,
  logs in, creates the e2e workspace, mints a user-scoped API token,
  and persists the token + resolved admin username to fixture.json.
- web/e2e/fixtures.ts: extends base test so every BrowserContext
  automatically gets Authorization: Bearer <token>. Uses a token
  rather than a session cookie because sessions are User-Agent bound
  in middleware_auth.go and a node-minted session would be rejected
  by a Chromium UA.

Tests
-----
- web/e2e/dashboard.spec.ts: a logged-in user lands on the seeded
  workspace, no login form is rendered, and the workspace name
  appears on the page. Runs in both project viewports.

CI
--
- New `e2e` job in .github/workflows/ci.yml: builds web UI + binary,
  installs Playwright chromium with OS deps, runs the suite, and
  uploads the HTML report as an artifact on failure. Timeout capped
  at 10 minutes (suite itself runs in ~4s today).

Local run (in mcr.microsoft.com/playwright:v1.59.1-noble): 2 passed
in 4.1s.

Parent: PLAN-644.
Follow-up: TASK-733 for broader flow coverage (Option B in the
original ship plan).

* fix(e2e): persist server-returned workspace slug instead of the constant (TASK-689)

Addresses Codex P2 on PR #225. When Playwright's `reuseExistingServer:
true` (local dev), a re-run of globalSetup hits `POST /api/v1/workspaces`
against a DB that already has `e2e`. The server uniquifies the slug
(`e2e` → `e2e-2` → …) and returns the uniquified value, but the old
code wrote `WORKSPACE_SLUG` (the constant) to fixture.json. Tests
then navigated to /e2e-admin/e2e — which might still exist from a
previous run with stale state — instead of /e2e-admin/e2e-2, missing
regressions in freshly-seeded content.

Fix: read `slug` back from the workspace-create response and use that
when writing fixture.json. Local re-runs now always point at the
workspace this run actually created.

Parent: PLAN-644.

* fix(e2e): cross-platform webServer bootstrap via Node wrapper (TASK-689)

Addresses Codex P2 on PR #225: `rm -rf && mkdir -p && pad server start`
in webServer.command is POSIX-only. Windows contributors on cmd.exe or
PowerShell can't run `npm run test:e2e` at all — the e2e suite becomes
Linux/macOS-only, defeating the "CI parity" goal.

Fix: extract the wipe-and-exec logic into web/e2e/run-pad.mjs. Node's
fs.rmSync / mkdirSync / child_process.spawn are uniform across
platforms, and the wrapper forwards SIGTERM/SIGINT so Playwright's
teardown still cleanly kills the child on suite exit.

Local re-run in mcr.microsoft.com/playwright:v1.59.1-noble: 2 passed.

Parent: PLAN-644.
2026-04-22 22:35:08 -04:00
xarmian 5b14c2e35f fix(a11y): BottomSheet tabindex + roles page label associations (TASK-685) (#221)
Closes the four a11y warnings svelte-check surfaces today.

- BottomSheet.svelte: the <div role="dialog"> needs tabindex so screen
  readers can focus it programmatically. Add tabindex="-1" — activates
  when explicitly focused without putting it in the tab order (matches
  the ARIA APG dialog pattern).

- roles/+page.svelte: three <label> elements for Icon & Name,
  Description, and Tools had no associated control. Give each target
  <input> a stable id (role-name, role-description, role-tools) and
  point each <label for={id}>. The dialog renders a single instance at
  a time so hardcoded ids are safe.

svelte-check before: 10 warnings (4 target + 6 pre-existing)
svelte-check after:  6 warnings (pre-existing only; no regressions)

Parent: PLAN-644.
2026-04-22 20:51:44 -04:00
xarmian 9909d7b7c6 fix(web): resolve 9 svelte-check errors blocking CI (TASK-674) (#200)
svelte-check was reporting 9 errors on main, blocking the CI gate.
All fixed:

 1. EditCollectionModal: make `open` prop bindable ($bindable()). This
    unblocks `bind:open={editCollectionOpen}` in two call sites:
    - routes/[username]/[workspace]/[collection]/+page.svelte:1153
    - routes/[username]/[workspace]/[collection]/[slug]/+page.svelte:1101

 2. [slug]/+page.svelte: narrow `item` inside callback-bound expressions:
    - Line 684 (.find closure) now uses a local @const for the slug
      rather than re-reading item.parent_collection_slug inside the
      callback (TS cannot narrow across the closure).
    - Line 744 star toggle handler now short-circuits on item presence,
      so both `item.slug` and `item.id` are safe.

 3. auth/cli/[code]/+page.svelte: guard against `$page.params.code`
    being `undefined` in both onMount and handleApprove.

 4. console/settings/+page.svelte: add @types/qrcode dev dependency
    so the dynamic `import('qrcode')` calls have proper typings.

`cd web && npx svelte-check` now reports 0 errors (warnings were
out of scope — addressed separately in TASK-685). `go build/vet/test`
and `cd web && npm run build` are green.

Parent: PLAN-644.
2026-04-22 14:59:21 -04:00
xarmian 69262c3b53 feat(server): periodically revalidate SSE subscriber membership (TASK-670) (#194)
* feat(server): periodically revalidate SSE subscriber membership (TASK-670)

handleSSE checked workspace access only at connection time. A removed
member kept receiving live events until they manually disconnected —
or, more commonly, indefinitely, because browser EventSource auto-
reconnects and the replay buffer filled any gaps. An owner who revoked
access had no way to stop the leak without restarting the server.

- New 60s membership revalidation ticker inside the SSE select loop.
- Store.sseSubscriberStillHasAccess mirrors RequireWorkspaceAccess's
  access matrix: fresh install bypass, admin role, direct membership,
  guest grants, legacy workspace-scoped API token. DB errors fail
  OPEN (keep connection) so a transient blip doesn't bounce every
  open tab; membership-absent fails CLOSED.
- On revocation we send the client a well-known {type:"unauthorized"}
  event with a human-readable reason BEFORE closing the stream, so
  frontend EventSource handlers can route to login / dismiss the
  workspace instead of tight-looping to reconnect.
- sseMembershipRevalInterval is a package-level var so tests can
  shrink it; pinned to the 30-300s reasonable range.
- Unit test exercises every branch: admin, active member, outsider,
  removed member, guest-grant (skipped when default collections aren't
  seeded), unauthenticated, legacy token scoped to same workspace, and
  legacy token scoped to a different workspace.

Parent: PLAN-643 (OSS Security Hardening).

* fix(web): handle server-emitted 'unauthorized' SSE event in client (TASK-670)

Addresses Codex P2 on PR #194: the server emits `{type:"unauthorized"}`
before closing a revoked stream, but the Svelte SSE service only listened
for "connected", "sync_required", and item events. Without a handler,
the default `EventSource.onerror` would auto-reconnect indefinitely on
the next /api/v1/events request — exactly the tight-loop the server
event was meant to prevent.

- New 'unauthorized' SSEStatus so surrounding UI can react (e.g.
  redirect to workspace list or show a "revoked" toast).
- Dedicated listener: on unauthorized, set status to 'unauthorized',
  close the EventSource explicitly (this prevents browser auto-
  reconnect), and null out currentWorkspace so a later connect()
  doesn't treat the closed connection as "already connected".

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): recompute SSE visibility on each revalidation tick (TASK-670)

Addresses Codex P1 on PR #194: previous revision only rebuilt the
filter maps at connect time, so a user whose scope was NARROWED
mid-stream (role downgraded to viewer, collection access tightened
to "specific", item grants revoked) kept receiving events from
collections they no longer had access to. Revocation-of-membership
was caught, but scope-tightening was not.

- Extract the filter-map computation into a new sseVisibility struct
  + (*Server).computeSSEVisibility method. Same logic as before,
  just reentrant so it can be re-run on a live connection.
- Store the snapshot in a local `vis` variable captured by the
  sseEventVisible closure (reads the CURRENT snapshot, so the next
  event dispatched after a tick sees the new permissions).
- On every revalidation tick where the subscriber still has access,
  call computeSSEVisibility again and reassign `vis`. The cost is
  one GetCollection + one GuestVisibleResources + friends per tick
  per connection — acceptable at the 60s cadence.
- New TestComputeSSEVisibility_ReflectsCurrentGrants verifies that
  a second call after membership revocation returns a different
  snapshot (isGuest flip), pinning the regression.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): jitter first SSE revalidation tick to avoid stampedes (TASK-670)

Addresses Codex P2 on PR #194: the earlier comment promised jitter but
the implementation wired a plain time.NewTicker(revalInterval). Every
stream then revalidated on a cadence tied to its connect time, which
synchronizes whenever a wave of clients connects close together (post-
deploy reconnect storm, login wave, cron-driven dashboard refresh).
The resulting periodic :00/:60 DB load spike is the exact anti-pattern
the comment warned about.

- Swap the Ticker for a Timer. First fire is delayed by a random
  uniform [0, revalInterval) window using math/rand so connect-time
  coincidence doesn't translate to revalidation-time coincidence.
- After the first fire, Timer.Reset(revalInterval) re-arms at the
  regular cadence — the jitter from connect-time is persistent for
  the lifetime of the connection, no need to re-jitter every tick.
- math/rand is fine here: this is load-spreading, not a security
  primitive, so a deterministic-at-boot PRNG is acceptable.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): re-fetch user during SSE revalidation to catch admin demotion (TASK-670)

Addresses Codex P1 on PR #194: sseSubscriberStillHasAccess early-
returned on currentUser(r).Role == "admin", but currentUser(r) is the
user snapshot cached in request context at SSE connect time. An admin
demoted mid-stream via /api/v1/admin/users/{userID} would keep the
admin short-circuit forever — the exact "admin forever" bug the
revalidation loop was meant to close.

- Re-fetch the user via s.store.GetUser(cachedUser.ID) at the start of
  each revalidation pass so role changes, disabled flags, and account
  deletions take effect on the next tick.
- User deleted → revoke.
- User disabled (IsDisabled) → revoke. Previously a disabled admin's
  stream also leaked.
- All downstream checks (admin short-circuit, membership lookup, grant
  check) use the fresh copy.

Tests:
- TestSSESubscriberStillHasAccess_AdminDemotion: bootstrap admin, hand
  it to the request context, then demote to "member" in the DB and
  verify access flips to false. Without the fresh fetch, this test
  passes even though the real system leaks — pins the regression.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): use fresh user for SSE visibility computation too (TASK-670)

Addresses Codex P1 on PR #194 (companion to the previous commit):
computeSSEVisibility called visibleCollectionIDs(r, ...), which reads
currentUser(r).Role — the cached snapshot placed in request context
when the SSE connection opened. A global admin demoted to "member"
mid-stream while keeping workspace membership would keep the admin
short-circuit forever — visibleCollectionIDs returned nil (all access)
based on the stale Role="admin", so events from collections outside
the user's new collection_access="specific" scope would keep flowing.

- computeSSEVisibility now re-fetches the user via s.store.GetUser
  before computing visibility. Transient DB errors fall back to the
  cached snapshot so a blip doesn't accidentally widen visibility.
- The admin short-circuit (visibleIDs nil) now comes from the fresh
  user.Role, so demotion immediately trips the "no, actually filter"
  path on the next revalidation tick.

Tests:
- TestComputeSSEVisibility_DemotedAdminGetsFilter: set up a global
  admin who is a workspace member with collection_access="specific"
  and NO granted collections. Before demotion the admin gets nil
  (unrestricted). Demote to "member" → the snapshot must flip to a
  non-nil visibleSlugSet (system collections only). The cached-role
  bug would keep returning nil here.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 13:30:20 -04:00
xarmian 3544e42de1 chore(web): npm audit fix + CI audit gate (TASK-654) (#181)
web/package-lock.json had 11 advisories (1 low, 1 moderate, 9 high)
before this PR: @sveltejs/kit (redirect/body-size), cookie<0.7.0,
dompurify<=3.3.3, vite 7.0.0-7.3.1, lodash-es, picomatch, chevrotain,
etc. All required a mix of `npm audit fix` and targeted upgrades.

Changes:
- web/package.json: upgrade @sveltejs/kit to ^2.57.1. Add `overrides`
  map pinning cookie to ^0.7.2 (upstream @sveltejs/kit@2.57.1 still
  ships cookie@0.6.0 which is LOW severity but trivially fixable).
- web/package-lock.json: regenerated via `npm install` + `npm audit fix`.
- .github/workflows/ci.yml: add `npm audit --audit-level=high --omit=dev`
  step after `npm ci`. Fails the build on any HIGH+ advisory in
  production deps; dev-only issues stay informational so CI isn't held
  hostage by unfixable upstream chevrotain/vite dev-server advisories.

`npm audit --audit-level=high --omit=dev` now reports 0 vulnerabilities
locally. `npm run build` and `go test ./...` remain green.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-21 21:37:16 -04:00
xarmian 42cc220024 fix(web): sanitize rendered markdown through DOMPurify (TASK-647) (#170)
* fix(web): sanitize rendered markdown through DOMPurify (TASK-647)

Comments (and any other caller of renderMarkdown) piped marked() output
straight to {@html}. A malicious comment could inject <script> / <img
onerror> / javascript: links that executed on every viewer's page —
stored XSS with full session takeover.

Wrap renderMarkdown's output in DOMPurify.sanitize with a strict
allowlist of markdown-produced tags and attributes. Also HTML-escape
the wiki-link title before interpolating it into the <a>/<span> so the
intermediate HTML is well-formed even for pathological titles.

Sanitization runs client-side only (adapter-static SPA mode has no
runtime SSR of user content). In SSR/prerender contexts we return ""
rather than emit unsanitized HTML — markdown-bearing views fetch their
data at runtime anyway, so the empty fallback is a no-op.

Parent: PLAN-643 (OSS Security Hardening).

* fix(web): allow ol start attribute in markdown sanitizer per Codex review
2026-04-21 18:33:11 -04:00
xarmian 2e00a6769a feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640) (#169)
* feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640)

Follow-up to TASK-637: the WorkspaceSwitcher component was built with a
BottomSheet branch on mobile but it was never rendered anywhere — the
TopBar had its own inline horizontal workspace list on both desktop
and mobile.

- Mobile: swap the TopBar's horizontal workspace list + "+" add button
  + "edit/reorder" button for a single <WorkspaceSwitcher /> chip. Tap
  opens the BottomSheet of workspaces + "+ New Workspace". Removes the
  horizontal-scroll discoverability problem when a user has many
  workspaces.
- Desktop: unchanged. Still uses the inline list with drag-to-reorder.
- Users who want to reorder workspaces can do it on desktop; mobile
  drag-reorder is a rarely-used workflow and the edit button added
  visible chrome on cramped mobile chrome.
- WorkspaceSwitcher now calls `uiStore.onNavigate()` on select/create
  so the mobile sidebar closes on workspace switch — preserves the
  previous TopBar link behavior.
- Removed now-unused state + handlers: mobileEditMode, enterEditMode,
  exitEditMode, handleMobileConsider, handleMobileFinalize, the
  reorder-overlay markup and CSS, the currentUsername derived (it was
  already unused).

Parent: PLAN-631.

* fix(web): let callers force WorkspaceSwitcher's mobile branch (Codex review)

Codex flagged a P2: TopBar branches mobile/desktop on uiStore.isMobile
(≤768px) but WorkspaceSwitcher uses its own 639.98px matchMedia. At
640–768px viewports (small tablets), the mobile TopBar would render
the desktop WorkspaceSwitcher dropdown — reintroducing the clipping
this PR was trying to fix.

- Add an optional `mobile?: boolean` prop to WorkspaceSwitcher that
  overrides the internal viewport detection when passed. Auto-detect
  still runs when the prop is omitted (for future callers).
- Mirror the rotation-reopen guard for the prop path: if `mobile`
  flips to false while the sheet is open, close it.
- TopBar passes `mobile={true}` when rendering inside its mobile branch
  so the decision stays consistent with `uiStore.isMobile`.

Per Codex review on PR #169.
2026-04-20 17:44:16 -04:00
xarmian 041472496b feat(web): select field editor renders as BottomSheet on mobile (TASK-638) (#168)
Scope note: the task also mentioned multi_select, but FieldEditor
currently has no custom UI for multi_select — it falls through to the
plain text input. Scoping this PR to `select`, where the absolute-
positioned inline dropdown is the actual mobile pain (clips off the
edge of the properties panel when the chip sits near the right edge).
A dedicated multi_select editor is a separate piece of work.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')`.
- Extract the options list into a `{#snippet selectOptions}` shared
  between branches so markup doesn't duplicate.
- Mobile: on `dropdownOpen`, render `<BottomSheet title="Set {label}">`
  with the options list. Sheet gated on `isMobile && dropdownOpen`
  (gate-on-open pattern) so the sheet's global keydown listener isn't
  mounted per idle FieldEditor.
- Desktop: unchanged inline `.select-dropdown` with keyboard nav.
- `handleWindowClick` bails early on mobile so it doesn't race the
  sheet's backdrop/Escape dismissal.
- Viewport-change handler closes the dropdown if the breakpoint leaves
  mobile so returning to mobile doesn't reopen the sheet.
- `selectOption` still calls `onchange(opt)` and closes — save
  semantics unchanged.

Parent: PLAN-631.
2026-04-20 15:22:52 -04:00
xarmian ee65e10562 feat(web): workspace switcher renders as BottomSheet on mobile (TASK-637) (#167)
The workspace switcher in the top bar is cramped on mobile; its
absolute-positioned dropdown runs off-screen when workspace names are
long or the list is deep.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')`.
- Extract workspace list + "+ New Workspace" row into a shared
  `{#snippet workspaceList}`.
- Mobile: render the list inside `<BottomSheet title="Switch workspace">`
  with roomier tap targets. Sheet gated on `open` (gate-on-open pattern)
  so BottomSheet's global keydown listener isn't mounted when idle.
- Desktop: unchanged dropdown + backdrop.
- Viewport-change handler closes the sheet if we leave mobile so it
  doesn't spring back open on rotation.
- Selecting a workspace navigates via `goto` as before; the sheet
  unmounts naturally on navigation.
- "+ New Workspace" still closes the sheet and calls
  `uiStore.openCreateWorkspace()` — the existing modal already works
  well on mobile.

Parent: PLAN-631.
2026-04-20 15:12:47 -04:00
xarmian e34c8e463a feat(web): view-mode selector renders as BottomSheet on mobile (TASK-636) (#166)
Scope note: the task described selectors for view-mode, sort-by, and
group-by, but only view-mode has a visible selector today (a 3-icon
segmented toggle). Sort and group-by are not user-selectable from the
collection page — they're derived from collection settings. Scoping
to the one visible selector that needed help; adding sort/group
selectors is a separate feature.

- On mobile (<640px), the segmented view-mode toggle is replaced by a
  labeled chip ("View: Board ▾") that opens a BottomSheet titled
  "Choose view" with each option labeled + iconed. Icon-only segmented
  buttons are hard to decode on touch; labeled options are clearer.
- On desktop, the segmented 3-icon toggle is unchanged.
- Sheet mounted only when open (gate-on-open pattern).
- Breakpoint-change handler closes the sheet if the viewport leaves
  mobile so it doesn't reopen on rotation back.
- saveViewMode + updateUrlFilters semantics preserved (localStorage +
  URL sync unchanged).

Parent: PLAN-631.
2026-04-20 15:06:53 -04:00
xarmian 6fa82d9b74 feat(web): FilterBar parent filter renders as BottomSheet on mobile (TASK-635) (#165)
* feat(web): filter-bar parent filter renders as BottomSheet on mobile (TASK-635)

Scope note: the task description envisioned chip-driven per-field
dropdowns, but FilterBar today is simpler: status is an inline
segmented button row (doesn't clip, just wraps) and parent is a
native <select>. The pragmatic change that matches the task's intent
("mobile-friendly BottomSheet UX on the FilterBar") is the parent
filter — long plan names + inconsistent native <select> styling
across iOS/Android are the real mobile pain here.

- Status segmented group: unchanged (already mobile-safe; wraps to
  second line when the toolbar is narrow).
- Parent filter on mobile: render as a chip trigger that opens a
  BottomSheet titled "Filter by plan" with the same option list.
- Parent filter on desktop: native <select> unchanged.
- Sheet mounted conditionally on `parentSheetOpen` to avoid the
  dormant global keydown listener (gate-on-open pattern from TASK-633).

Parent: PLAN-631.

* fix(web): reset parent sheet when viewport leaves mobile (Codex review)

Codex flagged a P2: when the parent filter sheet was open on mobile and
the viewport crossed above the mobile breakpoint (e.g. device rotation),
`parentSheetOpen` stayed `true`. The desktop branch hid the sheet, but
returning to mobile would immediately remount `{#if parentSheetOpen}`
and reopen the sheet without a user tap.

Fix: close the sheet in the `matchMedia` change handler whenever the
breakpoint no longer matches mobile.

Per Codex review on PR #165.
2026-04-20 14:59:39 -04:00
xarmian 72ecf66f53 feat(web): move-to menu renders as BottomSheet on mobile (TASK-634) (#164)
The "Move to…" dropdown on the item detail page sits in a cluster of
meta-actions near the right edge of the viewport; its absolute-positioned
list of collections clips off-screen on narrow mobile.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')` on the page.
- Extract the options list into a `{#snippet moveOptions}` so both
  branches share the same markup.
- Mobile: render `<BottomSheet title="Move to…">` gated on
  `isMobile && showMoveMenu` so the sheet (and its global keydown
  listener) isn't mounted when the menu is closed.
- Desktop: unchanged `.move-dropdown` popover.
- Mobile sheet option rows get a roomier padding / larger font to be
  thumb-reachable.

Parent: PLAN-631.
2026-04-20 14:44:40 -04:00
xarmian 424a60a5f4 feat(web): reaction picker renders as BottomSheet on mobile (TASK-633) (#163)
* feat(web): reaction picker renders as BottomSheet on mobile (TASK-633)

Swap `ReactionPicker` (used inside `TimelineCommentCard` for top-level
comments and replies) to a mobile-first BottomSheet branch while keeping
the existing absolute-positioned popover intact for desktop.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')` using the same
  pattern as `QuickActionsMenu`/`EmojiPickerButton`.
- Mobile: render the 12 emoji options inside `<BottomSheet title="React">`
  with a roomier 6-col grid + 48px tap targets since we have the viewport
  width on our side.
- Desktop: unchanged popover.
- The outside-click `$effect` only attaches when open AND not mobile so it
  doesn't race the sheet's own backdrop/Escape dismissal.
- Share the emoji grid between branches via a `{#snippet emojiGrid}` to
  avoid duplication.

Parent: PLAN-631.

* fix(web): gate mobile ReactionPicker sheet on open (Codex review)

Codex flagged a P2 performance regression: on mobile the BottomSheet
instance was mounted for every ReactionPicker regardless of `open`, and
each mounted instance installs a global keydown listener via
`<svelte:window onkeydown>` inside BottomSheet. On comment-heavy
timelines (top-level comments + replies) this fans every keystroke out
through many dormant listeners.

Fix: additionally gate the mobile branch on `open`, matching the
desktop branch semantics (only mount when active).

Per Codex review on PR #163.
2026-04-20 14:37:10 -04:00
xarmian 174be6f045 feat(web): emoji picker renders as BottomSheet on mobile (TASK-632) (#162)
Swap `EmojiPickerButton` to a mobile-first BottomSheet branch while keeping
the existing absolute-positioned portal dropdown intact for desktop.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')` using the same
  pattern as `QuickActionsMenu` (the reference implementation from TASK-628).
- When `isMobile`: render the picker inside a `<BottomSheet>` titled "Pick
  an emoji" so the 300ish-px grid is readable full-width and can't clip.
- When `!isMobile`: unchanged — portal + `getBoundingClientRect` math still
  owns positioning inside `<dialog>` modals and at the document root.
- `handleWindowClick` bails early on mobile so it doesn't race the sheet's
  own backdrop/Escape dismissal.

Parent: PLAN-631.
2026-04-20 13:53:42 -04:00
xarmian e4c2ff0a03 fix(web): simplify BottomSheet to fix broken mobile interactions (#161)
* fix(web): simplify BottomSheet to fix broken mobile interactions

The original BottomSheet layered on several advanced behaviors — portal
to body, module-level $state open-stack, focus trap, swipe-to-dismiss,
reactive z-index, stacked-sheet Escape routing — and something in that
stack broke click dispatch on Android Chromium for every button inside
the sheet except the existing quick-action rows.

Root cause investigation: every click handler I wrote in this session
failed to fire on Android (close button, backdrop, footer rows, even
an unrelated debug banner's dismiss), while the existing shipped UI
(CreateCollectionModal etc.) continued to work fine. That narrowed
the problem to something structural in the new components rather than
any specific CSS / event wiring.

Fix: rewrite BottomSheet as a ~100-line clone of the working
CreateCollectionModal pattern — plain {#if open} + overlay +
stopPropagation on the inner panel, nothing more. Mobile-first
CSS docks the sheet to the bottom of the viewport; a single
@media (min-width: 640px) rule centers it as a traditional modal on
desktop. No portal, no module-scope $state, no <svelte:window>, no
focus trap, no swipe gesture.

Tradeoffs deliberately accepted for now:
- Swipe-to-dismiss is gone. Backdrop tap + close button are the
  dismissal paths; the viewport-scoped overlay makes this fine.
- No focus trap. Every other modal in the app already ships without
  one, so this matches existing behavior.
- No stacked-sheet Escape prioritization. Single-sheet usage only.

Can be re-layered carefully later if any of those features are
actually needed, but only one feature at a time with mobile testing
between each.

* fix(web): restore Escape dismissal + ARIA dialog semantics on BottomSheet

Addresses both P2 comments from Codex on PR #161.

- Escape key closes the sheet. Added a svelte:window onkeydown that
  early-returns when !open, matching the pattern used elsewhere in
  the app. This is the keyboard dismissal path for desktop and users
  with hardware keyboards on mobile — and the only keyboard path
  when title is omitted (no close button rendered).
- Restored role=\"dialog\", aria-modal=\"true\", and aria-labelledby
  (pointing at the visible title heading when one is set, falling
  back to aria-label=\"Dialog\" otherwise). Without these, assistive
  tech wouldn't announce modal context and users could continue
  navigating background content.

Stable per-instance heading id uses \$props.id() (SSR-safe), bound to
a top-level const per the Svelte 5 placement rule.

Notably NOT reintroduced: focus trap, portal, module-scope stack,
swipe gesture, reactive z-index. Those were the culprits for the
Android click-dispatch regression and stay out of the simplified
implementation.
2026-04-20 12:27:52 -04:00
xarmian 1dde0d3b58 feat(web): inline + New and Manage affordances in QuickActionsMenu (TASK-629) (#160)
* feat(web): inline + New and Manage affordances in QuickActionsMenu (TASK-629)

Add discovery paths for creating and editing quick actions directly
from the menu. Closes the Problem 2 gap in IDEA-493 — the editor
already existed inside EditCollectionModal but was effectively
invisible from the menu surface.

QuickActionsMenu (gated behind a new canEdit prop):
- "+ New quick action" footer row → toggles an inline form (icon
  picker + label + monospace prompt input + template-variable help).
  On save, PATCHes the collection via api.collections.update, appends
  the new action to settings.quick_actions, and fires
  oncollectionupdated so the parent reloads. Toast on success / error.
- "⚙️ Manage actions" footer row → fires onmanage, which the parent
  wires to open EditCollectionModal deep-linked to the Quick Actions
  tab.
- Trigger button now stays visible for editors even when no actions
  exist yet, so they can bootstrap the first action without
  round-tripping through collection settings.

EditCollectionModal:
- New initialSection?: 'general' | 'fields' | 'display' | 'actions'
  prop. When set, opens the modal directly to that tab instead of
  the default 'general'. Default behavior unchanged.

Route wiring:
- [collection]/+page.svelte: passes wsSlug + canEdit={isOwner} +
  onmanage/oncollectionupdated; tracks editCollectionSection to
  deep-link the existing modal.
- [collection]/[slug]/+page.svelte: same QuickActionsMenu wiring, plus
  imports + renders EditCollectionModal inline (it wasn't present on
  item detail before) so the "Manage actions" link works from item
  pages too.

Parent: IDEA-493.

* fix(web): preserve emoji picker in QuickActionsMenu + navigate on archive

Addresses both P2 comments from Codex on PR #160.

- QuickActionsMenu: the EmojiPickerButton portals its dropdown to
  document.body (.epb-dropdown). The outside-click guard was treating
  portal clicks as "outside" the menu and closing it, losing the
  in-progress emoji selection before the bound value could update.
  Added an exemption for .epb-dropdown and .emoji-picker-button in
  handleWindowClick so clicks inside the picker keep the menu open.
- Item detail page: when EditCollectionModal archives the current
  collection, onupdated fires with no updated arg. The old handler
  just reloaded the sidebar, leaving the user on a now-invalid item
  route with stale state. It now also navigates back to the
  workspace root so follow-up actions don't hit deleted resources.

* fix(web): redirect on collection slug change from item-page modal

When an owner renames the current collection from the item detail
page's EditCollectionModal, the collection's slug can change. The
old onupdated handler updated local state but stayed on the now-
stale /[collection]/[slug] URL — subsequent loadData() calls fetch
by collSlug and would 404.

Mirror the collection-page behavior: navigate to the new collection
slug while preserving the item slug so the user stays on the same
item under its new route.

Addresses Codex round 2 P2 on PR #160.

* fix(web): apply returned collection state in oncollectionupdated

On the collection list page, the oncollectionupdated callback
ignored the updated collection returned by api.collections.update
and waited for loadCollection() to refetch. On slow responses, a
user saving a second quick action in rapid succession would build
the PATCH from stale collection.settings.quick_actions and
overwrite the first action.

Apply the returned collection to local state immediately, then
still trigger loadCollection as a defensive refresh. The item
detail page's handler already does the right thing, so only the
collection page is affected.

Addresses Codex round 3 P2 on PR #160.

* fix(web): reload item after non-navigating collection edit from item page

EditCollectionModal can change schema / field mappings. After a
non-archive, non-rename save on the item detail page, the callback
was only updating the collection reference — not the item — so
stale item.fields could survive a rename or migration. A subsequent
updateField() would then write the full stale fields JSON back to
api.items.update and clobber migrated values.

Call loadData() after non-navigating updates so the item is
refetched alongside the collection. Navigation cases (archive,
slug change) already trigger their own load via the route change,
so we skip the reload on those branches.

Addresses Codex round 4 P2 on PR #160.
2026-04-20 08:51:14 -04:00
xarmian 8592bed2b4 feat(web): mobile BottomSheet + viewport-aware dropdown in QuickActionsMenu (TASK-628) (#159)
Fixes the mobile clipping bug where the quick-actions dropdown opened
off-screen when the trigger wrapped to the left edge of the viewport.

- Below 640px, the menu now renders as a BottomSheet (shipped in
  TASK-627) — full-width, swipe-to-dismiss, backdrop tap / Escape.
- On desktop, the popover is kept but gains:
  - viewport-aware alignment: flips from right-anchored to left-
    anchored when the trigger is within 220px of the viewport's left
    edge, measured via getBoundingClientRect() at open time.
  - max-width: calc(100vw - var(--space-4)) as a defensive clamp.
- Action list is shared between modes via a Svelte 5 snippet to avoid
  markup duplication.
- Outside-click handler short-circuits on mobile so the BottomSheet
  owns dismissal.

Preserves existing clipboard copy + toast behavior and trigger styling.

Addresses Problem 1 in IDEA-493. Parent: IDEA-493.
2026-04-20 07:46:06 -04:00
xarmian e187573792 feat(web): add reusable BottomSheet primitive (TASK-627) (#158)
* feat(web): add reusable BottomSheet primitive (TASK-627)

Introduce $lib/components/common/BottomSheet.svelte — a controlled
bottom-sheet / modal component built on Svelte 5 runes with no new
runtime dependencies.

Features:
- Mobile (< 640px): docked to bottom, full-width, rounded top corners,
  swipe-down-to-dismiss via pointer events (80px threshold).
- Desktop (>= 640px): configurable via `desktopMode` prop — 'sheet'
  (default, bottom-anchored with max-width) or 'centered' (traditional
  centered dialog mirroring the existing .overlay/.modal pattern).
- Escape key, backdrop tap, and swipe-down all trigger onclose().
- Focus trap while open; restores focus on close to the previously
  focused element.
- Body scroll lock while open, safely restored on close/unmount.
- Svelte `fly` + `fade` transitions; honors prefers-reduced-motion.
- role="dialog", aria-modal="true", optional `title` wired to
  aria-labelledby.

No consumers yet — foundation for [[IDEA-493]] (quick-actions mobile fix
and inline New/Manage affordances will consume this in TASK-628 and
TASK-629).

Parent: IDEA-493.

* fix(web): harden BottomSheet focus trap + scroll lock per Codex review

- Focus trap: forward Tab now also pulls focus back when active is
  outside the sheet (assistive tech / programmatic focus change),
  mirroring the Shift+Tab branch. Without this, focus escaping the
  sheet broke modal isolation on subsequent Tab presses.
- Scroll lock: moved to module-level counter + shared prev-overflow
  via acquireScrollLock(). Stacked sheets no longer clobber each
  other — the original body overflow is captured on the first open
  and restored only when the last sheet closes.

Addresses both P2 comments on PR #158.

* fix(web): topmost-only Escape + hydration-safe IDs in BottomSheet

- Escape / Tab trap now gated to the topmost open sheet only. Added a
  module-level open-sheet stack (symbol tokens) so stacked instances
  can identify which one should handle global keyboard events. One
  Escape keypress no longer closes every open sheet at once.
- Replaced Math.random() heading ID with $props.id() (Svelte 5.20+)
  so the aria-labelledby target is stable across SSR and hydration.

Addresses P1 (stacked Escape) and P2 (SSR hydration mismatch) from
Codex review round 2 on PR #158.

* fix(web): tie BottomSheet z-index to open-stack position

Convert the module-level openStack to a Svelte 5 $state array so
each instance can reactively read its position in the stack.
Compute backdrop + sheet z-index from that position (BASE_Z 61, two
slots per stack level) and apply via inline style, replacing the
fixed CSS z-index values.

This keeps visual stacking aligned with the keyboard-topmost logic
(pushOpenSheet / isTopmostSheet) — if a sheet renders earlier in
the DOM but opens later, its visual layer now matches its logical
topmost role instead of being obscured by an older DOM sibling.

Addresses Codex round 3 P2 on PR #158.

* fix(web): skip BottomSheet focus restore when another sheet is open

In stacked-sheet scenarios, closing a non-topmost sheet would still
run previouslyFocused.focus() in the focus-management effect cleanup,
yanking focus out of the active dialog and onto background UI.

Gate the restoration on:
1. openStack contains no tokens other than this instance's token
   (no other sheet is still open), AND
2. document.activeElement is not already inside a different
   role="dialog" ancestor.

If either check fails, skip the restore — another sheet is still in
control of focus.

Addresses Codex round 4 P2 on PR #158.

* fix(web): refine BottomSheet focus restore for stacked topmost close

Round 4's blanket skip-when-others-open was too aggressive. When the
topmost sheet closes while another sheet remains behind it, the
previouslyFocused target typically lives inside that remaining sheet
(it was the active element when this sheet opened) — restoring it is
correct and keeps focus inside the remaining modal.

New rule:
- If no other sheets open → always restore (normal case).
- If others are still open → restore only when previouslyFocused
  lives inside a DIFFERENT still-open dialog (not this closing one).
  Otherwise skip, so we don't yank focus onto background UI.

Addresses Codex round 5 P2 on PR #158.
2026-04-20 07:35:23 -04:00
xarmian cf3ba5510d fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626) (#157)
* fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626)

Real-print testing after BUG-625 showed the repeating fixed-position
`.print-header` approach is fragile -- even with a generous @page top
margin, Chromium's handling of fixed elements during pagination can
overlap content on the first page, and there's no clean way to
coordinate the header with page-break behavior across browsers.

Replace the repeating header with a page-1 document header in normal
flow and simplify.

## Changes

### Template (+page.svelte)
- Remove `.print-header` entirely; drop the `workspaceStore` import
  (no longer needed in print).
- Tag non-computed field-rows with `class:print-empty={isFieldEmpty}`
  when the raw value is null / empty string / empty array. Flag at
  the template level because :empty can't see FieldEditor children.

### Styles (+page.svelte @media print)
- `.title-row` becomes a flex row: title on the left (20pt, wraps),
  item ref on the right (10pt, tabular-nums, nowrap), both aligned to
  the title's first-line baseline.
- `.meta-info` gets a 1px bottom border to separate the document
  header block from the properties card.
- `.field-row.print-empty { display: none !important; }`.
- Drop all `.print-header*` CSS (dead) and the padding/border shared
  rule between header+footer. `.print-footer` now stands alone.

### Global (app.css)
- Shrink @page top margin from 1.25in to 0.6in -- no reserved header
  strip means no clearance needed. Bottom margin stays 1in for the
  fixed footer + `@bottom-right` page number counter.

## Outcome

- No repeating header, no overlap, no workspace/collection context on
  subsequent pages (users who want it can leave browser headers
  enabled in the print dialog).
- Page 1 shows: title+ref header row, meta subtitle, border, properties
  (with empty rows skipped), body, relationships/children if present.
- Footer repeats on every page with Printed date, URL, and Page N.
- 116-line file net -16 lines smaller, app.css -2.

Verified locally via Ctrl+P preview in Chromium before committing.

* fix(web): flip print title-row order so title is left, ref is right (PR #157)

Address Codex P2: DOM order in the template is `[item-ref, title]`,
so `display: flex; justify-content: space-between` kept the ref on
the left and pushed the (flex:1) title to fill the remaining space on
the right — the opposite of the intended BUG-626 header layout.

Use the flex `order` property to reverse only the visual sequence in
print, keeping the template DOM untouched. `.title` gets `order: 1`,
`.item-ref` gets `order: 2` + `margin-left: auto` so the ref sits
baseline-aligned at the right edge and the title claims everything
to its left.
2026-04-18 19:04:32 -04:00
xarmian 7c29413685 fix(web): print title overlap, page number, and select chevrons (BUG-625) (#156)
* fix(web): print title overlap, page number, and select chevrons (BUG-625)

Address three issues surfaced by a real Ctrl/Cmd+P test on an Idea
detail page (PLAN-620 follow-up):

1. Title cut off at top of page 1. The `@page { margin: 1.1in ... }`
   rule was declared in +page.svelte's scoped style block, but Svelte
   scoped-CSS at-rule loading meant the 0.75in default from app.css
   (TASK-621) kept winning. The fixed print header was ~0.4in tall and
   the content area started at 0.75in, but layout timing left them
   overlapping. Consolidate to a single @page rule in app.css with a
   widened `margin: 1.25in 0.6in 1in 0.6in` -- guaranteed clearance.

2. Footer showed "Page 0" on every page. `counter(page)` inside the
   ::after pseudo-element of a fixed-positioned element is captured
   once at initial layout (before pagination) and reused, so it never
   increments. Move the page number into a `@page { @bottom-right {
   content: "Page " counter(page); } }` margin-box where the counter
   evaluates correctly per page. Remove the `.print-footer-page` span
   and its `.print-page-num::after` rule from the item detail page.
   Add `padding-right: 1.2in` to the fixed footer so its content
   doesn't overlap the new margin-box page number.

3. FieldEditor selects still showed a `∨` chevron in print output --
   the chevron is an inline <svg class="select-chevron">, not the
   native UA dropdown arrow, so `appearance: none` on the button had
   no effect. Hide `.select-chevron` and `.select-dropdown` explicitly
   in the global print block.

Bonus: skip empty `.field-row`s via `.field-row:has(.field-value:empty)`
so rows like an unset "Category" don't print as a label with no value.

* fix(web): drop dead empty-field-row print rules (PR #156)

Address Codex P2 review comment on BUG-625. The `:empty`-based rules
added as a bonus to hide label-only rows (e.g. unset "Category")
never actually match in this codebase:

- Non-computed fields wrap a `<FieldEditor>` child inside `.field-value`,
  so `.field-value` always has children and is never `:empty`.
- Computed fields call `formatFieldDisplay(value)`, which returns `"—"`
  for null / empty, so `.computed-value` is never `:empty` either.

Remove the rules rather than leaving dead selectors that suggest the
behavior exists. Hiding blank rows in print is worth revisiting with a
real signal (e.g. a `data-empty` attribute or a template `{#if}`
guard), but out of scope for BUG-625 -- the title / page-number /
chevron fixes are what this PR is about.
2026-04-18 16:27:40 -04:00
xarmian ed4cef94f2 feat(web): print child items as a flat checklist (TASK-624) (#155)
* feat(web): print child items as a flat checklist (TASK-624)

Render a print-friendly checklist of a parent item's children at the
bottom of the printed page, replacing the interactive `.child-items`
view (chart, drag-drop groups, expand toggles, progress bar) which
isn't meaningful on paper.

Format:

  Children (3/5 done)
    [x] TASK-621 · Base @media print stylesheet (done)
    [x] TASK-622 · Print-format the item detail page (done)
    [x] TASK-623 · Print header and footer (done)
    [ ] TASK-624 · Print child items as a flat checklist (in progress)

Implementation (Option A from the task spec):

- A `.print-children` block is rendered alongside the existing
  `.child-items` container, driven by the same `children` state.
- `display: none` on screen; `display: block` in `@media print`.
- The interactive `.child-items` view is hidden entirely in print.
- Checkboxes are textual `[x]` / `[ ]` so they survive in any font;
  status label appears in parentheses for disambiguation beyond the
  terminal-vs-open bucket.
- `page-break-inside: avoid` on the list and on each row so the
  checklist doesn't split awkwardly across pages when possible.
- Nothing renders when the item has no children (the outer
  `{#if loading || children.length > 0}` already short-circuits).

Parent: PLAN-620.

* fix(web): skip print checklist when child load has errored (PR #155)

Address Codex P2 review comment on TASK-624: the print-children block
was rendered whenever `!loading && children.length > 0`, but
`loadChildren()` sets `error` on failure without clearing `children`.
So a navigation or sync failure after a successful initial load could
produce a printed checklist from stale state that contradicts the
visible error banner on screen.

Guard the print block with `!error` so the checklist is suppressed when
the child data is known-bad. No change to the screen view.
2026-04-18 15:59:22 -04:00
xarmian 0778c68ca1 feat(web): print header + footer for item detail pages (TASK-623) (#154)
Add a rendered header and footer that repeat on every printed page.
Replaces the browser's default print chrome (localhost URL, page
title, date).

Header (top of each page):
  {Workspace name} · {Collection icon + name} · {Issue ID}

Footer (bottom of each page):
  Printed {date} · {full URL} · Page {n}

Implementation

- Two new `<div>`s rendered inside the item detail page template:
  `.print-header` and `.print-footer`. Both carry `aria-hidden` and
  are `display: none` on screen, so they never affect the live UI.
- A `@media print` block shows them as `position: fixed` elements at
  top: 0 / bottom: 0. In Chromium this causes the browser to repeat
  them on every page of the print output. Firefox and Safari render
  them only on the first page -- documented as a known limitation
  since Chromium is the expected print target.
- `@page { margin: 1.1in 0.6in 0.9in 0.6in }` carves out space for the
  header and footer strips so body content doesn't overlap them. This
  overrides the default 0.75in margin set in app.css (TASK-621).
- Page number uses `.print-page-num::after { content: counter(page); }`.
  `counter(page)` in a pseudo-element evaluates to the current printed
  page number in all modern browsers.
- Print date and URL are captured via a `beforeprint` listener so the
  values reflect the moment of print, not page-load time. Falls back
  to onMount values if `beforeprint` doesn't fire (older browsers).

Users should uncheck "Headers and footers" in the browser print dialog
for the cleanest result -- there's no CSS to suppress the browser's
default print chrome.

Parent: PLAN-620.
2026-04-18 15:50:27 -04:00