Commit Graph

233 Commits

Author SHA1 Message Date
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
xarmian 43b2565afe fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Codex round-1 findings on TASK-836:

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

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

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

Parent: PLAN-833.

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

Codex round-2 findings on TASK-836:

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

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

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

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

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

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

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

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

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

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

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

Closes PLAN-825's UI work.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reproducibility:

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

To regenerate:

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

Notes:

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

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

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

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

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

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

Implements IDEA-760.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Spec: IDEA-758.

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

Per Codex review on PR #244, round 1:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Parent: IDEA-753.

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

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

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

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

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

Implements IDEA-753.

Parent: IDEA-753.

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

Round 1 Codex findings (TASK-755):

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

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

Parent: IDEA-753.

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

Round 2 Codex findings (TASK-755):

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

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

Parent: IDEA-753.

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

Round 3 Codex finding (TASK-755):

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

Parent: IDEA-753.

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

Round 4 Codex finding (TASK-755):

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

Parent: IDEA-753.

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

Round 5 Codex findings (TASK-755):

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

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

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

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

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

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

Implements IDEA-753.

Parent: IDEA-753.

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

Round 1 Codex findings (TASK-754):

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

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

Parent: IDEA-753.

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

Round 2 Codex findings (TASK-754):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Address two LOW findings from Codex review of #235:

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

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

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

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

### Login page (/login)

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

### Settings page (/console/settings)

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

### Why this is a beta blocker

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

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

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

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

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

Changes on /console/billing:

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

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

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

Addresses PR #233 round 1 findings:

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

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

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

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

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

Changes:

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

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

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

- ActionPaymentFailedEmailSent audit constant for the new entry.

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

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

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

Addresses PR #232 round 1 findings:

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

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

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

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

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

Addresses PR #232 round 2 findings:

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

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

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

Addresses PR #232 round 3 LOWs:

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

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

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

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

Changes on /console/billing:

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

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

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

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

Addresses PR #231 review findings:

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

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

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

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

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

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

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

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

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

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

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

Addresses PR #229 review findings:

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

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

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

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

Addresses PR #229 round 2 finding:

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

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

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

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

Addresses PR #229 round 3 finding:

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

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

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

Addresses PR #229 round 4 findings:

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

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

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

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

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

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

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

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

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

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

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

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

Parent: PLAN-644.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

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

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

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

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

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

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

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

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

Parent: PLAN-643 (OSS Security Hardening).

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

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

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

Parent: PLAN-631.

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

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

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

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

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

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

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

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

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

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

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

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

Parent: PLAN-631.

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

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

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

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

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

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

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

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

Parent: PLAN-631.

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

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

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

Per Codex review on PR #163.
2026-04-20 14:37:10 -04:00