Commit Graph

189 Commits

Author SHA1 Message Date
xarmian 2e00a6769a feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640) (#169)
* feat(web): wire WorkspaceSwitcher into mobile TopBar (TASK-640)

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

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

Parent: PLAN-631.

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

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

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

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

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

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

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

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

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

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

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

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

Parent: PLAN-631.

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

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

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

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

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

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

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

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

Parent: PLAN-631.

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

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

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

Per Codex review on PR #163.
2026-04-20 14:37:10 -04:00
xarmian 174be6f045 feat(web): emoji picker renders as BottomSheet on mobile (TASK-632) (#162)
Swap `EmojiPickerButton` to a mobile-first BottomSheet branch while keeping
the existing absolute-positioned portal dropdown intact for desktop.

- Track `isMobile` via `matchMedia('(max-width: 639.98px)')` using the same
  pattern as `QuickActionsMenu` (the reference implementation from TASK-628).
- When `isMobile`: render the picker inside a `<BottomSheet>` titled "Pick
  an emoji" so the 300ish-px grid is readable full-width and can't clip.
- When `!isMobile`: unchanged — portal + `getBoundingClientRect` math still
  owns positioning inside `<dialog>` modals and at the document root.
- `handleWindowClick` bails early on mobile so it doesn't race the sheet's
  own backdrop/Escape dismissal.

Parent: PLAN-631.
2026-04-20 13:53:42 -04:00
xarmian e4c2ff0a03 fix(web): simplify BottomSheet to fix broken mobile interactions (#161)
* fix(web): simplify BottomSheet to fix broken mobile interactions

The original BottomSheet layered on several advanced behaviors — portal
to body, module-level $state open-stack, focus trap, swipe-to-dismiss,
reactive z-index, stacked-sheet Escape routing — and something in that
stack broke click dispatch on Android Chromium for every button inside
the sheet except the existing quick-action rows.

Root cause investigation: every click handler I wrote in this session
failed to fire on Android (close button, backdrop, footer rows, even
an unrelated debug banner's dismiss), while the existing shipped UI
(CreateCollectionModal etc.) continued to work fine. That narrowed
the problem to something structural in the new components rather than
any specific CSS / event wiring.

Fix: rewrite BottomSheet as a ~100-line clone of the working
CreateCollectionModal pattern — plain {#if open} + overlay +
stopPropagation on the inner panel, nothing more. Mobile-first
CSS docks the sheet to the bottom of the viewport; a single
@media (min-width: 640px) rule centers it as a traditional modal on
desktop. No portal, no module-scope $state, no <svelte:window>, no
focus trap, no swipe gesture.

Tradeoffs deliberately accepted for now:
- Swipe-to-dismiss is gone. Backdrop tap + close button are the
  dismissal paths; the viewport-scoped overlay makes this fine.
- No focus trap. Every other modal in the app already ships without
  one, so this matches existing behavior.
- No stacked-sheet Escape prioritization. Single-sheet usage only.

Can be re-layered carefully later if any of those features are
actually needed, but only one feature at a time with mobile testing
between each.

* fix(web): restore Escape dismissal + ARIA dialog semantics on BottomSheet

Addresses both P2 comments from Codex on PR #161.

- Escape key closes the sheet. Added a svelte:window onkeydown that
  early-returns when !open, matching the pattern used elsewhere in
  the app. This is the keyboard dismissal path for desktop and users
  with hardware keyboards on mobile — and the only keyboard path
  when title is omitted (no close button rendered).
- Restored role=\"dialog\", aria-modal=\"true\", and aria-labelledby
  (pointing at the visible title heading when one is set, falling
  back to aria-label=\"Dialog\" otherwise). Without these, assistive
  tech wouldn't announce modal context and users could continue
  navigating background content.

Stable per-instance heading id uses \$props.id() (SSR-safe), bound to
a top-level const per the Svelte 5 placement rule.

Notably NOT reintroduced: focus trap, portal, module-scope stack,
swipe gesture, reactive z-index. Those were the culprits for the
Android click-dispatch regression and stay out of the simplified
implementation.
2026-04-20 12:27:52 -04:00
xarmian 1dde0d3b58 feat(web): inline + New and Manage affordances in QuickActionsMenu (TASK-629) (#160)
* feat(web): inline + New and Manage affordances in QuickActionsMenu (TASK-629)

Add discovery paths for creating and editing quick actions directly
from the menu. Closes the Problem 2 gap in IDEA-493 — the editor
already existed inside EditCollectionModal but was effectively
invisible from the menu surface.

QuickActionsMenu (gated behind a new canEdit prop):
- "+ New quick action" footer row → toggles an inline form (icon
  picker + label + monospace prompt input + template-variable help).
  On save, PATCHes the collection via api.collections.update, appends
  the new action to settings.quick_actions, and fires
  oncollectionupdated so the parent reloads. Toast on success / error.
- "⚙️ Manage actions" footer row → fires onmanage, which the parent
  wires to open EditCollectionModal deep-linked to the Quick Actions
  tab.
- Trigger button now stays visible for editors even when no actions
  exist yet, so they can bootstrap the first action without
  round-tripping through collection settings.

EditCollectionModal:
- New initialSection?: 'general' | 'fields' | 'display' | 'actions'
  prop. When set, opens the modal directly to that tab instead of
  the default 'general'. Default behavior unchanged.

Route wiring:
- [collection]/+page.svelte: passes wsSlug + canEdit={isOwner} +
  onmanage/oncollectionupdated; tracks editCollectionSection to
  deep-link the existing modal.
- [collection]/[slug]/+page.svelte: same QuickActionsMenu wiring, plus
  imports + renders EditCollectionModal inline (it wasn't present on
  item detail before) so the "Manage actions" link works from item
  pages too.

Parent: IDEA-493.

* fix(web): preserve emoji picker in QuickActionsMenu + navigate on archive

Addresses both P2 comments from Codex on PR #160.

- QuickActionsMenu: the EmojiPickerButton portals its dropdown to
  document.body (.epb-dropdown). The outside-click guard was treating
  portal clicks as "outside" the menu and closing it, losing the
  in-progress emoji selection before the bound value could update.
  Added an exemption for .epb-dropdown and .emoji-picker-button in
  handleWindowClick so clicks inside the picker keep the menu open.
- Item detail page: when EditCollectionModal archives the current
  collection, onupdated fires with no updated arg. The old handler
  just reloaded the sidebar, leaving the user on a now-invalid item
  route with stale state. It now also navigates back to the
  workspace root so follow-up actions don't hit deleted resources.

* fix(web): redirect on collection slug change from item-page modal

When an owner renames the current collection from the item detail
page's EditCollectionModal, the collection's slug can change. The
old onupdated handler updated local state but stayed on the now-
stale /[collection]/[slug] URL — subsequent loadData() calls fetch
by collSlug and would 404.

Mirror the collection-page behavior: navigate to the new collection
slug while preserving the item slug so the user stays on the same
item under its new route.

Addresses Codex round 2 P2 on PR #160.

* fix(web): apply returned collection state in oncollectionupdated

On the collection list page, the oncollectionupdated callback
ignored the updated collection returned by api.collections.update
and waited for loadCollection() to refetch. On slow responses, a
user saving a second quick action in rapid succession would build
the PATCH from stale collection.settings.quick_actions and
overwrite the first action.

Apply the returned collection to local state immediately, then
still trigger loadCollection as a defensive refresh. The item
detail page's handler already does the right thing, so only the
collection page is affected.

Addresses Codex round 3 P2 on PR #160.

* fix(web): reload item after non-navigating collection edit from item page

EditCollectionModal can change schema / field mappings. After a
non-archive, non-rename save on the item detail page, the callback
was only updating the collection reference — not the item — so
stale item.fields could survive a rename or migration. A subsequent
updateField() would then write the full stale fields JSON back to
api.items.update and clobber migrated values.

Call loadData() after non-navigating updates so the item is
refetched alongside the collection. Navigation cases (archive,
slug change) already trigger their own load via the route change,
so we skip the reload on those branches.

Addresses Codex round 4 P2 on PR #160.
2026-04-20 08:51:14 -04:00
xarmian 8592bed2b4 feat(web): mobile BottomSheet + viewport-aware dropdown in QuickActionsMenu (TASK-628) (#159)
Fixes the mobile clipping bug where the quick-actions dropdown opened
off-screen when the trigger wrapped to the left edge of the viewport.

- Below 640px, the menu now renders as a BottomSheet (shipped in
  TASK-627) — full-width, swipe-to-dismiss, backdrop tap / Escape.
- On desktop, the popover is kept but gains:
  - viewport-aware alignment: flips from right-anchored to left-
    anchored when the trigger is within 220px of the viewport's left
    edge, measured via getBoundingClientRect() at open time.
  - max-width: calc(100vw - var(--space-4)) as a defensive clamp.
- Action list is shared between modes via a Svelte 5 snippet to avoid
  markup duplication.
- Outside-click handler short-circuits on mobile so the BottomSheet
  owns dismissal.

Preserves existing clipboard copy + toast behavior and trigger styling.

Addresses Problem 1 in IDEA-493. Parent: IDEA-493.
2026-04-20 07:46:06 -04:00
xarmian e187573792 feat(web): add reusable BottomSheet primitive (TASK-627) (#158)
* feat(web): add reusable BottomSheet primitive (TASK-627)

Introduce $lib/components/common/BottomSheet.svelte — a controlled
bottom-sheet / modal component built on Svelte 5 runes with no new
runtime dependencies.

Features:
- Mobile (< 640px): docked to bottom, full-width, rounded top corners,
  swipe-down-to-dismiss via pointer events (80px threshold).
- Desktop (>= 640px): configurable via `desktopMode` prop — 'sheet'
  (default, bottom-anchored with max-width) or 'centered' (traditional
  centered dialog mirroring the existing .overlay/.modal pattern).
- Escape key, backdrop tap, and swipe-down all trigger onclose().
- Focus trap while open; restores focus on close to the previously
  focused element.
- Body scroll lock while open, safely restored on close/unmount.
- Svelte `fly` + `fade` transitions; honors prefers-reduced-motion.
- role="dialog", aria-modal="true", optional `title` wired to
  aria-labelledby.

No consumers yet — foundation for [[IDEA-493]] (quick-actions mobile fix
and inline New/Manage affordances will consume this in TASK-628 and
TASK-629).

Parent: IDEA-493.

* fix(web): harden BottomSheet focus trap + scroll lock per Codex review

- Focus trap: forward Tab now also pulls focus back when active is
  outside the sheet (assistive tech / programmatic focus change),
  mirroring the Shift+Tab branch. Without this, focus escaping the
  sheet broke modal isolation on subsequent Tab presses.
- Scroll lock: moved to module-level counter + shared prev-overflow
  via acquireScrollLock(). Stacked sheets no longer clobber each
  other — the original body overflow is captured on the first open
  and restored only when the last sheet closes.

Addresses both P2 comments on PR #158.

* fix(web): topmost-only Escape + hydration-safe IDs in BottomSheet

- Escape / Tab trap now gated to the topmost open sheet only. Added a
  module-level open-sheet stack (symbol tokens) so stacked instances
  can identify which one should handle global keyboard events. One
  Escape keypress no longer closes every open sheet at once.
- Replaced Math.random() heading ID with $props.id() (Svelte 5.20+)
  so the aria-labelledby target is stable across SSR and hydration.

Addresses P1 (stacked Escape) and P2 (SSR hydration mismatch) from
Codex review round 2 on PR #158.

* fix(web): tie BottomSheet z-index to open-stack position

Convert the module-level openStack to a Svelte 5 $state array so
each instance can reactively read its position in the stack.
Compute backdrop + sheet z-index from that position (BASE_Z 61, two
slots per stack level) and apply via inline style, replacing the
fixed CSS z-index values.

This keeps visual stacking aligned with the keyboard-topmost logic
(pushOpenSheet / isTopmostSheet) — if a sheet renders earlier in
the DOM but opens later, its visual layer now matches its logical
topmost role instead of being obscured by an older DOM sibling.

Addresses Codex round 3 P2 on PR #158.

* fix(web): skip BottomSheet focus restore when another sheet is open

In stacked-sheet scenarios, closing a non-topmost sheet would still
run previouslyFocused.focus() in the focus-management effect cleanup,
yanking focus out of the active dialog and onto background UI.

Gate the restoration on:
1. openStack contains no tokens other than this instance's token
   (no other sheet is still open), AND
2. document.activeElement is not already inside a different
   role="dialog" ancestor.

If either check fails, skip the restore — another sheet is still in
control of focus.

Addresses Codex round 4 P2 on PR #158.

* fix(web): refine BottomSheet focus restore for stacked topmost close

Round 4's blanket skip-when-others-open was too aggressive. When the
topmost sheet closes while another sheet remains behind it, the
previouslyFocused target typically lives inside that remaining sheet
(it was the active element when this sheet opened) — restoring it is
correct and keeps focus inside the remaining modal.

New rule:
- If no other sheets open → always restore (normal case).
- If others are still open → restore only when previouslyFocused
  lives inside a DIFFERENT still-open dialog (not this closing one).
  Otherwise skip, so we don't yank focus onto background UI.

Addresses Codex round 5 P2 on PR #158.
2026-04-20 07:35:23 -04:00
xarmian cf3ba5510d fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626) (#157)
* fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626)

Real-print testing after BUG-625 showed the repeating fixed-position
`.print-header` approach is fragile -- even with a generous @page top
margin, Chromium's handling of fixed elements during pagination can
overlap content on the first page, and there's no clean way to
coordinate the header with page-break behavior across browsers.

Replace the repeating header with a page-1 document header in normal
flow and simplify.

## Changes

### Template (+page.svelte)
- Remove `.print-header` entirely; drop the `workspaceStore` import
  (no longer needed in print).
- Tag non-computed field-rows with `class:print-empty={isFieldEmpty}`
  when the raw value is null / empty string / empty array. Flag at
  the template level because :empty can't see FieldEditor children.

### Styles (+page.svelte @media print)
- `.title-row` becomes a flex row: title on the left (20pt, wraps),
  item ref on the right (10pt, tabular-nums, nowrap), both aligned to
  the title's first-line baseline.
- `.meta-info` gets a 1px bottom border to separate the document
  header block from the properties card.
- `.field-row.print-empty { display: none !important; }`.
- Drop all `.print-header*` CSS (dead) and the padding/border shared
  rule between header+footer. `.print-footer` now stands alone.

### Global (app.css)
- Shrink @page top margin from 1.25in to 0.6in -- no reserved header
  strip means no clearance needed. Bottom margin stays 1in for the
  fixed footer + `@bottom-right` page number counter.

## Outcome

- No repeating header, no overlap, no workspace/collection context on
  subsequent pages (users who want it can leave browser headers
  enabled in the print dialog).
- Page 1 shows: title+ref header row, meta subtitle, border, properties
  (with empty rows skipped), body, relationships/children if present.
- Footer repeats on every page with Printed date, URL, and Page N.
- 116-line file net -16 lines smaller, app.css -2.

Verified locally via Ctrl+P preview in Chromium before committing.

* fix(web): flip print title-row order so title is left, ref is right (PR #157)

Address Codex P2: DOM order in the template is `[item-ref, title]`,
so `display: flex; justify-content: space-between` kept the ref on
the left and pushed the (flex:1) title to fill the remaining space on
the right — the opposite of the intended BUG-626 header layout.

Use the flex `order` property to reverse only the visual sequence in
print, keeping the template DOM untouched. `.title` gets `order: 1`,
`.item-ref` gets `order: 2` + `margin-left: auto` so the ref sits
baseline-aligned at the right edge and the title claims everything
to its left.
2026-04-18 19:04:32 -04:00
xarmian 7c29413685 fix(web): print title overlap, page number, and select chevrons (BUG-625) (#156)
* fix(web): print title overlap, page number, and select chevrons (BUG-625)

Address three issues surfaced by a real Ctrl/Cmd+P test on an Idea
detail page (PLAN-620 follow-up):

1. Title cut off at top of page 1. The `@page { margin: 1.1in ... }`
   rule was declared in +page.svelte's scoped style block, but Svelte
   scoped-CSS at-rule loading meant the 0.75in default from app.css
   (TASK-621) kept winning. The fixed print header was ~0.4in tall and
   the content area started at 0.75in, but layout timing left them
   overlapping. Consolidate to a single @page rule in app.css with a
   widened `margin: 1.25in 0.6in 1in 0.6in` -- guaranteed clearance.

2. Footer showed "Page 0" on every page. `counter(page)` inside the
   ::after pseudo-element of a fixed-positioned element is captured
   once at initial layout (before pagination) and reused, so it never
   increments. Move the page number into a `@page { @bottom-right {
   content: "Page " counter(page); } }` margin-box where the counter
   evaluates correctly per page. Remove the `.print-footer-page` span
   and its `.print-page-num::after` rule from the item detail page.
   Add `padding-right: 1.2in` to the fixed footer so its content
   doesn't overlap the new margin-box page number.

3. FieldEditor selects still showed a `∨` chevron in print output --
   the chevron is an inline <svg class="select-chevron">, not the
   native UA dropdown arrow, so `appearance: none` on the button had
   no effect. Hide `.select-chevron` and `.select-dropdown` explicitly
   in the global print block.

Bonus: skip empty `.field-row`s via `.field-row:has(.field-value:empty)`
so rows like an unset "Category" don't print as a label with no value.

* fix(web): drop dead empty-field-row print rules (PR #156)

Address Codex P2 review comment on BUG-625. The `:empty`-based rules
added as a bonus to hide label-only rows (e.g. unset "Category")
never actually match in this codebase:

- Non-computed fields wrap a `<FieldEditor>` child inside `.field-value`,
  so `.field-value` always has children and is never `:empty`.
- Computed fields call `formatFieldDisplay(value)`, which returns `"—"`
  for null / empty, so `.computed-value` is never `:empty` either.

Remove the rules rather than leaving dead selectors that suggest the
behavior exists. Hiding blank rows in print is worth revisiting with a
real signal (e.g. a `data-empty` attribute or a template `{#if}`
guard), but out of scope for BUG-625 -- the title / page-number /
chevron fixes are what this PR is about.
2026-04-18 16:27:40 -04:00
xarmian ed4cef94f2 feat(web): print child items as a flat checklist (TASK-624) (#155)
* feat(web): print child items as a flat checklist (TASK-624)

Render a print-friendly checklist of a parent item's children at the
bottom of the printed page, replacing the interactive `.child-items`
view (chart, drag-drop groups, expand toggles, progress bar) which
isn't meaningful on paper.

Format:

  Children (3/5 done)
    [x] TASK-621 · Base @media print stylesheet (done)
    [x] TASK-622 · Print-format the item detail page (done)
    [x] TASK-623 · Print header and footer (done)
    [ ] TASK-624 · Print child items as a flat checklist (in progress)

Implementation (Option A from the task spec):

- A `.print-children` block is rendered alongside the existing
  `.child-items` container, driven by the same `children` state.
- `display: none` on screen; `display: block` in `@media print`.
- The interactive `.child-items` view is hidden entirely in print.
- Checkboxes are textual `[x]` / `[ ]` so they survive in any font;
  status label appears in parentheses for disambiguation beyond the
  terminal-vs-open bucket.
- `page-break-inside: avoid` on the list and on each row so the
  checklist doesn't split awkwardly across pages when possible.
- Nothing renders when the item has no children (the outer
  `{#if loading || children.length > 0}` already short-circuits).

Parent: PLAN-620.

* fix(web): skip print checklist when child load has errored (PR #155)

Address Codex P2 review comment on TASK-624: the print-children block
was rendered whenever `!loading && children.length > 0`, but
`loadChildren()` sets `error` on failure without clearing `children`.
So a navigation or sync failure after a successful initial load could
produce a printed checklist from stale state that contradicts the
visible error banner on screen.

Guard the print block with `!error` so the checklist is suppressed when
the child data is known-bad. No change to the screen view.
2026-04-18 15:59:22 -04:00
xarmian 0778c68ca1 feat(web): print header + footer for item detail pages (TASK-623) (#154)
Add a rendered header and footer that repeat on every printed page.
Replaces the browser's default print chrome (localhost URL, page
title, date).

Header (top of each page):
  {Workspace name} · {Collection icon + name} · {Issue ID}

Footer (bottom of each page):
  Printed {date} · {full URL} · Page {n}

Implementation

- Two new `<div>`s rendered inside the item detail page template:
  `.print-header` and `.print-footer`. Both carry `aria-hidden` and
  are `display: none` on screen, so they never affect the live UI.
- A `@media print` block shows them as `position: fixed` elements at
  top: 0 / bottom: 0. In Chromium this causes the browser to repeat
  them on every page of the print output. Firefox and Safari render
  them only on the first page -- documented as a known limitation
  since Chromium is the expected print target.
- `@page { margin: 1.1in 0.6in 0.9in 0.6in }` carves out space for the
  header and footer strips so body content doesn't overlap them. This
  overrides the default 0.75in margin set in app.css (TASK-621).
- Page number uses `.print-page-num::after { content: counter(page); }`.
  `counter(page)` in a pseudo-element evaluates to the current printed
  page number in all modern browsers.
- Print date and URL are captured via a `beforeprint` listener so the
  values reflect the moment of print, not page-load time. Falls back
  to onMount values if `beforeprint` doesn't fire (older browsers).

Users should uncheck "Headers and footers" in the browser print dialog
for the cleanest result -- there's no CSS to suppress the browser's
default print chrome.

Parent: PLAN-620.
2026-04-18 15:50:27 -04:00
xarmian f9a248f4da feat(web): print-format the item detail page (TASK-622) (#153)
* feat(web): print-format the item detail page (TASK-622)

Layer item-page print formatting on top of the base stylesheet added in
TASK-621:

- Title row renders as plain text (large, serif-friendly, no button
  affordance); issue ref prefix stays as a subtle prefix.
- Meta info (created/updated + actor) keeps as small-print subtitle.
- Properties panel becomes a definition-list block (label / value grid)
  wrapped in a light card, with form widgets stripped so the selected
  value reads as plain text.
- Content layout stacks the fields panel above the markdown body (no
  side-by-side columns in print).
- Code context section, relationships list, and child items stay.
- Comments / activity / version timeline are hidden entirely.
- Action buttons, breadcrumb, share/move/delete controls, edit-mode
  toggle, add-relationship form, save-status chip, link-delete buttons
  are all stripped.

Rendered markdown (.prose) gets a print tune-up in app.css: 11pt body,
inline URL suffix on external links (skipped for wiki-links and fragment
links), break-inside guards on code / images / tables, light-palette
overrides for code blocks and blockquotes. Editor overlays (bubble menu,
link popover, slash menu, mobile toolbar, table toolbar, editor
toolbar) are hidden in print.

Parent: PLAN-620.

* fix(web): keep relationship status chips + print title during edit mode (PR #153)

Address Codex P2 review comments on TASK-622:

- Relationship rows: previously hid the entire `.link-row-actions`
  wrapper, which silently dropped the `.link-status` chip alongside the
  destructive delete button. Hide only `.link-delete-btn` so the status
  stays visible in print.

- Title during inline edit: the screen renders either a `.title` button
  (read) or a `.title-input` textarea (edit); previous rules displayed
  the button and hid the textarea, so printing while editing produced a
  title-less page. Apply the same print typography to both, turning the
  textarea into a non-interactive, borderless plain-text heading.

* fix(web): preserve checkbox field state in print output (PR #153)

Address Codex P2 review comment on TASK-622. The form-widget strip rule
`.field-value button { border: none; background: transparent; }` killed
the visual state of `.toggle` (the checkbox field's switch button),
since it renders state purely via styling — no text label. The printed
page would lose the on/off signal entirely.

Exempt `.toggle` from the strip rule via `:not(.toggle)` and add a
dedicated print style that renders the toggle as an outlined 11pt box;
when the field is on, overlay a check mark via `::after`. The toggle-knob
is hidden (it's the sliding switch visual, not useful in print).

* fix(web): print URL suffixes for SafeLink + print raw markdown legibly (PR #153)

Address Codex P2 review comments on TASK-622:

- Rich-editor links (Tiptap SafeLink extension) render with `data-href`
  instead of `href`, so the print suffix rule `.prose a[href]::after`
  never fired for the main document body. Add a parallel selector
  `.prose a[data-href]::after { content: " (" attr(data-href) ")"; }`
  plus matching skips for internal data-href wiki-links.

- The Markdown editor's raw textarea had no print styling. Printing
  while the Markdown tab was active either clipped the textarea to its
  screen height or rendered with dark-theme chrome. Add a @media print
  block to `RawMarkdownEditor.svelte` that flattens the textarea into a
  plain monospace flow: no border, no background, auto height, visible
  overflow, page-break-inside: auto. Content prints as markdown source
  -- not ideal, but readable and content-preserving.

* fix(web): hoist FieldEditor print strip rules to global scope (PR #153)

Address Codex P2: the `.field-value select / input / button / .toggle`
print overrides were defined inside the item detail page's scoped style
block. Svelte scoped selectors don't cross component boundaries, so the
form widgets rendered inside `FieldEditor` kept their interactive
styling in print preview -- selects rendered with their screen chrome,
toggles disappeared, etc.

Move these rules into app.css's @media print block (which applies
globally) and leave a note in +page.svelte explaining why. The
`.assignment-select` rule stays in +page.svelte because those selects
are inline in this template and correctly scoped.
2026-04-18 15:39:53 -04:00
xarmian e81da7a24f feat(web): add base @media print stylesheet for workspace layout (TASK-621) (#152)
Tune Ctrl/Cmd+P output so Pad pages can be saved as clean PDFs. This is the
first of four tasks under PLAN-620 (Print-friendly item detail pages) and
handles the layout-level chrome: hides the sidebar, top bar, floating expand
toggles, toasts, command palette, modals, and any [data-print-hide] opt-in
element; unlocks the 100vh / overflow:hidden app shell so content flows
across pages; forces a light color palette regardless of theme; strips
shadows and background images; sets a default 0.75in @page margin.

Item-level formatting (title, properties, markdown body), the rendered
print header / footer, and the child-item checklist ship in TASK-622,
TASK-623, and TASK-624 respectively.

Parent: PLAN-620.
2026-04-18 14:59:08 -04:00
xarmian f6aa70efb3 fix(web): schema-driven trigger+scope options in create forms (IDEA-619) (#151)
* fix(web): schema-driven trigger+scope options in create forms (IDEA-619)

Follow-up to PLAN-609. Non-software templates (hiring, interviewing)
ship their own convention + playbook trigger vocabularies via the
Conventions and Playbooks collection schemas, but the web UI's
CREATE forms on both pages were still iterating hardcoded software-
only constants. Users in a non-software workspace could see seeded
items (thanks to the display tolerance added in PR #146) but could
not CREATE new items with the workspace's own vocabulary via the web
UI — only via the CLI.

Both conventions and playbooks pages now:

- Load their collection schema alongside items (non-blocking — a
  failed schema load falls back to the hardcoded software constants
  so the page stays functional offline or against an older server).
- Derive `createTriggers` and `createSurfaces`/`createScopes` from
  the schema's `trigger`/`scope` field `options`, with the hardcoded
  lists as the backstop.
- Drive the create-form `<select>` dropdowns from the derived lists
  instead of the hardcoded constants.
- Snap `newTrigger` / `newSurface` / `newScope` state into the
  effective list when the schema changes so the select never shows
  a phantom value.
- Use the schema-derived lists as the "known" baseline for the
  filter dropdowns (`allSurfaces` / `allTriggers` / `allScopes`),
  still unioned with any trigger/scope values discovered on loaded
  items (preserves the display tolerance from PR #146).

Net effect: in a hiring workspace, the New Convention form's trigger
dropdown shows `on-candidate-advance`, `on-offer-extended`, etc.;
the scope dropdown shows `sourcing`, `screening`, `interviewing`,
`offers`. Interviewing workspace gets its own vocabulary. Software
workspaces are unchanged.

Closes IDEA-619.

* fix(web): guard schema loads against workspace-switch stale responses

Per Codex review on PR #151. When a user navigates between workspaces
quickly, an earlier api.collections.get(...) call for workspace A
might resolve AFTER the user is on workspace B, overwriting the
current schema state with A's schema. The create/filter dropdowns
would then reflect the wrong workspace's trigger/scope vocabulary.

Fix: capture the workspace slug at call time; skip the state
assignment if the current workspace has changed by the time the
response resolves. Symmetrical guard on the catch branch so a failed
call from the previous workspace doesn't null out the current one.

Applied to both conventions and playbooks pages.

* fix(web): clear schema state before workspace-schema fetch

Per Codex review iteration 2 on PR #151. The previous guard only
dropped stale responses AFTER they resolved — but while a new
workspace's fetch was in flight, the old workspace's schema was
still present in state. In that window the create/filter dropdowns
showed the previous workspace's vocabulary on the new page, so a
user could submit a convention with stale trigger/scope values.

Fix: clear conventionsCollection / playbooksCollection to null at
the START of loadXCollection, before awaiting the fetch. During the
in-flight window, createTriggers/createSurfaces fall back to the
hardcoded software defaults — the correct conservative state for a
workspace whose schema we haven't observed yet. The existing
resolved-response stale guard remains.
2026-04-18 07:36:10 -04:00
xarmian dee968e309 feat(web): categorized template picker with icons (TASK-617) (#149)
Turns the web workspace-creation pickers into category-grouped lists
that mirror the CLI picker shipped in TASK-616. Both the full-page
new-workspace flow (/console/new) and the create-workspace modal
now group templates under Software / People / Research / Content /
Operations / Personal headings and render each template's icon.

- WorkspaceTemplate TS type gains optional `category` and `icon`
  (already emitted by /workspaces/templates since TASK-610).
- New shared helper at web/src/lib/utils/templates.ts exposes
  CATEGORY_ORDER (mirrors Go CategoryOrder), categoryLabel, and
  groupTemplatesByCategory. Keeps CLI and web pickers aligned on
  ordering + labels without a third source of truth.
- /console/new: replaced the flat template grid with a grouped
  layout; each group has a small category subhead; each button
  renders tmpl.icon alongside name + description. Offline
  fallback templates (used when the API call fails) updated to
  include category='software'.
- CreateWorkspaceModal: same grouped layout for the create tab,
  with the existing "blank" option retained as a trailing
  category-less button. Icon prefixed on every template card.

Tests
-----
Go side (existing library tests for grouping behavior via
TestGroupTemplatesByCategory cover the shared ordering contract).
Web build verified via `npm run build` — clean.

Parent: PLAN-609.
2026-04-18 06:39:40 -04:00
xarmian b891ba84a4 feat(templates): ship hiring template (TASK-614) (#146)
* feat(templates): ship hiring template (TASK-614)

First non-software template under PLAN-609. Proves the machinery
built by TASK-610 through TASK-613 end-to-end: category grouping,
per-template trigger vocabularies, template-owned starter packs,
domain-specific seed items.

Collections
-----------
- Requisitions (REQ) — open roles, with status/team/level/location
- Candidates (CAND) — applicants, parent-linked to a Requisition
- Interview Loops (LOOP) — interview rounds, parent-linked to a Candidate
- Feedback (FB) — per-interviewer debriefs, parent-linked to a Loop
- Docs — rubrics, process notes
- Conventions + Playbooks — using hiring trigger vocabulary

Trigger vocabularies
--------------------
- HiringConventionTriggers: always, on-candidate-advance,
  on-loop-scheduled, on-feedback-submitted, on-offer-extended,
  on-close-requisition
- HiringPlaybookTriggers: on-candidate-advance, on-interview-scheduled,
  on-feedback-submitted, on-close-requisition, manual
- HiringConventionScopes / HiringPlaybookScopes: all, sourcing,
  screening, interviewing, offers

Starter pack
------------
- Conventions (3): PII handling (must/always), requisition linking
  (should/always), 24h debriefs (should/on-feedback-submitted)
- Playbooks (2): "Advance a Candidate" (on-candidate-advance),
  "Hiring Workspace Onboarding" (manual)
- Seed items: one example Requisition, one example Candidate (both
  labeled as seeded so users can delete or overwrite)

Tests
-----
- TestHiringTemplate — collections present, trigger vocabulary uses
  hiring values and does not leak software triggers (on-commit etc.)
- TestSeedCollectionsFromTemplateHiring — end-to-end: seeding
  creates all seven collections plus populates the starter pack

Parent: PLAN-609.

* fix(web): display hiring triggers on conventions + playbooks pages

Per Codex review P1 on PR #146. The conventions page hardcoded a
software-only TRIGGERS list in its grouping loop, silently hiding
any convention whose trigger wasn't in that list — so a hiring
workspace's seeded conventions (on-candidate-advance etc.) never
appeared in the primary management UI. Same issue on the playbooks
page's filter dropdown.

- conventions page: grouping now iterates the union of the hardcoded
  TRIGGERS (in original order) plus any triggers discovered in the
  data (sorted alphabetically). Unknown triggers fall back to a
  generic bell icon + the raw trigger string via a triggerMeta helper.
  byTrigger is now SvelteMap<string, Item[]>; the narrow Trigger type
  still gates the create form.
- playbooks page: the filter dropdowns for trigger and scope now
  expose the union of the hardcoded list and any distinct values
  found on loaded playbooks. Create form still uses the narrow list.

The broader "derive options from collection schema so the CREATE
forms also follow the workspace's trigger vocabulary" is tracked
as IDEA-619 for a follow-up PR.

* fix(templates): ship explicit prefixes for hiring collections

Per Codex P2 on PR #146. The seeded candidate's content referenced
--parent REQ-1, but the default DerivePrefix turns "Requisitions"
into "REQUI" (strips trailing S, caps at 5), so the example
wouldn't resolve. Similar problems for Candidates (CANDI) and
Feedback (FEEDB).

- Add optional Prefix string field to DefaultCollection so templates
  can override the derived prefix. Empty (the default) preserves
  today's auto-derivation for every existing template.
- Thread DefaultCollection.Prefix through SeedCollectionsFromTemplate
  to the CollectionCreate call — the CreateCollection API already
  supported a Prefix field.
- Hiring template sets explicit prefixes: Requisitions → REQ,
  Candidates → CAND, Interview Loops → LOOP, Feedback → FB. The
  seeded onboarding text's --parent REQ-1 reference now resolves.
- Test asserts the expected prefixes land on the created collections.

* fix: add 'offers' to hiring playbook scopes + tolerate custom scopes in web UI

Per Codex review iteration 3 on PR #146.

- HiringPlaybookScopes now includes 'offers', matching
  HiringConventionScopes. The hiring pipeline has a distinct offer
  stage and playbook workflows tied to offer management were
  previously uncovered in the schema.
- web/conventions page: the scope filter dropdown now exposes the
  union of SURFACES (original order) plus any scopes discovered on
  loaded conventions, via a new allSurfaces $derived. Same pattern
  as the earlier triggers fix. Non-software scopes (sourcing,
  screening, interviewing, offers) now show up for hiring
  workspaces instead of being hidden by the narrow hardcoded list.

The conventions create form still uses the hardcoded SURFACES — that
broader "derive from collection schema" fix is tracked as IDEA-619.

* fix(templates): hiring Feedback BoardGroupBy uses submitted, not recommendation

Per Codex review iteration 4 on PR #146. Feedback items have two
select fields: recommendation (strong-hire/hire/mixed/no-hire/
strong-no, no terminal values) and submitted (pending/submitted,
terminal=submitted). The done-state pipeline prefers
settings.board_group_by when it's a select field, so grouping on
recommendation made terminal detection fall back to checking
recommendation against default done-statuses — never matching,
leaving submitted feedback perpetually 'active' in active-count
views. Group on submitted so completion actually registers.

* fix(templates): Advance-a-Candidate playbook uses valid Feedback fields

Per Codex review iteration 5 on PR #146. The seeded playbook told
agents to create Feedback items with recommendation=pending, but
recommendation's allowed values are only the concrete verdicts
(strong-hire, hire, mixed, no-hire, strong-no) — 'pending' would be
rejected at field validation. Updated the step to use
submitted=pending (which IS in the allowed options) and call out
that recommendation should stay blank until the interviewer
actually records a verdict.
2026-04-18 05:38:33 -04:00
xarmian f222131dfe fix(ui): always show faint hover-only sidebar + buttons and card stars (#141)
* fix(ui): always show faint hover-only controls

The sidebar + buttons and item-card star buttons were fully hidden
until hover, which wasn't discoverable. They're already muted enough
that showing them at reduced opacity is fine, and they still pop to
full opacity on hover.

- Sidebar .section-add-btn: opacity 0 -> 0.5
- Sidebar .nav-quick-add: visibility:hidden -> opacity 0.5
- ItemCard .star-btn: opacity 0 -> 0.4 (unstarred outline ☆ now visible)

Refs IDEA-605

* fix(ui): bump unstarred star opacity 0.4 -> 0.65 for mobile readability
2026-04-17 23:05:40 -04:00
xarmian 9e7daa779f feat: tie done-detection to the board group-by field (TASK-604) (#140)
* feat: tie done-detection to the board group-by field

Closes TASK-604. Make "is this item done?" follow the collection's
settings.board_group_by rather than the hardcoded `status` key. If a
collection's board is grouped by `resolution`, then resolution's
terminal options drive dashboard counts, progress bars, changelog,
and starred-items filtering. Collections without an explicit
board_group_by (every collection today) continue to behave exactly
as before because the fallback resolves to `"status"`.

Why this shape
- No ambiguity: one field per collection wins. No reconciling
  "status says in-progress, resolution says fixed."
- One JSON path to swap: every $.status query becomes
  $.<done_field>. No dynamic OR across schema-discovered fields.
- Matches the mental model: the field you organize the board by is
  the field that represents the item's current state. The old
  mismatch (board grouped by X, "done" count from status) is a
  latent bug this resolves.
- Non-breaking: board_group_by defaults to nil → DoneFieldKey
  returns "status" → behavior identical to pre-TASK-604.

Model layer (internal/models/terminal.go)
- DoneFieldKey(schema, settings) resolves the done-field key with a
  fallback chain: valid select on schema → that field, else "status".
- TerminalValuesForDoneField(schema, settings) returns (fieldKey,
  values) honoring the done field, falling back to
  DefaultTerminalStatuses when the resolved field has no
  terminal_options.
- TerminalPlaceholdersForDoneField(schema, settings) is the SQL
  convenience returning (fieldKey, placeholders, args).
- IsTerminalItem(fields, schema, settings) is the canonical
  Go-side membership check.
- Legacy API (TerminalStatusesFromSchema, IsTerminalStatus,
  TerminalStatusPlaceholders) kept as back-compat wrappers that
  delegate with empty settings — resolve to "status" for callers
  that don't have settings in scope yet.

SQL callers migrated to the new helpers
- internal/store/collections.go ListCollections active-count query
- internal/store/items.go GetItemProgress + GetAllItemProgress:
  - New collectionDoneFilter type + childrenDoneFiltersFor{Parent,
    Collection} + doneFiltersForWorkspace helpers load each
    candidate collection's (schema, settings) and resolve per-
    collection done keys + terminals.
  - buildChildrenDoneExpr(filters, alias) compiles filters into a
    single SQL boolean expression using per-collection OR clauses:
      ((alias.collection_id=? AND LOWER(...)
        IN (?,?)) OR (alias.collection_id=? AND LOWER(...)
        IN (?,?)) ...)
  - Each child item is evaluated against its own collection's
    done rules, so mixed-collection child progress is correct
    without a global union hack.
- internal/store/agent_roles.go GetRoleBreakdown + Go-side filter
- internal/store/item_stars.go starred-items filtering now uses a
  collectionDoneContext map (schema + settings) and IsTerminalItem.

Go-side callers migrated
- internal/server/handlers_dashboard.go: buildSchemaMap →
  buildDoneContextMap (carries settings), isItemTerminal →
  isItemDone (evaluates against the done field). 7 call sites
  updated.
- internal/server/handlers_items.go: plan-progress recompute and
  per-item /progress endpoint now use the done-context approach.

Left status-specific (per task scope)
- Link-payload $.status extracts in items.go getItemLink /
  GetItemLinks / GetParentForItem — these populate
  link.SourceStatus / link.TargetStatus, which are status-specific
  by design.
- cmd/pad reconcile paths — no schema in scope, default-list
  fallback is the right call.
- search.go facet "status breakdown" — a different UX concept
  (bucket search results by status values) than done-detection.

Web UI reactivity
- FieldEditor: new activeDoneField prop. Each modal derives it from
  boardGroupBy with the same fallback rule as the Go DoneFieldKey.
- Fields tab: the "Done?" column header on each select field renders
  an "Active" green pill when that field is the board group-by, or a
  muted "Saved" pill + inline hint otherwise ("Switch the board
  group-by to <key> to make them drive done-detection"). Reactive to
  boardGroupBy changes in the Display tab.
- DisplaySettingsEditor: "Board group by" label gets a helper line
  explaining the new responsibility.

Tests
- internal/models/terminal_test.go: 13 unit tests covering fallback
  resolution, placeholder args, membership (case-insensitive), and
  back-compat shim semantics.
- internal/store/done_field_test.go: 3 integration tests:
  1. Bugs collection grouped by resolution → items with terminal
     resolution values count as done; items with status=fixed but
     resolution=open do NOT count as done (proves status is no
     longer consulted when it isn't the done field).
  2. Collection without board_group_by still uses status terminals.
  3. Mixed-collection children: each child evaluated against its
     own done rules.
All pass alongside the full existing suite.

* fix: restrict done field to select (reject multi_select)

Two linked Codex P1 findings on PR #140, both rooted in the same
gap: multi_select fields store their values as JSON arrays, but both
the Go-side membership check (IsTerminalItem) and the SQL done
expression (buildChildrenDoneExpr) assume a scalar string. Naively
accepting multi_select as a done field would silently miss items
whose terminal value is one of several in the array — dashboards
and progress would report wrong counts.

Rather than implement array-containment semantics across both
paths (which would require deciding "any terminal value → done" vs
"all terminal values → done", SQL-dialect-aware JSON-contains, and
new tests for both shapes), close the gap with a constraint: only
select fields qualify as a done field. If array semantics become
a requirement later, that's a focused follow-up that can update
both paths together with a clear definition.

Changes
- DoneFieldKey and TerminalValuesForDoneField: loop bodies now
  match only `select`, not `select || multi_select`. A
  board_group_by pointing at a multi_select field falls back to
  'status' — matching the rule for non-existent or non-select
  fields.
- IsTerminalItem: docstring made the scalar contract explicit;
  non-string values (which would be the multi_select array shape)
  already returned false, which is now the deliberate behavior.
- buildChildrenDoneExpr: added a doc note that the scalar
  JSON_EXTRACT path is correct because the upstream resolution
  only hands us select fields.
- Web UI: EditCollectionModal + CreateCollectionModal derive
  activeDoneField matching the backend rule (select only), and
  FieldEditor.isActiveDoneField gates on field.type === 'select'.
  A multi_select field never lights up the green "Active" pill now,
  even if a user somehow pointed board_group_by at one.

Tests
- Replaced TestDoneFieldKey_AcceptsMultiSelect with
  TestDoneFieldKey_RejectsMultiSelect. Asserts that a multi_select
  board_group_by falls back to 'status' instead of being honored.
- Existing 12 unit tests + 3 integration tests all still pass.

* fix: include soft-deleted collections in done-filter loaders

Two related Codex P2s on PR #140. The done-filter loaders were
limiting their SELECT to collections with deleted_at IS NULL, but
the outer callers (GetItemProgress, GetAllItemProgress,
GetRoleBreakdown) count items regardless of their collection's
deleted_at. Net effect: after a collection was soft-deleted, its
items lost their per-collection clause in buildChildrenDoneExpr and
were always evaluated as non-terminal — undercounting done in plan
progress and inflating active counts in the role breakdown.

Fix
Drop the `c.deleted_at IS NULL` guard from all three filter
loaders:
- childrenDoneFiltersForParent
- childrenDoneFiltersForCollection
- doneFiltersForWorkspace

Soft-deleted collections still have valid schema + settings rows in
the DB, so the done rules remain applicable until a hard delete
cascades. This also matches what the outer queries count: if they
include items from a soft-deleted collection, the filter loaders
must too.

Regression test
TestGetItemProgress_HonorsSoftDeletedChildCollections:
  1. Create a parent + two children in a child collection where one
     child is done and one is open — assert done=1.
  2. DeleteCollection on the child collection (soft-delete).
  3. Re-run GetItemProgress — assert done is still 1, not 0.
Fails before the filter-loader fix, passes after.

* fix: avoid N+1 in plans progress + preserve done fallback on bad schemas

Two Codex P2s on PR #140.

P2: Avoid N+1 list-collection queries in plans progress
handlePlansProgress's restricted path was calling s.store.
ListCollections solely to build a ctxMap, but ListCollections runs a
separate active-item COUNT query per collection (collections.go),
burning O(number of collections) round-trips on every call. In
larger workspaces this materially inflates latency and can cause
timeouts. Add a lightweight Store.ListCollectionsMinimal that
returns only the ID / Schema / Settings needed for done-context
construction and skips the count queries entirely. Handler switches
to it.

P2: Preserve done fallback for unparseable collection schemas
scanCollectionDoneFilters was `continue`-ing past collections whose
schema failed to parse. Because buildChildrenDoneExpr composes a
per-collection OR clause and only applies the default-list fallback
when NO filters are constructed overall, a single malformed
collection could leave its items without a matching clause —
silently marking them as perpetually active in progress / role /
starred queries. Emit a fallback filter (status + DefaultTerminal-
Statuses) for that collection instead of skipping it, matching
pre-TASK-604 behavior for its items while still honoring the
configured rules for every other collection.

* fix: sanitize done-field keys + cover granted-item collections

Two more Codex findings on PR #140.

P1: Sanitize done-field keys before embedding SQL JSON paths
buildChildrenDoneExpr passes the resolved done-field key straight
into JSONExtractText, whose dialect implementations interpolate it
as a string literal inside `json_extract(..., '$.<key>')` /
`-->>'<key>'`. Schema / settings rows are persisted without backend-
side key validation, so a crafted board_group_by (e.g. a key with
quotes, semicolons, or SQL metacharacters) could break the
resulting query or inject. Since TASK-604 made done-field
resolution dynamic, this needs a chokepoint.

Fix: DoneFieldKey now refuses to resolve to any candidate that
doesn't match ^[a-zA-Z][a-zA-Z0-9_]*$ and falls back to the literal
"status" (which is always safe). The pattern matches the convention
already in use for search-field filtering in internal/server/
handlers_search.go.

Added TestDoneFieldKey_RejectsUnsafeKeys covering injection-shaped
strings, dots, dashes, leading digits, empty strings, and spaces.

P2: Include granted-item collections in dashboard done context
The dashboard was filtering `collections` by visibility BEFORE
building ctxMap, but allItems can still include items from
collections outside the visibility set via item-level grants
(dashItemIDs). Those items missed their own done-rules and
fell back to the status-default, misclassifying them for guests
with item-level grants in collections that use a non-status done
field.

Fix: build ctxMap from ListCollectionsMinimal(workspaceID) first —
always covering every collection in the workspace — then apply
visibility filtering to `collections` for the summary section only.
isItemDone now sees the real done rules for every item the
dashboard iterates, regardless of how visibility surfaced it.

* fix(web): mirror backend safe-key check in activeDoneField derivation

Codex P2 on PR #140. The previous commit added a safe-key regex on
the backend (DoneFieldKey rejects keys outside ^[a-zA-Z][a-zA-Z0-9_]*$
and falls back to "status"), but the Web activeDoneField derivation
in both modals only checked type === 'select'. For legacy / API-
created schemas carrying keys like `resolution-v2` or `foo.bar`, the
Fields tab would display an "Active" green pill on that field even
though the server silently ignores it and falls back to status. Users
could configure terminal options on the wrong field and never see
them take effect.

Fix: export isSafeDoneFieldKey from field-editor-types.ts (a tiny
helper wrapping the same regex the backend uses) and gate both
modals' activeDoneField derivations on it. Unsafe keys fall back to
'status' in the UI, matching the backend's behavior exactly —
Active/Saved pills are now truthful.
2026-04-17 21:45:55 -04:00
xarmian bafb3c2be5 feat(web): create-time Display/Quick Actions + live prompt preview (TASK-599) (#139)
* feat(web): create-time Display/Quick Actions + live prompt preview

Closes TASK-599 in PLAN-593 — the last task.

Closes the parity gap between Create and Edit modals by bringing the
Display and Quick Actions editors to the Create flow (under an
"Advanced" reveal so the default create path stays short), and adds a
live substitution preview to the Quick Actions prompt editor in both
modals.

New shared code
- web/src/lib/utils/quick-action-preview.ts: single source of truth
  for the template-variable list, kept in lockstep with the runtime
  substitution in QuickActionsMenu. Exports parsePrompt() that
  tokenizes a prompt into text / known-var / unknown-var segments,
  plus contextFromItem() (real items for Edit) and
  placeholderContext() (synthetic for Create or empty collections).

- DisplaySettingsEditor.svelte: extracts the 5 display selects
  (default view, layout, board/list group-by, list sort-by) into a
  reusable pure-presentation block with bindable props.

- QuickActionsEditor.svelte: extracts the full Quick Actions sub-UI
  (both Item and Collection sections) with add/remove/reorder logic
  internal to the component. Each action card now renders a live
  preview panel below the prompt input showing the resolved output
  with subtle blue highlights on known variables and red + wavy
  underline on unknown ones. An explicit warning line appears below
  the preview when typos are detected.

EditCollectionModal
- Replaces the inline Display tab markup with DisplaySettingsEditor.
- Replaces the inline Quick Actions tab markup with QuickActionsEditor.
- Fetches the first item in the collection on open
  (api.items.listByCollection limit=1) to build a realistic preview
  context; falls back to placeholder values if the collection is
  empty or the fetch fails.
- Net result: ~390 lines removed (deduped into the components), local
  state for action list and group-by derivation remains here since it
  drives the schema save.

CreateCollectionModal
- New collapsible "Advanced" section below the fields area, collapsed
  by default. Contains DisplaySettingsEditor + QuickActionsEditor.
- New state for default_view / layout / board_group_by / list_group_by
  / list_sort_by / quick_actions, wired into handleCreate's settings
  serialization.
- Template selection now pre-fills the Advanced state from the
  template's settings (board_group_by, default_view, quick_actions
  etc.), so template-provided settings are preserved even for users
  who never open the Advanced section.
- Derived selectFieldKeys / sortableFieldKeys from the (not-yet-saved)
  fields so the group-by pickers reflect what the user is building.
- A small $effect auto-corrects boardGroupBy / listGroupBy when the
  user removes the select field they pointed at (Advanced only —
  doesn't mutate state behind the user's back while collapsed).
- Preview context uses placeholderContext() since no items exist yet;
  the {collection} token updates live as the user types a name.

Out of scope
- Cross-field done-detection (separate, tracked in TASK-604).
- Any new field types / schema additions.

* fix(web): scope-aware previews and honest empty-resolution rendering

Two Codex findings on PR #139, both about preview accuracy:

P2: Use scope-aware context for collection action previews
  Collection-scope actions run with `item` unset in QuickActionsMenu,
  so item-only variables ({ref}, {title}, {status}, {priority},
  {content}, {fields}, {plan}, {phase}) resolve to empty strings at
  runtime. The preview was parsing collection-scope prompts with the
  same item-populated context used for item-scope actions, so the
  preview could show rich substitutions the user would never actually
  get when clicking the action.

  Fix: add toCollectionScope() in quick-action-preview.ts that clears
  item-only variables and keeps only {collection}. QuickActionsEditor
  now derives itemScopeContext (verbatim) and collectionScopeContext
  (reshaped), and the two sections parse against the right one.

P2: Render empty resolved variables as empty in preview
  The preview template `{seg.resolved || `{${seg.name}}`}` treated
  legit empty substitutions as falsy and fell through to the raw
  token, so a known variable that legitimately resolves to `""` at
  runtime (e.g. {plan} with no plan, or any item variable in a
  collection-scope action) was displayed as if the token would be
  copied literally. That's the opposite of what runtime actually
  does.

  Fix: when seg.resolved === '', render an italic muted "(empty)"
  pill with a tooltip explaining the variable resolves to an empty
  string. Non-empty resolutions render unchanged. This surfaces the
  emptiness to the user without lying about what gets copied.

Both fixes pair with the scope-aware context change — collection-
scope previews now correctly show all item variables as "(empty)"
instead of rich values, matching runtime output exactly.

* fix(web): drop template quick_actions from spread so user can clear them

Codex P2 (PR #139): the Create modal merged `selectedSettings` into
the final settings object and only wrote `quick_actions` when
`savedActions.length > 0`. After picking a template with pre-shipped
quick actions, a user who deleted every quick-action row would still
end up saving the template's original quick_actions because they were
re-introduced by `...selectedSettings`. "Remove all quick actions"
was effectively impossible for templates that defined them.

Fix: destructure `quick_actions` out of `selectedSettings` before the
spread, leaving only the non-action template fields (default_view,
board_group_by, etc.) to be merged. `quickActions` state is already
the single source of truth for quick actions — it's populated from
the template on pick and then edited by the user — so the spread no
longer needs to contribute them. This makes `savedActions` ← the
in-editor list authoritative, including when it's empty.
2026-04-17 18:15:17 -04:00
xarmian 6d5fa969e2 feat(web): visual redesign pass across collection modals (#138)
Closes TASK-598 in PLAN-593. Applies the design doc recorded on the
task before implementation.

The work:

1. Field card alignment (resolves the T2 regression)
   Key row moved out of the header flex into a full-width block
   below. Header is now baseline-aligned at a consistent height:
   drag handle, label input, type-select, remove button all sit on
   one row regardless of whether a key row is present. Label-and-
   key feel related without being cramped.

2. Emoji picker parity
   Both modals' General tabs now render EmojiPickerButton (size
   md) next to the name input, matching the Quick Actions pattern.
   The inline .icon-btn + <EmojiPicker> dance, its showEmojiPicker
   state, and all supporting CSS are deleted.

3. Danger zone
   Archive moved out of the footer into a dedicated "Danger zone"
   section at the bottom of the General tab. Red-tinted background,
   red section header, red destructive button that fills on hover.
   Confirmation flow lives inside the section (not crammed into
   the footer). Footer is now Cancel + Save Changes only.

4. Empty states
   Fields empty state gets icon + title + description (not a bare
   string). Quick Actions empty states explain what item / collection
   actions are for, inside a dashed-bordered suggestion block.

5. Template picker
   Blank card shares the same structure as other templates — a
   muted circular + icon wrapper instead of a dashed outline. All
   cards have a consistent min-height so Blank doesn't look stubby.
   Added :focus-visible outline for keyboard users.

6. Typography rhythm
   Section labels normalized to the app convention: 0.75em, 600,
   uppercase, 0.05em tracking, --text-muted. Applied to
   .fields-label, .form-label, .actions-section-title.

7. Responsive
   Both modals: 16px overlay padding, scrollable content, full-
   width under 640px. Edit modal tab bar scrolls horizontally with
   a right-edge fade mask under 640px; settings grid collapses to
   one column; footer buttons fill width.

8. Motion
   Modal fade + subtle scale-in (160ms ease-out). Respects
   prefers-reduced-motion. Existing tab/chevron transitions kept.

Out of scope (per plan): new features (T6), backend changes,
schema shape changes.
2026-04-17 17:27:59 -04:00
xarmian 26d124f19d feat(web): allow terminal toggle on any select/multi_select field (TASK-597) (#137)
* feat(web): allow terminal-option toggle on any select/multi_select field

Closes TASK-597 in PLAN-593 (scope A — UI + persistence).

FieldEditor previously gated the "Done?" column and per-option
terminal toggle on field.key === 'status', matching the pre-T4
behavior exactly. This commit lifts that gate so any select or
multi_select field with at least one option exposes the toggle, and
updates all three save paths (CreateCollectionModal, Edit addedFields,
Edit existingFields) to persist terminal_options for any
select-typed field instead of only status.

Changes
- FieldEditor.svelte: showsTerminalColumn now derives from
  isSelectType && options.length > 0. Removed the inner
  {#if field.key === 'status'} around both the option-done-toggle
  button and the option-terminal class:directive. Extended the
  column-header title to explain what terminal means (dashboard
  filtering, progress bars, changelog) instead of the prior
  status-specific wording.
- CreateCollectionModal / EditCollectionModal save paths: replace
  the key === 'status' gate with a select/multi_select type check.
  Stale terminal values are still filtered to the saved options set
  so renames/removals don't leave orphan terminal pointers.

Scope note: the backend's done-detection (dashboards, progress
bars, search filters, changelog) remains status-centric. Marking
terminal options on a non-status field persists the schema but
doesn't yet affect aggregation — that architectural change is
tracked separately in TASK-604 (follow-up). This scope-A ship
satisfies PLAN-593's "surface, don't hide" principle by making the
UI match the data model without coupling it to the bigger backend
refactor.

Manual smoke test
- Create a "resolution" select field; mark fixed/wontfix/duplicate
  as terminal; save. Reopen collection — terminal markings round
  trip correctly.
- Existing status field behavior unchanged: terminal toggles still
  render, apply, and persist.

* fix(web): align terminal tooltip with status-only backend semantics

Codex P2 (PR #137): the prior tooltip claimed terminal options on
any select field drive dashboard filtering / progress bars /
changelog, but the backend still only reads terminal_options from
the status field (internal/models/terminal.go,
TerminalStatusesFromSchema). That copy misled users into making
configurations that silently do nothing.

Rewrite the tooltip to be honest about current semantics: only the
status field drives aggregation today; markings on other fields are
persisted on the schema for API consumers and for the future
cross-field done-detection work tracked in TASK-604.
2026-04-17 16:32:27 -04:00
xarmian 3463c83bf7 feat(web): contextual browser-tab titles (IDEA-592) (#136)
* feat(web): add page title store and wire root layout (TASK-602)

Foundation for contextual browser-tab titles (IDEA-592 / PLAN-601).

Introduces a centralized rune store at web/src/lib/stores/title.svelte.ts
that composes titles as `{item|section} · {workspace} · Pad` with the most
specific label first (browsers truncate from the right). The root layout's
<svelte:head> renders `<title>{titleStore.title}</title>` reactively.

The store exposes `setPageTitle({ workspace?, section?, item? })` with
per-key merge semantics: omitted keys preserve, `null` clears, strings set.
This lets a layout set the workspace once while leaf pages contribute only
their own section or item ref without clobbering context.

With no route wired yet (TASK-603), all pages continue to render `Pad` —
identical behavior to before, now served via the store.

OG meta tags are unchanged on purpose; this only affects the browser tab.

* feat(web): wire contextual titles for big-four routes (TASK-603)

Completes contextual browser-tab titles for IDEA-592 / PLAN-601.

Each workspace-area route now calls `titleStore.setPageTitle(...)` from
a `$effect` to contribute its slice of context:

- `[username]/[workspace]/+layout.svelte` — sets `workspace` from
  `workspaceStore.current?.name`; clears on destroy so leaving the
  workspace area resets the tab to bare `Pad`.
- `[username]/[workspace]/+page.svelte` (workspace home) — clears
  section/item so only the layout-owned workspace name shows.
- `[username]/[workspace]/[collection]/+page.svelte` — section from
  the loaded collection's display name.
- `[username]/[workspace]/[collection]/[slug]/+page.svelte` — item
  from `formatItemRef(item)` (e.g. `IDEA-592`); section cleared so
  the format reads `{REF} · {Workspace} · Pad` (the ref prefix
  already encodes the collection).
- `[username]/[workspace]/activity/+page.svelte` — static section
  `Activity`; removes the old ad-hoc `<svelte:head><title>` block
  that conflicted with the store-driven root <title>.

Results:
- `/` → `Pad`
- `/{user}/{ws}` → `{Workspace} · Pad`
- `/{user}/{ws}/{collection}` → `{Collection} · {Workspace} · Pad`
- `/{user}/{ws}/{collection}/{ref}` → `{REF} · {Workspace} · Pad`
- `/{user}/{ws}/activity` → `Activity · {Workspace} · Pad`

Niche routes (settings, roles, console, billing) are unchanged and
continue to fall back to `Pad` — they can migrate to the store
incrementally.

* fix(web): clear stale title parts on route change in workspace layout

Addresses Codex P1 on PR #136: `setPageTitle` preserves omitted keys by
design, so navigating from a wired route (item detail, collection list,
activity) to an unwired route (settings, roles, dashboard, library,
playbooks, conventions) left the previous section/item in the tab title.

The workspace layout's title effect now reads `page.url.pathname` so it
re-runs on every SPA navigation and clears `section`/`item` alongside
the `workspace` set. Leaf pages that want to contribute their own parts
continue to do so in their own `$effect`s, which run after this one per
Svelte 5's parent-before-child effect ordering. Unwired routes inherit
the cleared state and correctly fall back to `{Workspace} · Pad`.

* fix(web): re-run activity title effect on pathname change

Addresses Codex P2 on PR #136. The activity page's title `$effect` set
`section: 'Activity'` with no reactive dependencies, so it only fired on
first mount. Because SvelteKit reuses the page component when navigating
between `/{user}/{ws1}/activity` and `/{user}/{ws2}/activity`, and the
workspace layout now clears `section` on every pathname change, the tab
title dropped to `{Workspace} · Pad` after cross-workspace navigation
until a full remount.

Reading `page.url.pathname` at the top of the effect gives it a dep that
changes on every SPA navigation, so the activity section is re-asserted
after the layout's clear.

The other wired leaf pages (workspace home, collection list, item detail)
are not affected: the home page sets only nulls (matches the layout's
clear), and the collection/item effects already depend on reactive state
(`collection?.name`, `formatItemRef(item)`) that gets refreshed on
navigation.

* fix(web): split workspace-name sync from section/item clear in layout

Addresses Codex P1 on PR #136 (third round). The previous combined
effect in the workspace layout depended on both `page.url.pathname` and
`workspaceStore.current?.name`, so every async resolution of the
workspace name would clear `section`/`item` in addition to updating
`workspace`. If a leaf page (e.g. activity) had already set its section
before the workspace resolved, the layout's rerun would wipe it.

Splitting the single effect into two:

1. Workspace-name sync — depends only on `workspaceStore.current`. Only
   touches the `workspace` slot. Safe to fire asynchronously after the
   leaf has set its context.
2. Route-change clear — depends only on `page.url.pathname`. Fires
   exactly once per SPA navigation, clearing `section`/`item`. Leaf
   `$effect`s run after (parent-before-child ordering) and re-assert
   their parts.

Unwired routes still correctly fall back to `{Workspace} · Pad`, and
the activity page retains `Activity · {Workspace} · Pad` after the
workspace-name resolution.
2026-04-17 16:21:08 -04:00
xarmian 5f7b7af50f feat(web): surface required/default/suffix/relation field controls (TASK-596) (#135)
* feat(web): surface required/default/suffix/relation field controls

Closes TASK-596 in PLAN-593.

Expose field capabilities that already round-tripped through
EditableField but had no UI. Controls live in a collapsible Advanced
section on each field card.

FieldEditor
- New exported CollectionOption type for the relation picker input.
- Advanced section (collapsed by default, auto-expanded when any of
  required/default/suffix/collection is already set) containing:
  * Required checkbox (all types)
  * Default value — type-appropriate input:
      text/url   -> text input
      number     -> number input (+ Suffix row below it)
      date       -> date picker
      checkbox   -> "Checked by default" toggle
      select     -> dropdown restricted to the field's options
      multi_select / relation -> deliberately skipped
  * Relates to dropdown (relation type only), populated from the
    workspace collections list passed in via props. Shows a helpful
    empty-state when no other collections exist.
- Computed fields render a muted "computed" badge and the advanced
  inputs are disabled (changing defaults / suffix / required on a
  computed field is nonsensical). Label / type / remove remain
  editable to preserve current behavior.
- Typed input handlers coerce field.default into the right shape
  (string / number / boolean) so the polymorphic value stays clean.

CreateCollectionModal + EditCollectionModal
- Both fetch api.collections.list(ws) lazily on open and pass the
  result down to every FieldEditor as `collections`.
- Both new-field save paths now emit required / computed / suffix /
  collection / default onto the serialized FieldDef. Existing-field
  save path in EditCollectionModal already handled these; this brings
  the new-field path to parity and adds equivalent handling in the
  Create modal.

Behavior notes
- Values round-trip: set in Advanced -> save -> reopen -> still there.
- Emit-when-set keeps payloads compact and compatible with existing
  schemas that don't carry these fields.
- Known visual quirk: the taller card may exacerbate the type-select
  alignment already tracked on TASK-598; deferred to the visual pass.

* fix(web): gate advanced field properties by current field type

Codex P2 (PR #135): the save paths emitted `suffix`, `collection`,
and `default` for every new field regardless of f.type, so a user
could set a number default/suffix, switch the field to `relation` or
`multi_select`, and still persist the hidden value — producing schema
defaults that don't match the final type and are then auto-applied
to new items by ValidateFields.

Fix: gate type-specific advanced-value emission by the current type
at save time. This keeps the user's in-memory state intact (no
surprise clears on type toggle) but prevents stale values from
leaking into the saved schema.

- suffix: only when type === 'number'
- collection (relation target): only when type === 'relation'
- default: only when typeSupportsDefault(type) returns true

Apply the gating in all three save paths:
- CreateCollectionModal.handleCreate (new fields)
- EditCollectionModal.handleSave addedFields (new fields)
- EditCollectionModal.handleSave updatedExisting (existing fields) —
  same pre-existing risk if the user changes an existing field's
  type and hits save

Extract the default-support check into typeSupportsDefault() in
field-editor-types.ts so FieldEditor (which gates the rendered
default input) and the save paths share one predicate. Adjust
FieldEditor's local `supportsDefault` derived to call through it.

* fix(web): coerce and normalize default values at save time

Two related Codex findings on PR #135:

P1: Coerce default values to active field type before save
  Type-switch drift — user sets a boolean default on a checkbox, then
  switches the type to `text`, the stale boolean was previously
  serialized as the text default. ValidateFields later auto-applies
  it to new items without re-validating the value type.

P2: Trim select defaults to match normalized option values
  Option text is trimmed on save ("open " -> "open"), but the select
  default handler stored raw option text, producing schemas with
  `options:["open"]` + `default:"open "` — defaults that aren't in
  the allowed set and get auto-injected as invalid values.

Fix: add coerceDefault(raw, type, options?) to field-editor-types.ts.
Returns undefined when the raw value can't be represented in the
target type (caller drops it). Handles:

- text/url    -> must be a non-empty string
- number      -> number, or parseable non-empty numeric string
- date        -> non-empty string (server validates format)
- checkbox    -> must be boolean
- select      -> trimmed string that exists in normalized options

Wire through all three save paths:
- CreateCollectionModal.handleCreate (new fields)
- EditCollectionModal.handleSave addedFields (new fields)
- EditCollectionModal.handleSave updatedExisting (existing fields,
  where the same type-switch risk applies)

The select-options branch passes the already-normalized `def.options`
into coerceDefault so whitespace drift is caught in the same step as
type coercion.

* fix(web): tighten date coercion, preserve opaque defaults, stable keys

Three Codex findings on PR #135:

P1: Validate date defaults before persisting them
  coerceDefault was accepting any non-empty string for the date type,
  so switching a field from text/select to date could serialize stale
  garbage like "soon" as the date default even though the date input
  renders blank. Tighten the date branch to require ISO 8601 format
  (YYYY-MM-DD, optionally followed by a T-prefixed datetime tail).
  Server still performs stricter parsing; this guard blocks obvious
  invalid strings from leaking through.

P1: Preserve unsupported field defaults during edit saves
  The existing-fields save path dropped `default` whenever
  typeSupportsDefault(f.type) returned false. Opening and saving a
  collection that contained a multi_select or relation default (e.g.
  from an API import) would silently strip those defaults as a side
  effect of unrelated edits — schema-mutating regression.

  Fix: in the existing-fields branch, if the active type isn't UI-
  editable for defaults, pass field.default through verbatim instead
  of dropping it. Types that *are* UI-editable still run through
  coerceDefault. New-field paths are unchanged because new fields
  never carry a pre-existing opaque default.

P2: Use stable unique keys for select default options
  The default-value dropdown for select fields keyed its <option>s by
  text, but duplicate option labels aren't prevented anywhere in the
  editor or save path. A collection with duplicate options would hit
  Svelte's keyed-each duplicate-key behavior and break the control.
  Switch to keying by index for display stability.

* fix(web): clear stale relation options before async reload

Codex P2 (PR #135): loadCollectionOptions() awaited the fetch before
replacing collectionOptions, so a reopened modal — especially after
a workspace switch — briefly showed the previous workspace's
relation targets. A fast user could pick one and persist a slug that
doesn't exist in the current workspace.

Fix: clear collectionOptions = [] synchronously at the start of
loadCollectionOptions(), before awaiting the request. If the fetch
fails the picker falls back to its empty-state hint. Applied in both
CreateCollectionModal and EditCollectionModal.

* fix(web): token-guard collection fetch + checkbox default clear

Two Codex findings on PR #135:

P2: Ignore stale collection-list responses before setting options
  The previous fix cleared collectionOptions at fetch start but still
  unconditionally applied whichever response resolved last. Rapid
  reopens or slow networks could let an older response land after a
  newer one and overwrite it, letting a user persist a relation slug
  from the wrong workspace.

  Fix: add a monotonic collectionsRequestToken in both modals. Bump it
  on each fetch, capture the current value, and drop the response if
  the token has moved on when it resolves. Applied in both success
  and error paths.

P2: Allow clearing checkbox defaults instead of forcing false
  The checkbox default was tri-state at the schema level (no default
  / default false / default true) but the UI only toggled between
  true and false. Unchecking stored `false`, and there was no way to
  get back to `undefined` — so ValidateFields would auto-inject
  `false` into new items even when the user meant "no default".

  Fix: add an explicit "Clear" affordance next to the checkbox that
  shows only when field.default is set. Clears to undefined, leaving
  schema with no default for that field. Preserves the intentional
  `false` case (user wants new items to default to unchecked).

* fix(web): calendar-validate date defaults instead of regex shape only

Codex P2 (PR #135): the date branch of coerceDefault accepted any
string matching the YYYY-MM-DD shape, so impossible dates like
"2026-99-99" or "2026-01-32" could be persisted when users switched
a field from text/select to date. ValidateFields later auto-applies
these as defaults on new items without re-checking, propagating
invalid dates silently.

Replace the shape-only regex with real calendar validation:

- Plain date branch (YYYY-MM-DD): parse month/day, then round-trip
  through Date.UTC and verify the resulting year/month/day match
  the input. Rejects out-of-range components (month > 12) and
  overflow cases (day 32 rolling to next month).

- RFC3339 datetime branch: keep the shape check (stricter than a
  loose `T.+` suffix — rejects "2026-01-01Tnot-a-time"), then confirm
  Date.parse yields a finite timestamp.

Both branches return undefined on rejection so the caller drops the
default rather than persisting garbage.

* fix(web): strict datetime coercion + drop select defaults w/ empty opts

Two follow-up Codex findings on PR #135:

P1: Reject non-RFC3339 datetime defaults in coercion
  Previous fix did shape + Date.parse, but `new Date(...)` silently
  rolls calendar-invalid dates (e.g. "2026-02-31T10:00:00Z" becomes
  March 3) so impossible timestamps still passed. Switch the datetime
  branch to the same component-parse + round-trip technique as the
  YYYY-MM-DD branch:

  - Extract Y/M/D + h/m[/s] from the regex capture groups
  - Range-check each component (month 1–12, day 1–31, h ≤ 23, m/s ≤ 59)
  - Construct a UTC Date from Y/M/D and verify the resulting
    components match the input to catch day overflow

  Date.parse is no longer trusted alone. Out-of-range days,
  impossible calendar dates, and non-RFC3339 strings are all dropped.

P2: Drop select defaults when normalized options are empty
  The save paths passed `def.options` into coerceDefault, but
  `def.options` is omitted when the normalized list is empty, so a
  select field with no options would skip the membership check and
  keep a stale string default. ValidateFields would then auto-apply
  a default that doesn't exist in any allowed set.

  Fix: in all three save paths, pass the already-normalized opts
  array (including []) to coerceDefault when the type is select.
  Non-select types continue to pass undefined since they don't
  consult the options parameter.

  - CreateCollectionModal: use the local `opts` variable
  - EditCollectionModal addedFields: use the local `opts` variable
  - EditCollectionModal updatedExisting: extract a
    `normalizedOpts` local (options were previously inlined) and
    reuse it for both def.options and the coerceDefault call

* fix(web): drop stale default on type switch to multi_select/relation

Two Codex findings on PR #135:

P1: Drop stale default when existing field switches to relation/multi_select
  The existing-fields save path preserved f.default verbatim for every
  UI-unsupported type. That's correct when the field was loaded with a
  pre-existing opaque default (API/import). But it misfires when the
  user sets a default while the field is text/number/select and then
  switches the type to relation or multi_select — the default UI
  hides, but the stale value persists and gets saved.

  Fix: track the load-time type as `originalType` on EditableField and
  only fall through to the verbatim-preserve branch when the active
  type still matches the original. In-session type switches to a
  UI-unsupported type now drop the default instead. New-field paths
  don't need this because new fields never carry pre-existing
  opaque defaults.

P2: Enforce strict RFC3339 datetime shape in default coercion
  The previous datetime regex accepted optional timezone and
  offsets without the colon, so "2026-01-01T10:00" and
  "2026-01-01T10:00+0100" round-tripped as defaults even though the
  backend's time.RFC3339 parser requires seconds + a colon in the
  offset. That lets defaults survive here that the server rejects.

  Fix: require seconds, require timezone, require colon in offset.
  Matches strict RFC3339 / Go time.RFC3339.

* fix(web): validate RFC3339 timezone offsets in date coercion

Codex P2 (PR #135): the datetime regex enforced the `±hh:mm` shape
but never validated the numeric ranges of the offset, so values like
"2026-01-01T10:00:00+99:99" were treated as valid and serialized.
Go's time.RFC3339 (backend parser) rejects those, and defaults are
auto-applied to new items without re-validation, so an invalid
offset would silently propagate.

Add explicit offset bounds: hours 0–23, minutes 0–59 (matching Go's
time.RFC3339 acceptance of ±23:59). `Z` skips the check. Applied
after the regex match in the datetime branch.

* fix(web): raw string number default + defaults-equal type switch check

Two Codex findings on PR #135:

P2: Preserve raw number input until commit
  The number-default input called Number(v) on every oninput and
  wrote the coerced value back to field.default. Because the input
  was controlled by `value={defaultAsString}`, partial typing states
  like "1." collapsed to "1" on each keystroke (Number("1.") === 1),
  making it impossible to type decimals. Negative signs had the same
  problem.

  Fix: keep the raw string in field.default while editing.
  coerceDefault already handles string→number conversion at save
  time and drops garbage strings, so no save-path change is needed.

P2: Track any type switch before preserving hidden defaults
  The existing-fields unsupported-type fallback preserved f.default
  whenever the active type matched originalType. That missed the
  round-trip case: relation → text → relation with a new default
  injected in the middle. Type matches at save but the default is
  stale and un-editable through the UI.

  Fix: snapshot originalDefault at load alongside originalType, and
  only preserve the default when BOTH are unchanged. Otherwise drop.
  Add defaultsEqual() helper to field-editor-types.ts for
  polymorphic comparison (JSON-stringify-based — fine for schema
  defaults, which are always JSON primitives/arrays).

* fix(web): truncate datetime defaults to YYYY-MM-DD for date input binding

Codex P2 (PR #135): <input type="date"> only accepts a YYYY-MM-DD
value. An RFC3339 datetime default like "2026-01-01T10:00:00Z" was
bound directly via defaultAsString and rendered blank, leading users
to believe the field had no default — while field.default remained
populated and was preserved on save through coerceDefault. Result:
hidden datetime defaults that silently survived unrelated edits.

Fix: derive a display-only dateDefaultDisplay string that truncates
anything after the YYYY-MM-DD prefix, and bind the date input to
that. field.default itself stays untouched until the user actually
picks a new date, at which point onDefaultDateInput writes the pure
YYYY-MM-DD value. This keeps API-loaded datetime defaults round-
tripping untouched (when the user doesn't edit them) while making
them visible for manual correction.
2026-04-17 15:36:24 -04:00
xarmian c9f16a04ea feat(web): key/label split + slugification in collection modals (TASK-595) (#134)
* feat(web): add key/label split + slugification to collection modals

Closes TASK-595 in PLAN-593.

Replace the Create modal's stripped-down row form with the shared
FieldEditor component from TASK-594 and introduce a proper key/label
split with auto-slugification, duplicate detection, and reserved-key
validation.

New UI
- FieldEditor shows a muted monospace Key input under the Label input
  for new (unsaved) fields only. The key auto-syncs from the label
  via slugify(). Once the user edits the key manually (keyTouched),
  auto-sync stops and an inline hint explains that keys are immutable
  after save.
- Inline error message + red outline on the key input when the key is
  invalid or collides with another field. Create/Save buttons are
  disabled (with a reason tooltip) while any field has a blocking
  error.

Shared helpers in field-editor-types.ts
- slugifyKey(): lowercase, strip non-[a-z0-9_\s-], collapse whitespace
  and hyphens to underscores, trim, max 40 chars.
- RESERVED_FIELD_KEYS: UI-side reserved list mirroring top-level Item
  JSON fields (id, slug, ref, title, content, created_at, etc.) that
  would shadow core item properties and cause confusion.
- validateFieldKey(): structural validation (non-empty, starts with a
  letter, only lowercase+digits+underscore, not reserved, length).
- fieldFromDef(): hydrate an EditableField from a FieldDef preserving
  the key verbatim — used when loading templates or existing fields
  so slugify doesn't overwrite already-valid keys.

CreateCollectionModal
- Drops the { key, type, options: string } row form.
- Now renders fields via FieldEditor bound to EditableField[].
- Template selection marks loaded fields keyTouched=true so template
  keys stay intact. Duplicate / reserved / empty keys disable Create
  with a hover tooltip reason.
- Save uses field.key directly (no longer derives key from label at
  submit time).

EditCollectionModal
- New fields gain the same key/label split + validation. Duplicate
  detection runs against existing field keys + other new-field keys.
- hasNewFieldBlockingErrors gates the Save button with a tooltip.
- Existing-field behavior is unchanged: label editable, key frozen,
  no key row rendered.

Behavior / regression notes
- User-visible outcome for a typed-and-submitted collection is now:
  label "Target Date" -> key "target_date" (was "Target Date" in
  pre-T2 behavior). This is the intended fix.
- Templates continue to ship with their existing keys verbatim.
- Alignment of the type dropdown against the taller new-field card
  will be revisited in TASK-598 (visual redesign pass).

* fix(web): persist terminal_options on newly-created status fields

Codex P2 (PR #134): the Create modal and EditCollectionModal's new-
fields serialization paths copied key/label/type/options into FieldDef
but not terminal_options. Users could toggle terminal markings on a
status field in the FieldEditor, click Create (or Save), and have
those choices silently dropped — making terminal-dependent behavior
fall back to defaults and misclassify statuses.

Mirror the existing-fields branch that already handles this in
EditCollectionModal: when the field key is "status" and terminalOptions
is non-empty, filter to the options that survived in the saved set and
write them to def.terminal_options.

Key === "status" gating matches the current FieldEditor UI. T4
(TASK-597) will generalize terminal options to any select field and
the gate can be removed there.
2026-04-17 13:18:02 -04:00
xarmian ecea75dcd8 refactor(web): extract shared FieldEditor component (#133)
Pull the field-card UI out of EditCollectionModal into a reusable
FieldEditor.svelte so existing and new fields render identically.
Foundation for PLAN-593 (Collection Modal Redesign, TASK-594).

- Add FieldEditor.svelte with its own scoped styles for the field
  card, reorder buttons, select/multi_select options editor, and
  status-field terminal toggle.
- Add field-editor-types.ts with the shared EditableField interface,
  FIELD_TYPES list, and a blankField() factory.
- EditCollectionModal adopts FieldEditor for both existing and new
  (unsaved) fields, replacing the stripped-down comma-separated row
  form.
- Merge the two field lists into one visual container so new fields
  flow continuously with existing ones; drop the horizontal divider.
- New fields gain reorder buttons within the new-fields list as a
  natural consequence of the unification.
- Save logic, migration building, and status-terminal behavior are
  preserved exactly. Key-from-label derivation for new fields now
  reads from 'label' (was 'key'), maintaining identical user-visible
  behavior until TASK-595 introduces slugification.
2026-04-17 12:38:49 -04:00
xarmian 311067ade1 fix: fall through to legacy title lookup when ref-shaped key misses
A body like `[[ISO-9001]]` or `[[BUG1-5]]` matches REF_PATTERN but may
legitimately be a pre-existing title-based wiki-link (the ref format is
just PREFIX-NUMBER, which overlaps with plausible real titles). The
previous commit returned the raw match immediately on a failed ref
lookup, effectively dropping any ref-shaped legacy titles.

Change the ref branch to fall through to the legacy title / collection
matching paths when no item ref matches. Canonical `[[BUG-585]]` → BUG-586
still resolves correctly because the ref branch wins whenever the ref
exists; only the miss case now continues searching.
2026-04-17 04:24:33 +00:00
xarmian a4496849dc fix: prioritize REF lookup over full-body title match
Codex flagged a precedence inversion: the earlier full-body-first checks
ran before REF_PATTERN, so a canonical ref link like `[[BUG-585]]` would
silently retarget onto a user item whose title happened to match the ref
literal (case-insensitive). Ref storage is now our canonical form via
markdownToWikiLinks, so this path must be deterministic.

Restructure the resolver so ref lookup is always checked first. The
legacy full-body title / collection-qualified title checks only run on
bodies that actually need them — i.e. when the body contains a pipe
(which is the only condition that motivated the full-body check in the
first place: recovering pre-existing "[[A|B]]" / "[[coll/A|B]]" titles).
2026-04-17 04:14:59 +00:00
xarmian 922cb26e1d fix: preserve legacy [[coll/Title]] links whose titles contain |
Follow-up to the previous commit: the full-body title lookup handled the
plain-title case, but collection-qualified legacy links like
`[[tasks/A|B]]` (where the item's real title is "A|B" in "tasks") were
still split on the pipe before the collection/title resolution ran, so
the lookup was attempted for title "A" and the link rendered as plain
text. Add a full-body collection-qualified lookup alongside the full-body
title lookup, both before the `key|display` split.
2026-04-17 04:04:57 +00:00
xarmian de21b1199d fix: preserve legacy [[Title]] links whose titles contain |
Codex flagged a behavioral regression in `wikiLinksToMarkdown`: splitting
the wiki body on the first unescaped `|` unconditionally meant a legacy
link like `[[A|B]]` — where the item's actual title is literally `A|B` —
would be parsed as `key=A, display=B`, fail to resolve, and render as
plain text.

Fix by attempting an exact full-body title match (with `|` intact) before
falling through to the `key|display` split. This keeps the new ref-based
forms (`[[REF]]`, `[[REF|Display]]`) working while recovering pre-existing
content whose titles were never escape-encoded.
2026-04-17 03:55:23 +00:00
xarmian d1a5ea3975 fix: robust wiki-link round-trip and navigable popover (BUG-586 follow-ups)
Follow-up to the BUG-586 fix that surfaced several edge cases under
real-world use. Covers three related improvements to the wiki-link
experience in the editor.

Reference-based wiki-link storage
  Previously `[Title](/url)` round-tripped to `[[Title]]`. That form
  broke for titles containing `[`, `]`, `/`, or `|`. Storage now uses
  the item's opaque ref (e.g. `[[BUG-586]]`, or `[[BUG-586|Custom]]`
  when the visible text differs from the item's current title).
  `wikiLinksToMarkdown` accepts three forms in preference order:
  ref-only, ref-with-display-override, and legacy title lookup.
  Titles can now contain any characters and links survive renames.

Escape-aware parsing
  Both `markdownToWikiLinks` and `wikiLinksToMarkdown` now recognize
  `\.` escape sequences inside their capture groups. tiptap-markdown
  emits `\[`, `\]`, `\\` in link text when the text contains literal
  brackets, so the prior regexes (`[^\]]+`) terminated prematurely and
  missed valid links. Helper functions escape/unescape the markdown
  link-text layer and the wiki-link body layer separately so `]`, `|`,
  and `\` can appear in display-override text.

Leave unresolved [[X]] untouched
  The `[[…]]` regex is greedy and can match spans that were never
  intended as wiki-links — notably `[[` sequences inside another
  markdown link's text. On miss, the function now returns the original
  match verbatim instead of emitting `[…](broken)`, which previously
  hijacked surrounding content and accumulated corruption on each
  save cycle. Broken items heal themselves on the next auto-save.

Picker: show ref + align URL with the route
  The `[[` picker now lists the ref badge next to the title and keys
  `{#each}` by `doc.id` so duplicate titles don't collide. `execLink`
  now reads `page.params.username`/`page.params.workspace` from the
  live route (previously `workspaceStore.current`, which could be
  empty), so the inserted `href` matches the URL shape that the
  round-trip expects.

Clickable link popover
  The popover's URL label is now a real `<a href="…">`. Plain click →
  `goto()` for internal paths, full navigation for external. Ctrl /
  Cmd / middle-click pass through to the browser so "new tab" and
  "copy link" work naturally. `onmousedown.stopPropagation` keeps the
  outer popover's focus-trap from swallowing the click.
2026-04-17 03:44:41 +00:00
xarmian e328844a1b fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
  Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
  is inside a code_block node and writes raw textBetween to the clipboard.
  NodeView for non-mermaid code blocks now shows a hover "Copy" button that
  uses the existing copyToClipboard() util (with execCommand fallback).

BUG-586 — Wiki-link picker matches on item ref
  Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
  so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
  shows the ref as a badge; {#each} key switched to doc.id so duplicate
  titles across collections don't collide.

BUG-588 — Can unlink OAuth provider when password is configured
  Adds a password_set column to track whether a user has a usable
  password vs. the random placeholder hash given to OAuth users.
  CreateUser sets it true, UpdateUser sets it true when a password is
  provided, and ValidatePassword auto-upgrades it on any successful
  email/password login (which transparently upgrades pre-existing users
  who linked OAuth after signing up with a real password — the OAuth
  placeholder hash cannot match user-supplied plaintext, so this is safe).
  handleOAuthUnlink now permits removing the last provider when
  user.HasPassword() is true.

BUG-589 — Pre-auth pages render standalone
  +layout.svelte: isAuthPage now also matches /forgot-password and
  /reset-password/* so those pages don't inherit the authenticated
  sidebar/topbar layout.

BUG-590 — Search no longer crashes with null results
  store.Search() returned a nil Results slice on no-match queries, which
  Go marshals as JSON null; CommandPalette then crashed on results.length.
  Backend now normalizes nil to []SearchResult{} before returning.
  CommandPalette also coalesces resp.results ?? [] on the initial search
  and loadMore paths as belt-and-suspenders hardening.
2026-04-17 03:03:49 +00:00
xarmian e530e5f1ab fix: force full navigation for OAuth link buttons (#131)
The "Link GitHub"/"Link Google" buttons on /console/settings and the OAuth
sign-in buttons on /login were plain <a href="/auth/..."> tags. SvelteKit
intercepted the clicks and did client-side navigation, so the request
never hit the nginx router and pad-cloud never saw it. SvelteKit would
then try to match /auth/github/link against the [workspace]/[collection]
route, 404 on the API calls, and render "Collection not found".

Add data-sveltekit-reload so the browser performs a real HTTP navigation
and the nginx router can forward /auth/* to the pad-cloud sidecar.
2026-04-16 16:41:39 -04:00
xarmian 55a779d838 fix: load workspaces reactively after post-auth navigation (BUG-584) (#130)
* fix: load workspaces reactively after post-auth navigation (BUG-584)

Root layout only loaded workspaceStore inside onMount, guarded by
!isAuthPage. When a user first lands on /login, onMount skips the load
(isAuthPage is true). After login, goto('/console') does a client-side
navigation — the root layout's onMount does NOT re-run, so the
workspace store stays empty. When the user then opens a workspace,
<TopBar /> renders with a blank workspace list until a hard refresh.

The same failure mode affects register, join, reset-password, and any
other post-auth redirect path, since all of them mount the root layout
on an auth page first.

Fix: replace the onMount-local loadAll() call with a reactive $effect
that fires whenever the user is authenticated, the page is an app page
(not auth/share), and the store hasn't been loaded yet. A
workspacesLoaded latch prevents re-firing for users who legitimately
have zero workspaces.

Also fix a broken template-literal in the mobile header <a href>:
\${workspaceStore.current?.slug} was a JS template-literal in a plain
HTML attribute string, which Svelte renders as a literal '$'. Changed
to Svelte's {...} attribute interpolation.

* fix: drop authStore.authenticated gate from workspace loader effect

Addresses Codex P1 feedback on #130. The onMount auth-check block
intentionally swallows authStore.load() failures with a comment
explaining the server may not support auth. Gating the $effect on
authStore.authenticated therefore regressed the original !isAuthPage
behavior for deployments where /api/v1/auth/session is unavailable:
the effect never fired and the workspace list stayed empty.

Remove that one gate. The onMount flow still redirects unauthenticated
users to /login before authReady flips true, so by the time the effect
runs on an app page we're either authenticated or auth is
unsupported/errored — both cases should load workspaces, matching the
original behavior. Comment updated to document why.

* fix: skip workspace load during logged-out-to-/login redirect window

Addresses second Codex P1 on #130. The previous commit dropped the
authStore.authenticated gate from the workspace loader effect, which
fixed auth-unsupported deployments but introduced a new regression:
authReady is set to true inside the !auth / setup_required /
!auth.authenticated branches of onMount *before* goto('/login')
completes. For a logged-out user who first hits a protected route,
during that window the effect saw authReady=true and isAuthPage=false
(still on the protected URL), fired loadAll() (silently 401), and
latched workspacesLoaded=true — blocking the retry after login.

Gate on (authStore.authenticated || authLoadFailed) where
authLoadFailed is set only in the catch branch. This preserves both
the original backward-compat for auth-unsupported deployments and the
correct skip-during-redirect behavior, without coupling the effect
to the rest of the redirect machinery.
2026-04-16 15:45:30 -04:00
xarmian fb56ada1cd fix: align Starred page layout with sibling pages (BUG-579) (#129)
The Starred page used hardcoded layout values (max-width: 800px,
asymmetric padding, vertically-stacked header, smaller h1) while
every other workspace page uses the shared design tokens. Swap to
the canonical pattern from activity/+page.svelte so the page feels
consistent with Activity, Conventions, Settings, etc.

- max-width: 800px -> var(--content-max-width) (960px)
- padding: var(--space-6) var(--space-6) var(--space-12) -> var(--space-8) var(--space-6)
- .page-header: flex row with justify-content: space-between
- .header-top renamed to .page-header-left; redundant margin-bottom removed
- h1 font-size: 1.5em -> 1.6em
2026-04-16 14:03:49 -04:00
xarmian 91920c9085 feat: add collection-level search with Cmd+F (#127)
* feat: add collection-level search with Cmd+F

Intercept Cmd+F / Ctrl+F on collection pages to focus the search
input instead of opening browser search. Replace client-side substring
filtering with API-backed FTS search scoped to the collection, with
200ms debounce and instant client-side fallback while the API responds.

- Cmd+F / Ctrl+F focuses the FilterBar search input
- Escape clears search and blurs the input
- Search uses /search?collection=<slug> for full-text matching
- Client-side filter used as fallback during API debounce
- searchResultIds cleared on all filter/view reset paths
- FilterBar exposes searchInputEl via $bindable prop

* fix: open filters panel on Cmd+F and guard stale search responses

- Open filtersOpen panel before focusing search input so it exists
  in the DOM; use requestAnimationFrame to wait for mount
- Snapshot query before async search and discard response if query
  changed while loading

Addresses codex review on PR #127.

* fix: route Cmd+F through layout keydown handler via UI store

The collection page's svelte:window onkeydown couldn't reliably
intercept Cmd+F because the layout already registers the window
keydown handler. Move Cmd+F handling to the layout's handler and
dispatch via a uiStore.collectionSearchRequested signal that the
collection page watches with $effect.

* fix: clear stale search IDs immediately on new query

Set searchResultIds to null as soon as a new query arrives so the
client-side fallback filter kicks in immediately while the API
debounce is pending. Previously stale IDs from the prior query
would persist during the 200ms gap.

Archived filtering and limit concerns are already handled by the
existing filteredItems pipeline which applies field/status filters
on top of search results.

Addresses codex review on PR #127.
2026-04-15 09:26:17 -04:00
xarmian 9616374a80 feat: upgrade CommandPalette with filters, grouping, and pagination (#126)
* feat: upgrade CommandPalette with filters, grouping, and pagination

Rewrite the Cmd+K search modal as a full-featured search experience:

- Filter chips: collection and status filters from facets, toggle on click
- Grouped results: items grouped by collection with section headers
- Better result cards: ref, title, priority dot, status badge, relative date
- Pagination: "Load more" button appends next page of results
- Recent searches: last 10 queries persisted in localStorage
- Result count displayed below filter chips

* fix: reset loading state on empty query and guard stale loadMore

- Clear loading flag when query is emptied so spinner doesn't stick
- Capture query/filter snapshot before loadMore API call and discard
  the response if they changed while loading

Addresses codex review on PR #126.
2026-04-15 08:10:04 -04:00
xarmian 8351b8194f feat: add faceted counts to search results (#125)
* feat: add faceted counts to search results

Add collection and status faceted counts to SearchResponse so the
frontend can show breakdowns like "Tasks (24) · Ideas (4)". Facets
reflect the full unpaginated result set via two GROUP BY queries
using the same filters as the main search.

- Add SearchFacets type with collections and statuses maps
- Add searchFacets() method using appendSearchFilters for consistency
- Add SearchFacets TypeScript type to frontend
- Add TestSearchFacets covering counts and pagination independence

* fix: include ref-hit items in facet counts

Ref hits (e.g. searching "TASK-42") bypass FTS and wouldn't appear
in FTS-based facet aggregation. Merge them into facets after the
facet queries run so collection/status counts include all results.

Addresses codex review on PR #125.

* fix: remove ref-hit facet merge to avoid double-counting

Unconditionally adding ref hits to facets overcounts when the item
is also found by FTS (the common case). Since we can't cheaply
detect overlap, leave facets as FTS-only. Ref searches typically
return 1 exact match, so the off-by-one is acceptable.

Addresses codex review on PR #125.
2026-04-15 07:37:07 -04:00
xarmian 999bd3cfca feat: add pagination and sorting to search API (#123)
* feat: add pagination and sorting to search API

Extend the search endpoint with limit/offset pagination and sort options.
The response now includes total count (from a separate count query) so
frontends can paginate properly.

- Add Limit, Offset, Sort, Order to SearchParams with Normalize() defaults
- Return SearchResponse struct with total/limit/offset metadata
- Count query runs alongside results query for accurate totals
- Sort options: relevance (default), created_at, updated_at, title
- Add --sort, --limit, --offset flags to CLI search command
- Update frontend SearchFilters and SearchResponse types
- Add TestSearchPagination and TestSearchSorting integration tests

* fix: count ref hits in search totals and handle empty pages

- Ensure total is never less than actual results when direct ref
  matches (e.g. "TASK-5") aren't captured by the FTS count query
- Handle empty page in CLI output: show "No results on this page"
  instead of an invalid descending range like "Showing 11-10 of 5"

Addresses codex review on PR #123.

* fix: paginate ref hits correctly and add sort tie-breaker

- Ref hits now occupy slots on page 0 only; FTS limit/offset adjusted
  so combined results respect the requested pagination contract
- On subsequent pages, ref hits are excluded (already shown on page 0)
- Add i.id as deterministic tie-breaker to all ORDER BY clauses to
  prevent duplicate/missing items across paginated pages

Addresses codex review on PR #123.
2026-04-14 23:40:23 -04:00
xarmian aef0e2326a feat: add collection and field filtering to search API (#122)
* feat: add collection and field filtering to search API

Extend the /search endpoint to support scoping by collection slug and
filtering by structured field values (status, priority, and generic
field.* params). Works on both SQLite FTS5 and PostgreSQL tsvector.

- Add Collection and FieldFilters to SearchParams (store layer)
- Parse collection, status, priority, field.* query params (handler)
- Add SearchFilters type and update api.search() signature (frontend)
- Add --collection, --status, --priority flags to CLI search command
- Add integration tests for collection, field, and combined filtering

* fix: validate field filter keys to prevent SQL injection

Reject field filter keys containing special characters before they
reach JSONExtractText, which interpolates keys directly into SQL.
Keys must match ^[a-zA-Z][a-zA-Z0-9_-]*$ — validation is applied
in both the handler and the store layer as defense in depth.

Addresses codex review on PR #122.
2026-04-14 22:31:18 -04:00
xarmian f7d93d878d feat: add starred items to dashboard (#119)
* feat: add starred items to dashboard

Add starred items section to the dashboard (PLAN-564, TASK-569):

- Dashboard API: fetches non-terminal starred items for the current user,
  applies RBAC visibility filtering, returns as starred_items array
- TypeScript types: add starred_items to DashboardResponse
- Dashboard UI: renders starred items section with card grid, item refs,
  status pills, and "View all" link to /starred page

* fix: cap starred items in dashboard response to 10

Match the same limit applied to active_items, preventing large payloads
on the polling dashboard endpoint for users with many starred items.
2026-04-14 20:31:37 -04:00
xarmian 77d3fe2d72 feat: add Starred sidebar entry and starred items page (#118)
* feat: add Starred sidebar entry and starred items page

Add dedicated starred items view (PLAN-564, TASK-568):

- Sidebar: new " Starred" link below Activity, with active state
- Starred page: shows user's starred items grouped by collection
- "Show completed" toggle to include/exclude terminal status items
- Loading skeleton, empty state with usage instructions
- Excludes "starred" and "roles" from collection slug detection

* fix: reactively remove unstarred items, guard against stale responses

Two fixes for the starred page:

1. Items list is now derived from starredStore.isStarred, so unstarring
   an item via the ItemCard toggle immediately removes it from the page
   without a refetch.

2. Request sequencing via loadSeq counter prevents stale responses from
   overwriting the UI when rapidly toggling "Show completed".

* fix: reserve collection slugs that collide with workspace UI routes

Prevent collections from being created or renamed to slugs that shadow
workspace-level routes (settings, activity, roles, starred, library,
new). If a reserved slug is generated, "-collection" is appended
(e.g. "starred" becomes "starred-collection").

Also fixes starred page: items list is now reactive to unstar actions,
and loadStarred uses request sequencing to prevent stale responses.

* fix: skip store filter on starred page until store is loaded

Trust the API response when starredStore hasn't loaded yet, since
/starred only returns starred items. Apply the reactive filter only
after the store is loaded, so unstar actions still remove items
immediately but initial render isn't broken by async timing.
2026-04-14 20:15:49 -04:00
xarmian 5d23791c1d feat: add star toggle to web UI item views (#117)
* feat: add star toggle to web UI item views

Add per-user item starring to the web UI (PLAN-564, TASK-567):

- API client: star(), unstar(), starStatus(), starred() methods
- Starred store: loads starred IDs on workspace init, optimistic toggle
- ItemCard: star button (☆/★) in top-left, hidden until hover, always
  visible when starred, amber color
- Item detail page: star button in meta-actions header row
- Workspace layout: loads starred store on workspace change

* fix: guard starred store against stale workspace responses

Add a monotonic request counter so that if a user switches workspaces
quickly, an older in-flight response won't overwrite the current
workspace's starred state. Also guards toggle revert against workspace
changes.

* fix: merge in-flight toggles with load result in starred store

Track toggles that occur while the initial load is in-flight via a
pendingToggles map. When the load completes, merge local mutations
on top of the server response so optimistic updates aren't overwritten.
Reverts also update the pending map for consistency.

* fix: preserve toggles on load error, serialize per-item toggles

Two fixes for starred store edge cases:

1. Error path now applies pendingToggles instead of resetting to empty,
   so optimistic toggles survive a failed initial load.

2. Per-item toggle lock (toggleInFlight set) drops rapid duplicate
   clicks while a toggle API call is in flight, preventing out-of-order
   requests from producing inconsistent state.

* fix: clear stale stars immediately on workspace load

Reset starredIds at the start of load() before the async fetch, so a
previous user/workspace's stars are never briefly visible during SPA
navigation or re-authentication flows.
2026-04-14 19:27:52 -04:00
xarmian b620b7e5cc feat: add edit button to collection detail page header (#113)
* feat: add edit button to collection detail page header

Wire up the existing EditCollectionModal to the collection detail page
with a pencil icon button in the header actions bar. Owner-only,
responsive (icon-only on mobile), refreshes page data after saves.

Closes IDEA-494

* fix: navigate to new slug after collection rename in edit modal

When a collection is renamed, the backend regenerates the slug. The
onupdated callback now receives the updated Collection object, so the
collection page can detect a slug change and navigate to the new URL
instead of 404ing on the old slug.

Addresses PR review feedback from #113.

* fix: refresh collectionStore after edit modal update

Ensures the sidebar reflects updated collection name/slug/icon
immediately after editing, matching the settings page behavior.

Addresses P2 review feedback from #113.
2026-04-14 14:37:45 -04:00
xarmian aeda627320 fix: sidebar collection drag-and-drop only moving by one slot (#111)
Fixed race condition where $effect reset sidebarCollections mid-update
because isDraggingSidebar was cleared before API calls completed.

- Capture reordered array locally; keep drag flag true during updates
- Fire all sort_order updates in parallel with Promise.all
- Wrap in try/finally so drag state always resets on failure
- Guard reset with generation counter to prevent stale finalize from
  clobbering a newer drag operation

Fixes BUG-562
2026-04-14 11:19:06 -04:00
xarmian 75bed01701 feat: sticky breadcrumb header with copy item ID button (#112)
* feat: sticky breadcrumb header with copy item ID button

Make the breadcrumb navigation bar sticky so it stays visible when
scrolling on item detail pages. Add a copy-to-clipboard button next
to the item ref (e.g. TASK-5) that shows a "Copied!" tooltip on click.
Uses the existing copyToClipboard utility with non-SSL fallback.

Closes IDEA-501

* fix: lower sticky breadcrumb z-index below mobile header

The mobile layout has a sticky header at z-index 5. Lower the
breadcrumb's z-index to 4 so it slides under the mobile header
instead of overlapping navigation controls.

* fix: offset sticky breadcrumb below mobile header instead of hiding it

Instead of lowering z-index (which hides the breadcrumb behind the
mobile header), offset it with top: 45px on mobile so it stacks
neatly below the mobile nav. Keeps z-index: 10 so the sticky
effect works properly on all screen sizes.
2026-04-14 10:06:27 -04:00