Commit Graph

14 Commits

Author SHA1 Message Date
xarmian 5d327c96d1 feat(web): add the cross-workspace copy dialog (TASK-2355) 2026-07-31 19:53:16 +00:00
xarmian a335033415 feat(web): Chip primitive + canonical fieldColors util — 48 badge sites migrated (TASK-2292) (#1019)
* feat(web): Chip primitive + canonical fieldColors util; migrate 48 badge sites (TASK-2292)

PLAN-2290 Phase 2, PR 1. Extracts the first shared primitives:

- lib/utils/fieldColors.ts — ONE statusColor/priorityColor (+ hasCanonicalStatus,
  formatFieldLabel), replacing four drifted implementations (ItemCard,
  fields/FieldEditor, CommandPalette, workspace home); shareView.ts re-exports
  it so public shares stay in lockstep. Deliberate unifications: open/new/todo/
  planned -> --status-blue (was text-secondary on cards); active -> green (was
  cyan in palette/home); draft -> muted (was blue); rejected/cancelled/wontfix
  -> gray; priority medium -> text-secondary.
- lib/components/common/Chip.svelte — tinted-pill primitive per the refresh
  mock (color-mix tint via new --chip-alpha token, colored text, dot/size/
  onclick/pulse props); svelte-autofixer clean.
- 48 badge usages across 15 files migrated to Chip; scoped .badge CSS deleted
  (net -355 lines). Deliberate leave-alones: GraphToolbar count bubble +
  filter toggles, stat tiles, timeline rail markers, avatars.

Gates: svelte-check 0 errors, 488 web tests, make check green; board/settings
screenshots verified in both themes.

* fix(web): Chip button variant always preventDefaults (never navigates a parent <a>)

Codex finding on #1019: an onclick Chip inside a link card would activate
the link after the callback. preventDefault always (a chip is never a
link); propagation intentionally continues so click-outside closers work —
callers in interactive cards stopPropagation per the house pattern.
2026-07-24 14:34:54 -04:00
xarmian 563371ee9b feat(web): define missing token families + zero-change drift sweep (TASK-2291) (#1017)
PLAN-2290 Phase 1, PR A. Defines --accent-red, --status-blue, --text-on-accent,
--shadow-sm/md/lg, --modal-shadow, --scrim in app.css (values matching the
long-standing inline fallbacks), then mechanically sweeps:

- var(--accent-red, #hex) fallback forms collapsed (52+4+1 sites); 3 bare
  var(--accent-red) sites that previously resolved to NOTHING now render
- phantom var(--color-danger, #dc2626) repointed to --accent-red
- bare #ef4444/#dc2626 danger literals -> var(--accent-red) (57 files);
  #c0392b/#e53e3e/#dc2626 outliers unify to #ef4444 (deliberate)
- shadow fallback forms collapsed to the now-defined tokens
- 10 categorical literally-blue sites (status maps, burndown chart, info
  badge) repointed --accent-blue -> --status-blue so PR B's violet accent
  flip won't drag status colors

Verified: svelte-check 0 errors, 488 web tests, make check green; Playwright
before/after pixel diff on 4 surfaces x 2 themes — identical except the
sidebar build-id string.
2026-07-24 13:48:56 -04:00
xarmian f49c14c86b refactor(web): unify mobile breakpoint into one shared isMobile store (TASK-2028) (#886) 2026-07-09 17:20:12 -04:00
xarmian 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.
2026-05-30 01:58:17 -04:00
xarmian 38aa872864 fix(fields,activity): debounce typed-input field saves + collapse same-field activity runs (BUG-1466) (#549)
* fix(web): debounce typed-input field saves to stop per-keystroke activity rows (BUG-1466)

Text / number / URL fields in FieldEditor wired oninput directly to
onchange, so every keystroke became an item PATCH and an activity row.
Typing `ui/editor/tiptap` into a `component` field produced a 30-step
keystroke chain in the audit metadata (visible on BUG-1419's timeline).

Wrap the typed-input branches in a 500ms idle debounce, flush on blur
so tabbing away commits immediately, and flush on unmount so navigation
never drops a pending value. Discrete inputs (select / date / checkbox,
number ±1 buttons) keep firing on the user action — they aren't typing.
Mirrors the markdown content debounce pattern in the detail page.

* fix(activity): collapse same-field runs in merged changes metadata + unify diff separator (BUG-1466)

Follow-up to the web-side typing debounce. Two related changes:

1) collapseChanges() walks the merged "; "-delimited changes string and
   collapses runs of consecutive same-field entries into a single
   "field: first-old → last-new". Drops net no-ops (typed then backspaced).
   When the web-side debounce in FieldEditor still produces multiple
   PATCHes within the 5-minute coalesce window — or for older rows that
   pre-date the debounce — the timeline now reads as one transition
   instead of a chain. Run-based (not global) collapse so interleaved
   edits on different fields keep their chronology.

2) diffFields now joins entries with "; " instead of ", " so the joiner
   is consistent with mergeActivityMeta and TimelineActivityCard.svelte's
   split delimiter. Multi-field PATCHes previously rendered as a single
   unparseable blob in the web timeline because the parser only split on
   ";" — fixed as a side-effect.

Adds TestCollapseChanges (10 cases including the BUG-1419 repro) and
TestMergeActivityMeta_CollapsesSameFieldRun. Updates the existing
TestDiffFieldsPrimitives expectation to match the new joiner.

* fix(fields,activity): two follow-ups per Codex review (round 1)

[P1] FieldEditor.svelte::handleNumberStep
  The ±1 buttons computed `(Number(value) || 0) + delta` AFTER calling
  flushPendingSave(). But `value` is the parent prop — flushing fires
  onchange asynchronously, so at the moment of the step computation
  the prop still holds the pre-typed value. Typing 10 over 5 and
  clicking + would flush 10 then send 6, overwriting the typed value.
  Compute `base` from `pendingValue` (if hasPending) BEFORE clearing
  the timer state, then send `base + delta` in one onchange call.

[P2] activities.go::collapseChanges
  The drop-net-no-op step removed entries where `from == to`. But
  diffFields intentionally emits same-display entries for
  same-cardinality structured-field replacements like
  `implementation_notes: (1 note) → (1 note)` (see
  TestDiffFieldsSameCardinalityArrayChangeStillReported) — the labels
  match because formatChangeValue summarizes by count, not content,
  but the underlying data did change. Dropping them silently hides
  real updates from the activity feed.
  Track `mergedCount` per entry: increment when collapsing a run,
  initialize to 1 on parse. Only drop when `mergedCount > 1 && from == to`
  — i.e. only when the no-op resulted from collapsing multiple input
  segments (the typed-then-backspaced case).

Tests: 2 new TestCollapseChanges cases (single structured-field
preserved, interleaved structured-field around a typed run).

* fix(fields,activity): two follow-ups per Codex review (round 2)

[P1] FieldEditor.svelte number stepper focus race
  The number ±1 buttons race with the input's onblur handler: blur
  fires before click in the natural focus-transfer flow, so
  flushPendingSave clears hasPending → handleNumberStep reads stale
  `value` from the parent prop → typing 25 over 10 and clicking +
  sends 25 then 11, losing the typed value.
  Add onmousedown={preventDefault} on both ±1 buttons. Mousedown
  precedes blur, and preventDefault on mousedown suppresses the
  natural focus transfer — the input keeps focus through the click,
  so hasPending survives until handleNumberStep reads it.

[P2] collapseChanges still drops repeated same-display structured runs
  Round 1's mergedCount>1 rule still dropped runs like
  `implementation_notes: (1 note) → (1 note); implementation_notes: (1 note) → (1 note)`
  — two real updates whose display strings happen to match because
  formatChangeValue summarises array-valued fields by count. Each
  PATCH represented a different underlying note (diffFields uses
  reflect.DeepEqual to detect that), but the merged display showed
  no transition.
  Track `hadTransition` per entry: true iff the run had a display-
  level transition (initial from != to, or a subsequent entry's `to`
  differed from the anchored from). Drop only when
  mergedCount > 1 && from == to && hadTransition — i.e. only true
  net-cancellations (typed-then-backspaced). Same-display structured
  repeats stay; real `foo → bar → foo` swings still drop.

Tests: 2 new TestCollapseChanges cases (repeated same-display preserved,
real foo→bar→foo swing still dropped).

* fix(fields,activity): two follow-ups per Codex review (round 3)

[P1] FieldEditor cross-item leak via debounce timer
  When the parent reuses a FieldEditor instance across an item swap
  (same schema, same field.key, different item — common when
  navigating between items in the same collection), the parent's
  `updateField` closure reads `item.id` at CALL time. A pending
  timer set while item A was active would fire after item B mounted,
  patching B with A's typed value.
  Two-pronged fix in FieldEditor:
  - Add a $effect that tracks the `value` prop and drops any pending
    save the moment the parent re-props us. The user actively typed
    for a now-stale context; aborting is safer than silently writing
    to the new context. Also covers external collab/SSE rebases of
    the same field on the same item.
  - Switch the unmount cleanup from flush → drop. When the parent
    navigates to an item whose schema lacks this field, the
    FieldEditor unmounts AFTER the parent's `item` has already been
    replaced, so a final onchange call would route through
    updateField → wrong item. Blur is the supported commit gesture
    (clicking elsewhere within the page, ±1 buttons, tab-out — all
    flush eagerly); unmount-without-blur is treated as "user
    abandoned the edit."

[P2] collapseChanges still dropped structured count-return swings
  Round 2's hadTransition rule still dropped a run like
  `implementation_notes: (1 note) → (2 notes); implementation_notes: (2 notes) → (1 note)`
  — the user added a note then removed the original, ending with a
  different single note. The merged display reads `(1 note) → (1 note)`
  with hadTransition=true, indistinguishable from a typed-then-
  backspaced cancellation. But formatChangeValue summaries are
  LOSSY: same display label can wrap entirely different raw values.
  Add a `hasLossySummary` flag per entry — true iff either `from`
  or `to` matches the `(text)` format formatChangeValue emits for
  structured fields. Propagate stickily across the run (any lossy
  entry pins the whole run). Skip the drop step when the run
  carries lossy summaries — we can't recover the raw delta from the
  merged string, so we preserve the entry rather than risk hiding
  a real change.

Tests: 2 new TestCollapseChanges cases (count-return swing
preserved, lossy-on-one-side still pins the run).

* fix(fields): demote hasPending from \$state to plain let — \$effect was cancelling every keystroke (round 4 [P1])

Critical regression in round 3: the value-track \$effect read
\`hasPending\` inside its body, which Svelte 5 promotes to a reactive
dependency. scheduleSave() setting \`hasPending = true\` retriggered
the same \$effect, whose body cleared the typing timer + pendingValue
+ hasPending before the debounce could fire. Net effect: typing into
any text / number / URL field was silently dropped — onchange never
ran, the field never saved.

hasPending is only read from imperative handlers (scheduleSave,
flushPendingSave, handleNumberStep, the value-track \$effect, the
unmount cleanup) — never from a template or other reactive context.
Demoting it to a plain \`let\` removes the unwanted subscription while
preserving the round-3 behaviour: external value-prop changes still
trigger the \$effect (it tracks \`value\`), the body reads hasPending
imperatively to decide whether to clear pending state.

No tests added — this is a Svelte reactivity edge case that can't
be unit-tested without a DOM. svelte-autofixer's pre-existing
"variable assigned inside \$effect" suggestion previously flagged the
hasPending mutation; that signal is gone now.

Per Codex review round 4.
2026-05-14 22:10:12 -04:00
xarmian c38b3bf5cd feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378) (#517)
* feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378)

Foundational change for PLAN-1377 — playbooks become first-class invokable
procedures. Two new optional fields land on the Playbooks collection
schema:

- `invocation_slug` (text, kebab-case, unique-per-workspace among non-null
  values): enables `/pad <slug>` direct invocation. Nullable so
  trigger-only playbooks (e.g. on-release checklists) don't need one.
- `arguments` (json, array of {name, type, required, default, description}):
  declares the playbook's argument contract; mirrors the body's
  `## Arguments` section in queryable form.

Plumbing pieces:

- `models.FieldDef` grows two general-purpose options — `Pattern` for
  regex validation and `UniqueScope` for collection-level uniqueness.
  Both are opt-in; existing schemas are unaffected.
- `items.ValidateFields` learns the `json` field type (accepts any
  JSON-decodable value) and applies `Pattern` to string-typed values.
- `handlers_items.checkUniqueFields` queries `Store.ListItems` to enforce
  `UniqueScope == "workspace_collection"` on create + update.
- Two migrations (SQLite 054, Postgres 033) JSON-patch the playbooks
  schema on existing workspaces so the new fields show up without a
  workspace re-init.
- TypeScript `FieldDef` mirrors the Go side.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 1 findings (TASK-1378)

P1 — EditCollectionModal now round-trips opaque pattern/unique_scope
metadata. EditableField carries the new keys; the load + save paths
preserve them so re-saving the playbooks collection from the UI doesn't
strip server-side validation rules the modal doesn't yet expose
dedicated controls for. fieldFromDef mirrors the change for templates.

P2 — checkUniqueFields' pre-write ListItems check is now backed by a
partial unique index (idx_items_invocation_slug_per_collection,
SQLite + Postgres) scoped to non-empty, non-deleted rows. The pre-check
still gives users a friendly error message in the common case; the
index closes the TOCTOU race between two concurrent writers. The
create-conflict error message is now generic enough to cover both the
slug constraint and the new invocation_slug index.

P2 — `json` field type now rejects raw strings, numbers, and bools. Only
objects, arrays, and null are accepted, so a generic web text input
can't silently corrupt a structured field by emitting "[]" instead of
an actual array. FieldEditor.svelte routes `json` fields to a
read-only summary in both readonly and edit modes; dedicated editors
(like TASK-1384's playbook editor that owns `arguments`) own the
structured form.

P3 — invocation_slug regex now requires a minimum of two characters
(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) in the Go const, the SQLite migration,
the Postgres migration, and the validate tests. Single-letter slugs
would shadow plausible NL tokens (e.g. `/pad a ...`) and the doc
comment already claimed the two-char floor; this aligns code with
intent.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 2 findings (TASK-1378)

P2.1 — checkUniqueFields no longer passes IncludeArchived=true. The
application-layer pre-check now matches the partial unique index's
`deleted_at IS NULL` predicate so a soft-deleted playbook releases its
slug back to the pool and reclaiming it succeeds instead of 409'ing.

P2.2 — handleUpdateItem now maps UNIQUE constraint / duplicate key
errors from UpdateItem to HTTP 409, mirroring the create path. A true
concurrent-update race that slips past checkUniqueFields and trips the
partial unique index used to surface as a misleading 500.

(Not addressed in this round: Codex's third finding — concern about the
partial unique index applying to "every collection" — is, on close
reading, not what the index does. `ON items(collection_id, json_extract(...))`
scopes uniqueness to the (collection_id, slug) pair, so two items in
different collections with the same `invocation_slug` value coexist
fine. The migration-failure risk is theoretical: `invocation_slug` is
a brand-new field key, so no pre-existing items can have it set, and
no migration-time duplicates can exist. If a future custom collection
adopts the same field name, opting into per-collection uniqueness is
exactly the intended semantic of FieldDef.UniqueScope.)

Parent: PLAN-1377.

* fix(playbooks): map restore-path UNIQUE violations to 409 (TASK-1378)

Codex round 3: restoring an archived playbook can hit the partial
unique index on invocation_slug if a replacement item already claimed
the slug. Map UNIQUE constraint / duplicate key errors from RestoreItem
to HTTP 409 with a targeted message, matching the create + update paths.

Parent: PLAN-1377.

* fix(playbooks): map collab-snapshot UNIQUE violations to 409 (TASK-1378)

Codex round 4: the collab-snapshot PATCH branch under
`s.collab.UnderItemLock` ran its own UpdateItem call and fell through
to writeInternalError on any non-stale-snapshot error. A concurrent
edit racing the invocation_slug partial unique index would surface as
500 instead of 409. Mirror the main UpdateItem error mapping.

Codex's other round-4 finding — the partial unique index applying to
"every collection" — is not addressed because the index IS already
collection-scoped: `ON items(collection_id, json_extract(fields,
'$.invocation_slug'))`. Two items in different collections with the
same slug coexist; only same-collection duplicates conflict. Migration
duplicates are impossible because `invocation_slug` is a brand-new
field key with no pre-existing items setting it. A custom collection
that later adopts the same field name opts into per-collection
uniqueness, matching the FieldDef.UniqueScope="workspace_collection"
semantic.

Parent: PLAN-1377.
2026-05-12 17:17:56 -04:00
xarmian a8b158829b feat(item-detail): gate write affordances on canEditItem (TASK-1105) (#419)
* feat(item-detail): gate write affordances on canEditItem (TASK-1105)

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

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

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

Parent: PLAN-1100.

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

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

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

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

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

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

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

Three findings from round 3:

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

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

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

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

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

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

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

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

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

* fix(item-detail): always reassign pendingNewItemEdit per Codex review (round 6)
2026-05-05 09:41:12 -04:00
xarmian 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 367116b3a0 Unify relation fields and item links into single dependency system (#66)
* feat: unify relation fields and item links into single dependency system

Phase membership (Task→Phase) was previously stored as a UUID in the
item's fields JSON, separate from the item_links table used for
blocks/related/implements relationships. This unifies both into the
item_links table so all item relationships use one system.

Backend:
- Add 'phase' link type to item_links constants
- Migration 021: migrate existing phase field values to item_links,
  strip phase from fields JSON, remove phase field from tasks schema
- Rewrite GetPhaseProgress, GetAllPhasesProgress, GetTasksForPhase
  to JOIN on item_links instead of json_extract(fields, '$.phase')
- Add SetPhaseLink, ClearPhaseLink, GetPhaseForItem, GetTaskPhaseMap
  store helpers with single-phase constraint enforcement
- Create/update handlers intercept 'phase' in fields and route through
  links system; enrich item responses with phase_id/ref/title
- Dashboard orphan detection uses batch GetTaskPhaseMap lookup
- Add PhaseID filter to ItemListParams for link-based list filtering

Frontend:
- Remove relation field type from FieldEditor (no longer needed)
- Add link CRUD UI to item detail page: "Add relationship" inline form
  with link type picker + item search, delete buttons on existing links
- Phase links appear in Relationships section as "In phase"/"Phase"
- ItemCard reads phase from item.phase_title instead of fields.phase
- FilterBar phase filter uses item.phase_id for client-side filtering
- Add api.links.delete to frontend API client
- Fix duplicate {#each} key on dashboard attention list

Implements IDEA-106.

* fix: remove relationLabels prop from BoardView, ListView, TableView

ItemCard no longer accepts relationLabels (phase info now comes from
item.phase_title), so remove the prop from all parent view components
that were passing it through. Also remove unused .cell-relation CSS.

* fix: address PR review — atomic SetPhaseLink, migration safety, error handling

1. Migration 021: remove deleted_at filters so archived tasks and tasks
   pointing to archived phases also get their phase links migrated.

2. SetPhaseLink: wrap delete+insert in a transaction so a failed insert
   doesn't leave the item with no phase link (previously non-atomic).

3. Create/update handlers: return proper HTTP errors when phase link
   operations fail instead of logging warnings and returning 200 OK.
2026-04-04 19:47:57 -04:00
xarmian 8d00ae822d fix: relation field UUID display + link bubble auto-show (BUG-18, BUG-10) (#64)
* fix: show phase title instead of UUID in relation fields

FieldEditor only loaded relation items when the dropdown was opened,
so the initial render showed the raw UUID. Now eagerly fetches relation
items on mount when the field has a value, and shows a loading state
while fetching.

Fixes BUG-18.

* fix: prevent link bubble from auto-showing on page load

The EditorLinkPopover subscribed to the transaction event which fires
during initial document load. If the cursor landed inside a link, the
popover appeared without user interaction. Removed the transaction
listener — selectionUpdate alone correctly handles user-initiated
cursor changes.

Fixes BUG-10.
2026-04-04 16:27:59 -04:00
xarmian 7ba69abb88 Misc improvements: CLI field summaries, editor enhancements, CI and UI polish
Show field summary after create/update CLI commands. Make svelte-check
blocking in CI. Improve editor block handling, field editor layout,
conventions page, and minor UI consistency fixes across pages.
2026-03-27 01:13:07 +00:00
xarmian cc5f7ec20f Fix inconsistent input heights in FieldEditor
Add min-height: 30px to .field-input and .select-trigger so inputs
without placeholders match the height of selects and populated inputs.
2026-03-26 02:00:25 +00:00
xarmian 81579847c6 Initial release
Pad — project management for developers and AI agents.
Single Go binary with embedded SvelteKit web UI, SQLite storage,
CLI, and Claude Code /pad skill integration.

https://getpad.dev
2026-03-26 01:52:36 +00:00