mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
de4d28d576cdbe2c3af7d45844a407fd64bb4838
224 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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). |
||
|
|
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
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|