mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
77dcd07ecda3c72ee62af2eaf0d8d5a38374759f
813 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
77dcd07ecd |
feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735) Toolbar grows a type-ahead search (ref/title over the post-filter node list; ArrowUp/Down + Enter picks, Escape closes without stealing the page's deselect) that routes through the existing selectNode() — same camera fly-to, highlight, and detail card as a click. Client-side filters subset the rendered graph: collection chips with palette dots, status chips, and a role select (hidden when no node carries a role; the graph endpoint now emits the assigned agent-role slug per node). Edges survive only when both endpoints do; counts read "X of Y" while filtered. Workspace switch resets filters; show-completed doesn't. Filter changes deselect so a vanished node can't strand focus mode. New GraphToolbar.svelte owns the presentational toolbar; the page owns authoritative filter state (CONVE-1688 discipline unchanged). Parent: PLAN-1730. * fix(web): close graph search dropdown on blur per Codex review (round 1) The dropdown opened on focus/input but only closed on pick or Escape, leaving stale results floating over the canvas after clicking away. The result buttons already pick on mousedown+preventDefault, so the input never blurs mid-pick — a plain onblur close is safe. * fix(web): gate search Escape on dropdown visibility per Codex review (round 2) Escape in a focused-but-empty search now falls through to the page-level deselect instead of being swallowed by the searchOpen flag. |
||
|
|
1c3db435a3 |
feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)
* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) Clicking a node now enters focus mode instead of navigating away: the camera flies to the node (800ms, standard distance-ratio pattern with at-origin guard), its neighborhood (any shared edge type) keeps full color while everything else dims to low-alpha, adjacent links brighten and the rest fade hard. A DetailCard slides in from the right: collection dot + ref + title, status pill (terminal styling), child count, relative updated-at, plus priority/assignee fetched lazily via the items API (stale-select token), and the "Open item" button carrying the old click-through navigation. Deselect via background click, Escape (only when a selection is active), or automatically when the workspace/show-completed payload changes. Selection sets stay plain non-reactive lets per CONVE-1688; accessor re-evaluation is explicit via graph.refresh(). Parent: PLAN-1730. * fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1) The force layout mutates link source/target from ref strings into node objects after ingest, so linkColor's adjacency check against selectedRef silently failed once the simulation ran. Preserve the raw refs as sourceRef/targetRef at mapping time and compare those. |
||
|
|
db3917f6d2 |
feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733) (#701)
* feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733)
New full-viewport graph page at /{username}/{workspace}/graph rendering
the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through
a dynamic import inside onMount, landing in its own ~1.3MB chunk
referenced solely by the graph route node — entry bundle unchanged.
Node color = collection (local hex palette; the chart PALETTE's CSS
vars can't reach WebGL), node size = 1 + 2×child_count, blocks edges
red with directional arrows, structural links brighter than soft ones.
Click navigates to the item page (ResolveItem accepts refs in the slug
param). Active items by default with a "Show completed" toggle that
refetches in place; workspace switches refetch with a stale-response
guard. Teardown via _destructor + ResizeObserver disconnect.
Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary
destinations, getActiveKey) and a Sidebar entry after Insights — the
mobile More sheet picks it up from the shared source automatically.
Parent: PLAN-1730.
* fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1)
1. The renderer-sync effect now pushes empty graphData when the
reactive payload is null (workspace switch in flight / load error)
instead of early-returning — the previous workspace's nodes no
longer linger behind the loading overlay.
2. 'graph' added to reservedCollectionSlugs so a collection can't
shadow the /{username}/{workspace}/graph route, matching the
frontend's RESERVED_SLUGS.
|
||
|
|
a20095e870 |
feat(web): graph TS types + API client method (TASK-1732) (#700)
GraphNode / GraphEdge / GraphResponse types mirroring the
GET /workspaces/{ws}/graph payload (TASK-1731), and
apiClient.graph.get(ws, includeTerminal) to fetch it.
Deliberately web-only: no CLI command, no MCP action — the graph
payload is a rendering data feed for the upcoming /graph route, not
an agent surface.
Parent: PLAN-1730.
|
||
|
|
93220845a0 |
feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731) (#699)
* feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731)
GET /api/v1/workspaces/{ws}/graph returns the whole workspace as
{nodes, edges} in one call, feeding the 3D graph view (PLAN-1730).
Nodes carry ref/title/collection/status/is_terminal/child_count/
updated_at; edges are typed (parent | blocks | implements | related |
wiki-link), with wiki-link edges sourced from the PLAN-1593 reverse
index, deduped per pair, self-links dropped.
Default response is active items only; ?include_terminal=true returns
the full history. Visibility follows the dashboard model (collection
visibility + guest item-level grants), and edges are filtered to the
visible node set so hidden items can't be inferred from dangling
endpoints.
Parent: PLAN-1730.
* fix(server): normalize graph edge types to advertised vocabulary per Codex review (round 1)
item_links can carry split_from / supersedes / wiki_link beyond the
documented enum. Map stored types to the hyphenated graph vocabulary
(wiki_link → wiki-link, split_from → split-from), dedupe (source,
target, type) so a stored wiki_link row and a parsed [[...]] mention
of the same pair emit once, and document the full edge enum. Unknown
future link types pass through rather than being dropped.
* fix(store): close graph edge enum against unknown link types per Codex review (round 2)
Route stored link types through models.NormalizeItemLinkType; values
it rejects (possible via the import path — no DB CHECK on
item_links.link_type) degrade to 'related' instead of leaking
undocumented edge types past the advertised vocabulary.
|
||
|
|
9dc46fddbf |
chore: bump Go toolchain to 1.26.4 (TASK-1739) (#698)
govulncheck (make vuln + the CI gate) is red on main: GO-2026-5039 (net/textproto error escaping) and GO-2026-5037 (crypto/x509 hostname parsing), both reachable from our call graph and both fixed in go1.26.4. CI's setup-go resolves "1.26" to the latest patch, so only go.mod needs the bump. |
||
|
|
299119403c |
test(e2e): blog screenshot capture for BLOG-1704 (pad v0.7) (#696)
Adds a PAD_BLOG_SCREENSHOTS-gated describe block that seeds a tasks collection, creates a public collection share link, and captures the /s/<token> board view — the source for the v0.7 post's cover/og. |
||
|
|
3704cc2c9f |
fix(store): cast jsonb metadata to text for Postgres LIKE + gofmt (BUG-1702) (#693)
The status-transition backfill query used `a.metadata LIKE '%→%'`, but activities.metadata is jsonb on Postgres where LIKE (~~) is undefined, failing TestBackfillStatusTransitions(_SeedSeqBelowHop) and erroring in any Postgres deployment. Cast to ::text on Postgres (dialect-guarded), matching AttachmentReferenced. Also gofmt comment.go + the share-links test that were tripping golangci-lint.v0.7.0 v0.7.0-rc.1 |
||
|
|
5c491b78b5 |
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* feat(web): mobile bottom navigation + quick-capture (PLAN-1694)
Add a mobile-only persistent bottom navigation bar that coexists with the
existing TopBar. Five slots: Dashboard, Search, Quick-capture (center +),
Activity, More. The "More" slot opens a BottomSheet with the sidebar's
overflow destinations + collections; the center action opens a quick-capture
sheet that creates an item via the existing API.
- New shared nav source (lib/nav/destinations.ts) consumed by both the
desktop Sidebar and the new BottomNav/More sheet so the two can't drift;
Sidebar refactored to derive active-state from it (lossless).
- BottomNav mounts in the workspace layout (workspace-only), shows only on
mobile (uiStore.isMobile, ≤768px), and toggles body.has-bottom-nav so
app.css reflows .main-content above the fixed bar.
- iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding.
- z-index 40 (above TopBar/sidebar, below sheets).
Header retirement + full "You" (workspace/account) consolidation are
deliberately deferred to a human design checkpoint (DR-3).
Verify: web npm run check (0 errors) + npm run build (✓). Codex review loop
clean (1 P2 fixed: quick-capture re-defaults a stale collection slug).
Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28.
* feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694)
Complete the mobile nav vision on top of the bottom bar: inside a workspace
the global mobile header is gone, replaced by the BottomNav + a "You" sheet +
a contextual back/title bar on detail screens.
- BottomNav 5th slot More → You (👤). New YouSheet composes the workspace
switcher, the nav overflow, and the full account menu carried over from the
retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/
Sign out) so nothing is lost.
- Root layout gates <TopBar mobile /> on !inWorkspace (page.params.workspace),
keeping it for the non-workspace picker/home which has no BottomNav; the
.app-layout top offset now applies only via body.has-mobile-topbar.
- New MobileContextBar: fixed back+title bar shown only on detail screens
(path depth ≥2); body.has-context-bar reflows .main-content top. Root/tab
screens render full-height (reclaimed space).
Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check
via afterNavigate (deep-link fallback to parent URL), humanized title fallback
for pages that don't wire titleStore, and the You sheet closes on navigation
so a workspace switch dismisses it.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28.
* feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694)
Address design feedback: the previous "You" sheet reused common/BottomSheet
(covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted).
Replace it with two purpose-built surfaces that dock ABOVE the nav.
- DockedSheet (new): bottom sheet anchored above the bottom nav — backdrop
stops at the nav's top edge so the bar stays visible + tappable and the
active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down /
tap-out / Escape to dismiss. No more full-screen overlay covering the nav.
- Bottom-nav slot 1 Dashboard → Workspace (icon = current workspace avatar),
opening WorkspaceSheet: a designed switcher card (inline-expand list) +
Navigate tile grid + Collections.
- "You" slot → YouSheet, now account-only: profile header, theme toggle,
account Settings / Workspaces / Billing / Admin / Connect / Resources /
Sign out. Search / + / Activity unchanged.
- New avatar util (avatarColor/avatarInitial) for workspace + user avatars.
Splits the overstuffed single sheet into two focused surfaces (where-am-I vs
me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
* feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694)
Tapping the Workspace or You nav slot a second time now closes its open
sheet (tap to open, tap again to close). Opening one still closes the other.
The docked backdrop stops above the nav, so the slot button stays tappable
while its sheet is open.
Refs PLAN-1694 (TASK-1701).
* feat(web): dock mobile search to match the nav sheets (PLAN-1694)
The CommandPalette was the last mobile surface that didn't match — a
full-screen takeover (square, no handle, hid the nav). Give its mobile
presentation the same docked treatment as the Workspace/You sheets;
desktop ⌘K is untouched.
- Mobile: dock the palette above the bottom nav (backdrop stops at the nav's
top edge so the bar stays visible), rounded top, grab handle with
swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only
when it's present (:global(body.has-bottom-nav)); flush to bottom on the
non-workspace picker. Keyboard handling preserved: input stays at the sheet
top, .results is the sole scroll area, min-height keeps the input clear of
the keyboard when empty.
- BottomNav: Search slot toggles (tap to open, tap again to close) and lights
up while open; the three surfaces (Workspace / You / Search) are now
mutually exclusive.
Codex review CLEAN. Verify: web npm run check (0 errors) + build (✓).
Note: on-screen-keyboard feel should get an on-device smoke test.
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
|
||
|
|
72d8963c4c |
fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:
1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
(handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
lazily fetch resolved content the first time a diff version is expanded.
2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
buildTimeline now collapses uninterrupted collab-snapshot bursts (within
10 min, no intervening event) to their newest entry, and the source badge
renders as "Autosave" instead of the raw slug.
Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
|
||
|
|
ae8173b42d |
fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618) (#690)
* fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618) resolverWorkspaceRole returned "owner" for any platform admin regardless of auth surface, so a bearer-borne admin (PAT / CLI / MCP) could probe the existence of refs in workspaces they never joined via the /-/r/ 302 redirect — leaking workspace + ref existence plus the owner username and collection slug in the redirect target. Site 1 (real fix): thread isBearerAuth(r) into resolverWorkspaceRole and gate the admin branch on !authIsBearer; the workspace-owner check stays unconditional. Bearer-admins fall through to the member-then-grants check (membership-only stance, matching BUG-1616/1617). Cookie-session admins keep the owner bypass so the web-UI affordance is preserved. Added TestRefResolver_AdminBearer_404OnNonMemberWorkspace (bearer -> 404) and TestRefResolver_AdminCookie_StillRedirects (cookie -> 302). Site 2 (audit, no logic change): the workspace sort-order bulk-update's silent-skip needs no auth gate — UpdateWorkspaceSortOrder is scoped to the caller's own workspace_members row, so a non-member PATCH touches zero rows (no cross-ws write or leak), and handleListWorkspaces has been membership-only for all authenticated users including admins since BUG-982. Rewrote the stale comment to record both facts. Parent: BUG-1617. Sibling: BUG-1616. * fix(server): deny bearer-admin grant fallback in resolver per Codex review (round 1) A bearer admin who isn't a member but holds a stray collection/item grant got "guest" from resolverWorkspaceRole, then checkItemVisible's own `user.Role == "admin"` bypass returned visible — reopening full resolver access + 302 URL leakage the BUG-1618 fix was meant to close. Add the membership-only guard (return "" for bearer-admin non-members before the grant fallback), matching RequireWorkspaceAccess and the SSE/collab sibling gates. New regression test TestRefResolver_AdminBearer_404EvenWithGrant. |
||
|
|
d2e80efefc |
fix(web): reflow page content when sidebar collapses (BUG-1651) (#689)
Desktop .sidebar.collapsed only applied translateX(-100%), which slides the sidebar off-screen but keeps its column reserved in the .app-shell flex row — .main-content (flex: 1) had nothing to expand into, leaving a blank gap. Release width/min-width on desktop-collapsed so the flex row reflows; scoped :not(.mobile) since the mobile sidebar is position: fixed and uses its width to drive the slide-in drawer. Width animates alongside the existing transform transition for a smooth close. |
||
|
|
a5c7fc986e |
fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.
Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.
Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).
Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
|
||
|
|
034b540a77 |
fix(web): de-reactify open-transition trackers in 3 more modals/banners (#687)
Sweep follow-up to BUG-1687. ConnectBanner, CreateCollectionModal, and ConnectWorkspaceModal each tracked their open→close transition with a `$state` variable (`prevOpen`/`lastOpen`) that their `$effect`/`$effect.pre` both read and wrote — the same self-invalidating pattern that silently wedges Svelte's effect scheduler in production builds on close. Make each tracker a plain `let` (they're only used for edge-detection inside the effect), so the effects depend solely on `open`/`connectOpen` and never re-trigger themselves. Found via a codebase-wide audit; these were the only other genuine matches. |
||
|
|
a4e0eb13d8 |
fix(share): stop ShareDialog effect from wedging the scheduler in prod (#686)
ShareDialog tracked the open transition with `let prevOpen = $state(false)` and an `$effect.pre` that both read and wrote `prevOpen` — a self-invalidating effect. In dev this trips `effect_update_depth_exceeded`; in a production build the guard differs and it silently wedges Svelte's global effect scheduler the moment `open` flips on close. Result: after opening+closing the share dialog, effects stop flushing app-wide — clicks change the URL but the view never re-renders (no error, no CPU spin). Make `prevOpen` a plain variable (it's only used for edge-detection inside the effect), so the effect depends solely on `open` and never re-triggers itself. |
||
|
|
d29392443a |
feat(share): inline read-only row expand in public shared view (TASK-1684) (#685)
* feat(share): inline read-only row expand in public shared view (TASK-1684)
Clicking an item row/card in the public `/s/[token]` collection view now
expands the item's fields + sanitized markdown content INLINE on the same
page — no navigation. Items aren't individually shared, so a link would 404
or bypass item-level share ACLs; the expansion is gated by the same share
link and is strictly read-only.
- New PublicItemExpansion panel renders fields + content; it does NOT
sanitize — it receives pre-sanitized HTML from the route's single
marked()+DOMPurify pipeline (the same one the single-item share view uses),
so there's no new {@html} XSS surface.
- Wired `expandable`/`onactivate`/`expandedKey`/`renderContent` through
PublicCollectionView → board/list/table renderers. Toggle on re-click.
- Keyboard accessible: Enter/Space toggles, aria-expanded + aria-controls on
each interactive row/card.
- Works across all three views: board card, list row, and table row (the
table panel spans the full grid width as a presentation row).
- Per-item sanitized HTML is memoized; expansion collapses on view switch.
Parent: PLAN-1677.
* fix(share): render literal field values verbatim, label-format only categorical fields per Codex review (round 1)
formatLabel() was title-casing/de-hyphenating every expanded field value,
corrupting literals like dates (2026-05-31), slugs, IDs, and URLs. Now only
status/priority/select values get label-formatting + color; all other fields
render verbatim, matching the single-item share view.
|
||
|
|
b422988601 |
feat(share): schema-driven colors + empty/large-collection states (TASK-1683) (#684)
Phase 3 polish for the public shared collection view: - Schema-driven status/select colors via fieldValueColor(): canonical open/in_progress/done/blocked palette matches the owner exactly, and a custom terminal option (schema terminal_options, e.g. shipped/closed) now reads as "done" green in cards, list rows, table cells, and board column accents — the bare literal switch missed these. Table colors any select-type column, not just status/priority. - Polished empty state (icon + title + hint, dashed card) shared across all three view types. - Large-collection cap: PUBLIC_ITEM_CAP=200 with a visible "showing N of M" banner (no silent truncation). Applied after saved-view filtering so the count reflects the visible set. - Documented the saved-view sort deferral: payload carries no sort_order/timestamps, so list_sort_by stays a no-op (owner's sort_order order is already honored); a faithful field sort is a backend/payload decision. Parent: PLAN-1677. |
||
|
|
d4ff473715 |
feat(share): read-only view switcher on shared collection route (TASK-1682) (#683)
* feat(share): read-only view switcher on shared collection route (TASK-1682) Add a read-only Board/List/Table view switcher to /s/[token]. Anonymous viewers can toggle the presentation of a shared collection; the choice drives PublicCollectionView's `view` prop, overriding the owner's settings.default_view (which is the initial selection, falling back to 'list'). Saved views from the share payload (collection.views) surface as selectable chips alongside the three base types; selecting one resolves through its view_type (kanban -> board) to a base renderer. Purely presentational — no mutation affordances. Selection persists via the ?view= URL param + localStorage keyed by share token, mirroring the logged-in collection page's saveViewMode/loadSavedViewMode pattern (read-only context, so no server save). URL precedence: ?view= > localStorage > owner default. Parent: PLAN-1677. * fix(share): apply saved-view config (grouping + filters) per Codex review (round 1) Saved-view chips previously only pinned the base view_type, ignoring the view's config — so a public saved view could show the whole collection in the wrong grouping. Now overlay the saved view's config onto the rendered output (read-only): config.group_by maps to board_group_by/list_group_by per active base type, and config.filters (eq/in) narrow the item set. Render path feeds derived parsedCollection/parsedItems to PublicCollectionView. Parent: PLAN-1677. * fix(share): graceful filter skip + correct URL persistence per Codex review (round 2) - Saved-view filters now skip fields absent from the public payload (share items carry only `fields`, not top-level tags/parent/phase), so a saved view filtering on tags no longer hides the entire collection; it degrades to showing the superset. - syncUrl drops ?view= only when the base selection equals the owner default (not hard-coded 'list'), so picking List on a board-default share produces a link that reproduces the choice. Parent: PLAN-1677. * fix(share): schema-based filter evaluability + URL normalization per Codex review (round 3) - Decide saved-view filter applicability from the public collection schema, not item values: a filter on a schema field stays applied even when no shared item sets it (returns none, matching the owner). Only fields genuinely absent from the public schema (tags/parent/phase) are skipped. - initSelection normalizes the URL after resolving, so a stale/invalid ?view= (e.g. deleted saved view) no longer leaves the address bar describing a view the page isn't rendering. Parent: PLAN-1677. |
||
|
|
be53856223 |
feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)
Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.
Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.
Parent: PLAN-1677.
* fix(share): pin distinct view sort_order in test per Codex review (round 1)
CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.
Parent: PLAN-1677.
|
||
|
|
ae055ab412 |
feat(share): render matching view on /s/[token] (TASK-1680) (#681)
Wire PublicCollectionView into the public share page so a shared
collection renders the owner's chosen view type (board/list/table,
falling back to list) instead of a hardcoded flat list. The raw
`collection` + `items` branches of the share payload are passed
straight through — the component parses settings/schema/fields
defensively. expandable={false} for now (inline expand is TASK-1684).
Add a typed SharePayload (+ PublicShareCollection/Item/Settings) to
$lib/types and annotate api.share.get's return type to reflect the
enriched payload (collection.settings, collection.schema,
items[].content).
Existing share-page chrome (header/footer/password/auth states) is
untouched; only the collection BODY rendering swaps. Dead collection
row/header CSS removed.
Parent: PLAN-1677.
|
||
|
|
a58c64fb07 |
feat(web): public read-only collection view renderers (TASK-1679) (#679)
* feat(web): public read-only collection view renderers (TASK-1679)
Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.
New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
fields tolerated as JSON string OR object, since TASK-1678 ships in
parallel) + label/status/priority color + grouping helpers lifted
from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
renderers; board groups by board_group_by|status, list optionally
groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
and switches on settings.default_view, delegating to a leaf renderer.
Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.
Not wired into any route yet; npm run build passes.
Parent: PLAN-1677.
* fix(web): stable unique each-keys for public renderers per Codex review (round 1)
PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.
Parent: PLAN-1677.
|
||
|
|
873d351e24 |
feat(server): enrich collection share payload with settings, schema, item content (TASK-1678) (#680)
The public collection share-link resolver (`handleResolveShareLink`,
`collection` branch) previously returned only `{name, icon, description}`
plus a flat `{title, ref, fields}` per item. The public viewer at
`/s/{token}` therefore could not reproduce the owner's chosen view
type, grouping, field labels, or status colors, and had no body to
show for an inline read-only row expand.
Enrich the public collection DTO with:
- `collection.settings` — a presentation-only projection of
CollectionSettings (`layout`, `default_view`, `board_group_by`,
`list_sort_by`, `list_group_by`), emitted as a parsed JSON object.
The authoring-only fields (`quick_actions`, `content_template`) are
deliberately excluded from the public path.
- `collection.schema` — the parsed CollectionSchema object
(`fields[]` with key/label/type/options/terminal_options/suffix),
emitted as an object rather than a raw JSON string.
- `items[].content` — each item's markdown body, for the inline
read-only row expand decided in TASK-1684.
Both settings and schema are parsed defensively: a malformed stored
JSON blob is simply omitted from the response rather than failing the
resolve. No internal IDs, creator info, workspace internals, or
timestamps are exposed. Adds an HTTP-level test asserting the enriched
shape and guarding against leakage of forbidden tokens.
Frontend integration (consuming this shape) is TASK-1680; security
review of the content exposure is tracked in TASK-1685.
Parent: PLAN-1677.
|
||
|
|
634e834f22 |
feat(board): Trello-style inline draft card creation (TASK-1676) (#678)
* feat(board): Trello-style inline draft card creation (TASK-1676) Replace the lane `+` direct-navigate (TASK-1671) with an inline draft card. Clicking `+` (or "Add item here") opens an editable card in the lane — no item exists yet. Enter creates it (group value pre-filled) and opens it; blur keeps the card without saving; Escape hides it but retains the text so reopening restores it (until reload). Drafts are per-lane and in-memory. A beforeNavigate guard intercepts in-app navigation while any lane has unsaved draft text and shows a Save / Discard / Stay dialog: Save creates all pending drafts (in place, no navigate) then proceeds, Discard drops them, Stay cancels. Reload/tab-close aren't guarded (nav.to is null) — drafts intentionally live only until reload. quickCreateInColumn now takes (groupValue, title, navigate) and returns the created item / throws so the draft can be restored on failure. The draft card renders above the dndzone so it isn't draggable. * fix(board): lift draft state to page + clear-each-on-save per Codex review (round 1) (1) BoardView unmounts on a board↔list view switch, which destroyed unsaved inline drafts and bypassed the leave guard. Move the draft state (draftText/draftOpen) to the page and bind it into BoardView, and move the beforeNavigate guard + Save/Discard/Stay dialog to the page (always mounted). A draft now survives view switches (state persists; the card just isn't rendered off-board) and the guard fires regardless of view. (2) leaveSaveAll cleared all drafts only after the whole loop, so a retry after a partial failure re-created already-saved drafts. Clear each draft as its create succeeds. * fix(board): only guard true leaves, not same-page URL syncs per Codex review (round 2) beforeNavigate fired on every goto while a draft existed — including the replaceState query/view-state syncs from updateUrlFilters (view toggle, filters, search). Switching Board→List or tweaking a filter wrongly opened the Save/Discard dialog. Skip navigations whose destination pathname equals the current one (internal same-page sync); the draft survives those as page state, so only true page leaves are guarded. * fix(board): don't guard willUnload navigations per Codex review (round 3) For native/external navigations (willUnload), goto can't replay the leave after cancel, so Save/Discard could strand the user. Skip guarding willUnload (and full-unload) navigations — treated like reload, drafts intentionally lost — so the replay path is always a client-side goto. * fix(board): replay Back/Forward with history.go to preserve history per Codex review (round 4) Cancelling a popstate (Back/Forward) navigation then replaying via goto(url) pushed the target as a new history entry, corrupting Back. Capture nav.type/nav.delta and replay popstate with history.go(delta); non-popstate leaves still replay with goto. |
||
|
|
710c76fb66 |
feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)
Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral per-lane override on top of the page-wide sort. BoardView holds the override map (Record<lane, SortMode>, not persisted), applies the effective mode (override ?? page default) per lane in propColumnData, and disables drag per-lane under a non-manual effective sort. The submenu lists the same options as the toolbar (Priority hidden when no priority field) plus a "Page default" entry to clear the override, with a check on the active one. Sort is a view preference, available to everyone — so the kebab now shows for any non-empty lane (not just editors), and the menu's separators are section-gated so nothing dangles when a viewer sees only Sort. |
||
|
|
2cd8932148 |
feat(board): confirm + undo toast for destructive bulk actions (TASK-1674) (#676)
* feat(board): confirm + undo toast for destructive bulk actions (TASK-1674 frontend) Extend the toast store with an optional inline action button (label + onAction) and render it in ToastContainer. Wire Undo onto the two destructive bulk lane actions: - Archive all → "Archived N" with Undo → bulk `restore` of the affected ids (the backend op from the companion PR). - Move all to → "Moved N" with Undo → bulk move back to the source lane (all lane items shared its status). runBulk gains an optional undo callback (invoked with the affected ids) and gives undo-bearing toasts a longer 7s dwell. Archive keeps its in-menu count confirm (TASK-1672); the undo toast is the new safety affordance replacing the old single-item inline confirm. * fix(board): undo targets the original workspace per Codex review (round 1) The 7s undo toast is global and runBulk read the live wsSlug, so navigating to another workspace before clicking Undo would target the wrong one. Thread an explicit captured `ws` through runBulkOn; the archive/move handlers capture wsSlug at action time and the undo closure reuses it, so a deferred undo always hits the workspace the action ran on. Immediate (non-undo) actions keep a thin runBulk bound to live ws. |
||
|
|
1d9a611508 |
feat(api): bulk restore op for undo (TASK-1674 backend) (#675)
Add a 'restore' verb to the bulk endpoint so an undo of a bulk archive is one call. The loop resolves include-deleted for restore (archived rows are hidden from ResolveItem); applyBulkOp calls store.RestoreItem, mapping UNIQUE-constraint races to a conflict and sql.ErrNoRows to not-found, and logging action="restored". Also make ResolveItemIncludeDeleted UUID-aware (mirrors ResolveItem) so restore resolves by the ids the bulk response returns. Adds 'restore' to the TS BulkItemOp / BulkItemsRequest union and a Go test (archive → restore round-trip by id). |
||
|
|
99e8802095 |
feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672) (#674)
* feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672)
Extract LaneActionsMenu.svelte — a drill-down kebab menu — and populate
it with the full mutation set, each operating on the lane's CURRENTLY-
FILTERED items via api.items.bulk:
- Archive all (count + "(filtered)" in the confirm)
- Move all to ▸ (other status lanes; status-grouped boards only)
- Tag all ▸ (free input + workspace tag suggestions)
- Untag all ▸ (tags present on the lane's items)
- Set priority ▸ (the priority field's options)
- Assign all ▸ (workspace members by name)
Page handlers funnel through a shared runBulk() that calls the bulk
endpoint once, deltaSyncs, and toasts the updated/failed counts; it
returns the affected ids for TASK-1674's undo. BoardView threads the
lane's items + member/tag data + callbacks into the menu; the old
inline menu markup/styles (TASK-1671) move into the component. All
actions canEdit-gated. Drill-down clicks stopPropagation (the Svelte 5
same-click detach fix). Move scope = status, Assign = members only.
* fix(board): chunk bulk lane actions at the 1000-id server cap per Codex review (round 1)
The bulk endpoint rejects >1000 ids; the old per-item archive loop had
no ceiling, so a >1000-item filtered lane would fail every action with
"too many items". runBulk now chunks ids at 1000 (one bulk call per
chunk — a few SSE events instead of thousands) and aggregates the
updated/failed counts across chunks.
* fix(board): finalize partial bulk results when a chunk throws per Codex review (round 2)
A thrown chunk returned early, skipping deltaSync and the count toast —
leaving the UI stale after earlier chunks had already mutated items
server-side, behind a generic error. Move the try/catch inside the loop
(break on throw), then always deltaSync (when anything succeeded) and
toast the partial result: 'Verb N, M failed' on partial success, the
server error message only when nothing succeeded.
* fix(board): gate bulk lane actions on owner/editor role per Codex review (round 3)
canEditThisCollection is grant-aware (true for collection edit grants),
but the bulk endpoint requires workspace owner/editor (TASK-1668), so a
grant-only editor saw the lane bulk actions and got a 403 per click.
Gate the bulk callbacks (archive-all/move/tag/untag/set-priority/assign,
in both Board and List) on a new canBulkEdit = owner|editor role. The
single `+` create stays grant-aware (canEditThisCollection). LaneActions-
Menu's action callbacks are now optional and each entry hides when its
callback is absent, so a grant-editor sees only "Add item here".
* fix(board): drive lane-action visibility off callbacks, not canEdit per Codex review (round 4)
The kebab wrapper was still inside {#if canEdit} (= canEditThisCollection),
so an owner/editor without a collection edit grant (canBulkEdit true,
canEdit false) couldn't open the menu at all. Show the `+` when
onCreateInColumn is wired and the kebab when any menu action is wired
(hasMenuActions) — each callback already encodes its own permission.
Same fix for ListView's archive-group button (gate on onArchiveGroup
alone, not canEdit && onArchiveGroup).
* fix(board): lane-aware kebab visibility per Codex review (round 5)
The kebab showed whenever any bulk callback existed, but the bulk menu
entries only render for a non-empty lane — so a role-only bulk editor
(no onCreateInColumn) saw a ⋯ that opened an empty panel on empty
schema lanes. Show the kebab only when onCreateInColumn is wired OR the
lane has items AND a bulk action is wired.
|
||
|
|
520380946e |
feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673)
Add a Sort dropdown to the collection toolbar (Manual, Priority, Recently updated, Created, A→Z) applied within each lane/group in both BoardView and ListView. A shared helper (lib/collections/itemSort.ts) builds the comparator; the priority weight reads the `priority` select field's own options order, so high/medium/low and must/should/nice both rank naturally (top option = highest) with no hardcoded map. - 'manual' (default) resolves to the stored sort_order — prior behavior. - Non-manual sorts disable item drag (like the preserveOrder seam): a comparator-ordered lane can't accept a drag, so DnD is suppressed. - 'Priority' is hidden when the collection has no priority field; a stale persisted 'priority' falls back to 'manual'. - Sort persists per collection in localStorage (mirrors view mode). - Table view keeps its own column sorting; the control is board/list only. |
||
|
|
9dd4fa653e |
feat(board): lane-header + add button + kebab menu shell (TASK-1671) (#672)
* feat(board): lane-header + add button + kebab menu shell (TASK-1671, folds IDEA-1159) Replace the hover-only .archive-col-btn in BoardView lane headers with two always-visible, touch-sized affordances: - `+` add-into-this-lane button → onCreateInColumn(colValue), which quick-creates an item with the lane's group field pre-filled (status or a custom board_group_by) and opens it. - `⋯` kebab → a lane menu (the new home for column actions). Shell carries "Add item here" + "Archive all (N)" with an in-menu confirm; move/tag/priority/assign land in TASK-1672. One menu open at a time, dismissed on outside click (QuickActionsMenu pattern). Both affordances are canEdit-gated. Touch targets bump to 32px ≤768px. * fix(board): stopPropagation on lane-menu clicks per Codex review (round 1) Clicking "Archive all" set confirmArchiveColumn, which re-rendered and detached the clicked button before the event bubbled to the window outside-click handler — closest() on the orphaned node returned null and slammed the menu shut, so the confirm never showed. Stop propagation on the kebab toggle and every in-menu click (the documented Svelte 5 same-click bubbling fix from console/+layout.svelte). |
||
|
|
2721d890ec |
feat(web): bulk-mutation API client method + types (TASK-1669) (#671)
* feat(web): bulk-mutation API client method + types (TASK-1669)
Add api.items.bulk(ws, data) hitting POST /workspaces/{ws}/items/bulk
(TASK-1668), plus types in lib/types: BulkItemOp, a discriminated-union
BulkItemsRequest (each verb requires only its own params), and
BulkItemOutcome / BulkItemFailure / BulkItemsResponse for the per-row
outcome envelope. Wire contract matches the backend struct field-for-
field; `details` is unknown (server json.RawMessage).
Unblocks the lane-header bulk-action UI (TASK-1672).
* fix(web): assign bulk op ids are string-only, not nullable per Codex review (round 1)
The server treats JSON null as absent (mirrors ItemUpdate) and rejects an
assign with no real id and no clear flag, so a nullable type would let a
caller type-check `assigned_user_id: null` into a 400. Type these string-
only and document that clearing goes through the clear flags.
|
||
|
|
57995c5898 |
fix(sync): moved-out tombstones for cross-visibility collection moves (BUG-1675) (#670)
* fix(sync): emit moved-out tombstones for cross-visibility collection moves (BUG-1675) /items-changes filtered deltas by an item's CURRENT collection, so an item moving from a collection a restricted member can see into one they can't vanished with no eviction signal — the stale, now-unauthorized row lingered in their local cache until a full rebootstrap. Server: - store.ListMovedOutSince: finds items that changed since the cursor, are now outside the caller's visible scope, and have a 'moved' activity FROM a collection the caller CAN see. Returns id+seq only — no destination data leaks (the caller has read access to the source). - handleListItemsChanges merges these in as moved_out tombstones, then seq-sorts + caps the combined stream so pagination stays gap-free. - Bulk collection moves now log a proper 'moved' activity with from/to collection slugs (mirroring handleMoveItem) — the signal the tombstone query reads. Previously they logged generic 'updated'. Client: - ItemChangeRow gains moved_out; applyDelta hard-evicts those ids from RAM + search and queues the IDB delete into the SAME atomic cursor-advance tx (persistDelta gains removeIds) so it can't resurrect on warm boot. Full members (nil visibility) skip the extra query entirely — the path only runs for restricted members/guests. Tests: store-level matrix (ListMovedOutSince), end-to-end restricted member /items-changes tombstone, bulk-move 'moved' activity logging. * fix(sync): tie moved-out tombstone to the move event's seq per Codex review (round 1) Keying the tombstone on the item's CURRENT seq meant any later change while it sat in a hidden collection re-emitted a moved_out row — leaking that an invisible item keeps mutating, and never settling. Stamp the post-move seq into the 'moved' activity metadata (both single + bulk move paths) and key the tombstone on THAT seq: it fires once, for the move that crossed the visibility boundary, and the cursor settles past it. Moves logged before the seq stamp are skipped (evict on rebootstrap) rather than risk the re-fire. Test: re-fire regression (a post-move hidden-collection update must not re-emit the tombstone). * fix(sync): page moved-out tombstones by move seq, not current seq per Codex review (round 2) Ordering/capping candidates by the item's current seq could strand an item that moved out early (low move seq) but later churned in the hidden collection (high current seq): it fell past the limit while the cursor advanced beyond its move seq, never to be emitted again. Collect all eligible rows, keep the earliest qualifying move per item, sort by move seq, then apply the limit at a move-seq boundary so dropped rows re-fetch cleanly on the next poll. Test: 3 items move out ascending; the earliest churns to a high current seq; limit=2 must still return the two smallest move seqs, then the third on the next page with no gap. * fix(sync): durable item_collection_moves table for moved-out detection per Codex review (round 3) Moved-out detection read the 'moved' activity row, which is written after the move commits and best-effort (errors discarded) — so a delta poll racing the audit write, or a failed write, could advance the cursor past the move seq and strand the unauthorized item forever. Record every cross-collection move in a new item_collection_moves table inside the SAME transaction as the move (MoveItemWithPreCheck), carrying the workspace seq the move assigned. ListMovedOutSince now reads that table — fully SQL/indexed (from_collection_id IN visible, MIN(seq) for multi-hop, current-collection NOT IN visible), no JSON parsing, no best-effort dependency. The 'moved' activity stays for audit only. Migration 066 adds the table + indexes. Tests updated to rely on the durable record (MoveItem writes it) rather than hand-logged activity. * fix(sync): add Postgres migration for item_collection_moves per Codex review (round 4) Postgres reads the separate pgmigrations/ tree, so the SQLite-only migration 066 left item_collection_moves absent on PG deploys — every cross-collection move would fail at the in-tx insert and moved-out queries would error. Add pgmigrations/045 with the equivalent table + indexes. |
||
|
|
dfd3811eee |
feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)
Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.
Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.
Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.
Parent: PLAN-1667.
* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)
- Enforce per-item collection visibility (checkItemVisible) in the bulk
loop so a member with collection_access="specific" can't bulk-mutate
items in hidden collections by guessing refs; report invisible rows as
not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
open-children guard (destination schema), closing the bypass where a
collection move + terminal status could mark a parent terminal with
open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
override + mutation-safety).
* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)
The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.
* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)
The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.
Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.
Test asserts the event is collection-scoped and carries no item_ids.
* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)
A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.
* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)
A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.
Adds a unit test covering the visibility matrix.
* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)
A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
|
||
|
|
1be42e5406 |
feat(collections): multi-select tag filter + per-collection tag counts (TASK-1666) (#668)
Add a TagFilter chip row to the collection page FilterBar — one toggle chip per tag present in the collection, each showing its item count, with OR selection semantics. Tag counts are derived client-side from the local index (no network round-trip) and honor the showArchived toggle. Selection round-trips through the `tags` URL param and the saved-view ViewConfig (rides along as a single `op: 'in'` filter). Implements TASK-1666. |
||
|
|
0d5f96657b |
feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) (#667)
* feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) Completes PLAN-1662. Adds inline edit mode for comments and replies, built on the backend (TASK-1663) + CommentEditor (TASK-1664). - Edit affordance (hover pencil) next to Delete on each comment and reply, shown only when editable: comment.user_id === currentUserId || isAdmin — distinct from canEdit (item perm, which gates delete/reply/react). Mirrors the server's canEditComment; null user_id → admin-only. - Clicking Edit swaps the rendered body for a CommentEditor seeded with the current body; Save calls api.comments.update via an onEdit callback (throws on failure so the editor keeps the draft); Cancel restores. One reply edits at a time (editingReplyId). - Image removal falls out: removing an image is just deleting its node in the editor and saving — no separate endpoint/button. - "edited" marker (· edited, title=updated_at) when updated_at is meaningfully after created_at. Reactions live in separate tables and don't bump updated_at, so it's edit-specific. - handleReply now re-throws so the reply editor also preserves its draft on failure; comment_updated SSE refresh was wired in TASK-1663. Parent: PLAN-1662. * fix(comments): flag edited on any positive updated_at delta per Codex review (round 1) created_at and updated_at are set identically on create, so a strict inequality (not a >1000ms threshold) is the correct 'edited' signal at RFC3339 second precision — the old threshold missed edits exactly 1s later. Same-second edits remain undetectable without an explicit edited_at column; acceptable for v1. |
||
|
|
6ed16ef930 |
feat(comments): lean Tiptap CommentEditor — inline image thumbnails (TASK-1664) (#666)
Replaces the plain-textarea comment composer and reply box with a small WYSIWYG editor so pasted/dropped images render as inline thumbnails instead of `` markdown text. - web/src/lib/components/CommentEditor.svelte: a purpose-built Tiptap instance (NOT the heavy Editor.svelte) — StarterKit basics + Link + Placeholder + tiptap-markdown + the shared attachment pipeline (AttachmentUpload plugin + AttachmentImage/AttachmentChip nodes). No tables/slash/collab/URL-modal. Emits markdown via the markdown storage (round-trips through the nodes' addStorage serializers), so comment.body stays markdown — display, lightbox, search, and orphan-GC are untouched. Wraps the upload fn to track in-flight uploads and gate submit (the plugin doesn't expose its placeholder count). Ctrl/Cmd+Enter submits, Esc cancels (reply mode). - ItemTimeline composer + TimelineCommentCard reply box now render CommentEditor; submitComment/submitReply take the markdown string and throw on failure so the editor preserves the draft. Removed the textarea + commentAttachments paste/drop wiring and now-dead CSS. Inline thumbnails in the editor are capped to match the rendered-comment display. Parent: PLAN-1662. Unblocks TASK-1665 (edit mode reuses this). |
||
|
|
076fb9b2e7 |
feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)
Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.
- Populate user_id on create/reply: CreateComment takes an explicit userID
param (passed from currentUserID by the handlers, not via the request body
so it can't be spoofed). Expose user_id on models.Comment + all comment
SELECTs/scans. The workspace export path is left as-is — imported comments
keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
(canEditComment), rejects empty body. Editing is an authorship op, distinct
from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.
Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.
Parent: PLAN-1662.
* fix(account): detach authored comments on account deletion per Codex review (round 1)
Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
|
||
|
|
4c2c4d1e1d |
feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660) (#664)
* feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660)
Tier 1 + Tier 2 of the IDEA-1650 follow-up.
Tier 1 — thumbnails:
- Thread an image `variant` through the markdown render pipeline
(renderMarkdown → AttachmentRenderContext → resolveAttachmentImage →
renderAttachmentImage), defaulting to thumb-md. Comment + reply bodies
pass thumb-sm (256px) so pasted screenshots fetch the small variant.
- CSS caps inline attachment images to a 280×180 box with cursor:zoom-in.
Tier 2 — lightbox:
- New reusable Lightbox.svelte (common/) — full-resolution overlay,
Esc/backdrop close, ←/→ paging, image counter. Loads the original
(un-variant) blob for full detail.
- ItemTimeline attaches a delegated click handler (via a use: action, so
no a11y lint on the static container) that opens the lightbox on any
img[data-attachment-id], collecting sibling images in the same
comment/reply body for paging.
No backend changes. Tier 3 (dims-via-headers, multi-image grid,
loading=lazy) intentionally deferred.
* fix(comments): keyboard-activatable comment image thumbnails per Codex review (round 1)
Thumbnails come from sanitized {@html}, so they can't be wrapped in a
<button> at render time. Instead make each img[data-attachment-id] a
focusable role=button with an aria-label imperatively (re-applied on
entries change, covering SSE-added comments), and add a delegated
keydown (Enter/Space) alongside the existing click so keyboard users
can open the lightbox.
* fix(comments): re-run thumbnail focusability pass when attachment metadata resolves per Codex review (round 2)
An attachment renders as <img> only after its HEAD-probe metadata
resolves (before that it's a 'missing' placeholder span). The
focusability pass depended only on entries, so it missed images that
appeared on metadata resolution. Add attMeta as a dependency.
|
||
|
|
e179c595e4 |
feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) (#663)
* feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) Comment composers were plain textareas with no upload path, and comment bodies rendered markdown without an attachment resolver — so a `pad-attachment:UUID` reference would never display. This wires both halves end to end: - Compose: paste or drop files into the comment composer (ItemTimeline) and the reply box (TimelineCommentCard). A shared helper (commentAttachments.ts) splices an "Uploading…" placeholder at the caret, uploads concurrently via the existing attachment API, and swaps each placeholder for its `pad-attachment:UUID` markdown ref (image syntax for image MIMEs, link/chip syntax otherwise — mirrors the editor's split). Submit is gated while uploads are in flight. - Display: ItemTimeline lazily HEAD-probes each referenced UUID (reusing the editor's fetchAttachmentMetadata cache), builds a reactive resolver, and threads it into renderMarkdown for comments and replies so refs render as inline images / file chips. - Orphan GC: comment uploads leave attachments.item_id NULL (like the editor), but the GC reference scan only checked items.content/fields. Renamed AttachmentReferencedInItems -> AttachmentReferenced and extended it to scan comments.body, so a screenshot referenced only from a comment isn't reclaimed after the grace period. Added TestOrphanGC_KeepsAttachmentReferencedFromComment. Follow-up refinement (thumbnails + click-to-expand lightbox) captured as IDEA-1660. * fix(comments): escape markdown-significant chars in attachment filenames per Codex review (round 1) Filenames containing [ ] or backslash could break the generated  markdown. P2 (grant-aware upload auth) is a pre-existing endpoint-wide gap shared with the rich editor — tracked as BUG-1661, not fixed here to keep the PR focused. * fix(comments): preventDefault on dragover for file drops per Codex review (round 2) Browsers only deliver a file drop to a custom target if its dragover cancels the default; without it the page navigates to the file. Gated on isFileDrag so in-textarea text drag-drop is unaffected. |
||
|
|
b9ddd02ae7 |
feat(cli): add --tag filter to pad item list (TASK-1658) (#662)
The HTTP item-list endpoints already parse ?tag= (cross-collection on the workspace route), but the CLI had no flag to forward it. Add --tag, wired as a query param alongside --status/--role/--parent. The MCP pad_item list action inherits it via the cmdhelp passthrough (no catalog change). Parent: PLAN-1652. |
||
|
|
5c0d367c12 |
feat(tags): list/board view toggle on the tag page (TASK-1656) (#661)
* feat(tags): list/board view toggle on the tag page (TASK-1656) Adds a board (kanban) view to the per-tag page: one lane per collection, reusing ItemCard (compact). Read-only — no drag/status changes, since moving a card between collection lanes would mean reclassifying the item. Implemented as a focused layout on the tag page rather than overloading the shared DnD/status-grouping BoardView (582 lines), whose semantics don't fit a read-only cross-collection view. Reuses the existing collection-grouped `groupedItems` derived; view mode persists per workspace via localStorage. Parent: PLAN-1652. * fix(tags): guard localStorage access on the tag view toggle per Codex review (round 1) localStorage get/set can throw when storage is disabled or blocked; wrap both so the page loads and the toggle works (in-session) regardless. |
||
|
|
1e3b3254d7 |
feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) (#660)
* feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) - lib/types: shared parseTags() — defensive JSON-array parse + case-insensitive dedupe; reused by ItemCard and the detail page (which drops its inline dupe). - ItemCard (shared by BoardView + ListView): a clickable tag-chip row. The card is an <a>, so chips are buttons that goto the tag page with stopPropagation — same pattern as the existing star/PR/status controls — avoiding nested anchors. - Item detail header: tag chips as real <a> links to the tag page. Chips link to /[ws]/tags/[tag]; those pages arrive in TASK-1657. Parent: PLAN-1652. * feat(tags): dedicated tag pages (index + per-tag aggregated view) + nav (TASK-1657) Folded into the chip-display PR because the chip links require the routes to exist — without them /[ws]/tags/[tag] is swallowed by the generic [collection]/[slug] route and hits the item error path (Codex PR #660 round 1). - /[ws]/tags — index: tag cloud with per-tag item counts (GET /tags), each linking to its tag page. - /[ws]/tags/[tag] — aggregated view: cross-collection items for the tag, grouped by collection (the shared axis across heterogeneous status enums), modeled on the starred page. A static `tags` segment takes routing precedence over [collection], so the collision is resolved. - Sidebar: a Tags nav entry; `tags` added to the reserved-slug guard so the route isn't mistaken for a collection. Parent: PLAN-1652. * fix(tags): reserve 'tags' collection slug + tighten Tags nav active-match per Codex review (round 2) - backend: add 'tags' to reservedCollectionSlugs so a collection can't be created with a slug that the /tags routes would shadow. - Sidebar: isTagsPage now matches exactly /tags or the /tags/ boundary, not any /tags* prefix (e.g. a 'tags-collection' path no longer marks it active). * fix(tags): don't drop item-granted rows whose collection isn't listable per Codex review (round 3) A restricted member can see an item via item-level grant without being able to list its collection, so getCollection() missed and the row was dropped while still counted (count != rendered). Synthesize a minimal collection from the item's embedded collection_* metadata so those rows render (empty schema = ItemCard omits status/priority). |
||
|
|
feb068a91f |
feat(tags): tag chip editor on the item detail page (TASK-1654) (#659)
* feat(tags): tag chip editor on the item detail page (TASK-1654) Tags live on item.tags (a JSON-array string), not the collection schema, so this adds a TagInput sibling to FieldEditor rather than a field type. - TagInput.svelte: chip editor — Enter/comma to add, Backspace/× to remove, case-insensitive dedupe (stored as typed), autocomplete dropdown sourced from the workspace tag set; readonly mode renders plain chips. - Item detail page: derive `tags` from item.tags (defensive parse), load `tagSuggestions` via a workspace-keyed $effect kept separate from the item-load path (Svelte 5 effect-splitting convention), and updateTags() mirrors updateField() — optimistic with revert-on-failure, PATCHing `tags`. The Tags row renders between the schema fields and the Assignment section. api.items.update already accepted `tags` via ItemUpdate, so no client change was needed there. Parent: PLAN-1652. * fix(tags): guard overlapping tag saves with a sequence counter per Codex review (round 1) Rapid chip edits can issue overlapping PATCHes; a late-resolving older request could clobber the newer tag set with stale data or an errant revert. Only the latest save (by monotonic seq) applies its result or reverts. * fix(tags): drop stale tag-suggestion results across workspace navigation per Codex review (round 2) loadTagSuggestions now only assigns when the in-flight workspace still matches the current one, so a slower old-workspace /tags response can't overwrite the new workspace's autocomplete. * fix(tags): dedupe tags + key chips by index per Codex review (round 3) An item can carry duplicate tags (e.g. ["ux","ux"]) since the write path doesn't enforce per-item uniqueness, which would collide value-based Svelte keys. Key chips by index, and dedupe case-insensitively at the source so the cleaned set persists on the next save. * fix(tags): gate tag-save completion UI on item freshness per Codex review (round 4) If the user navigates to another item while a tag save is in flight (no further edit, so the seq guard doesn't trip), skip showSaved()/toast/refresh so completion UI can't fire on an unrelated page. * fix(tags): serialize+coalesce tag saves, revert to last confirmed per Codex review (round 5) Replace the concurrent-PATCH-with-seq-guard approach with a single in-flight, coalescing saver scoped per item. Eliminates the overlap class structurally: no stale completion clobbers a newer set, and `confirmed` tracks the last server-acknowledged tags so a failed save reverts to server truth rather than an optimistic unconfirmed value. Subsumes the round-1 race guard and round-4 navigation gate. * fix(tags): key tag savers by item id to prevent cross-navigation concurrency per Codex review (round 6) A single saver slot let navigating away from an item mid-save and back spawn a second concurrent saver for it. Hold savers in a Map keyed by item id so edits coalesce into the existing in-flight saver; evict on drain. * fix(tags): reapply in-flight desired tags after item reload per Codex review (round 7) Navigating away and back mid-save reloaded stale server tags; a follow-up edit computed from that stale set could drop the in-flight edit. The saver now tracks the latest desired set and loadData reapplies it when a save is still in flight for the reloaded item. * fix(tags): keep save indicator active across reload so refresh guards hold per Codex review (round 8) loadData reset saveStatus to idle while a tag PATCH was still in flight, letting SSE/sync snapshot adoption bypass the saveStatus==='saving' guard and land stale tags. Restore 'saving' when reapplying an in-flight saver so the existing refresh guards keep skipping until the save drains. * fix(tags): overlay in-flight tags at every server-snapshot assignment per Codex review (round 9) The saveStatus guard is racy (checked before the refresh handlers' own await, not after), so a concurrent snapshot could still drop optimistic tags. Extract withInflightTags() and route ALL item = <server snapshot> sites through it (realtime SSE/sync, initial load, content-save echoes, title/field/assignment/ role update echoes, post-action refresh, version restore). Overlaying the saver's desired set at assignment time is race-free regardless of the guard. * fix(tags): overlay tags on field-save + forced-retry echoes per Codex review (round 10) updateField's success echo (item = fresh) and the forced open-children retry (item = forced) were the last two un-overlaid server-snapshot assignments; route both through withInflightTags so a concurrent tag save isn't clobbered. * fix(tags): preserve unsaved content when reconciling tag-save echo per Codex review (round 11) flushTagSaver adopted the full tag PATCH response (item = fresh), which carries server content and could clobber unsaved editor edits. Route it through adoptServerItem so local content is preserved (non-collab) like the other snapshot adoption sites. |
||
|
|
1b1068537c |
feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)
Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.
- store: dialect.JSONArrayElements unnests a JSON text-array column
(json_each on SQLite, jsonb_array_elements_text on Postgres);
Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
count desc then tag asc, with the same collection/item ACL filters as
ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
non-nil-empty = empty, archived excluded) and handler-level (a Task + an
Idea sharing one tag; GET /tags counts + ordering).
Parent: PLAN-1652.
* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)
COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
|
||
|
|
9870f364ac |
feat(insights): "What shipped" card with linked item IDs on the dashboard (#657)
Mirror the print report's completed-items list on the live Insights
dashboard, grouped by collection. Each item ID is a link to the item
(/{user}/{ws}/{collection}/{ref}) — the route resolves PREFIX-NUMBER refs
via ResolveItem, so no slug lookup is needed. Fetches the report with
include_items=true; the card is toggleable via Customize and persists like
the other cards (new "completed_items" id, defaults visible).
|
||
|
|
a6667152cc |
fix(insights): professional print formatting for the exec report (TASK-1647) (#656)
* fix(insights): professional print formatting for the exec report (TASK-1647) Polish the /insights/print @media print block (screen view unchanged): - Print typography: 10pt base so em sizes cascade down; capped headings (title 16pt, section 12pt, stat values 14pt, labels 8–9pt). - Charts: cap canvas height to 150px in print (overrides the inline 200–240px); smaller legend. - Page breaks: break-inside:avoid on status tables + rows + stat cards + chart canvases; section headings kept with their content (break-after:avoid). - Density: tighter report/section/stat/list gaps for a compact report. Parent: PLAN-1628 (IDEA-1627). * redesign(insights): charts-up-front dashboard layout + fix LayerCake print overlap (TASK-1647) - Move the three charts to a compact 2-up grid right after the headline stats (charts up front, half-width, not full-bleed). - Fix the chart-overlap bug: charts were sized via an inline screen height that LayerCake measured and cached, then an @media-print rule shrank the box afterwards, leaving the SVG drawn at the old range and spilling onto the next panel. Now pass small heights (130-150px) identical for screen + print so the measurement matches; add overflow:hidden as a clip-safety. - Cycle time becomes a full-width band: metrics rail + short chart. - What shipped flows into two columns (dense, fills page width) and is no longer kept whole — .shipped-group/.block/.status-table dropped from the break-inside:avoid set so a tall section no longer jumps to a fresh page and leaves the big blank gap on page 1. Only small atoms (tr, shipped line) stay unsplit. - Compact table cell padding + repeat thead across page breaks. * fix(insights): stop print charts clipping + shorten throughput X labels (TASK-1647) - Charts were measured at the 960px app width on screen (~450px per 2-col panel) then printed into ~360px columns, so LayerCake's cached width drew the SVG too wide and overflow:hidden clipped the right edge (only ~3 of 4 collection bars showed). Constrain the print page's on-screen report to a paper column (720px) so the measured width already fits the printed sheet — WYSIWYG, no clipping, all bars visible. - Throughput X-axis used full ISO dates (2026-05-23) that overlap once several buckets share a narrow chart; render compact M/D (or 'M/D Hh' for hourly) labels via fmtBucket. maxTicks=8 already thins longer windows. |
||
|
|
333f509274 |
fix(store): monotonic seq tiebreak for same-second status transitions (TASK-1643) (#655)
* fix(store): monotonic seq tiebreak for same-second status transitions (TASK-1643) Resolves the documented historical-snapshot precision edge: created_at is second-precision and ids are random UUIDs, so "latest transition <= T" was nondeterministic for 2+ same-second hops on one item. Add a monotonic `seq` (MAX+1 at insert, like items.seq; assigned inside the workspace seq lock so per-item order is serialized — cross-row dupes are harmless) and order the as-of-T reconstruction by created_at DESC, seq DESC, id DESC. - migrations 065 / 044: add seq + backfill existing rows chronologically (ROW_NUMBER over created_at,id), dual-dialect. - seq set on all four insert sites (update/move/create-seed hooks + backfill). - reportSnapshotAsOf ORDER BY uses seq; doc caveat updated (now fixed). - Test inserts same-created_at rows where id-order and seq-order DISAGREE, proving seq is the tiebreak. Parent: PLAN-1628. * fix(store): insert backfill create-seeds before hops so seq stays chronological per Codex review (round 1) The backfill inserted activity hops first, then create-seeds, so a seed got a HIGHER seq than a same-second hop — and since the as-of query orders by created_at DESC, seq DESC, the seed (the initial state) wrongly won "latest" for same-second create-and-change histories. Buffer the hops during the activity scan and insert them AFTER the seeds, so every hop's seq exceeds its item's seed seq (a seed is always chronologically first; on a created_at tie the hop's higher seq correctly wins). Adds a seed-seq-below-hop test. |
||
|
|
619465a24b |
feat(onboard): suggest an independent AI code reviewer (model != implementer) (TASK-1645) (#654)
Encode the independent-reviewer principle (a reviewer model different from the implementer catches more than self-review) as opt-in onboarding guidance — no tool-specific operational lore (that stays ours). - New generic library convention "Independent AI code review" (quality, on-pr-create, nice-to-have): states the principle; names review tools as examples (a review CLI, claude review, a GitHub bot) without operational depth. - /pad onboard build (B3) + audit (A3) steps: the agent notes its own model (the implementer), probes for / asks about a review tool, and when a different-model reviewer is available, proposes activating the convention, naming the detected tool (e.g. codex) as the concrete suggestion. Skips when the only reviewer would be the same model. Never blocks. Parent: PLAN-1628. |
||
|
|
24707fc2ad |
chore(templates): make seeded ship playbook review loop tool-neutral (TASK-1644) (#653)
The seeded ship playbook ships into every new workspace, so it must not carry our local Codex operational lore. Strip the Codex name + the < /dev/null / --full-auto / stdin-wedge details PR #646 added → a tool-neutral review loop ("use whatever synchronous review tool you have"), with a pointer that /pad onboard can wire up an independent reviewer. Our Codex specifics stay only in this workspace's PLAYB-1405 + the personal ship-tasks skill. |
||
|
|
0d46842cf7 |
feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) (#652)
* feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) Add /[username]/[workspace]/insights/print — a clean, shareable report of "what's been done this period" for the active selection, exported via browser print (no server-side PDF dependency). A "Print report" link on the Insights page passes the current window/offset/collections as query params. The report renders: header (workspace, period label honoring offset, generated-on), headline stats (completed/created/net flow/median cycle-time), "what shipped" (completed_items grouped by collection, ref + title, +N more on overflow), all charts (throughput, completed-by-collection, cycle-time) as SVG, and a compact status table. A layout reset (+layout@) drops the workspace chrome; @media print + :global hides the root shell/sidebar + the Print button, with sensible page breaks. Loading/error/empty states handled. Parent: PLAN-1628. * fix(insights): hide mobile topbar in print report CSS per Codex review (round 1) The print @media rules only hid chrome inside .app-layout, but the mobile TopBar (<header class="topbar topbar-mobile">) renders as a sibling before .app-layout, so a Save-as-PDF from a mobile viewport included the app bar. Hide :global(.topbar-mobile) in print too. |
||
|
|
b68164a714 |
feat(report): opt-in 'what shipped' completed-items list (TASK-1641) (#651)
* feat(report): opt-in 'what shipped' completed-items list (TASK-1641)
Add ?include_items=true → completed_items[{ref,title,collection,completed_at}]
on the report: items that reached a positive terminal in the window, deduped
by item (newest completion first), capped at 500 with
completed_items_overflow_count. Same positive-terminal source as
totals.completed (joins live items, deleted_at IS NULL), so the list reconciles
with the count. Opt-in so the interactive dashboard stays count-only; the
print/export report (TASK-1642) requests it.
Web ReportData gains completed_items + the api.report.get includeItems flag.
Parent: PLAN-1628.
* fix(report): scope completed-items list to the item's current visible collection per Codex review (round 1)
The list scoped transitions by st.collection_id (visible at completion) but
returned the item's CURRENT title/ref/collection — so an item completed while
visible then moved to a hidden collection could leak its hidden collection
slug/prefix + current title to a restricted caller. Require i.collection_id to
be in the resolved (scoped) collection set on both the count and list queries.
Adds a move-to-hidden-collection visibility test.
|