264 Commits

Author SHA1 Message Date
xarmian 40352a32e1 feat(auth): PAD_BYPASS_SETUP_TOKEN open-bootstrap escape hatch (#429)
Adds an env-var that lets self-host operators on trusted networks
(Unraid behind a firewall, Tailscale-only deployments, homelabs)
claim the first admin via the web UI without copying a bootstrap
token out of the container logs.

Behavior when PAD_BYPASS_SETUP_TOKEN=true:

- handleBootstrap accepts non-loopback first-admin POSTs without an
  X-Bootstrap-Token header. The UserCount==0 invariant is unchanged,
  so the bypass auto-closes the moment the first admin claims the
  seat (subsequent bootstrap requests get 409 regardless of bypass).
- handleSessionCheck returns setup_method=open so the /setup page
  skips the paste-token UI and renders the form directly.
- Token generation is skipped at startup (no .bootstrap-token file
  written). A distinct WARN-flavored banner makes the open-mode
  trade-off obvious in operator logs.
- Cloud mode (PAD_CLOUD/PAD_MODE=cloud) ignores the flag entirely.
  Three layers of defense: cmd/pad masks the env-var with
  !cfg.IsCloudServer(), Server.openBootstrapEnabled() checks
  !s.cloudMode, and the cloud branch in handleBootstrap never reads
  the bypass field.

Unraid template gets a new "Bypass Setup Token" field (default false,
Display="always") with a description that calls out the trust-the-
network trade-off.

Tests pin all the security-critical contracts: bypass admits non-
loopback, bypass off keeps existing 403, cloud mode hard-ignores,
loopback works either way, post-bootstrap gate stays closed, bypass
wins over logs_token in session payload, cloud mode never advertises
'open' setup method.

Codex review: CLEAN (round 1).
2026-05-06 13:27:12 -04:00
xarmian 05a9665f50 feat(auth): first-run logs-token bootstrap flow (TASK-1167) (#424)
One-time bootstrap token generated on first start with no users in self-host
mode. Token is logged in a banner the operator can grab from `docker logs`,
persists at <DataDir>/.bootstrap-token (mode 0600), and bypasses the
loopback-only gate via the X-Bootstrap-Token header — letting the user
claim the first admin from a remote browser at /setup#token=<x>.

Header-only contract + URL-fragment (browser-only, never transmitted) +
log-redaction middleware keeps the secret out of access logs, proxy logs,
and browser history. Cloud mode unchanged: token never loaded, never
honored. Validate → UserCount-check → CreateUser → consume sequence is
mutex-serialized to prevent concurrent valid-token requests from creating
multiple admins.

Part of PLAN-1166 (Pad on Unraid — Community Apps launch).
2026-05-06 08:40:11 -04:00
xarmian 6c44291e78 fix(layout): let Cmd+F fall through to browser-native find on item views (BUG-986) (#423)
The layout's global keydown handler unconditionally intercepted Cmd+F
and routed it through a `collectionSearchRequested` boolean that only
the collection list page polled. On item / document views (and any
non-collection page) nothing watched the flag, but `e.preventDefault()`
had already blocked the browser's native find — leaving users with no
way to search inside a document.

Invert the model from "always intercept, broadcast a flag" to "only
intercept when a page registers a handler":

- ui.svelte.ts: replace `collectionSearchRequested` with a
  `collectionSearchHandler` registry exposing
  `registerCollectionSearch` / `unregisterCollectionSearch` /
  `triggerCollectionSearch` / `hasCollectionSearchHandler`.
- +layout.svelte: only `e.preventDefault()` and dispatch when
  `uiStore.hasCollectionSearchHandler` is true; otherwise let Cmd+F
  pass through to the browser.
- [collection]/+page.svelte: register the existing
  filters-open + focus-search behaviour in an `$effect` and unregister
  it via the effect's cleanup so it lives only while the page is
  mounted.

Result: collection list view keeps its existing Cmd+F filter-search
shortcut; item views and any other page get the browser's native find
back. `make check` clean (Go tests, lint, web build, svelte-check 0
errors).
2026-05-05 17:18:09 -04:00
xarmian 50d04944c5 feat(sweep): gate role board + child reorder + run grep verification (TASK-1108) (#422)
* feat(sweep): gate role board + child reorder + run grep verification (TASK-1108)

Final sweep across PLAN-1100 (client-side permission audit). Confirms no
remaining open-coded permission checks and gates the few remaining surfaces
not covered by tasks 1102-1107.

Code-only acceptance grep results:
- `members.find(...)` outside workspaceStore: ZERO matches
- `m.role === 'owner' / 'editor'` open-codes outside permissions.ts: ZERO matches
- `isOwner = $derived(workspaceMembers...)` open-codes: ZERO matches

Surfaces gated in this PR:
- Role board (`/{workspace}/roles`):
  - "+ New" item button gated on canEditAnyItem (owner|editor)
  - "+ Add Role" column owner-only
  - Lane edit button (✎) owner-only, lane drag handle owner-only
  - Lane-header drag (column reorder) owner-only — handlers and draggable
    attribute conditional on isOwner
  - Lane-items dndzone receives dragDisabled: !canEditAnyItem (zone-level
    library limitation, same constraint as TASK-1106)
- ChildItems component (used on item detail to render children):
  - New canEdit prop (default true). Slug page passes canEdit (= canEditItem
    of parent). dndzone receives dragDisabled: !canEdit so non-editors
    can't drag-reorder children.

BUG-984 closure remains gated on HT-1157 (manual three-role smoke test),
which will sign off on real walks as owner / editor / viewer / guest.

Parent: PLAN-1100.

* fix(sweep): empty-state role create gate + grant-aware canEditAnyItem per Codex review (round 1)

Two findings from round 1:

1. Empty-state "Create your first role" button at roles/+page.svelte:530
   wasn't gated. Now wrapped in {#if isOwner}.

2. canEditAnyItem was role-only (owner|editor). Server's reorder handler
   is grant-aware per-item, so a viewer/guest with even one
   CollectionGrant.edit or ItemGrant.edit can legitimately mutate via the
   role board. Helper now ORs in any active edit grant — matches what
   the server enforces.

Parent: PLAN-1100. Refs TASK-1108 PR #422.

* fix(sweep): align role-board create/lane-reorder gates with server per Codex review (round 2)

Two findings from round 2:

1. The role-board "+ New" item flow was gated on canEditAnyItem (any role
   or any edit grant), but server's handleCreateItem requires collection-
   level edit (collection grant or role+visibility), not item-only grant.
   Item-grant-only users would see "+ New" but get a 403 on submit.
   Filter eligibleCollections by workspaceStore.canEditCollection(coll.id)
   and gate the button on eligibleCollections.length > 0.

2. Lane reorder was owner-only, but server's handleRoleBoardLaneReorder
   uses requireMinRole "editor". Editors lost an allowed operation.
   Introduced canReorderLanes (owner | editor) for lane drag handles +
   ondrag handlers; role create/edit/delete remain owner-only.

Parent: PLAN-1100. Refs TASK-1108 PR #422.
2026-05-05 10:08:08 -04:00
xarmian 7f06a9845a feat(comments): gate composer / replies / reactions / delete on canEditItem (TASK-1107) (#421)
Comment timeline previously rendered composer, reply, reaction picker,
and per-reaction toggle for all roles. Server enforces edit per-item on
comment writes; UI now matches.

Note on file paths: TASK-1107's original spec referenced
`web/src/lib/components/comments/CommentThread.svelte`, but that file is
unused anywhere in `web/src/`. Real comment UI lives in:
- `ItemTimeline.svelte` (composer + thread layout)
- `TimelineCommentCard.svelte` (per-comment delete, replies, reactions)

Changes:
- ItemTimeline: imports workspaceStore, accepts itemId + collectionId
  props (optional with safe fallback), derives canEdit reactively. The
  composer is hidden entirely when !canEdit.
- TimelineCommentCard: accepts canEdit prop. Delete / reply / reaction
  picker / reply-comment delete / reply reaction picker all gated.
- Existing reactions still render with counts so read-only viewers see
  who reacted; the chip's onclick is gated and disabled={!canEdit} so
  toggling is blocked for them.
- Slug page passes item.id + item.collection_id so ItemTimeline can
  resolve canEditItem itself (no prop-drilling of canEdit).

Parent: PLAN-1100.
2026-05-05 09:54:07 -04:00
xarmian fe9b76ab93 feat(views): gate drag/archive in ListView/BoardView on canEditCollection (TASK-1106) (#420)
The collection page's ListView and BoardView allowed all roles to drag
items, drag-status-change, reorder groups/columns, and archive groups.
Server enforces edit per-item and per-collection on these mutations
(handlers_items.go, handlers_role_board.go) — UI now matches.

Changes:
- ListView + BoardView accept a `canEdit?: boolean` prop (default true to
  preserve behavior in existing callers).
- ListView: dndzone for groups + intra-group items receives
  `dragDisabled: !canEdit`. Group drag handle and archive-group button
  hidden when !canEdit.
- BoardView: column-cards dndzone receives `dragDisabled: isMobile || !canEdit`.
  Column-header drag (column reorder) gated via `draggable={canEdit}` and
  conditional drag handlers. Column-drag-handle indicator and
  archive-column button hidden when !canEdit.
- Collection page passes `canEdit={canEditThisCollection}` to both views.

Scope note: per-item drag gating (e.g. a guest with ItemGrant.edit on one
item dragging just that one card) is not implemented — svelte-dnd-action
only supports zone-level dragDisabled. Achieving per-item would require
switching to dragHandleZone+dragHandle and shipping an explicit handle UI
for everyone, which is a larger UX change. Server already enforces per-item
edit on the resulting mutations, so no security gap. Documented as a
follow-up if needed.

TableView: excluded from drag/archive scope — no drag handlers to gate.
Status-cell editing is already gated via FieldEditor's readonly prop from
TASK-1105.

Parent: PLAN-1100.
2026-05-05 09:47:55 -04:00
xarmian a8b158829b feat(item-detail): gate write affordances on canEditItem (TASK-1105) (#419)
* feat(item-detail): gate write affordances on canEditItem (TASK-1105)

Item detail page hides title-edit, content editing, FieldEditor inputs,
delete button, and assignment dropdowns when the user lacks edit on this
specific item. Mirrors the server's per-item permission cascade so the UI
cannot show affordances the server would 403.

Per-item gate via workspaceStore.canEditItem(item) — owner → item grant →
collection grant → role + visibility → deny. Handles guests with single
ItemGrant.edit (full edit on that one item, read-only on siblings) and
the precedence regression where ItemGrant.view + CollectionGrant.edit on
the same item resolves to read-only (item grant wins per server cascade).

Changes:
- FieldEditor: new `readonly?: boolean` prop. When true, renders a unified
  display block per field type (select / checkbox / date / number / url /
  text) — same visual language as the editor's idle state, no inputs, no
  dropdowns, no mutation handlers. Documented in the component header.
- RawMarkdownEditor: new `readonly?: boolean` prop, applied to the
  underlying textarea.
- [slug]/+page.svelte: derived canEdit predicate. Title swaps from
  click-to-edit button to plain h1 when read-only. Editor passes
  editable=canEdit; EditorBubbleMenu / EditorLinkPopover only mount when
  editable. RawMarkdownEditor passes readonly. Delete button hidden.
  FieldEditor receives readonly={!canEdit}. Assignment + role dropdowns
  swap to read-only display spans.
- New CSS: .title-readonly (no hover, default cursor),
  .assignment-readonly (matches assignment-select height for layout
  stability when the user gains/loses edit permission).

Parent: PLAN-1100.

* fix(item-detail): gate Editor toolbars + ?new=1 title bypass per Codex review (round 2)

Two read-only escape hatches found by Codex re-review:

1. Editor.svelte mobile toolbar (line 818) and table toolbar (line 846)
   rendered without checking the `editable` prop. tiptap's editor instance
   correctly refuses commands when editable=false, so the buttons would
   no-op, but they still rendered and were visually misleading. Both
   toolbars now gated on `editable`.

2. The slug page's auto-start-title-edit path for ?new=1 didn't check
   canEdit. A read-only user appending ?new=1 would land on the title
   textarea (which the visible-branch gate now hides). Added canEdit to
   the auto-start condition AND to startEditTitle() itself as a defensive
   second line.

Round 1 disagreements stand: Move-to / item-links / ChildItems are
explicitly TASK-1108 sweep scope and intentionally not addressed here.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): exclude BlockDragHandle in read-only + gate Move-to / links per Codex review (round 3)

Three findings from round 3:

1. Editor's BlockDragHandle ProseMirror plugin (registered in Editor's
   extensions list) is not gated by tiptap's `editable` flag — its drag
   handle is injected into the view DOM regardless. A read-only user
   could drag blocks to dispatch transactions through onUpdate. Fix:
   conditionally include the plugin in the extensions array based on
   `editable`.

2 + 3. Move-to button and item-links add/delete affordances. These were
       originally TASK-1108 sweep scope, but Codex re-flagged them in
       round 3 despite the round-2 deferral. Absorbed into TASK-1105
       rather than burn more review rounds — the gating is mechanical
       (a few {#if canEdit} wrappers). TASK-1108 sweep will still grep
       for any remaining open-coded patterns elsewhere.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): re-key Editor on canEdit change so BlockDragHandle reattaches per Codex review (round 4)

Round 3 excluded BlockDragHandle from the editor's extensions array when
editable=false. Round 4 caught the construction-time-only nature of that
gate: on cold/direct navigation /me resolves after the editor mounts, so
canEdit starts false → editor created without BlockDragHandle → /me
resolves → canEdit flips true but the existing $effect only calls
editor.setEditable(true) and does not re-register extensions.

Fix: add canEdit to the {#key} value so the editor is reconstructed when
permission flips. Cost is a brief loss of cursor/scroll position on the
flip — acceptable since the only path that flips canEdit mid-session is
a grant change while the page is open, which is rare.

Same approach is appropriate for any future extension whose registration
is gated on `editable`.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): handle ?new=1 auto-edit reactively for slow /me per Codex review (round 5)

* fix(item-detail): always reassign pendingNewItemEdit per Codex review (round 6)
2026-05-05 09:41:12 -04:00
xarmian d311245654 feat(collection-page): gate item-create affordances on canEditCollection (TASK-1104) (#418)
The collection list page renders multiple "create item" affordances —
header "+ New" button, quick-create input, empty-state CTA, and view-level
buttons via EmptyState — to all roles regardless of whether they can
actually create items in this collection. Server rejects the writes; this
hides the affordances entirely.

Per-collection gate via workspaceStore.canEditCollection(collection.id) —
not the binary canEdit. A viewer with a CollectionGrant.edit on Tasks
sees "+ New" on /tasks but not on /ideas. A guest with only an ItemGrant
sees no create affordance anywhere (server cascade: item grant doesn't
promote to collection-wide write).

Changes:
- Header "+ New" button: hidden when !canEditThisCollection.
- Quick-create input: only renders when both quickCreateOpen AND
  canEditThisCollection (defensive, since openQuickCreate is no longer
  callable through any visible affordance).
- Empty-state-box CTA: hidden when !canEditThisCollection. Message also
  switches from "Create your first ..." to "This collection is empty."
- View-level oncreate prop: undefined when !canEditThisCollection, so
  EmptyState (the shared empty-state component) hides its own create
  button automatically.

Parent: PLAN-1100.
2026-05-05 09:11:47 -04:00
xarmian 0035a9dad9 feat(settings): gate collection management UI to owners (TASK-1103) (#417)
Settings → Collections currently shows "+ Create Collection" and clickable
edit cards to all roles. The server already enforces owner-only on create,
update, and delete (handlers_collections.go:48, :113, :164). UI now matches.

Changes:
- Collection cards remain clickable for owners (open EditCollectionModal);
  for non-owners they render as non-interactive divs with the same content
  visible. The "Edit" hint is hidden for non-owners.
- "+ Create Collection" button hidden entirely for non-owners.
- CreateCollectionModal / EditCollectionModal mount only for owners — a
  non-owner can't reach them via the UI.

Note: TASK-1103 spec floated "create gated to editor+", but the server is
owner-only. Aligned UI to server (server is the security boundary).

Parent: PLAN-1100.
2026-05-05 09:04:28 -04:00
xarmian 3524a5ed92 feat(settings): gate Danger Zone tab + General write affordances on owner role (TASK-1102) (#416)
The presenting symptom of BUG-984: editors and viewers currently see the
Danger Zone tab + workspace name/context/export controls. All of those are
owner-only on the server. Gates them in the UI so the affordances aren't
rendered to begin with.

Changes:
- Tabs are now derived: Danger Zone is filtered out for non-owners. Direct
  URL access to #danger as a non-owner snaps back to General.
- Hash-driven tab restoration deferred to a validTabIds-aware $effect so
  owners deep-linking to #danger don't land on General because /me was
  still in flight at mount time.
- General tab → Name input rendered readonly for non-owners; Save button
  hidden.
- General tab → Context JSON textarea rendered readonly for non-owners;
  Save / Reset / Clear buttons hidden.
- General tab → Export bundle gated to editor+ (canExport).

Theme toggle remains available to all roles (personal preference, not
workspace state).

Owner experience unchanged. Non-owners now see a read-only General tab
with workspace context visible (so they know what they're working in) but
no controls that would 403.

Parent: PLAN-1100.
2026-05-05 08:59:31 -04:00
xarmian 1ff6158468 feat(workspace): expose currentRole + resource-scoped permission helpers (TASK-1101) (#415)
* feat(workspace): expose currentRole + resource-scoped permission helpers (TASK-1101)

Foundation for PLAN-1100 (client-side permission audit). Lands the primitive
that every other task in the plan consumes, with no UI behavior changes.

Server:
  - new GET /api/v1/workspaces/{ws}/me — returns role, collection_access,
    visible_collection_ids (computed via VisibleCollectionIDs /
    GuestVisibleCollectionIDs so it covers system collections, member access,
    direct collection grants, and item-grant collections), plus the user's
    direct collection_grants and item_grants.
  - admins normalize to "owner"; legacy workspace-scoped tokens normalize to
    "editor"; non-members with no grants are rejected upstream by
    RequireWorkspaceAccess and never reach the handler.

Frontend:
  - new $lib/utils/permissions module exporting pure cascade functions:
    canEditWorkspace / canViewCollection / canEditCollection /
    canViewItem / canEditItem.
  - cascade mirrors server's ResolveUserPermission exactly:
        owner → item grant → collection grant → membership role + visibility
    so item grant beats collection grant beats role even when less permissive
    (ItemGrant.view + CollectionGrant.edit on same item → effective view).
  - workspaceStore wraps the pure functions with currentMembership state
    fetched in setCurrent. New getters: currentRole, currentMembership,
    isOwner, canEditWorkspace; new methods: canViewCollection /
    canEditCollection / canViewItem / canEditItem.
  - WorkspaceMembership type added.
  - api.workspaces.me(slug) added.

Refactor:
  - settings/+page.svelte, [collection]/+page.svelte,
    [collection]/[slug]/+page.svelte: drop open-coded role derivation
    (members.find + m.role open-codes), consume workspaceStore.isOwner.
    members.list calls remain — still needed for assignee dropdowns / member
    rows in settings — only the role-derivation path moves to the store.

Tests:
  - server: handlers_me_test.go covers 6 scenarios
    (admin, editor with all-access, viewer with collection grant,
     restricted member, guest with item grant, non-member with no grants).
  - frontend unit tests deferred — web/ has no unit-test runner today.
    Pure-function module makes them trivial to add when the runner lands.
    Cascade is independently covered by store/permissions_test.go and
    store/grants_test.go on the server.

Parent: PLAN-1100.

* fix(workspace): per-item visibility uses strict full-access set + setCurrent race guard per Codex review (round 1)

P1: canViewItem fell back to canViewCollection, which uses the broad nav
    set (visible_collection_ids — includes collections containing
    item-granted items so they appear in nav). This meant a guest with one
    ItemGrant on TASK-5 in Tasks would see canViewItem(any-other-task-in-Tasks)
    return true, while the server only allows direct item grants or full
    collection grants.

    Fix: /me now also returns full_access_collection_ids — the strict set of
    collections in which every item is accessible (collection grants +
    member_collection_access + system collections; item-grant collections
    intentionally excluded). This mirrors guestResourceFilter's fullCollIDs
    in handlers. canViewItem and canEditItem now consult full_access_collection_ids
    on the membership-fallthrough path, NOT the nav set.

    Test added: TestMe_GuestWithItemGrant now asserts the item-grant collection
    is in visible_collection_ids (nav) but NOT in full_access_collection_ids
    (strict). TestMe_RestrictedMember updated to check both sets.

P2: workspaceStore.setCurrent had no guard against stale async /me responses.
    A slow /me for workspace A could clobber a freshly-fetched membership
    for workspace B if the user navigated mid-flight, briefly exposing
    permission-gated UI for the wrong workspace.

    Fix: monotonic membershipSeq counter incremented per setCurrent / create
    call. Each /me response only writes back if its captured token still
    matches at resolution time. Also clears currentMembership immediately on
    setCurrent so helpers don't briefly answer "yes" using the previous
    workspace's grants while /me is in flight.

Parent: PLAN-1100. Refs TASK-1101 PR #415.

* fix(workspace): canEditCollection uses strict full-access set per Codex review (round 2)

Same nav-vs-strict bug pattern as round 1's canViewItem fix, but in
canEditCollection. The editor-membership fallback path previously gated
on canViewCollection (broad nav predicate using visible_collection_ids),
which incorrectly returned true for a restricted editor whose only access
to a collection was an item grant. The collection appears in nav (correct)
but the editor must NOT see collection-wide write affordances like "+ New"
because the server rejects collection-level writes there.

Fix: editor membership fallback now requires either collection_access ===
"all" or the collection to be in full_access_collection_ids.

canEditItem already used full_access_collection_ids on its fallback path
(it was added in round 1) — verified unchanged.

Parent: PLAN-1100. Refs TASK-1101 PR #415.
2026-05-05 08:52:05 -04:00
xarmian 504e22d7bc fix(console): add mobile hamburger menu + scrollable admin tabs (BUG-1118) (#414)
The /console navbar's horizontal pill row crammed/clipped on narrow
viewports, and the admin sub-tab strip wrapped awkwardly. Add a hamburger
menu that toggles a dropdown panel below the navbar on mobile, and make
the admin tab strip horizontally scrollable on the same breakpoint.

Console layout (web/src/routes/console/+layout.svelte):
- Hamburger button (32x32) appears in .nav-left on mobile (<=640px),
  switches to an X when open. Same SVG/sizing as TopBar.svelte's
  .mobile-hamburger so the chrome stays consistent.
- .nav-links becomes a full-width dropdown panel below the navbar when
  open. Visual style mirrors TopBar.svelte's .user-dropdown
  (--bg-secondary, border, --radius-lg, box-shadow, dropdown-in keyframe).
- Closes on link click, Escape, outside-click, and route change.
- Route-change auto-close kept as its own single-purpose $effect per
  CONVE-606.
- a11y: aria-expanded, aria-controls, aria-label on toggle; role=menu
  on panel, role=menuitem on links.
- Desktop layout (>640px) is unchanged.

Admin layout (web/src/routes/console/admin/+layout.svelte):
- On <=640px the .admin-tabs strip becomes overflow-x: auto with
  -webkit-overflow-scrolling: touch, scrollbar-width: none, and
  flex-wrap: nowrap so all tabs are reachable without clipping.
- Tabs stay flex-shrink: 0 + nowrap to keep labels readable.
- Active-tab underline + colors preserved.
2026-05-05 07:22:28 -04:00
xarmian abf017c4e7 feat(onboarding): make banner + CLI hint template-aware (TASK-1150) (#409)
The IDEA-1 trigger phrase is no longer hardcoded — fresh scrum
workspaces surface "use pad to get BACK-1", product workspaces surface
"use pad to get FEAT-1", and any future template that ships an
agent-onboarding seed declares its primary ref once and gets the
banner / hint for free.

Mechanism:

  1. WorkspaceTemplate gains an OnboardingPrimaryRef string field —
     the canonical declaration of "this template's IDEA-1-style
     primary entry." Set per template that ships the pattern
     (startup → "IDEA-1", scrum → "BACK-1", product → "FEAT-1");
     left empty for hiring/interviewing/demo where the agent-onboarding
     pattern intentionally doesn't apply.

  2. Server: handleGetDashboard identifies the seeded primary by
     walking allItems looking for item_number=1 + source="template"
     + created_by="system" + collection_slug ∈ {ideas, backlog,
     features}. The collection-slug whitelist is what keeps hiring's
     REQ-1 (also seeded with item_number=1 + source=template) from
     being flagged as an onboarding entry — those are example items,
     not agent scripts. The dashboard response gains an
     onboarding_seed field with ref/title/slug/collection_slug/status
     plus a server-computed `active` boolean (true iff status equals
     the schema initial value).

  3. CLI: printOnboardingHints accepts the template name, looks up
     the primary ref via collections.GetTemplate, and prints the
     right "use pad to get X-1" line. Templates without a declared
     primary skip the line entirely (so hiring's pad init success
     doesn't promise a non-existent BACK-1 / IDEA-1).

  4. Web frontend: dashboard reads dashboard.onboarding_seed,
     gates the banner on `active=true`, passes ref/slug/collection
     to OnboardingIdeaBanner. The component renders the trigger
     phrase, copy button, and "Read it first" deep link from those
     props — no more hardcoded IDEA-1.

ensureWorkspace's signature gains a returned templateName so init.go
+ main.go can pass it through to printOnboardingHints. The five
existing test call sites updated.

New tests:

  internal/collections/templates_test.go
    - TestTemplatesDeclareOnboardingPrimaryRef — locks the per-template
      OnboardingPrimaryRef values (and the explicit emptiness of
      hiring/interviewing/demo).

  internal/server/handlers_dashboard_test.go
    - TestDashboardOnboardingSeed_StartupTemplate
    - TestDashboardOnboardingSeed_ScrumTemplate
    - TestDashboardOnboardingSeed_ProductTemplate
    - TestDashboardOnboardingSeed_HiringTemplate (asserts NO seed —
      hiring's REQ-1 is example data, not an onboarding entry)
    - TestDashboardOnboardingSeed_EmptyWorkspace (no template)

Removes the loadIdeaOne race-guard from +page.svelte — the dashboard
poll itself now carries the onboarding_seed.active flag so the banner
state lives entirely in the dashboard response. Drops ~50 lines of
frontend code.

Parent: PLAN-1146.
2026-05-04 12:05:07 -04:00
xarmian 0a5eb777b9 feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134) (#403)
* feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134)

Make the seeded onboarding entry point discoverable without prior
knowledge. CONVE-191 calls for full-stack thinking on user-facing
features — this lands on every surface a fresh user might check.

CLI surfaces:
  • `pad auth setup` success message gains a closing hint pointing at
    `use pad to get IDEA-1` in a new agent session. New helper
    printIdeaOneTriggerHint() so future templates can reuse the shape.
  • `printOnboardingHints` (used after `pad init` / workspace creation)
    now leads with the trigger phrase before the existing /pad prompt
    suggestions. IDEA-1 is named because it's the seeded primary entry
    in software-category templates; people-category templates will
    seed REQ-1 / APP-1 etc. and need a template-aware version of this
    hint — tracked under PLAN-1140.

Web UI surfaces:
  • New OnboardingIdeaBanner component renders on the workspace
    dashboard whenever IDEA-1 is in status=new. Shows the trigger
    phrase verbatim with a copy button and a "Read it first" deep link
    into the seeded item itself. Disappears the moment the user (or
    agent) flips IDEA-1 out of `new`.
  • Dashboard fetches IDEA-1 alongside its existing dashboard +
    collections calls (cheap, indexed by ref) and re-checks on every
    poll (default 30s) plus every sync signal so the banner is
    self-correcting.
  • Existing OnboardingChecklist gate (`totalItems === 0`) is left
    alone. It still serves empty / non-templated workspaces; the new
    banner is the templated-workspace surface.

No tests added — both surfaces are pure copy/render. Existing
dashboard + auth-setup tests still pass.

Parent: PLAN-1131. Origin: IDEA-1128.

* fix(onboarding): pin IDEA-1 lookup to exact prefix+number match per Codex review (round 1)

Server-side ResolveItem (via GetItemByRef) falls back from PREFIX-NUMBER
to a number-only lookup when the prefix doesn't match any collection in
the workspace. That fallback exists so an item moved between collections
is still resolvable by its old ref — but it has a bad interaction with
my new dashboard lookup:

  In a non-software-category workspace (hiring, interviewing, …), there
  is no Ideas collection. `api.items.get(ws, 'IDEA-1')` would silently
  return whatever item has item_number=1 — typically REQ-1 (Requisition)
  or APP-1 (Application). If that item happened to have status=new
  (which the seeded Requisition / Application entries do), the dashboard
  would render the IDEA-1 onboarding banner pointing at a /ideas/... URL
  that 404s.

Fix: verify item.collection_prefix === 'IDEA' && item.item_number === 1
before trusting the result. Mismatch (or missing) → ideaOneStatus = null,
banner stays hidden. Software workspaces with a real IDEA-1 still match;
hiring / interviewing / interview-loop-style workspaces stop seeing the
banner entirely.

Caught by Codex on PR #403.

* fix(onboarding): guard IDEA-1 lookup against stale-workspace writes per Codex review (round 2)

Previous round addressed the wrong-collection match. This round fixes a
related race: rapid workspace navigation could let a slow loadIdeaOne()
from workspace A resolve after the user is already on workspace B and
write A's status into B's state, briefly rendering the IDEA-1 banner on
a workspace that doesn't have it.

Two-part fix:

1. The dashboard $effect that triggers load() now resets
   ideaOneStatus = null synchronously when wsSlug changes, so any
   leftover `new` status from the previous workspace can't briefly
   render the banner during the window between navigation and the new
   fetch resolving.

2. loadIdeaOne() now compares its captured slug against the current
   wsSlug at every assignment point (success and error paths). If
   they've diverged, the response is dropped — only the active
   workspace's request can write ideaOneStatus.

Standard "was this still the active request" pattern. No behavior
change for the common case (single-workspace dashboard); the guard
only fires when navigation interleaves with an in-flight fetch.

Caught by Codex on PR #403.
2026-05-04 09:56:55 -04:00
xarmian 95025793b9 feat(mobile): consolidate topbar + search palette UX (IDEA-1121) (#401)
Mobile chrome was previously split: a full <TopBar mobile /> (logo +
switcher + avatar) when the sidebar was open, and a slim inline
.mobile-header (hamburger + switcher) when it was closed. Every
mobile-chrome feature had to be added in two places, and the original
ask — a search button — surfaced the architectural debt.

Consolidated to a single always-rendered mobile chrome:
- TopBar.svelte mobile branch: PadLogo replaced with a hamburger that
  toggles the sidebar; new search-icon button calls openSearch() AND
  onNavigate() so the sidebar closes before navigating to a result
  (caught by Codex review, mirrors the desktop sidebar pattern).
- +layout.svelte: dropped the &&sidebarOpen gate so TopBar always
  renders on mobile; deleted the inline .mobile-header and its CSS;
  added padding-top: var(--topbar-height) on .app-layout via @media
  (max-width: 768px) so content doesn't slide under the fixed bar.
- [collection]/[slug]/+page.svelte: removed the now-stale 45px sticky
  offset that was pushing the breadcrumb below the deleted slim
  header.

Search palette mobile UX (CommandPalette.svelte, all in one
@media (max-width: 768px) block — desktop is byte-identical):
- Full-screen takeover (100dvh, no max-width / shadow / radius) so
  input anchors at top instead of fighting a vertically-centered
  layout against the on-screen keyboard.
- 16px input font to suppress iOS Safari focus-zoom.
- X close button (.mobile-close) replacing the useless 'esc' kbd hint.
- Body-scroll lock effect (overflow: hidden only — touch-action: none
  would have killed child scroll).
- .results pinned as the sole scroll target with flex: 1; min-height: 0
  so the search input stays at the top regardless of result-list size.

Editor toolbar leak fix (Editor.svelte): the .mobile-toolbar (z-index
100) rendered whenever the on-screen keyboard appeared for ANY input
— including the global search palette on a page with a tiptap editor
mounted. Gated the render condition on editorFocused (already tracked
via editor.on('focus')/on('blur')) so the toolbar only appears when
the editor itself is focused.

Refs: IDEA-1121, TASK-1122, TASK-1124
2026-05-03 21:35:17 -04:00
xarmian d6c0073409 chore(web): point ConnectMCPModal docs link at /mcp/remote (TASK-1117 follow-up) (#397)
The connect-MCP modal had DOCS_HREF set to a temporary fallback at
getpad.dev/docs/mcp because the canonical /mcp/remote landing didn't
exist yet (TODO comment noted that). pad-web PR #80 (TASK-1117) just
shipped /mcp/remote as the proper sibling to /mcp/local. Update the
link target to match.

The previous URL /docs/mcp now 404s on getpad.dev (page <24h old when
moved; redirect explicitly waived per the project owner). This commit
ensures every Pad instance points at the live URL going forward.

Parent: PLAN-1111. Companion to pad-web #80.
2026-05-03 11:49:39 -04:00
xarmian 78ec39daa2 feat(web): add ConnectMCPModal + wire into ConnectBanner (TASK-1115) (#396)
* feat(web): add ConnectMCPModal + wire it into ConnectBanner (TASK-1115)

Ships the Remote MCP onboarding modal that the MCP-mode banner has been
waiting for. With this PR, on any deployment that exposes a public MCP
URL (Pad Cloud + any self-host with PAD_MCP_PUBLIC_URL set), users with
an empty workspace see:

- A "Connect an AI agent — zero install →" banner (TASK-1114)
- Click → ConnectMCPModal with:
  * The canonical MCP URL in a copy-block (sourced from
    authStore.mcpPublicUrl, never hardcoded — works for self-hosted
    deploys too)
  * Four client cards (Claude Desktop, Cursor, Windsurf, ChatGPT) each
    linking to the existing getpad.dev/docs/mcp/<client> page
  * Footer links: Connected agents (in-app), Documentation
    (getpad.dev/docs/mcp — TASK-1117 will swap to /mcp/remote when
    that page lands), and "Prefer the CLI? →" which closes this modal
    and opens the existing CLI install modal

ConnectBanner now mounts BOTH modals with independent open states; the
visibility predicate ORs them so the banner hides during interaction.
The "Prefer the CLI?" cross-link calls a parent callback so the banner
owns both states — ConnectMCPModal never directly mounts the CLI modal.
The transitional `mode === 'cli'` visibility gate from TASK-1114 is
removed (the gate's reason for existing — no MCP modal — is gone).

Validated:
- Svelte autofixer: 0 issues / 0 suggestions on the new component
- make check: 0 errors, 703 files (was 702 — confirms new file is
  picked up by svelte-check)

Parent: PLAN-1111. Depends on TASK-1114 (banner refactor — shipped).

* fix(web): refetch on CLI-modal close regardless of banner mode (Codex round 1)

Codex caught a real bug: Effect C was gated on `mode === 'cli'`, but
the new MCP modal can flip the user to the CLI flow via "Prefer the
CLI? →". In that path, mode stays 'mcp' but the user runs `pad init`
and closes the CLI modal — and Effect C wouldn't refetch, leaving the
banner stale until a route change.

Fix: track `prevCliOpen` specifically and refetch on its true → false
transition, regardless of banner mode. The MCP-modal close transition
is still no-op (correct — user is off in a separate agent client).
2026-05-03 11:10:08 -04:00
xarmian 393d8f1d7d feat(web): ConnectBanner two-mode refactor (CLI / MCP) (TASK-1114) (#395)
* feat(web): ConnectBanner two-mode refactor (CLI / MCP) (TASK-1114)

Adds mode-aware rendering to the connect banner. When the server exposes
a Remote MCP URL via /auth/session.mcp_public_url (Pad Cloud + any
self-host with PAD_MCP_PUBLIC_URL set), the banner renders in MCP mode:

- Plug icon (vs the historical terminal-arrow)
- Copy: "Connect an AI agent to this workspace — zero install →"
- CTA: "Connect" (vs "Get the CLI")

Self-hosted instances without an MCP public URL keep the existing
CLI-mode copy + flow (regression-safe — no behavior change there).

Effect C (refetch on modal close) now runs CLI-mode only. In MCP mode
the user leaves the page entirely — off to Claude Desktop / Cursor /
Windsurf to paste the URL — so refetching the dashboard right after
modal close doesn't help. Effect B (workspace-change refetch) and the
SSE feed catch the first MCP-sourced item on the next visit.

localStorage dismiss key migration: writes now go to
`pad-connect-banner-dismissed-{ws}` (was `pad-cli-banner-dismissed-{ws}`).
Reads OR the new and legacy keys for one release as a soft migration so
existing dismissals carry over without re-pestering. Legacy key is left
in localStorage as harmless dead state — we don't own the cleanup path.

Transitional state: MCP-mode banner currently routes to the existing
ConnectWorkspaceModal as a fallback. TASK-1115 ships ConnectMCPModal
and will swap the binding. Until then, MCP-mode users who click see the
CLI install flow — worse UX than the destination, but coherent (no
broken click). Clearly TODO'd in the markup.

Validated with the Svelte autofixer (0 issues; advisory suggestions
about $effect usage are justified — localStorage reads + async fetches
+ previous-value tracking can't be expressed as $derived).

Parent: PLAN-1111. Depends on TASK-1112 + TASK-1113 (both shipped).

* fix(web): suppress MCP-mode banner until ConnectMCPModal ships per Codex review (round 1)

Codex P1: the MCP-mode copy promises "zero install" but the click still
opens ConnectWorkspaceModal (CLI flow), which is misleading for users
who land in that state.

Fix: gate `visible` on `mode === 'cli'` for now. The mode-detection,
branched copy/icon/CTA, and dismiss-key migration all stay — they're
ready to light up when TASK-1115 mounts the new modal. The transitional
gate is removed in TASK-1115 along with the modal swap.

Net effect this PR: cloud / MCP-exposed deploys see no banner at all
(strictly safer than misleading); self-hosted deploys are unchanged
(same CLI banner + flow).

Codex finding addressed: PR #395 round 1.
2026-05-03 11:00:53 -04:00
xarmian a1179f1c07 feat(dashboard): broaden agent-activity signal to include MCP source (TASK-1112) (#394)
Renames the "has_cli_source" signal to "has_agent_activity" — semantically
the dashboard flag for "this workspace's agent loop is wired up." Existing
behavior is preserved (CLI activity still flips it on); the SQL widens to
match source IN ('cli', 'mcp') so the signal stays correct if attribution
is later split (today, all MCP-via-HTTPHandlerDispatcher activity persists
as source='cli' per dispatch_http_test.go's contract).

Renames:
- store: WorkspaceHasCLISource → WorkspaceHasAgentActivity
- dashboard struct: HasCLISource → HasAgentActivity
- JSON tag: has_cli_source → has_agent_activity
- Svelte state: hasCliSource → hasAgentActivity
- Svelte fn: refreshHasCliSource → refreshHasAgentActivity
- TS field: has_cli_source → has_agent_activity (DashboardData)
- Test: TestWorkspaceHasCLISource* → TestWorkspaceHasAgentActivity*

New test case in TestWorkspaceHasAgentActivity asserts that an item with
source='mcp' also flips the signal on, exercising the broadened SQL clause.
Comment updates explain today's "MCP attribution = source='cli'" reality
so future readers don't search in vain for source='mcp' writers.

The Svelte localStorage dismiss key (`pad-cli-banner-dismissed-`) is left
unchanged in this PR — TASK-1114 will rename it with a soft-migration
read of the old key for one release. This PR's goal is the rename + signal
broadening, not the banner UX refactor.

Unblocks TASK-1114 (banner two-mode refactor).

Parent: PLAN-1111.
2026-05-03 10:50:54 -04:00
xarmian 92c05cb029 feat(auth): expose mcp_public_url on /auth/session (TASK-1113) (#393)
Adds mcp_public_url to the /auth/session response (and the parallel
setupStatePayload for the pre-bootstrap state). Sourced from the existing
s.mcpPublicURL field that SetMCPTransport populates from PAD_MCP_PUBLIC_URL
at startup. Empty string when unset — never null, never absent — so the
web UI can branch on `mcp_public_url !== ''` as the gate for "this Pad
instance exposes a Remote MCP server."

Frontend gets a parallel `authStore.mcpPublicUrl` getter mirroring the
existing `cloudMode` pattern. AuthSession.mcp_public_url is typed as
required (string), since the server always emits it.

Tests cover both shapes: empty string when PAD_MCP_PUBLIC_URL is unset
(both pre-setup and post-bootstrap), and verbatim echo when configured.

Unblocks TASK-1114 (banner two-mode refactor) which gates on this field.

Parent: PLAN-1111.

Note: AuthSession lives in web/src/lib/api/client.ts, not types/index.ts —
the task description had the wrong file. Type was edited in client.ts.
2026-05-03 10:40:44 -04:00
xarmian f9d3244660 feat(connected-apps): user-facing OAuth connection management page (TASK-954) (#390)
* feat(connected-apps): user-facing OAuth connection management page (TASK-954)

Adds /console/connected-apps where a logged-in user can see every
OAuth grant chain they've authorized via the MCP consent flow
(Claude Desktop, Cursor, …) and revoke any of them. Joins to the
DCR client metadata for the display name + logo, and to the MCP
audit log (TASK-960) for the "last used" + "30-day calls" columns.

Pieces:

- internal/store/connected_apps.go — ListUserOAuthConnections walks
  oauth_access_tokens + oauth_refresh_tokens, dedups by request_id,
  hydrates client metadata, parses session_data for the workspace
  allow-list, classifies granted_scopes into a coarse capability
  tier. RevokeUserOAuthConnection verifies ownership (ErrConnection
  NotFound for stranger's chains — anti-enumeration; same shape as
  for unknown chains) then calls the existing RevokeRefreshTokenFamily
  + RevokeAccessTokenFamily so the next /mcp call gets 401.

- internal/models/connected_apps.go — OAuthConnection + CapabilityTier
  models.

- internal/server/handlers_connected_apps.go — REST endpoints:
  GET /api/v1/connected-apps (list) + DELETE /api/v1/connected-apps/{id}
  (revoke, idempotent, 204). Wrapped in requireCloudMode group.
  List enriches with MCPConnectionStatsForUser (audit aggregates) —
  soft-fails on the audit lookup so a broken audit table degrades
  to "no last-used data" instead of a broken page. Revoke records
  an "oauth_connection_revoked" entry in audit_trail via the
  existing CreateActivity path.

- web/src/routes/console/connected-apps/+page.svelte — list with
  per-app card (logo, name, capability badge, workspace chips with
  +N expander, connected/last-used relative times, 30-day count),
  Details expander showing scope_string + workspace list + redirect
  URIs, Revoke button → confirm modal → optimistic refresh, friendly
  empty state linking to /connect.

- web/src/routes/console/+layout.svelte — Connected Apps nav link
  (cloud-mode-gated, between Settings and Billing).

- web/src/lib/api/client.ts + types/index.ts — typed client +
  ConnectedApp interface.

Tests cover:
- Store: chain dedup across rotation siblings, subject filtering
  (Bob can't see Alice's), inactive chains excluded, ownership
  check on revoke, idempotent re-revoke, capability tier mapping,
  session-data allowed_workspaces parsing (both []string and JSON
  []interface{} round-trips).
- Handler: cloud-mode gate (404 outside), owner-only filtering,
  DTO field shape + audit enrichment populating last_used_at +
  calls_30d, revoke ownership 404 (not 403 — anti-enumeration),
  idempotent 204, audit_trail row written.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(connected-apps): point empty-state link at getpad.dev (Codex review round 1)

Codex caught: the empty-state link to /connect 404s because /connect is a
pad-web (marketing site) route, not a docapp route. From inside the
authenticated console at app.getpad.dev, the right target is the
absolute https://getpad.dev/connect URL — same pattern the +error.svelte
page uses for its "Back to getpad.dev" + "/docs" links.

* fix(console nav): exclude /console/connected-apps from Workspaces active match (Codex round 2)

Codex caught: the Workspaces nav predicate `isActive('/console') && !isActive('/console/settings') && ...` was missing the new /console/connected-apps prefix, so both Workspaces AND Connected Apps lit up when viewing the connected-apps page.

Same shape as the existing exclusions for settings / billing / admin.
2026-05-02 23:21:07 -04:00
xarmian d8b1d98e08 feat(mcp): persistent audit log for /mcp tool calls (TASK-960) (#389)
* feat(mcp): persistent audit log for /mcp tool calls (TASK-960)

Adds a 90-day-retention audit log of every MCP request. Drives the
"last used" + "30-day calls" columns the connected-apps page (TASK-954)
will read, and gives ops + on-call a forensics surface via a new
admin /console/admin/mcp-audit page.

Schema deviation from the spec, documented in migration 049:
the original task body called for `token_id REFERENCES oauth_tokens(id)`
but pad has no `oauth_tokens` table — instead an OAuth grant chain is
identified by `request_id` (preserved across refresh-token rotations,
see migration 048), and PAT-authenticated MCP requests have no OAuth
identity at all. The audit row therefore carries `(token_kind,
token_ref)` — `oauth` + request_id for OAuth, or `pat` + api_tokens.id
for PATs. The connected-apps page in TASK-954 will filter on
token_kind='oauth' to surface third-party connections only.

Pieces:
- internal/store/migrations/049_mcp_audit.sql + pgmigrations/028 — table.
- internal/models/mcp_audit.go — typed entry + 30-day stats DTO.
- internal/store/mcp_audit.go — insert / list-by-user / list-by-connection
  / list-all / per-connection-stats aggregator / 90-day retention sweeper.
- internal/server/middleware_mcp_audit.go — async writer + sweeper +
  middleware that wraps /mcp behind MCPBearerAuth. Hot path is
  non-blocking enqueue with drop-on-overflow + atomic drop counter.
- internal/server/middleware_mcp_auth.go — both PAT + OAuth branches now
  stash WithMCPTokenIdentity so the audit row attributes correctly.
- internal/server/handlers_mcp_audit.go — read endpoints:
  GET /api/v1/connected-apps/{id}/audit (owner-scoped) +
  GET /api/v1/admin/mcp-audit (admin-only).
- web/src/routes/console/admin/mcp-audit/+page.svelte + tab in admin layout.
- Tests cover required-field validation, round-trip, pagination,
  owner-only filtering, last-used + 30-day aggregates, retention sweep,
  body-sniff parser, canonical-JSON arg hashing, buffer-full drop path,
  status-to-result classification, admin gate, DTO field shape.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(mcp-audit): emit denied row on rate-limit reject per Codex review (round 1)

PR #389 round 1 caught: MCPAuditLog is mounted INSIDE MCPBearerAuth, so
when bearer auth's per-token rate-limit fires (429) it returns before
next.ServeHTTP — and the wrapping audit middleware never sees the
response. classifyMCPResult mapped 401/403/429 with no path that could
actually reach it.

Fix: emitMCPAuditDenied helper called directly from the rate-limit
deny branches of both PAT + OAuth paths. Resolved user + token
identity are already in scope at that point, so the audit row gets
attributed correctly. Pre-auth rejections (no/invalid bearer) stay
un-audited because there's no user to attribute them to and the
audit_trail table covers those auth-event signals already.

Threading: handleMCPPATAuth + handleMCPOAuthAuth now take the entry
timestamp so the denied row carries real latency.

Test: TestMCPAudit_RateLimited_RecordsDeniedRow drives a real PAT
through the rate limiter, drains to 429, and asserts the audit row
lands with status="denied" + error_kind="rate_limited" + the right
tool_name from the request body.
2026-05-02 22:56:10 -04:00
xarmian 8cdf582066 fix(auth): full-page navigation for server-owned post-auth redirect targets (BUG-1083) (#386)
Unauthenticated users hitting /oauth/authorize were 302'd through /login,
but the post-login goto(redirectTarget) used SvelteKit's client-side router
to navigate back to /oauth/authorize — a Go-server route with no SPA match.
SvelteKit fell into the [username]/[workspace] catchall, parsed it as
username="oauth" + workspace="authorize", and rendered "No dashboard data
available."

Add isServerOwnedPath() + navigateToRedirectTarget() helpers in
web/src/lib/auth/redirect.ts. The helper picks window.location.replace for
paths the Go server owns (/oauth/, /api/, /.well-known/, /mcp, /metrics)
and goto() for genuine SPA routes. window.location.replace matches the
prior replaceState: true semantics so back-button doesn't return to /login.

Swap all 5 post-auth call sites in login/+page.svelte (onMount, password
submit, 2FA verify) and register/+page.svelte (onMount, register submit)
to use the helper. goto import is no longer needed in either file.
2026-05-02 21:06:58 -04:00
xarmian 12bd442711 feat(auth): contextual OAuth-intent banner on /login + /register (TASK-1001) (#368)
Add a small informational banner that renders when a user lands on
/login or /register mid-OAuth-flow (i.e. ?redirect=/oauth/authorize?...).
Tells them what they're in the middle of so the form doesn't read as a
non sequitur for first-touch users coming from the marketing site's
"Connect to Claude" CTA.

The banner is generic-only for now — once TASK-951 ships the OAuth
authorization server and the /api/v1/oauth/clients/{id}/public-info
endpoint, a follow-up will parse client_id from the inner query
string and substitute a friendly name ("connect Claude Desktop"
instead of "connect an AI agent"). Component contract is shaped to
allow that extension without consumer changes.

Detection is heuristic: redirectTarget.startsWith('/oauth/authorize').
False positives only mean a slightly more specific banner; false
negatives leave the user with the same UX they had before.

Mode prop drives the verb: signin ("signing in") on /login, signup
("creating an account") on /register.

Parent: PLAN-943.
2026-05-02 00:03:32 -04:00
xarmian a9ad767a45 feat(auth): extract <AuthOAuthButtons> + render on /register (TASK-1000) (#367)
* feat(auth): extract <AuthOAuthButtons> + render on /register (TASK-1000)

Lift the cloud-mode SSO block (Continue with GitHub / Google) out of
/login into a shared AuthOAuthButtons.svelte component, render it on
both /login and /register, and extract redirect= validation +
query-string helpers into $lib/auth/redirect so both pages compose
the same encoding.

Why: the marketing-site "Sign up to connect Claude" CTA lands new
users on /register, which had no SSO buttons — first-touch users
fell off the 30-second-onboard path. With this PR, /register
exposes the same one-click SSO buttons as /login and preserves the
?redirect= query through the click so completing SSO returns to
the original destination (e.g. /oauth/authorize?... once TASK-998
ships pad-cloud's redirect= honoring).

Behavior changes:
- /register reads the same `redirect` query param /login does and
  honors it on goto() after password registration.
- /register populates the "Last used" pill from localStorage so
  returning users see the visual lift on their preferred provider.
- /login is byte-identical: the inline SSO block is replaced with
  the component, the inline redirect helpers replaced with helper
  imports, and the now-unused CSS rules removed.

Out of scope (separate tasks): pad-cloud's OAuth callback honoring
?redirect= (TASK-998), the OAuth-intent banner (TASK-1001).

Parent: PLAN-943.

* fix(auth): preserve redirect= across login↔register cross-links per Codex review (round 1)

The "Don't have an account? Sign up" link on /login and the
"Already have an account? Sign in" link on /register were dropping
the current `redirect` query, breaking the OAuth/deep-link flow when
a user mid-/oauth/authorize bounced between the two pages. Now both
links append the encoded redirect target via redirectQueryFragment.

Parent: PLAN-943, TASK-1000.
2026-05-02 00:00:06 -04:00
xarmian 4536892923 feat(brand): new tagline — Project Management for the agent era (#351)
Retire "Collaborate with your AI agents" in favor of
"Project Management for the agent era". Companion change to
PerpetualSoftware/pad-web#45 — they ship together so the brand
reads consistently across the marketing site and the product.

The phrasing leans into the moment without trend-chasing.
"agent" carries more weight than "AI" — it points at *how* the
technology shows up in your workflow (an autonomous teammate),
not just *that* it exists. It's also the unit of change Pad is
uniquely structured around (issue IDs, conventions, playbooks —
things agents read).

This commit only updates plain-text surfaces (README, goreleaser
description, embedded PWA manifests, app meta tags). Visual
accenting of the word "agent" lives in pad-web (homepage hero
<h1> + OG card image), the only places that render the tagline
to humans rather than to package managers / OG crawlers.

## Files

- README.md — top-of-readme tagline.
- .goreleaser.yaml — Homebrew formula description.
- web/static/site.webmanifest — embedded PWA description.
- web/static/manifest.json — duplicate PWA manifest in the same dir.
- web/src/routes/+layout.svelte — <meta name="description"> and
  <meta property="og:description"> on every app page.

## Verification

- make check: 0 errors. golangci-lint, go test ./..., govulncheck,
  and `cd web && npm run build` all pass. The 6 svelte-check
  warnings are all pre-existing in files this PR doesn't touch
  (NestedChildren, ChildItems, roles/+page, console/admin/+page).
2026-05-01 15:01:08 -04:00
xarmian c4f7d243e6 fix(billing): gate Pro upgrade CTAs while Stripe is unwired (#324)
Stripe isn't configured on the cloud sidecar yet, so the
"Upgrade to Pro" buttons on /console/billing dead-end at a 404
from /billing/checkout. Hide the Current-Plan CTA and replace
the Compare-Plans CTA with a "Pro — coming soon" block plus a
mailto:info@getpad.dev link to capture interest while we get
the integration ready.

The post-checkout polling/banners are left wired — they only
fire on ?checkout=success, which can't happen until the gate
flips back on. Flip the STRIPE_AVAILABLE constant (or thread
it through a server flag like billing_enabled) once Stripe is
live to restore the buttons.
2026-04-30 19:57:19 -04:00
xarmian b783d06144 feat(web): last-used auth method banner on /login (TASK-923) (#323)
Returning users who are logged out land on /login with no context about
how they signed in before. This adds a soft "last time, you used X to
sign in" hint and visually elevates the matching CTA so the right next
step reads at a glance — without overwhelming first-time visitors who
still see all methods equally.

Implementation
--------------
- New helper `web/src/lib/auth/lastMethod.ts` reads/writes a
  `pad_last_auth_method` value (`'password' | 'github' | 'google'`) and
  a `pad_last_auth_at` timestamp in localStorage. Wrapped in try/catch
  so SSR, private mode, and disabled storage never break auth pages.
- Login page records `password` on successful credential or 2FA login,
  and records the OAuth provider speculatively on button click. The
  OAuth handshake completes outside the SPA (provider → pad-cloud →
  pad backend session → redirect), so there's no JS callback to hang
  the write on. If the user bails at the consent screen the value still
  reflects "what the user tried last", which is the right answer for
  the next-visit banner.
- Register page records `password` on successful registration so newly
  registered users see the same hint when they next return logged out.
- Banner above the form names the method; matching OAuth button gets a
  border lift + "Last used" pill. Banner is suppressed when an OAuth
  error banner for the same provider is already showing — surfacing
  both at once muddles the message.

Privacy
-------
- Only the method *name* is stored — never an email, user ID, or token.
- localStorage is per-origin and never sent over the wire.
- No cookie, no URL param, no server log entry, no new endpoints.

Parent: PLAN-776 (Post-launch Backlog).
Promotes IDEA-922.
2026-04-30 17:14:56 -04:00
xarmian f8ed3e10a7 fix(search): explicit selection on Enter + numeric go-to (BUG-864, BUG-910) (#320)
* fix(search): require explicit selection on Enter; add bare-number go-to (BUG-864, BUG-910)

The command palette had two related issues:

- BUG-864: Pressing Enter armed the first search result automatically — the
  user could close the modal and navigate without ever pressing an arrow key.
  selectedIdx now starts at -1 and only advances on ArrowDown/ArrowUp.
- BUG-910: Typing a bare number (e.g. "843") returned no results because
  parseItemRef requires PREFIX-NUMBER and FTS doesn't index item_number.

Backend (internal/store):
- Add parseItemNumber() helper alongside parseItemRef.
- In Search(), add a bare-numeric direct-lookup path that mirrors the existing
  ref-lookup block but without a collection prefix filter. item_number is
  unique per workspace (idx_items_workspace_number) so this resolves to at
  most one direct hit, prepended with rank=-1000.

Frontend (CommandPalette.svelte):
- selectedIdx defaults to -1; reset to -1 (not 0) on modal open and after
  every search.
- Enter on a non-numeric query is a no-op unless the user has arrow-selected.
- Numeric queries are a deliberate exception: Enter on a bare-number query
  flushes the debounce, navigates directly to the matching item, and lets
  the search palette double as a quick "go to item N" jump.

Tests:
- TestSearch_BareNumericQueryFindsItemByNumber covers the new path.
- TestParseItemNumber covers helper edge cases.

* fix(search): exclude direct hits from FTS WHERE to keep pagination correct

Codex review (round 1) on PR #320:

> Numeric direct hits are appended before the FTS query, but the later
> pagination only removes duplicates after SQL LIMIT/OFFSET. If item #2
> also matches FTS for query "2" through its title/content, that
> duplicate consumes an FTS slot, so page 1 can return fewer than `limit`
> results and later pages can repeat/skip rows.

Hoist the direct-hit (ref + numeric) snapshot to before the FTS query
is built, then append `AND i.id NOT IN (...)` to both the SELECT and
COUNT FTS queries. After a successful count, add refCount back so
SearchResponse.Total still reflects the full result set (since FTS
itself no longer counts those rows).

The flaw also applied to the pre-existing parseItemRef path; this fix
covers both. The post-LIMIT dedup loop is now defense-in-depth.

New test TestSearch_BareNumericQueryDedupsAgainstFTS guards the case:
an item whose title/content literally contains its own item_number
(so it matches both the direct lookup and FTS) appears exactly once
in Results and Total counts it exactly once.

* fix(search): paginate direct hits properly across workspaces

Codex review (round 2) on PR #320:

> P1: Bare numeric direct hits break pagination in global search.
> item_number is only unique per workspace, so q=1 with WorkspaceIDs
> spanning N workspaces returns N direct hits — all appended without
> being sliced to Limit. limit=1 with three workspaces each having #1
> returns three results on page 0, and offset=1 drops all direct hits
> then returns FTS rows instead of the second direct hit.

The same flaw applied to the pre-existing parseItemRef path: the global
search "TASK-5" can match TASK-5 in multiple workspaces.

Fix:
- Add deterministic ORDER BY i.workspace_id, i.id to both ref and bare-
  numeric direct-hit lookups so pagination is stable across pages.
- Replace the offset==0/offset>0 branching pagination with a uniform
  slice: directStart = min(Offset, refCount); directEnd =
  min(Offset+Limit, refCount); results = results[directStart:directEnd];
  ftsLimit = Limit - directConsumed; ftsOffset = max(Offset - refCount, 0).
  This honours (offset, limit) whether direct hits, FTS, or both fill
  the page.

Total stays correct because the FTS count was already excluding direct
hits (round-1 fix) and we add refCount back unconditionally.

New test TestSearch_BareNumericQueryPaginatesAcrossWorkspaces creates
three workspaces each with item #1 and verifies that limit=1 with
offsets 0/1/2 returns three different direct hits in stable order, and
limit=10 returns all three.

* chore: gofmt — column alignment in struct field declarations

CI Go (SQLite) lint failed on two files:

- internal/store/store_test.go (TestParseItemNumber, this PR's new test) —
  unaligned column widths and inconsistent comment spacing.
- internal/config/config.go (drive-by) — pre-existing alignment regression
  in the Config struct that snuck in via an earlier landed PR; included
  here because it blocks merge.

No semantic changes — `gofmt -w` only.
2026-04-30 14:23:23 -04:00
xarmian f122bec84a feat(layout): in-app Resources menu in user dropdown (TASK-905) (#316)
* feat(layout): in-app Resources menu in user dropdown (TASK-905)

New UserMenuResources component adds a Resources block to the user-menu
dropdown in TopBar, closing the product → marketing handoff seam.
Logged-in users now have a clear path back out to Docs / Changelog /
GitHub / Status / Support without having to remember getpad.dev URLs
or visit the marketing site separately.

Cloud-mode (cloudMode=true) shows: Docs / Changelog / GitHub / Status /
Support. Replaces the prior inline Support/Status pair — that block
became a special case of this unified Resources component.

Self-hosted (cloudMode=false) shows the trimmed Docs / GitHub set.
Changelog / Status are Cloud-specific surfaces; getpad.dev's
support@getpad.dev mailbox isn't the operator's to direct people to.
The Docs link still points at getpad.dev because that's the canonical
project documentation regardless of deployment shape.

Component is wired into BOTH the desktop and mobile branches of
TopBar (the existing dropdown duplication). All links open in a new
tab so a user mid-task doesn't lose state. Each entry has a small
external-link icon so the off-property nature is visible without the
user having to hover-and-read the title.

The `:global(.user-dropdown)` selectors keep the new styles scoped to
the existing dropdown surface in TopBar without forcing a CSS
refactor of that component.

Visual contract: docs/brand.md §6/§7. Companion to AuthHeader,
AuthFooter, and +error.svelte from PLAN-900.

Test plan:
- web/npm run check — 0 errors (694 files, +1 new component)
- web/npm run build — clean
- Svelte autofixer — clean

* fix(layout): UserMenuResources mirrors dropdown-item styles per Codex (round 2)

Codex caught that .dropdown-item and .dropdown-divider rules in
TopBar.svelte's <style> are scoped to that component — Svelte's
scoped CSS attaches a per-component hash so the rules don't apply to
DOM rendered by UserMenuResources.svelte (a separate component). The
new resource links lost the dropdown padding/color/text-decoration/
hover styling, and the divider rendered as an unstyled empty 1px row.

Mirror the base .dropdown-item / .dropdown-divider / .dropdown-item:hover
rules inside UserMenuResources using :global(.user-dropdown) qualifiers
so the dropdown surface remains the styling boundary — the rules apply
to anything dropped into the menu but never leak outside it.

Same scoping pattern that already worked for .resources-label and
.external-icon in this component, just extended to the base classes.

* fix(layout): respect canonical link order from brand spec per Codex (round 3)

Codex caught that UserMenuResources rendered links in the order
Docs / Changelog / GitHub / Status / Support, but docs/brand.md §7
defines a canonical relative order with GitHub before Docs and
Changelog. The whole point of the brand spec is one canonical order
across surfaces; violating it in the user menu undermines that.

Reorder Cloud to GitHub / Docs / Changelog / Status / Support, and
self-hosted to GitHub / Docs. Status and Support are user-menu-specific
additions that don't appear in the marketing footer; they land at the
end so the brand-spec subset stays in canonical position at the front.
2026-04-29 23:57:16 -04:00
xarmian 8f2be1b391 feat(error): branded 404 + 500 error pages with cloud-mode chrome (TASK-906) (#315)
* feat(error): branded 404 + 500 error pages with cloud-mode chrome (TASK-906)

New web/src/routes/+error.svelte renders for any unhandled error or
unmatched route in the SvelteKit tree. Friendly status-specific titles
+ hints (404, 401/403/500 covered explicitly; falls through to a
generic "An error occurred" + framework message for anything else).

Cloud mode wraps the error in marketing chrome — AuthHeader at the
top, AuthFooter at the bottom — and adds two extra escape CTAs ("Back
to getpad.dev" + "Open docs") in addition to the always-present "Go to
home" button. So a 404 doesn't drop the user out of the brand and they
always have somewhere to go.

Self-hosted (cloudMode=false) renders a minimal centered card with
just the status code, friendly title/hint, the inline Pad wordmark
(matching the auth-card pattern), and a single "Go to home" CTA. No
getpad.dev branding imposed on operators' deployments — same gating
philosophy as TASK-902/903.

The page hydrates authStore in onMount so cloudMode resolves on first
paint, fire-and-forget; if the session fetch fails we render the
self-hosted variant — safe fallback.

Reuses AuthHeader and AuthFooter from TASK-902/903; no need for
hand-rolled chrome since those components landed first.

Parent: PLAN-900.

Test plan:
- web/npm run check — 0 errors (693 files; +1 from new page)
- web/npm run build — clean
- Svelte autofixer — clean

* fix(error): context-aware chrome for in-app vs marketing routes per Codex (round 2)

Codex caught that +error.svelte unconditionally rendered the Cloud
marketing AuthHeader/AuthFooter, but the root +layout.svelte already
wraps workspace pages in the Sidebar/TopBar/main-content app shell.
On a workspace 404 the result would be both chromes stacked: app
shell underneath plus a fixed-position marketing header floating
over the top.

Fix: branch on the same paths the root layout uses to decide whether
to render bare children. "Marketing context" (auth/share/console
paths) keeps the full Cloud-mode AuthHeader + AuthFooter treatment;
"app-shell context" (everything else, i.e. workspace pages) renders a
minimal centered block inside the existing main-content area with no
fixed-position chrome of its own.

This means the user-facing experience in each context is correct:

- /this-does-not-exist (no auth): Cloud → branded marketing 404;
  self-hosted → minimal centered card with Pad wordmark
- /login → same (auth-page family)
- /[user]/[ws]/some/missing/route: workspace shell stays intact
  with a centered "Page not found" inside the main-content area

The marketing-context list mirrors the bare-render condition in
web/src/routes/+layout.svelte (isAuthPage || isSharePage ||
isConsolePage) plus the share-page prefix.

Verification: npm run check 0 errors; web build clean.

* test(e2e): wait for workspace heading before probing topbar trigger

The bundle-roundtrip test fired a synchronous isVisible() check on
the desktop topbar trigger immediately after `domcontentloaded`. The
workspace shell is fully client-rendered (adapter-static has no SSR
for app routes), so isVisible() raced hydration: on slower CI runners
the topbar wasn't in the DOM yet, the check returned false, the test
fell through to the mobile branch, and it then timed out waiting for
an element that doesn't exist on the desktop-chromium project.

Surfaced by TASK-906 (this PR), which adds ~8 KB of root-level JS
(error page + AuthHeader/AuthFooter chunks). That extra chunk-loading
shifted the hydration race past the test's check on GitHub Actions
runners; it had been winning consistently before. Locally the test
passes in ~4s either way — the race is real but tight.

Anchor the wait on the workspace heading ("E2E Workspace") which the
dashboard route renders the moment hydration completes. Keeps the
existing desktop/mobile branching intact and adds one toBeVisible()
gate so the rest of the flow runs against a fully-hydrated UI on any
runner speed.

Verified locally: 4.0s pass after the change.
2026-04-29 23:46:07 -04:00
xarmian 2900a66861 feat(auth): footer parity with marketing site on auth-page family (TASK-903) (#314)
* feat(auth): footer parity with marketing site on auth-page family (TASK-903)

New <AuthFooter cloudMode={...} /> replaces the prior LegalFooter +
SupportFooter pair. Single component matches the brand spec
(docs/brand.md §7) which describes ONE footer pattern, not two
separate strips.

Cloud mode (cloudMode=true) carries the full getpad.dev marketing
footer: copyright line ("© <year> Pad · Perpetual Software") + the
nine-link list in canonical order — GitHub, Docs, Changelog,
Contribute, FAQ, Security, Privacy, Terms, Sub-processors. Visual
contract anchored on pad-web/src/routes/+layout.svelte (border-top,
max-w-6xl, flex-wrap, sm: breakpoint at 640px).

Self-hosted (cloudMode=false) renders the legal-essentials only —
Terms / Privacy / Sub-processors — preserving the visual treatment of
the prior LegalFooter exactly so existing self-hosted deployments see
no change after this PR. The Status / Support / GitHub / Changelog /
Contribute / FAQ / Security links were Cloud-only in the prior shape
too; that stays the case.

Wired the new AuthFooter into all five auth-family pages:
  - /login            (replaces LegalFooter + SupportFooter)
  - /register         (replaces LegalFooter + SupportFooter)
  - /forgot-password  (replaces LegalFooter + SupportFooter)
  - /reset-password/[token]  (NEW — was footer-less)
  - /join/[code]      (NEW — was footer-less)

LegalFooter.svelte and SupportFooter.svelte are deleted; they were
internal to the auth-pages feature and never used elsewhere
(grep-verified). AuthHeader's comment that referenced them is
updated to point at AuthFooter instead.

Year is computed once per page render via new Date().getFullYear()
— no auto-refresh needed since auth pages don't sit open across a
year boundary in any realistic flow.

Parent: PLAN-900.

Test plan:
- web/npm run check — 0 errors (692 files now, was 693; net -1 reflects
  2 deletions + 1 addition)
- web/npm run build — clean
- go build ./... && go vet ./... && go test ./... — all pass
- Svelte autofixer — clean

* fix(auth): self-hosted AuthFooter renders nothing per Codex review (round 2)

Codex P1: the prior LegalFooter + SupportFooter both gated their entire
body on `{#if cloudMode}` — i.e. self-hosted rendered nothing at all.
The first draft of AuthFooter incorrectly assumed self-hosted should
get the legal-essentials subset (Terms / Privacy / Sub-processors
links), which would have rendered getpad.dev's hosted-service legal
links on someone else's deployment, misrepresenting the operator's
own legal terms.

Revert the self-hosted branch to render nothing. The brand spec
(docs/brand.md §7) already flags an operator-owned legal/footer
mechanism as deferred to the operator-branding follow-up plan, so
this restores the prior behavior exactly.

Removed the now-dead self-hosted link list, the .auth-footer-legal
CSS rules, and the $derived links computation.

* fix(auth): flex-direction on reset-password + join wrappers per Codex (round 3)

Codex P2: AuthFooter on /reset-password/[token] and /join/[code] sat
horizontally next to the auth card instead of below it because those
two page wrappers were `display: flex` without `flex-direction: column`.
The other three auth pages (login, register, forgot-password) already
had column layout so they were unaffected — only the two pages that
gained a footer in this PR were broken.

Add `flex-direction: column` to .page (reset-password) and .join-page
so the footer renders below the card on those routes too.
2026-04-29 23:23:39 -04:00
xarmian 973301847c feat(auth): shared marketing header on auth-page family in Cloud mode (TASK-902) (#313)
New <AuthHeader cloudMode={...} /> component renders a top header that
visually continues getpad.dev's marketing nav, so users clicking
"Login"/"Sign Up" from the marketing site land on auth pages without
the sense of jumping properties.

Wired into all five pre-login pages:
  - /login
  - /register
  - /forgot-password
  - /reset-password/[token]
  - /join/[code]

When cloudMode === false (self-hosted) the component renders nothing,
so operators ship Pad under their own brand without our chrome
imposed on them — matches the existing pattern in
LegalFooter.svelte / SupportFooter.svelte.

The inline <h1 class="logo">Pad</h1> wordmark on each auth card is now
hidden when cloudMode === true (the fixed header carries the wordmark)
and kept on self-hosted (where the header is absent). Each page wrapper
gets a .cloud-mode class with padding-top: 4rem so the card does not
collide with the fixed header.

Two pages (reset-password, join) did not previously hydrate authStore;
both now call authStore.ensureLoaded() in onMount, matching the pattern
already established in /forgot-password.

Visual contract anchored on docs/brand.md sections 5–6 — colors and
spacing pulled from the app's existing CSS variables; structure matches
pad-web/src/routes/+layout.svelte byte-for-byte for the SVG hamburger,
flex layout, max-w-6xl container, and md: breakpoint at 768px. Verified
via the Svelte autofixer (caught a misplaced <svelte:window> on first
draft and was corrected).

Parent: PLAN-900.

Test plan:
- web/npm run check — 0 errors
- web/npm run build — clean
- go build ./... && go vet ./... && go test ./... — all pass
- Manual: covered in PR body
2026-04-29 23:06:15 -04:00
xarmian 89ae5369ae feat(web): settings page exports .tar.gz bundle (TASK-892) (#309)
* feat(web): settings page exports .tar.gz bundle (TASK-892)

Replace the legacy "Download JSON" button on the workspace
settings page with a single "Download .tar.gz" link that hits the
existing ?format=tar dispatch on handleExportWorkspace. The bundle
ships items + comments + version history + attachment blobs +
manifest in a single archive — same shape the CLI's
'pad workspace export' command produces.

Behavior:

- Field label changed from "Export" to "Export bundle"
- Button text changed from "Download JSON" to "Download .tar.gz"
- href appended ?format=tar
- download attribute changed from {slug}-export.json to
  {slug}-export.tar.gz
- Added a title= tooltip explaining the bundle contents and that
  it's re-importable via the Create Workspace dialog

No JSON-export UI surface remains in the settings page. The legacy
JSON path on the server side stays for back-compat (any operator
still hitting /export with no query keeps getting JSON).

Parent: PLAN-890. Sibling task TASK-893 will flip the import
modal to consume .tar.gz so the round-trip closes.

* feat(web): import workspace bundle (.tar.gz) in CreateWorkspaceModal (TASK-893)

Folded into the same PR as TASK-892 because Codex (correctly) flagged
that exporting .tar.gz while still importing JSON ships a half-baked
state — the settings page tooltip even tells users the bundle is
re-importable via this modal. Now it actually is.

Changes in CreateWorkspaceModal.svelte:

- importWorkspace() now calls api.workspaces.importBundle(file, name)
  instead of reading + JSON.parse-ing the file and POSTing through
  api.raw.post. The new method sets Content-Type: application/gzip
  and posts the raw File body, which the server's existing dispatch
  in handleImportWorkspace routes to the bundle path
  (handlers_workspaces.go:361).
- File picker accept attribute changed from ".json" to
  ".tar.gz,.tgz,application/gzip,application/x-gzip" — UI advertises
  only the new format.
- Drag-drop guard accepts .tar.gz, .tgz, AND .json (legacy
  back-compat — server still supports JSON imports for any operator
  with an old archive lying around, even though we don't advertise
  it).
- Drop-zone hint and import explanatory text updated to mention the
  bundle format and what's preserved (items, comments, attachments,
  version history).
- Auto-fill regex strips -export.tar.gz, .tar.gz, .tgz, AND .json
  suffixes when seeding the workspace name from the filename.

New api.workspaces.importBundle method in web/src/lib/api/client.ts:

- Bypasses the JSON-only `request` helper — sets Content-Type:
  application/gzip and posts the File body raw.
- Handles CSRF token, 401 redirect, and shaped error responses the
  same way `request` does.
- Mirrors the CLI's `pad workspace import <bundle.tar.gz>` flow.

Server-side: no changes — handleImportWorkspace dispatches on
Content-Type and the bundle path was already audited + hardened in
PR #308.

Parent: PLAN-890. Closes the import/export round-trip alongside
TASK-892. TASK-894 (Playwright e2e) covers the round-trip.

* fix(web): drop .json from import accept list per Codex review (round 2)

Codex P2 on PR #309: I left .json in the drag-drop guard
isAcceptedBundleFile, intending to be lenient for users with legacy
JSON exports. But api.workspaces.importBundle always POSTs as
Content-Type: application/gzip — so a dropped .json file would
route to the server's bundle path and fail with a gzip decode
error. Confusing UX.

Make the modal strictly tar.gz-only:

- isAcceptedBundleFile regex narrowed to /(\.tar\.gz|\.tgz)$/i
- name auto-fill regex narrowed to strip only -export.tar.gz, .tar.gz,
  .tgz suffixes
- Comment documents that operators with legacy JSON exports can
  still curl them against POST /workspaces/import directly — the
  server keeps the JSON dispatch for back-compat.

The file picker accept attribute was already strict (.tar.gz, .tgz,
application/gzip, application/x-gzip) — this commit makes the
drag-drop path consistent with it.

Parent: PLAN-890.
2026-04-29 20:34:42 -04:00
xarmian d3a543db6f feat(attachments): admin per-user storage quota override UI (TASK-883) (#304)
* feat(attachments): admin per-user storage quota override UI (TASK-883)

Surfaces the storage_bytes plan_overrides key in the admin user-detail
page so operators can lift or tighten an individual user's quota
without poking at JSON via the API directly.

Frontend (console/admin/+page.svelte):
- Dedicated "Storage quota override" input below the existing
  overrides grid. Storage is byte-counted, not row-counted, so a
  number input forcing the admin to type 536870912 for 512MB
  would be hostile. Accepts:
    • "10 GB" / "500MB" / "1.5 GB" (IEC shorthand)
    • "1024" (raw bytes)
    • "-1" (unlimited)
    • "" (clear → falls back to plan default)
- Live parse preview ("= 10.0 GB (10,737,418,240 bytes)") so the
  admin can verify the unit was understood.
- "Reset to plan default" button clears the field; save commits
  the absence as a removed override key.
- Pre-fills with the current effective override formatted in the
  largest exact unit so a previously-set "10 GB" doesn't reload as
  "10737418240".

Backend:
- ActionPlanOverridesChanged audit constant.
- handleAdminUpdateUser now logs an audit event with old/new
  override JSONs whenever plan_overrides is patched. Lets operators
  correlate a mysteriously-allowed upload with the override that
  enabled it.

Tests:
- TestAdminUpdateUser_StorageOverrideRoundTrip: PATCH with
  storage_bytes:1073741824 → GET shows the new override → audit
  feed contains plan_overrides_changed event → clearing the
  override removes it.
- TestAdminUpdateUser_NonAdminForbidden: member-role user cannot
  PATCH another user's plan_overrides (regression guard for the
  audit-log path).

Parent: PLAN-866. The Settings → Storage page (TASK-882) reflects
the new effective limit immediately after save because both call
the same WorkspaceStorageInfo helper.

* fix(admin): parse plan_overrides JSON on read, clear via empty string per Codex (round 1)

Two related bugs in the admin user-detail page that Codex caught
in PR #304 round 1:

1. The save path sent JSON null when every override field was
   blank, but the Go handler uses a *string and JSON null decodes
   to a nil pointer — the handler's existing nil-vs-non-nil branch
   then skips the update, meaning "Reset to plan default" reported
   success without actually clearing the override. Fixed by
   sending "" (empty string) which routes through
   SetUserPlanOverrides("") and clears the column.

2. The form-populate path treated u.plan_overrides as an object
   while the API actually returns the raw column value as a JSON
   string. So `'storage_bytes' in ov` was checking string indices
   on a literal '{"storage_bytes":1073741824}' string, returning
   false, and any user with stored overrides loaded a blank form.
   This was a pre-existing bug in the workspaces / api_tokens /
   etc. fields too — fixed for all of them by parsing the JSON in
   parsePlanOverrides() before reading keys, with a defensive
   "future-proof" branch in case the API ever switches to a
   decoded object.

TS type for AdminUser.plan_overrides updated to `string | null`
to match the actual API contract.

Backend regression test added (TestAdminUpdateUser_OmittedOverrides
Preserved) that pins the other half of the contract: PATCH with
plan_overrides absent must NOT clear the column. The test was
straightforward to add because the existing test infrastructure
(bootstrapFirstUser, doRequestWithCookie) already covers the
admin auth path.
2026-04-29 17:56:19 -04:00
xarmian 504d348917 feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)

Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.

Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
  attached/unattached, collection_id) + sort allowlist (size, filename,
  created_at — each with desc variant). LEFT JOIN to items + collections
  enriches each row with item_title/slug + collection_slug for the
  "in [[Item]]" link. Hides derived (thumbnail) rows by default — they
  count toward quota but are managed automatically and would clutter
  the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
  on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
  {attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
  derived rows directly (returns 400 with derived_attachment code) and
  invalidates the storage-usage cache.

Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
  (color thresholds at 80%/100%, override badge), 5-select filter row
  (category, item, collection, sort, page size), attachment list with
  thumbnails (image variants via thumb-sm, emoji icon otherwise), item
  link, MIME, size, date, and per-row delete with confirm() dialog.
  Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.

Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
  limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
  the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
  storage usage drops to 0 (cache invalidation hook fires) → second
  delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
  directly via the API.

Parent: PLAN-866.

* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)

Three findings from Codex on PR #303 round 1:

P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.

Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.

P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.

P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".

Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
  restricted to one collection sees only that collection's row +
  orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
  archive/other each return exactly the matching MIME types.

* fix(attachments): item-level visibility on list + delete per Codex (round 2)

Two more findings from Codex on PR #303 round 2:

1. The list filter used VisibleCollectionIDs alone — but that set
   includes collections containing any item-level grant for the user.
   A guest with one item granted in collection B would still receive
   attachment metadata for every item in collection B. Replaced with
   the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
   the SQL ORs collection-level full access against per-item grants,
   matching how handlers_search / handlers_activity narrow lists.

2. The delete endpoint validated workspace membership but never
   checked the attachment's parent item is visible to the caller.
   An editor with restricted collection access could delete
   attachments in hidden collections by guessing/obtaining the
   attachment ID. Added requireItemVisible after fetching the parent
   item, plus a fallback gate for orphan attachments (item_id IS
   NULL) so restricted users get 404 there as well.

Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.

* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)

Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.

Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.

Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.

* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)

Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.

Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.

UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).

Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
  restricted-to-correct-collection sees it, restricted-to-other-
  collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
  to pass on the handler side.)
2026-04-29 17:44:12 -04:00
xarmian 335762c2bf feat(attachments): storage usage API + effective-limit computation (TASK-881) (#302)
* feat(attachments): storage usage API + effective-limit computation (TASK-881)

Adds GET /api/v1/workspaces/{ws}/storage/usage returning
{used_bytes, limit_bytes, plan, override_active}. Resolves the effective
limit through the existing three-tier chain (per-user override → platform
setting → hardcoded plan default) and surfaces the override flag for the
upcoming Settings → Storage and admin user-detail UIs.

Implementation:
- store.WorkspaceStorageInfo consolidates SUM(size_bytes) + owner-plan
  resolution in one call; WorkspaceStorageLimit is now a thin wrapper so
  the upload-time quota check and the API path stay consistent.
- Server.storageInfoCache is a 30s TTL memoizer to absorb repeated
  Settings → Storage page loads. Invalidation hooks fire on upload,
  thumbnail derivation, and transform — the ~30s eventual-consistency
  window is bounded by TTL only when invalidation isn't reachable.
- Defensive copy on cache read so a caller mutating the returned struct
  can't poison subsequent reads.
- New CLI command `pad workspace storage` prints "X used of Y (Z%)" with
  IEC units (humanBytes helper) and surfaces the override flag.
- TS api.attachments.storageUsage() + WorkspaceStorageInfo type ready
  for TASK-882's Settings → Storage page consumer.

Tests:
- Store-level: no-owner fallback, free-plan resolution chain, override
  flip, pro-plan override-active visibility, soft-delete exclusion.
- Server-level: empty-workspace happy path, two uploads with cache
  invalidation between, dedicated cache TTL/invalidate/copy-safety test.

Parent: PLAN-866.

* fix(attachments): gate storage usage on viewer+ per Codex review (round 1)

Codex correctly flagged that the storage/usage handler relied solely on
RequireWorkspaceAccess, which admits item-grant guests with
workspaceRole=="guest". Workspace-wide quota numbers (used_bytes, plan,
override status) shouldn't surface to guests — every other workspace-
level read handler uses requireMinRole("viewer") for exactly this case.

Adds the explicit gate + a regression test that calls the handler with
a guest-role context and asserts 403.
2026-04-29 17:05:19 -04:00
xarmian 3bf1b60365 feat(attachments): editor image crop with aspect presets (TASK-880) (#298)
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.

Editor:
  - attachment-crop-modal.ts (new): pure-DOM crop modal in the same
    style as the existing image lightbox. Returns a Promise that
    resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
    when the user clicks Apply, or null on cancel / dismiss /
    image-load failure.
    - Image fits to a centered <dialog> via flex layout; backdrop
      click and Esc both cancel cleanly.
    - Crop rectangle starts at 80% of the image, centered. Body is
      a "move" handle; four corner handles resize.
    - Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
      current rect to the new ratio while preserving its center;
      subsequent corner drags clamp to the locked ratio.
    - Pointer events (touch + mouse for free) with setPointerCapture
      so drag continues even if the cursor leaves the handle.
    - Coordinate translation: rect in preview-pixel space →
      naturalWidth / offsetWidth scale → original-image pixel
      space. Result is clamped to natural bounds so a fractional-
      rounding overrun doesn't push the rect off-image.

  - attachment-image.ts: extracts swapNodeUuid() helper from
    runRotate so runCrop can share the setNodeMarkup +
    invalidate-old-metadata flow. The toolbar gains a fourth
    button (⌶ Crop…) that opens the modal pointed at the original
    variant. Per-format gating (refreshToolbarState) treats the
    crop button identically to the rotate trio — both go through
    /transform, so a libvips-only format (e.g. WebP on the pure-Go
    build) disables the whole toolbar with the same explanatory
    tooltip.

  - app.css: full styling for the crop modal — header with aspect
    toolbar, image stage with shadow-cutout overlay around the
    crop rect, four corner handles, footer with Cancel + Apply.
    Uses the existing CSS-variable palette so light/dark mode
    track automatically.

Server tests (3 new):
  - TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
    PNG crop, verify the response dimensions AND that the served
    bytes decode at the same dimensions (guards against an
    encode-pipeline off-by-one).
  - TestTransform_CropClipsToImageBounds: rect that extends past
    the image boundary clips rather than 400ing — the editor's
    rounding can produce rect+1px past natural width/height in
    rare fractional-scale cases, and the processor's Crop
    intersects with image bounds for exactly this reason.
  - TestTransform_CropRejectsBadRect: missing rect, zero width,
    negative xy, rect entirely outside → 400.

Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
2026-04-29 15:02:07 -04:00
xarmian f93b0ee4ce feat(attachments): server-side rotate transform + editor toolbar (TASK-879) (#297)
* feat(attachments): server-side rotate transform + editor toolbar (TASK-879)

Adds the POST /transform endpoint and the editor's rotate toolbar
on top of TASK-878's Processor abstraction. Rotation produces a NEW
content-addressed attachment row; the editor swaps the AttachmentImage
node's UUID via setNodeMarkup and the original ages into orphan GC.

Server (internal/server/handlers_attachments_transform.go):
  POST /api/v1/workspaces/{slug}/attachments/{id}/transform with
  body {operation, ...params}. Phase 1 wires the "rotate" branch
  (degrees: 90 | 180 | 270 only — pixel-exact reorderings, no
  resampling, matches what the editor emits). The "crop" branch
  is parsed and validated but the transform path is wired in
  TASK-880; defining the wire format here keeps both PRs aligned.

  Auth: editor+ on the workspace. Cross-workspace and deleted-parent
  probes return 404 (not 403) so the new endpoint can't become a
  side-channel for ID enumeration. Unsupported MIME → 415; oversized
  image → 413; bad params → 400; missing processor → 503. Output
  format follows the same PNG-stays-PNG / else-JPEG policy as the
  thumbnail pipeline so derived blobs deduplicate cleanly.

  Tests (10): rotate 90 swaps WxH, rotate 180 keeps WxH, bad degrees
  → 400, unknown op → 400, non-existent attachment → 404, cross-
  workspace → 404, no processor → 503, derived row has fresh hash +
  inherits workspace/uploader/item, served bytes decode at the new
  dimensions, deleted-parent → 404.

Web client (web/src/lib/api/client.ts + types):
  api.attachments.transform(slug, id, payload) hits the new endpoint
  with a discriminated AttachmentTransformRequest type. New
  api.server.capabilities() reads the public capability profile
  added in TASK-878. Both surface PadApiError on failure so the
  editor can show actionable messages.

Editor:
  - attachment-metadata.ts (new): shared HEAD-probe cache extracted
    from attachment-chip.ts so AttachmentImage's toolbar can probe
    the image's MIME with the same zero-extra-network-cost
    deduplication. Adds mimeToFormat() — maps MIME to the canonical
    short format name the server's Capabilities reports.

  - attachment-chip.ts: swapped to use the shared cache. Behavior
    unchanged.

  - attachment-image.ts: NodeView now wraps the <img> in a
    positioned <span> and lazy-builds a 3-button rotate toolbar
    (rotate left 90°, rotate 180°, rotate right 90°). selectNode
    shows it; deselectNode hides it. On click → calls
    options.transform → setNodeMarkup with the returned UUID at
    getPos(); cached metadata for the OLD UUID is invalidated.

    Per-button gating via refreshToolbarState: empty
    supportedFormats list (degraded build) → all disabled with a
    "this build doesn't have image processing" tooltip. MIME
    probed and not in supportedFormats → disabled with a format-
    specific tooltip ("Image editing for image/webp requires
    libvips"). Otherwise → enabled with the action tooltip.

  - Editor.svelte: configures AttachmentImage with the workspace
    slug, the supportedFormats list (initially empty, populated
    asynchronously after capabilities resolve), and the transform
    callback wired to api.attachments.transform. Errors surface via
    console.error + window.alert — same fallback as the upload
    plugin until a centralized toast system lands.

  - app.css: wrapper + toolbar styles. Toolbar pinned top-right with
    absolute positioning; selected-state ring on the image; disabled
    button state at 40% opacity.

Parent: PLAN-866. Unblocks TASK-880 (crop) — the /transform endpoint
already accepts the crop op shape, the editor's toolbar pattern is
the same, and the supportedFormats gating composes cleanly.

* fix(attachments): rotate attribution + toolbar refresh per Codex review (round 1)

Two findings from the round-1 Codex review:

1. The transform handler set UploadedBy = currentUserOrSystem(r),
   contradicting the comment that said "inherit attribution from
   the parent" and creating an audit-attribution drift whenever a
   user rotated/cropped someone else's upload. Inherit
   parent.UploadedBy instead — same policy as the thumbnail
   pipeline. Added TestTransform_DerivedRowInheritsUploadedByFromParent
   to lock in the contract. Removed the now-unused
   currentUserOrSystem helper.

2. The rotate toolbar's per-format gating could permanently stick
   in "all-disabled" state if the user selected an image before
   the async capabilities fetch resolved. supportedFormats started
   as [] (matching "no processor"), refreshToolbarState ran once
   in that state, and the later mutation of ext.options.
   supportedFormats had no observer to push the change down to
   already-open toolbar DOM. Fix: module-level toolbarRefreshers
   set, populated by each NodeView at ensureToolbar() and torn
   down in destroy(); a new notifyAttachmentImageCapabilitiesChanged()
   export iterates the set and re-runs each toolbar's refresh
   hook. Editor.svelte calls it after the capabilities fetch
   updates ext.options.supportedFormats, so any toolbar opened
   during the in-flight request snaps to its correct state the
   moment caps arrive.

Verification: go test ./internal/server -run TestTransform passes
(11 cases now); npm run check passes with the existing 6 warnings.
2026-04-29 14:54:48 -04:00
xarmian 7e5b15722f feat(attachments): editor paste + drag-drop upload plugin (TASK-875) (#294)
Tiptap extension that intercepts paste/drop events with files, uploads
each through the attachment API, and replaces the placeholder with the
right node (attachmentImage for image MIMEs, attachmentChip for
everything else) at the position the user dropped.

The plugin's flow:
  1. Detect file payloads in clipboardData.items / dataTransfer.files
     (skip the event when there are none, so plain text paste / cursor
     drag still go through tiptap-markdown's transformPastedText path).
  2. Insert a position-tracked placeholder at the paste/drop position
     via a setMeta transaction. Placeholders are widget decorations
     (zero document width) so they never enter serialized markdown
     even if the user navigates away mid-upload.
  3. Race the network. The plugin's apply() handler maps every
     placeholder position through every intervening transaction, so
     continued editing doesn't strand the spinner.
  4. On success: schema-aware replacement — attachmentImage for
     category=image, attachmentChip otherwise. The placeholder is
     removed in the same transaction.
  5. On error: remove the placeholder and surface the failure via the
     injected onError callback. The upload bytes that did land become
     orphans; orphan-GC reclaims them after the grace period.
  6. If the placeholder has been deleted before the upload completes
     (user cancelled, navigated away, etc.) the upload is dropped
     silently — same orphan-GC outcome.

Multiple files in a single drop fan out as concurrent uploads; each
gets its own placeholder and replaces independently as the network
completes.

Editor.svelte wires:
  - upload  -> api.attachments.upload(workspaceSlug, file)
              (rejects with a clear message when no workspace context)
  - onError -> console.error + window.alert as a minimal fallback
              until a centralized toast system lands.

Styles (app.css) cover the placeholder bubble (dashed border, faded
colour) and a CSS spinner — kept inline via decoration widget DOM so
ProseMirror's selection ignores it (ignoreSelection: true).

Parent: PLAN-866. Closes the editor-input flow on top of TASK-874
(markdown resolver), TASK-876 (image node), TASK-877 (chip node).
2026-04-29 13:41:05 -04:00
xarmian 934794b606 feat(attachments): editor file chip node (TASK-877) (#293)
* feat(attachments): editor file chip node (TASK-877)

Custom Tiptap node `attachmentChip` for non-image `pad-attachment:UUID`
references — Notion-style chip rendering with type icon, filename, and
human-readable size.

Node shape:
  - uuid:     string — the attachments-row UUID
  - filename: string — display name; preserved across save/reload

Markdown round-trip:
  - Serialize: `[filename](pad-attachment:UUID)` — same standard link
    syntax the markdown resolver in TASK-874 understands. `]` and `\`
    in the filename are escaped to keep the link label balanced.
  - Parse: markdown-it's link token produces
    `<a href="pad-attachment:UUID">filename</a>`. Our parseHTML rule
    `a[href^="pad-attachment:"]` runs at priority 1000 to beat
    SafeLink's default mark rule (priority 50), so attachment refs
    become a chip Node instead of a Link Mark on plain text.

Editor display (NodeView):
  - <a class="file-chip"> with icon + name + optional size span
  - Icon: filename-extension heuristic on first paint, upgraded to a
    MIME-based icon once a single HEAD request resolves the canonical
    Content-Type. The HEAD goes against the existing GET handler — no
    new API endpoint required, and Go's net/http strips the body
    automatically for HEAD.
  - Size: rendered from Content-Length once HEAD resolves; hidden
    until then (CSS `:empty { display: none }`).
  - Module-level Promise cache keyed by `${ws}:${uuid}` deduplicates
    repeated chips for the same attachment and survives undo/redo
    without re-fetching.
  - target=_blank + download attribute so a click opens / saves the
    file with its canonical filename.
  - atom: true → Backspace/Delete remove the chip as a single unit.

Styles in app.css (not Editor.svelte) so they reach the read-only
markdown render path too — TASK-874's Go and TS resolvers emit the
same `.file-chip` / `.file-chip-icon` / `.file-chip-name` /
`.file-chip-size` markup.

Parent: PLAN-866. Unblocks TASK-875 — the upload plugin needs a chip
node to insert on successful non-image uploads.

* fix(attachments): chip HEAD route + chip click handler per Codex review (round 1)

Two findings from the round-1 Codex review:

1. chi router does not auto-route HEAD to GET handlers, so the chip's
   metadata HEAD probe was returning 405 and chip size + MIME-refined
   icons never loaded. Fix: register HEAD on the same path/handler;
   http.ServeContent already strips the body for HEAD on the seekable
   path, and the streaming fallback short-circuits before io.Copy so
   future S3-style backends don't burn GetObject bandwidth on HEAD.

   Tests added: HEAD returns 200 with Content-Type + Content-Length
   and an empty body; HEAD cross-workspace returns 404 (not 403) so
   the new endpoint can't become a side-channel for ID enumeration.

2. Editor.svelte installs a global anchor-click suppressor that
   preventDefaults every <a> inside the editor, so the chip looked
   clickable but did nothing in edit mode. Fix: the chip's NodeView
   now attaches an explicit click handler that calls window.open with
   the download URL and stops propagation before the global handler
   runs. Mirrors the AttachmentImage lightbox click pattern.
2026-04-29 13:35:15 -04:00
xarmian f1ce9ca24a feat(attachments): editor inline image node (TASK-876) (#292)
Custom Tiptap node for inline `pad-attachment:UUID` image references.
Stores the attachment UUID (not a backend URL) so item content survives
a storage-backend migration untouched. See DOC-865.

Node shape:
  - uuid: string  — the attachments-row UUID (required)
  - alt:  string  — preserved across save/reload for accessibility

Markdown round-trip:
  - Serialize:  ![alt](pad-attachment:UUID)  via tiptap-markdown's
    addStorage.markdown.serialize, with [/] in the alt text escaped so
    brackets stay balanced.
  - Parse: markdown-it's default image token already produces
    <img src="pad-attachment:UUID" alt="…">, captured by parseHTML
    rule img[src^="pad-attachment:"]. The alternate parseHTML rule
    img[data-attachment-id] catches editor-rendered HTML on copy/paste.

Editor display:
  - addNodeView renders <img class="attachment-image" loading="lazy">
    pointing at /api/v1/workspaces/{ws}/attachments/{id}?variant=thumb-md
    via an injected getDownloadUrl callback (Editor.svelte resolves the
    workspace slug from page.params at mount time, falling back to the
    workspace store).
  - Single-click opens a native <dialog> lightbox with the original-
    resolution variant; multi-click events fall through so users can
    drag-select around the image.
  - atom: true means Backspace/Delete remove the image as a single
    unit and the cursor never lands inside the node.

Lightbox styles live in app.css because the <dialog> is appended to
document.body, outside Editor.svelte's scoped style block.

The configure() default returns the literal `pad-attachment:UUID`
href — sufficient for markdown round-trip in headless / SSR contexts
and a clearly-broken render in any environment that hasn't wired the
URL builder, which is the right signal to fix.

Parent: PLAN-866. Unblocks TASK-875 (the upload plugin needs a node
to insert on success).
2026-04-29 13:22:13 -04:00
xarmian 5af54ddc05 feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874) (#291)
* feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874)

Add the shared step that translates `pad-attachment:UUID` markdown
references into rendered HTML for image embeds, file chips, and missing
placeholders. Wired into the editor preview path; Go-side helpers seed
the future server-side rendering pipeline (export / shared item view).

TS side (`web/src/lib/markdown/attachments.ts`):
  - Pure helpers: parseAttachmentHref, attachmentDownloadUrl, isImageMime,
    formatAttachmentSize, renderAttachmentImage/Chip/Missing
  - resolveAttachmentImage / resolveAttachmentLink for the marked hooks
  - Image MIME → <img src=...?variant=thumb-md data-attachment-id=...>
  - Non-image MIME (or link syntax) → file chip with download attribute
  - Missing/deleted → "Missing attachment" placeholder span

`web/src/lib/utils/markdown.ts`:
  - renderer.image override (defaulting to marked's standard image when
    href is not pad-attachment:)
  - renderer.link checks for pad-attachment: prefix before the existing
    external/internal-link logic
  - renderMarkdown gains an optional attachmentResolver parameter; the
    resolver is threaded via a per-call module slot (synchronous render)
  - DOMPurify allowlist extended with data-attachment-id, download,
    width, height — ALLOW_DATA_ATTR stays false so only this single
    data-* attribute slips through

Go side (`internal/server/render/attachments.go`):
  - Mirror of the TS API so server-rendered output matches client output
    byte-for-byte for the same input
  - ResolveAttachmentReferences scans markdown source via regex,
    skipping fenced code blocks (backtick + tilde), substitutes both
    image and link forms
  - Comprehensive table-driven tests (24 cases) covering: href parsing,
    URL building, MIME detection, size formatting, image/chip/missing
    rendering, escape safety against script-tag injection in alt /
    filename / display text, fenced-code skip, tilde fences, title
    suffix on link destinations, nil resolver pass-through, no false
    positives on non-attachment URLs, deterministic round-trip

References are stored as opaque `pad-attachment:UUID` so a backend
migration (FS → S3) can rewrite storage_keys without touching item
content. See DOC-865 for the architecture.

Parent: PLAN-866 (Attachments Phase 1).

* fix(attachments): chip label double-escape + escaped-bracket lockstep per Codex review (round 1)

Two findings from the round-1 Codex review:

1. TS chip labels were double-escaped. renderer.link was passing the
   parseInline(tokens) HTML output to resolveAttachmentLink, which feeds
   it into renderAttachmentChip → escapeHtml. A label like
   `[**Report**](pad-attachment:id)` rendered literal
   `&lt;strong&gt;Report&lt;/strong&gt;` instead of plain text. Switched
   to the link token's raw `text` field; markdown emphasis inside chip
   labels now degrades to literal markers (acceptable for filename-style
   labels) and matches what the Go regex extracts.

2. Go regex didn't accept CommonMark `\]` / `\\` escapes inside link/image
   labels, so `[Q1 \] report](pad-attachment:id)` resolved on the TS side
   (marked handles escapes) but stayed literal on the Go side — breaking
   the documented lock-step contract. Updated the regex to accept escaped
   characters inside the alt/text capture, and added unescapeMarkdownText
   to mirror marked's behavior of dropping the backslash before the label
   reaches the render helpers.

Tests added: TestResolveAttachmentReferences_EscapedBrackets covers
image alt, link text, and combined backslash/bracket escapes;
TestUnescapeMarkdownText is the unit-level table for the unescape
helper (including dangling-backslash and non-punctuation pass-through).
2026-04-29 13:09:42 -04:00
xarmian fc1c47f124 feat(attachments): CLI + TypeScript clients + types (TASK-873) (#290)
* feat(attachments): CLI + TypeScript clients + types (TASK-873)

Rounds out the API surface with Go and TS client methods + a
\`pad attachment\` Cobra subcommand for ops debugging.

internal/cli/client.go
  AttachmentUploadResult struct mirrors POST /attachments JSON.
  UploadAttachment streams a multipart file part via io.Pipe — never
  buffers the upload in memory. itemRef is optional. Uses a fresh
  http.Client with a 5-minute timeout per request so a 25 MiB upload
  over a constrained link doesn't trip the package-shared 10s default.
  DownloadAttachment streams the bytes into the caller's writer,
  returning Content-Type + total bytes copied. Optional ?variant=
  parameter for thumbnails (server falls back to original silently
  per TASK-872).

cmd/pad/main.go
  pad attachment upload <item-ref|-> <path> [--filename NAME]
  pad attachment download <id> <out|-> [--variant thumb-sm|thumb-md]

  Item arg accepts an issue ref (TASK-5) or slug; "-" means no parent.
  Out arg "-" streams to stdout (with status messages on stderr) so
  callers can pipe into image viewers etc. Resolves the item via
  GetItem first so a typo'd ref fails fast with a useful error.

  List + delete subcommands intentionally omitted — those endpoints
  ship with TASK-881 (storage usage) and the future GC task. Adding
  client methods that hit 404s would mislead callers; same logic kept
  the upload response's "url" out of TASK-871 until TASK-872 wired GET.

web/src/lib/types/index.ts
  Attachment interface mirroring the Go model (pointer types → optional).
  AttachmentUploadResult interface for the upload response shape.

web/src/lib/api/client.ts
  api.attachments.upload(workspaceSlug, file, itemId?) — multipart
  POST via direct fetch (skips shared request() because that helper
  hard-codes Content-Type: application/json). Carries CSRF, cookies,
  and the same 401 → /login redirect.
  api.attachments.downloadUrl(workspaceSlug, attachmentId, variant?)
  is a pure URL builder so callers can wire <img src> directly without
  going through fetch.

End-to-end smoke verified:
  pad attachment upload TASK-869 /tmp/tiny.png   # uploads PNG
  pad attachment download <id> /tmp/dl.png       # bytes are identical
  cmp /tmp/tiny.png /tmp/dl.png                  # PASS

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass
  cd web && npm run build — clean
  make install — server restarts on the new binary

Parent: PLAN-866.

* fix(cli): atomic download — write to temp + rename so a failed download doesn't truncate the destination per Codex review (round 1)

P2: pad attachment download <bad-id> /existing/file used to wipe the
existing file on auth/network/404 errors because os.Create truncated
before the request was even attempted. The bytes were never written
because DownloadAttachment errored out, but the destination was
already 0 bytes — a footgun for anyone running the CLI in scripts.

Fix: for the file-path case, write to a sibling .tmp via os.CreateTemp
in the destination directory, fsync, close, then os.Rename only on
success. Same atomic-write pattern as FSStore.Put. The defer cleans
up the .tmp on any error path.

The stdout case (outPath == "-") is unchanged — bytes already
streamed to stdout can't be rolled back, so any partial write is
just visible to the caller as a short payload.

Verified end-to-end:
  echo X > /tmp/existing.png
  pad attachment download not-a-real-id /tmp/existing.png  # errors
  cat /tmp/existing.png   # still "X" — file untouched

* docs(cli): clarify os.Rename atomic-replace behavior on Windows (Codex round 2 disagreement)

Round 2 flagged this as P2: "os.Rename does not replace an existing
destination on Windows." That is technically incorrect for modern Go.

Verified directly against the Go stdlib source:

  src/internal/syscall/windows/syscall_windows.go:
    func Rename(oldpath, newpath string) error {
      ...
      return MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)
    }

MoveFileEx with MOVEFILE_REPLACE_EXISTING atomically replaces an
existing destination on Windows. This has been the behavior since
Go 1.5 (2015), so every version of Go this codebase supports already
gets the desired replace-on-rename semantics on every platform.

Added an inline code comment so future readers don't worry about the
same false alarm. No code-path change.
2026-04-29 12:48:57 -04:00
xarmian 2f58193f22 chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install
links were placeholders pointing at GitHub README anchors while
TASK-863's docs page didn't exist yet. That page is now live at
getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the
three URLs to the real docs:

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

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

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

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

Server:

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

Web:

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

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

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

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

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

Two findings from Codex review on PR #284:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes:

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

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

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

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

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

Two findings from Codex review on PR #283:

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

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

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

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

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

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

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

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

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

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

Fixes BUG-855.

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

Two findings from Codex review of PR #281:

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

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

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

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

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

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

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

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

Source: IDEA-848.
Parent: PLAN-833.
2026-04-28 23:33:01 -04:00