mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 09:33:28 +00:00
504d348917c2fb8ed2c139bbbc352e07fccae19a
170 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
504d348917 |
feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)
Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.
Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
attached/unattached, collection_id) + sort allowlist (size, filename,
created_at — each with desc variant). LEFT JOIN to items + collections
enriches each row with item_title/slug + collection_slug for the
"in [[Item]]" link. Hides derived (thumbnail) rows by default — they
count toward quota but are managed automatically and would clutter
the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
{attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
derived rows directly (returns 400 with derived_attachment code) and
invalidates the storage-usage cache.
Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
(color thresholds at 80%/100%, override badge), 5-select filter row
(category, item, collection, sort, page size), attachment list with
thumbnails (image variants via thumb-sm, emoji icon otherwise), item
link, MIME, size, date, and per-row delete with confirm() dialog.
Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.
Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
storage usage drops to 0 (cache invalidation hook fires) → second
delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
directly via the API.
Parent: PLAN-866.
* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)
Three findings from Codex on PR #303 round 1:
P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.
Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.
P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.
P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".
Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
restricted to one collection sees only that collection's row +
orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
archive/other each return exactly the matching MIME types.
* fix(attachments): item-level visibility on list + delete per Codex (round 2)
Two more findings from Codex on PR #303 round 2:
1. The list filter used VisibleCollectionIDs alone — but that set
includes collections containing any item-level grant for the user.
A guest with one item granted in collection B would still receive
attachment metadata for every item in collection B. Replaced with
the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
the SQL ORs collection-level full access against per-item grants,
matching how handlers_search / handlers_activity narrow lists.
2. The delete endpoint validated workspace membership but never
checked the attachment's parent item is visible to the caller.
An editor with restricted collection access could delete
attachments in hidden collections by guessing/obtaining the
attachment ID. Added requireItemVisible after fetching the parent
item, plus a fallback gate for orphan attachments (item_id IS
NULL) so restricted users get 404 there as well.
Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.
* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)
Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.
Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.
Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.
* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)
Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.
Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.
UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).
Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
restricted-to-correct-collection sees it, restricted-to-other-
collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
to pass on the handler side.)
|
||
|
|
335762c2bf |
feat(attachments): storage usage API + effective-limit computation (TASK-881) (#302)
* feat(attachments): storage usage API + effective-limit computation (TASK-881)
Adds GET /api/v1/workspaces/{ws}/storage/usage returning
{used_bytes, limit_bytes, plan, override_active}. Resolves the effective
limit through the existing three-tier chain (per-user override → platform
setting → hardcoded plan default) and surfaces the override flag for the
upcoming Settings → Storage and admin user-detail UIs.
Implementation:
- store.WorkspaceStorageInfo consolidates SUM(size_bytes) + owner-plan
resolution in one call; WorkspaceStorageLimit is now a thin wrapper so
the upload-time quota check and the API path stay consistent.
- Server.storageInfoCache is a 30s TTL memoizer to absorb repeated
Settings → Storage page loads. Invalidation hooks fire on upload,
thumbnail derivation, and transform — the ~30s eventual-consistency
window is bounded by TTL only when invalidation isn't reachable.
- Defensive copy on cache read so a caller mutating the returned struct
can't poison subsequent reads.
- New CLI command `pad workspace storage` prints "X used of Y (Z%)" with
IEC units (humanBytes helper) and surfaces the override flag.
- TS api.attachments.storageUsage() + WorkspaceStorageInfo type ready
for TASK-882's Settings → Storage page consumer.
Tests:
- Store-level: no-owner fallback, free-plan resolution chain, override
flip, pro-plan override-active visibility, soft-delete exclusion.
- Server-level: empty-workspace happy path, two uploads with cache
invalidation between, dedicated cache TTL/invalidate/copy-safety test.
Parent: PLAN-866.
* fix(attachments): gate storage usage on viewer+ per Codex review (round 1)
Codex correctly flagged that the storage/usage handler relied solely on
RequireWorkspaceAccess, which admits item-grant guests with
workspaceRole=="guest". Workspace-wide quota numbers (used_bytes, plan,
override status) shouldn't surface to guests — every other workspace-
level read handler uses requireMinRole("viewer") for exactly this case.
Adds the explicit gate + a regression test that calls the handler with
a guest-role context and asserts 403.
|
||
|
|
3bf1b60365 |
feat(attachments): editor image crop with aspect presets (TASK-880) (#298)
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.
Editor:
- attachment-crop-modal.ts (new): pure-DOM crop modal in the same
style as the existing image lightbox. Returns a Promise that
resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
when the user clicks Apply, or null on cancel / dismiss /
image-load failure.
- Image fits to a centered <dialog> via flex layout; backdrop
click and Esc both cancel cleanly.
- Crop rectangle starts at 80% of the image, centered. Body is
a "move" handle; four corner handles resize.
- Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
current rect to the new ratio while preserving its center;
subsequent corner drags clamp to the locked ratio.
- Pointer events (touch + mouse for free) with setPointerCapture
so drag continues even if the cursor leaves the handle.
- Coordinate translation: rect in preview-pixel space →
naturalWidth / offsetWidth scale → original-image pixel
space. Result is clamped to natural bounds so a fractional-
rounding overrun doesn't push the rect off-image.
- attachment-image.ts: extracts swapNodeUuid() helper from
runRotate so runCrop can share the setNodeMarkup +
invalidate-old-metadata flow. The toolbar gains a fourth
button (⌶ Crop…) that opens the modal pointed at the original
variant. Per-format gating (refreshToolbarState) treats the
crop button identically to the rotate trio — both go through
/transform, so a libvips-only format (e.g. WebP on the pure-Go
build) disables the whole toolbar with the same explanatory
tooltip.
- app.css: full styling for the crop modal — header with aspect
toolbar, image stage with shadow-cutout overlay around the
crop rect, four corner handles, footer with Cancel + Apply.
Uses the existing CSS-variable palette so light/dark mode
track automatically.
Server tests (3 new):
- TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
PNG crop, verify the response dimensions AND that the served
bytes decode at the same dimensions (guards against an
encode-pipeline off-by-one).
- TestTransform_CropClipsToImageBounds: rect that extends past
the image boundary clips rather than 400ing — the editor's
rounding can produce rect+1px past natural width/height in
rare fractional-scale cases, and the processor's Crop
intersects with image bounds for exactly this reason.
- TestTransform_CropRejectsBadRect: missing rect, zero width,
negative xy, rect entirely outside → 400.
Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
|
||
|
|
f93b0ee4ce |
feat(attachments): server-side rotate transform + editor toolbar (TASK-879) (#297)
* feat(attachments): server-side rotate transform + editor toolbar (TASK-879)
Adds the POST /transform endpoint and the editor's rotate toolbar
on top of TASK-878's Processor abstraction. Rotation produces a NEW
content-addressed attachment row; the editor swaps the AttachmentImage
node's UUID via setNodeMarkup and the original ages into orphan GC.
Server (internal/server/handlers_attachments_transform.go):
POST /api/v1/workspaces/{slug}/attachments/{id}/transform with
body {operation, ...params}. Phase 1 wires the "rotate" branch
(degrees: 90 | 180 | 270 only — pixel-exact reorderings, no
resampling, matches what the editor emits). The "crop" branch
is parsed and validated but the transform path is wired in
TASK-880; defining the wire format here keeps both PRs aligned.
Auth: editor+ on the workspace. Cross-workspace and deleted-parent
probes return 404 (not 403) so the new endpoint can't become a
side-channel for ID enumeration. Unsupported MIME → 415; oversized
image → 413; bad params → 400; missing processor → 503. Output
format follows the same PNG-stays-PNG / else-JPEG policy as the
thumbnail pipeline so derived blobs deduplicate cleanly.
Tests (10): rotate 90 swaps WxH, rotate 180 keeps WxH, bad degrees
→ 400, unknown op → 400, non-existent attachment → 404, cross-
workspace → 404, no processor → 503, derived row has fresh hash +
inherits workspace/uploader/item, served bytes decode at the new
dimensions, deleted-parent → 404.
Web client (web/src/lib/api/client.ts + types):
api.attachments.transform(slug, id, payload) hits the new endpoint
with a discriminated AttachmentTransformRequest type. New
api.server.capabilities() reads the public capability profile
added in TASK-878. Both surface PadApiError on failure so the
editor can show actionable messages.
Editor:
- attachment-metadata.ts (new): shared HEAD-probe cache extracted
from attachment-chip.ts so AttachmentImage's toolbar can probe
the image's MIME with the same zero-extra-network-cost
deduplication. Adds mimeToFormat() — maps MIME to the canonical
short format name the server's Capabilities reports.
- attachment-chip.ts: swapped to use the shared cache. Behavior
unchanged.
- attachment-image.ts: NodeView now wraps the <img> in a
positioned <span> and lazy-builds a 3-button rotate toolbar
(rotate left 90°, rotate 180°, rotate right 90°). selectNode
shows it; deselectNode hides it. On click → calls
options.transform → setNodeMarkup with the returned UUID at
getPos(); cached metadata for the OLD UUID is invalidated.
Per-button gating via refreshToolbarState: empty
supportedFormats list (degraded build) → all disabled with a
"this build doesn't have image processing" tooltip. MIME
probed and not in supportedFormats → disabled with a format-
specific tooltip ("Image editing for image/webp requires
libvips"). Otherwise → enabled with the action tooltip.
- Editor.svelte: configures AttachmentImage with the workspace
slug, the supportedFormats list (initially empty, populated
asynchronously after capabilities resolve), and the transform
callback wired to api.attachments.transform. Errors surface via
console.error + window.alert — same fallback as the upload
plugin until a centralized toast system lands.
- app.css: wrapper + toolbar styles. Toolbar pinned top-right with
absolute positioning; selected-state ring on the image; disabled
button state at 40% opacity.
Parent: PLAN-866. Unblocks TASK-880 (crop) — the /transform endpoint
already accepts the crop op shape, the editor's toolbar pattern is
the same, and the supportedFormats gating composes cleanly.
* fix(attachments): rotate attribution + toolbar refresh per Codex review (round 1)
Two findings from the round-1 Codex review:
1. The transform handler set UploadedBy = currentUserOrSystem(r),
contradicting the comment that said "inherit attribution from
the parent" and creating an audit-attribution drift whenever a
user rotated/cropped someone else's upload. Inherit
parent.UploadedBy instead — same policy as the thumbnail
pipeline. Added TestTransform_DerivedRowInheritsUploadedByFromParent
to lock in the contract. Removed the now-unused
currentUserOrSystem helper.
2. The rotate toolbar's per-format gating could permanently stick
in "all-disabled" state if the user selected an image before
the async capabilities fetch resolved. supportedFormats started
as [] (matching "no processor"), refreshToolbarState ran once
in that state, and the later mutation of ext.options.
supportedFormats had no observer to push the change down to
already-open toolbar DOM. Fix: module-level toolbarRefreshers
set, populated by each NodeView at ensureToolbar() and torn
down in destroy(); a new notifyAttachmentImageCapabilitiesChanged()
export iterates the set and re-runs each toolbar's refresh
hook. Editor.svelte calls it after the capabilities fetch
updates ext.options.supportedFormats, so any toolbar opened
during the in-flight request snaps to its correct state the
moment caps arrive.
Verification: go test ./internal/server -run TestTransform passes
(11 cases now); npm run check passes with the existing 6 warnings.
|
||
|
|
7e5b15722f |
feat(attachments): editor paste + drag-drop upload plugin (TASK-875) (#294)
Tiptap extension that intercepts paste/drop events with files, uploads
each through the attachment API, and replaces the placeholder with the
right node (attachmentImage for image MIMEs, attachmentChip for
everything else) at the position the user dropped.
The plugin's flow:
1. Detect file payloads in clipboardData.items / dataTransfer.files
(skip the event when there are none, so plain text paste / cursor
drag still go through tiptap-markdown's transformPastedText path).
2. Insert a position-tracked placeholder at the paste/drop position
via a setMeta transaction. Placeholders are widget decorations
(zero document width) so they never enter serialized markdown
even if the user navigates away mid-upload.
3. Race the network. The plugin's apply() handler maps every
placeholder position through every intervening transaction, so
continued editing doesn't strand the spinner.
4. On success: schema-aware replacement — attachmentImage for
category=image, attachmentChip otherwise. The placeholder is
removed in the same transaction.
5. On error: remove the placeholder and surface the failure via the
injected onError callback. The upload bytes that did land become
orphans; orphan-GC reclaims them after the grace period.
6. If the placeholder has been deleted before the upload completes
(user cancelled, navigated away, etc.) the upload is dropped
silently — same orphan-GC outcome.
Multiple files in a single drop fan out as concurrent uploads; each
gets its own placeholder and replaces independently as the network
completes.
Editor.svelte wires:
- upload -> api.attachments.upload(workspaceSlug, file)
(rejects with a clear message when no workspace context)
- onError -> console.error + window.alert as a minimal fallback
until a centralized toast system lands.
Styles (app.css) cover the placeholder bubble (dashed border, faded
colour) and a CSS spinner — kept inline via decoration widget DOM so
ProseMirror's selection ignores it (ignoreSelection: true).
Parent: PLAN-866. Closes the editor-input flow on top of TASK-874
(markdown resolver), TASK-876 (image node), TASK-877 (chip node).
|
||
|
|
934794b606 |
feat(attachments): editor file chip node (TASK-877) (#293)
* feat(attachments): editor file chip node (TASK-877)
Custom Tiptap node `attachmentChip` for non-image `pad-attachment:UUID`
references — Notion-style chip rendering with type icon, filename, and
human-readable size.
Node shape:
- uuid: string — the attachments-row UUID
- filename: string — display name; preserved across save/reload
Markdown round-trip:
- Serialize: `[filename](pad-attachment:UUID)` — same standard link
syntax the markdown resolver in TASK-874 understands. `]` and `\`
in the filename are escaped to keep the link label balanced.
- Parse: markdown-it's link token produces
`<a href="pad-attachment:UUID">filename</a>`. Our parseHTML rule
`a[href^="pad-attachment:"]` runs at priority 1000 to beat
SafeLink's default mark rule (priority 50), so attachment refs
become a chip Node instead of a Link Mark on plain text.
Editor display (NodeView):
- <a class="file-chip"> with icon + name + optional size span
- Icon: filename-extension heuristic on first paint, upgraded to a
MIME-based icon once a single HEAD request resolves the canonical
Content-Type. The HEAD goes against the existing GET handler — no
new API endpoint required, and Go's net/http strips the body
automatically for HEAD.
- Size: rendered from Content-Length once HEAD resolves; hidden
until then (CSS `:empty { display: none }`).
- Module-level Promise cache keyed by `${ws}:${uuid}` deduplicates
repeated chips for the same attachment and survives undo/redo
without re-fetching.
- target=_blank + download attribute so a click opens / saves the
file with its canonical filename.
- atom: true → Backspace/Delete remove the chip as a single unit.
Styles in app.css (not Editor.svelte) so they reach the read-only
markdown render path too — TASK-874's Go and TS resolvers emit the
same `.file-chip` / `.file-chip-icon` / `.file-chip-name` /
`.file-chip-size` markup.
Parent: PLAN-866. Unblocks TASK-875 — the upload plugin needs a chip
node to insert on successful non-image uploads.
* fix(attachments): chip HEAD route + chip click handler per Codex review (round 1)
Two findings from the round-1 Codex review:
1. chi router does not auto-route HEAD to GET handlers, so the chip's
metadata HEAD probe was returning 405 and chip size + MIME-refined
icons never loaded. Fix: register HEAD on the same path/handler;
http.ServeContent already strips the body for HEAD on the seekable
path, and the streaming fallback short-circuits before io.Copy so
future S3-style backends don't burn GetObject bandwidth on HEAD.
Tests added: HEAD returns 200 with Content-Type + Content-Length
and an empty body; HEAD cross-workspace returns 404 (not 403) so
the new endpoint can't become a side-channel for ID enumeration.
2. Editor.svelte installs a global anchor-click suppressor that
preventDefaults every <a> inside the editor, so the chip looked
clickable but did nothing in edit mode. Fix: the chip's NodeView
now attaches an explicit click handler that calls window.open with
the download URL and stops propagation before the global handler
runs. Mirrors the AttachmentImage lightbox click pattern.
|
||
|
|
f1ce9ca24a |
feat(attachments): editor inline image node (TASK-876) (#292)
Custom Tiptap node for inline `pad-attachment:UUID` image references.
Stores the attachment UUID (not a backend URL) so item content survives
a storage-backend migration untouched. See DOC-865.
Node shape:
- uuid: string — the attachments-row UUID (required)
- alt: string — preserved across save/reload for accessibility
Markdown round-trip:
- Serialize:  via tiptap-markdown's
addStorage.markdown.serialize, with [/] in the alt text escaped so
brackets stay balanced.
- Parse: markdown-it's default image token already produces
<img src="pad-attachment:UUID" alt="…">, captured by parseHTML
rule img[src^="pad-attachment:"]. The alternate parseHTML rule
img[data-attachment-id] catches editor-rendered HTML on copy/paste.
Editor display:
- addNodeView renders <img class="attachment-image" loading="lazy">
pointing at /api/v1/workspaces/{ws}/attachments/{id}?variant=thumb-md
via an injected getDownloadUrl callback (Editor.svelte resolves the
workspace slug from page.params at mount time, falling back to the
workspace store).
- Single-click opens a native <dialog> lightbox with the original-
resolution variant; multi-click events fall through so users can
drag-select around the image.
- atom: true means Backspace/Delete remove the image as a single
unit and the cursor never lands inside the node.
Lightbox styles live in app.css because the <dialog> is appended to
document.body, outside Editor.svelte's scoped style block.
The configure() default returns the literal `pad-attachment:UUID`
href — sufficient for markdown round-trip in headless / SSR contexts
and a clearly-broken render in any environment that hasn't wired the
URL builder, which is the right signal to fix.
Parent: PLAN-866. Unblocks TASK-875 (the upload plugin needs a node
to insert on success).
|
||
|
|
5af54ddc05 |
feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874) (#291)
* feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874)
Add the shared step that translates `pad-attachment:UUID` markdown
references into rendered HTML for image embeds, file chips, and missing
placeholders. Wired into the editor preview path; Go-side helpers seed
the future server-side rendering pipeline (export / shared item view).
TS side (`web/src/lib/markdown/attachments.ts`):
- Pure helpers: parseAttachmentHref, attachmentDownloadUrl, isImageMime,
formatAttachmentSize, renderAttachmentImage/Chip/Missing
- resolveAttachmentImage / resolveAttachmentLink for the marked hooks
- Image MIME → <img src=...?variant=thumb-md data-attachment-id=...>
- Non-image MIME (or link syntax) → file chip with download attribute
- Missing/deleted → "Missing attachment" placeholder span
`web/src/lib/utils/markdown.ts`:
- renderer.image override (defaulting to marked's standard image when
href is not pad-attachment:)
- renderer.link checks for pad-attachment: prefix before the existing
external/internal-link logic
- renderMarkdown gains an optional attachmentResolver parameter; the
resolver is threaded via a per-call module slot (synchronous render)
- DOMPurify allowlist extended with data-attachment-id, download,
width, height — ALLOW_DATA_ATTR stays false so only this single
data-* attribute slips through
Go side (`internal/server/render/attachments.go`):
- Mirror of the TS API so server-rendered output matches client output
byte-for-byte for the same input
- ResolveAttachmentReferences scans markdown source via regex,
skipping fenced code blocks (backtick + tilde), substitutes both
image and link forms
- Comprehensive table-driven tests (24 cases) covering: href parsing,
URL building, MIME detection, size formatting, image/chip/missing
rendering, escape safety against script-tag injection in alt /
filename / display text, fenced-code skip, tilde fences, title
suffix on link destinations, nil resolver pass-through, no false
positives on non-attachment URLs, deterministic round-trip
References are stored as opaque `pad-attachment:UUID` so a backend
migration (FS → S3) can rewrite storage_keys without touching item
content. See DOC-865 for the architecture.
Parent: PLAN-866 (Attachments Phase 1).
* fix(attachments): chip label double-escape + escaped-bracket lockstep per Codex review (round 1)
Two findings from the round-1 Codex review:
1. TS chip labels were double-escaped. renderer.link was passing the
parseInline(tokens) HTML output to resolveAttachmentLink, which feeds
it into renderAttachmentChip → escapeHtml. A label like
`[**Report**](pad-attachment:id)` rendered literal
`<strong>Report</strong>` instead of plain text. Switched
to the link token's raw `text` field; markdown emphasis inside chip
labels now degrades to literal markers (acceptable for filename-style
labels) and matches what the Go regex extracts.
2. Go regex didn't accept CommonMark `\]` / `\\` escapes inside link/image
labels, so `[Q1 \] report](pad-attachment:id)` resolved on the TS side
(marked handles escapes) but stayed literal on the Go side — breaking
the documented lock-step contract. Updated the regex to accept escaped
characters inside the alt/text capture, and added unescapeMarkdownText
to mirror marked's behavior of dropping the backslash before the label
reaches the render helpers.
Tests added: TestResolveAttachmentReferences_EscapedBrackets covers
image alt, link text, and combined backslash/bracket escapes;
TestUnescapeMarkdownText is the unit-level table for the unescape
helper (including dangling-backslash and non-punctuation pass-through).
|
||
|
|
fc1c47f124 |
feat(attachments): CLI + TypeScript clients + types (TASK-873) (#290)
* feat(attachments): CLI + TypeScript clients + types (TASK-873)
Rounds out the API surface with Go and TS client methods + a
\`pad attachment\` Cobra subcommand for ops debugging.
internal/cli/client.go
AttachmentUploadResult struct mirrors POST /attachments JSON.
UploadAttachment streams a multipart file part via io.Pipe — never
buffers the upload in memory. itemRef is optional. Uses a fresh
http.Client with a 5-minute timeout per request so a 25 MiB upload
over a constrained link doesn't trip the package-shared 10s default.
DownloadAttachment streams the bytes into the caller's writer,
returning Content-Type + total bytes copied. Optional ?variant=
parameter for thumbnails (server falls back to original silently
per TASK-872).
cmd/pad/main.go
pad attachment upload <item-ref|-> <path> [--filename NAME]
pad attachment download <id> <out|-> [--variant thumb-sm|thumb-md]
Item arg accepts an issue ref (TASK-5) or slug; "-" means no parent.
Out arg "-" streams to stdout (with status messages on stderr) so
callers can pipe into image viewers etc. Resolves the item via
GetItem first so a typo'd ref fails fast with a useful error.
List + delete subcommands intentionally omitted — those endpoints
ship with TASK-881 (storage usage) and the future GC task. Adding
client methods that hit 404s would mislead callers; same logic kept
the upload response's "url" out of TASK-871 until TASK-872 wired GET.
web/src/lib/types/index.ts
Attachment interface mirroring the Go model (pointer types → optional).
AttachmentUploadResult interface for the upload response shape.
web/src/lib/api/client.ts
api.attachments.upload(workspaceSlug, file, itemId?) — multipart
POST via direct fetch (skips shared request() because that helper
hard-codes Content-Type: application/json). Carries CSRF, cookies,
and the same 401 → /login redirect.
api.attachments.downloadUrl(workspaceSlug, attachmentId, variant?)
is a pure URL builder so callers can wire <img src> directly without
going through fetch.
End-to-end smoke verified:
pad attachment upload TASK-869 /tmp/tiny.png # uploads PNG
pad attachment download <id> /tmp/dl.png # bytes are identical
cmp /tmp/tiny.png /tmp/dl.png # PASS
Verification
go build ./... — clean
go vet ./... — clean
go test ./... — all packages pass
cd web && npm run build — clean
make install — server restarts on the new binary
Parent: PLAN-866.
* fix(cli): atomic download — write to temp + rename so a failed download doesn't truncate the destination per Codex review (round 1)
P2: pad attachment download <bad-id> /existing/file used to wipe the
existing file on auth/network/404 errors because os.Create truncated
before the request was even attempted. The bytes were never written
because DownloadAttachment errored out, but the destination was
already 0 bytes — a footgun for anyone running the CLI in scripts.
Fix: for the file-path case, write to a sibling .tmp via os.CreateTemp
in the destination directory, fsync, close, then os.Rename only on
success. Same atomic-write pattern as FSStore.Put. The defer cleans
up the .tmp on any error path.
The stdout case (outPath == "-") is unchanged — bytes already
streamed to stdout can't be rolled back, so any partial write is
just visible to the caller as a short payload.
Verified end-to-end:
echo X > /tmp/existing.png
pad attachment download not-a-real-id /tmp/existing.png # errors
cat /tmp/existing.png # still "X" — file untouched
* docs(cli): clarify os.Rename atomic-replace behavior on Windows (Codex round 2 disagreement)
Round 2 flagged this as P2: "os.Rename does not replace an existing
destination on Windows." That is technically incorrect for modern Go.
Verified directly against the Go stdlib source:
src/internal/syscall/windows/syscall_windows.go:
func Rename(oldpath, newpath string) error {
...
return MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)
}
MoveFileEx with MOVEFILE_REPLACE_EXISTING atomically replaces an
existing destination on Windows. This has been the behavior since
Go 1.5 (2015), so every version of Go this codebase supports already
gets the desired replace-on-rename semantics on every platform.
Added an inline code comment so future readers don't worry about the
same false alarm. No code-path change.
|
||
|
|
2f58193f22 |
chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install links were placeholders pointing at GitHub README anchors while TASK-863's docs page didn't exist yet. That page is now live at getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the three URLs to the real docs: - "Other install options →" → https://getpad.dev/docs#installation (broader install matrix: Homebrew + Binary + Docker + Source) - "Documentation" → https://getpad.dev/docs/connect-workspace - "Troubleshooting" → https://getpad.dev/docs/connect-workspace#troubleshooting Updated the in-source comment to reflect that the URLs are now the canonical ones, not placeholders. This closes out PLAN-859 (web-first onboarding on-ramp): a user who creates a workspace in the web UI now has a complete in-app + docs path to connecting that workspace to their local project. |
||
|
|
e5eae5e94e |
feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)
Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.
Server:
- New store method WorkspaceHasCLISource(workspaceID) — backed by
EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
one cli item flips it on, soft-delete flips it back off, and
cross-workspace isolation.
Web:
- New <ConnectBanner> Svelte 5 component
(web/src/lib/components/ConnectBanner.svelte). Self-contained:
reads dismissed state from localStorage, fetches has_cli_source
itself, mounts <ConnectWorkspaceModal> internally. Two split
$effect blocks per CONVE-606 — one for the localStorage sync, one
for the dashboard fetch — so a workspace change doesn't entangle
the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
onboarding-dismissed pattern. Per-browser only; TODO comment in
source about backing it with a workspace_user_state row if cross-
device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
above {@render children()} so it appears on every workspace page
(dashboard, collection lists, item detail, search, activity, etc.)
and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
has_cli_source: boolean.
Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).
Test plan:
- go build ./... && go test ./... — all green (incl. new
TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.
Parent: PLAN-859. Driving idea: IDEA-750.
* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)
Two findings from Codex review on PR #284:
1. Stale-response race: rapid workspace switches could let a slow
dashboard fetch from workspace A overwrite hasCliSource for
workspace B after the user navigated. Capture the requested slug
at fetch time, ignore the response if wsSlug has changed since.
2. Auto-hide didn't work in-session: if a user opened the banner
modal, copied the command, ran it elsewhere, and closed the modal,
the banner stayed visible because hasCliSource was stale. Refetch
when the modal transitions from open → closed (the natural moment
the user has just connected). Uses $effect.pre with a tracked
previous value, matching the transition pattern in ShareDialog.
The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.
* fix(server/items): persist source from auth context on create (Codex round 2)
Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.
Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).
Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
session token in the header since the auth middleware validates
token format and rejects fake values with 401 before the handler
runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')
* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)
Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.
Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.
* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)
Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.
Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.
* fix(store): visibility-filter has_cli_source query (Codex round 5)
Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.
Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.
Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
|
||
|
|
a28767d323 |
feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750. Gives a user who created a workspace via the web UI a one-line copy-paste to connect that workspace to their local repo, exposed in the two zero-state surfaces where they'd look for it. Changes: - New `<ConnectWorkspaceModal>` Svelte 5 component (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no host-page coupling. Matches ShareDialog's modal pattern (overlay + centered modal, native, open = $bindable(), Escape closes). Props: serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed install — macOS/Linux/Windows/Docker, default tab from detected platform) and Step 2 (pad init --url ... --workspace ... snippet with a copy button on the full snippet). Footer links to docs + troubleshooting. - New web/src/lib/utils/platform.ts — tiny dependency-free OS detection helper. SSR-safe (defaults to "macos" with no navigator). - Mounted in the workspace landing page as a "Connect your local project" card directly under <OnboardingChecklist> in the empty- workspace .onboarding-wrapper. Modal itself is mounted unconditionally at the page root so it survives re-renders of the conditional empty state. - Mounted in TopBar.svelte's user menu (both desktop and mobile branches): "Connect a project..." entry between Theme/Cloud-support links and the Sign-out divider. Modal lives outside the dropdown so it doesn't unmount when the dropdown closes. Both gated on workspaceStore.current?.slug since the modal needs a workspace to interpolate. Docs URLs in the modal footer (getpad.dev/docs/install, getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in PLAN-859 will publish those pages and we'll wire the final URLs then. Test plan: - go build ./... && go test ./... clean - cd web && npm run build clean - make install clean, server restarted - Svelte MCP autofixer ran on all four touched files — no findings Parent: PLAN-859. Driving idea: IDEA-750. * fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1) Two findings from Codex review on PR #283: 1. macOS install command was `brew install xarmian/pad/pad`, but the actual tap is `PerpetualSoftware/tap/pad` (per README.md and skills/INSTALL.md). Users would have hit a failing install. 2. Footer links pointed at `getpad.dev/docs/install` and `getpad.dev/docs/connect-local-project` — pages TASK-863 will publish but don't exist yet. Until they do, point at the GitHub README's #installation and #getting-started anchors so clicks at least land somewhere useful instead of 404. The TASK-863 follow-up will swap these back to the dedicated docs URLs once the pages ship. * fix(web/connect-modal): use real install commands from README (Codex round 2) Round 2 caught that Linux/Windows/Docker commands were fabricated: - Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist - Docker used wrong volume mount (/root/.pad vs the image's /data) and didn't publish ports All four tabs now mirror the README's Installation section exactly: - macOS + Linux: brew install PerpetualSoftware/tap/pad - Windows: pointer to the GitHub releases page (no first-party one-liner) - Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad |
||
|
|
86a2f3c55b |
fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) (#281)
* fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) ProseMirror's default copy serialization for selections inside a table included the wrapping <table>...</table> in the text/html clipboard payload. Pasting into rich-text apps (or anywhere that prefers HTML over plain text) reproduced the table styling when the user just wanted the cell text. Add a tableCopyPlugin mirroring the existing codeBlockCopyPlugin pattern: when the selection lives entirely inside a table, write a plain-text representation to text/plain and clear text/html. Cut also deletes the range, same as the code-block plugin. Behavior: - Text selection inside a single cell: cell text on text/plain. - CellSelection (multi-cell drag): tab between cells, newline between rows. Pastes correctly into Excel/Sheets/Numbers. - Selection that spans into/out of the table: falls through to default. Trade-off (accepted): re-pasting a multi-cell copy into our own editor yields TSV text, not a reconstructed table. Matches Linear/Notion/Slack. Fixes BUG-855. * fix(web/editor): preserve parent Table plugins + selection-aware cut per Codex review (round 1) Two findings from Codex review of PR #281: 1. Table.extend's addProseMirrorPlugins was returning only [tableCopyPlugin], replacing the parent extension's plugins and silently dropping columnResizing (negating resizable: true) and tableEditing (cell selection / table editing). Now spreads ...(this.parent?.() ?? []) and appends tableCopyPlugin. 2. Cut path used tr.delete(from, to) which is unsafe for CellSelection — a contiguous document range can include unrelated cells (or row structure) between the rectangular cell-selection's endpoints. Switched to tr.deleteSelection(), which routes through prosemirror-tables' CellSelection.replace override and clears each selected cell's content. Still correct for the TextSelection-inside-one-cell case (deletes the text range as before). The codeBlockCopyPlugin's tr.delete(from, to) is intentionally left alone — that path validates the selection sits inside a single code_block, where from/to is a flat text range and no structural risk exists. |
||
|
|
cc4f1c16b6 |
feat(web): let users switch collection inside the Quick Add modal (TASK-857) (#280)
The Quick Add modal previously locked users into the collection they
launched it from. Replace the static `{icon} New {Singular}` header with
a clickable pill that opens a small popover listing every regular
collection in the workspace; selecting one swaps the target collection
without losing the typed title.
Behavior preserved:
- Default collection still comes from the launch entry point (sidebar
`+`, dashboard buttons, Cmd-N).
- Picker excludes agent collections (conventions, playbooks) via the
existing `regularCollections` filter.
- If only one regular collection exists, the pill renders as a non-
interactive label (no caret, no popover).
- `submitQuickAdd` already re-derives default fields and content
template from the current `quickAddCollection`, so swapping mid-flow
Just Works.
Keyboard:
- Enter / Space / ArrowDown on the pill opens the picker.
- ArrowUp/Down/Home/End navigate; Enter selects; Esc closes the picker
only (textarea Esc still closes the modal).
The outside-click handler is kept as its own `$effect` per CONVE-606
(don't combine reactive triggers in a single effect).
Implements IDEA-749.
|
||
|
|
43b2565afe |
fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849) The custom link renderer called marked.parseInline on the raw text of a link's child tokens to render the visible text. For autolinks (bare URLs that GFM auto-detects as links) the raw text *is* the URL, so the recursive parseInline re-tokenized it as another autolink and re-entered the same renderer — stack overflow, browser console spammed with "Please report this to https://github.com/markedjs/marked", and the item page rendered as fallback text. Triggered on any item whose content or comments contained a bare URL, e.g. HT-786 had a comment with https://manage.maileroo.app. Use marked's intended API: this.parser.parseInline(tokens) renders the already-parsed inline tokens directly, no re-tokenization. Required: - regular function (not arrow) so `this` binds to the Renderer instance (marked invokes overrides via override.apply(rendererInstance, args)) - import Renderer for the `this: Renderer` annotation - escape the title attribute via escapeHtml() at the source instead of relying on DOMPurify after the fact * fix(web): encode href in markdown link renderer (defense-in-depth) Mirror marked's internal cleanUrl() so the custom link renderer produces well-formed HTML even when href contains spaces, quotes, or other URL-unsafe characters — and degrades gracefully to plain text when encodeURI throws (lone surrogates). Before: an href like `http://x" onclick="alert(1)` (reachable via marked's `[x](<...>)` URL-with-spaces syntax) would land in the attribute verbatim, producing malformed HTML the sanitizer then had to repair. After: encodeURI turns the quotes into %22, so the intermediate HTML is already well-formed before DOMPurify runs. The %25 → % round-trip avoids double-encoding hrefs that already contain percent-encoded bytes (e.g. %20). DOMPurify is still the URL-safety authority — javascript:/data: schemes are stripped by sanitizeMarkdownHtml's ALLOWED_URI_REGEXP. This change is defense-in-depth plus correctness for the intermediate HTML, matching the behavior of marked's default renderer. Flagged in Codex review of #274. |
||
|
|
8e067c19db |
feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)
New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').
Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
/admin/metrics/billing with the X-Cloud-Secret header (the same secret
pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
the account-delete tests still satisfy the extended interface.
Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
to render: cloud_unreachable=true (sidecar errored or unwired) and
stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
non-admin gets 403.
Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.
Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
non-200 → SidecarError, transport error stays bare, malformed JSON,
nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
(merges local + remote correctly, handles plan="" → "free", filters
new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
degrades to local-only, transport error degrades, sidecar 5xx degrades,
stripe_configured=false propagates verbatim with cloud_unreachable=false.
Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).
* fix(admin): address Codex review (round 1) on billing-stats proxy
- Replace handler-side ListUsers walk with store.CountBillingAggregates
(two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
for new pro signups). Removes the per-row TOTP decrypt overhead that
ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
healthy" requires cloud_unreachable=false AND stripe_configured=true,
not "both flags false" as previously stated.
Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.
* fix(store): GROUP BY normalised plan expression in CountBillingAggregates
Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.
Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
|
||
|
|
a1bbfabf67 |
fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow menu shipped in IDEA-758 / TASK-759: - Layout collapse: `.workspace-list` had no `flex: 1`, so ResizeObserver fed the shrinking content width back into the fitting calc and ratcheted down to "active pill only". Wrap pills, trigger, and add button in a centered `.workspace-row` that owns `flex: 1`; the row's full width now drives the split. - Trigger position + menu anchoring: trigger now sits next to the last visible pill, and the menu opens directly under the trigger via a `position: relative` `.overflow-anchor` wrapper. - Overflow zone not registering as a drop target: switched from `pointer-events: none` / `transform: scale(0)` to `visibility: hidden` for the closed state. svelte-dnd-action's hit-test uses bounding-rect math (not `elementsFromPoint`), and `scale(0)` confuses its transform-undoing on percentage origins. - Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone is registered before drag starts (mid-drag mount isn't picked up). - Post-drop snap-back: set `dropCooldown = true` synchronously in finalize handlers, before flipping `isDragging`, so the resync effect doesn't clobber the post-drag zones before the persist microtask runs. - Click-after-drop navigation: `dropClickGuard` swallows the synthetic click that fires on the dragged `<a>` after mouseup, preventing `goto()` from firing on every drop. - Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s synchronous `workspaces.find(...)` was leaking a reactive dep on `workspaceStore.workspaces` into both the workspace `+layout` effect and the dashboard `+page` load effect. Wrap both in `untrack(...)` so they only re-run on `wsSlug` change. - Active-pin reject cleanup: rejection paths now call `clearCooldownAfterRejection()` so a stuck `dropCooldown` from the source-zone finalize doesn't gate sync effects forever. - A11y: `aria-expanded` on the trigger now uses a `menuVisible` derived (`overflowOpen || isDragging || dragArmed`) so it matches the visual open state. - Replace `CHROME_RESERVATION = 72` magic number with named parts derived from the actual CSS box model (= 68, was off by 4). |
||
|
|
441f624584 |
feat(web): mobile navbar workspace switcher always present, preserve sidebar state on switch (TASK-761) (#245)
* feat(web): mobile workspace switcher always present, preserve sidebar state on switch (TASK-761)
Implements IDEA-760.
- web/src/routes/+layout.svelte: replace the mobile-header workspace-name link
with <WorkspaceSwitcher mobile /> so the switcher is reachable from both
sidebar states. Add `.mobile-switcher-slot` to flex-fill the gap next to the
hamburger; drop the now-unused `.mobile-title` rules.
- web/src/lib/components/layout/WorkspaceSwitcher.svelte: drop uiStore.onNavigate()
from select() so workspace switching no longer collapses the mobile sidebar —
the user's sidebar state carries over to the new workspace per IDEA-760. Add
same-workspace dashboard parity (mirrors TopBar.handleWsClick) so tapping the
current workspace still gives a one-tap path back to the dashboard.
openCreateModal() retains its uiStore.onNavigate() — separate modal-overlay UX.
* fix(web): tighten WorkspaceSwitcher dashboard URL + a11y on switcher trigger
Codex P2 + nit follow-up to TASK-761:
- WorkspaceSwitcher.select(): same-workspace dashboard branch now reads
owner_username from workspaceStore.current rather than ws.owner_username
(which is typed optional). When isCurrent is true `current` is non-null and
shares the slug, so its owner_username is guaranteed present. Avoids the
edge case where a caller passing a workspace without owner_username would
produce `//slug` (scheme-relative URL) instead of an in-app path.
- WorkspaceSwitcher trigger: add aria-haspopup="menu", aria-expanded={open},
and aria-hidden on the chevron glyph so screen readers get the menu
semantics + open/closed state on the new primary mobile navbar control.
* fix(web): aria-haspopup type matches actual popup (dialog mobile, menu desktop)
Codex follow-up nit on TASK-761: the WorkspaceSwitcher trigger advertised
aria-haspopup="menu" unconditionally, but on mobile the popup is a
role="dialog" BottomSheet and on desktop it's a dropdown of buttons.
Make the hint match the actual surface by deriving from isMobile.
* fix(web): drop aria-haspopup on desktop WorkspaceSwitcher popup
Codex follow-up nit on TASK-761: the desktop popup is a plain dropdown
<div> of buttons without role=menu/menuitem or arrow-key keyboard nav,
so aria-haspopup="menu" overstated the semantics. Mobile keeps
aria-haspopup="dialog" because that branch genuinely renders a
role="dialog" BottomSheet. Desktop falls back to aria-expanded alone,
which is sufficient for "button toggles a popup" without claiming
specific popup type semantics that aren't backed by roles.
|
||
|
|
8346f9348e |
feat(web): replace desktop navbar scroll with overflow menu (TASK-759) (#244)
* feat(web): replace desktop navbar scroll with overflow menu (TASK-759) The desktop top bar's workspace list previously used `overflow-x: auto` with a hidden scrollbar — workspaces past the visible edge were reachable only by horizontal scroll, with no visual cue that anything was hidden. Mobile already solved this via BottomSheet (TASK-637); desktop never got the equivalent. This change implements a "priority+" overflow pattern in TopBar.svelte: - Pills are measured in a hidden ghost row keyed by slug. - A ResizeObserver tracks the visible container's width. - Pills that don't fit move into a `…` overflow menu anchored under the trigger. The active workspace is pinned to the visible row regardless of fit position so the "you are here" cue is never hidden. - The trigger is always rendered (with `visibility: hidden` when empty) to prevent layout oscillation as workspaces are added or removed. Drag-and-drop works to and from the overflow menu on day one. Three dndzones share `type: 'topbar-workspace'`: the visible row, the menu, and the trigger as a single-slot drop target. A 400 ms spring-loaded auto-open lets the user drag onto the trigger and place the dropped item at a precise position inside the menu. Dropping on the trigger without waiting appends to overflow. Active is rejected from overflow finalize and snapped back to visible. Persistence reuses the existing `api.workspaces.reorder()` path. Both zones' finalize events are coalesced into a single persist via queueMicrotask. A 1s `dropCooldown` prevents store→local sync from fighting the just-written order, mirroring BoardView's pattern. Mobile (≤640px) is unchanged — still uses WorkspaceSwitcher BottomSheet. Spec: IDEA-758. * fix(web): address Codex review round 1 (TASK-759) Per Codex review on PR #244, round 1: HIGH — Drop active onto `…` trigger silently dropped active from the persisted order. handleTriggerFinalize stripped active from droppedSafe without restoring it to visibleZone, so persistGlobalOrder rebuilt fullOrder = visibleZone + overflowZone with active missing from both. Now both rejection paths (overflow zone and trigger zone) reset all zones from the un-mutated propVisible/propOverflow derived split and cancel the queued persist via cancelPersist(). MEDIUM — Active-pin rejection in the overflow zone snapped active to the END of visible instead of restoring its original position. Same fix as above — reset from the derived split, which preserves sort order. MEDIUM — Failure rollback was hidden by dropCooldown for ~1s. The catch block now also clears the cooldown timer, immediately resyncs zones from the restored derived split, and unblocks the sync effect. MEDIUM — dropCooldown setTimeouts stacked. Track a single cooldownTimer, clearTimeout it on each new write, and cancel on rollback. MEDIUM — A single long active-workspace name could blow past the bar because active is pinned visible. Cap `.workspace-name` at max-width 200px with ellipsis inside `.workspace-list` and `.workspace-ghost` (not in the overflow menu — full names read better there). LOW — Lost the "click current workspace → workspace dashboard" override during the click-handler refactor. The pre-PR onclick branched on `ws.slug === currentSlug`. Restored. LOW — Pending springLoadTimer / cooldownTimer would survive component destroy. Added an $effect cleanup that cancels both on unmount. * fix(web): address Codex review round 2 (TASK-759) HIGH — Active-pin rejection only worked when the target zone's finalize fired AFTER the source's. svelte-dnd-action does not guarantee the order, so when handleVisibleFinalize ran AFTER handleOverflow/Trigger finalize, it overwrote the freshly-restored visibleZone with its own post-drag items (which excluded active). Added a `dragRejected` flag: target-zone rejection sets it, handleVisibleFinalize early-returns if set so the reset isn't clobbered. Cleared at the start of every consider event so it doesn't bleed across drags. MEDIUM — Cooldown timer race: a prior persist's pending timer was only cleared AFTER awaiting the new persist's reorder/load, so it could fire mid-request and flip dropCooldown false while a newer write was still in flight. Cleared the prior timer at the start of persistGlobalOrder (before the await) instead. * fix(web): address Codex review round 3 (TASK-759) MEDIUM — persistCancelled could leak past a rejected active-pin drag. On pointer DnD svelte-dnd-action finalizes the target zone BEFORE the source. In that order, cancelPersist() runs in the rejection handler when no microtask was queued (the source's schedulePersist hadn't fired yet), then handleVisibleFinalize early-returns on dragRejected without scheduling. The flag was left set, so the next legitimate reorder was silently dropped. Fixed by clearing persistCancelled at the start of schedulePersist — each new schedule begins from a clean slate, regardless of what stale state a prior rejection may have left. |
||
|
|
2c59bfa925 |
fix(web): wire desktop topbar workspace switching through last-route restore (#243)
* fix(web): wire desktop topbar workspace switching through last-route restore (TASK-754 follow-up)
The TASK-754 restore logic only fired from WorkspaceSwitcher.svelte
(used on mobile). On DESKTOP, the workspace switcher is the topbar's
horizontal workspace icon list, which used plain `<a href>` links to
`/{owner}/{slug}` — bypassing restore entirely and silently
overwriting the workspace's saved deep route on every left-click.
Symptom (reported by user): "navigate to a deep page → storage updates
to that page → navigate to another workspace → saved value sticks →
click back via topbar → lands on dashboard, and the saved value gets
overwritten back to dashboard."
Fix:
- Extract the validation+pickup logic into a pure helper at
`web/src/lib/utils/workspace-route.ts` (`workspaceRestoreTarget`).
- WorkspaceSwitcher.svelte's `select()` now delegates to the helper
(no behavior change on mobile).
- TopBar.svelte intercepts plain left-click on each workspace `<a>` to
goto the restore target. `href=` stays pointed at the dashboard so
modifier-clicks (cmd/ctrl/shift/alt) and middle-click still open a
fresh dashboard in a new tab.
Other workspace nav surfaces are left alone on purpose:
- Sidebar Dashboard nav item, mobile-header workspace name, and
/console workspace cards are not "switchers" — semantically they're
Home/breadcrumb/picker navigation that should always land on the
dashboard.
Parent: IDEA-753.
* fix(web): clicking current workspace in topbar goes to dashboard
When the user clicks the workspace they're already in, override the
last-route restore and go straight to the dashboard. Gives users a
way back to the workspace home from any nested route. Clicking a
different workspace still restores its last-visited route.
|
||
|
|
b999a7aaee |
feat(web): restore last-visited route on workspace switch (TASK-754) (#240)
* feat(web): restore last-visited route on workspace switch (TASK-754)
The workspace switcher previously always landed on the dashboard. Now
the workspace +layout writes the current pathname to localStorage on
every navigation (keyed by `pad-last-route-{wsSlug}`), and the
switcher reads that key on click and routes there instead — falling
back to the dashboard on miss, storage error, or any saved path that
doesn't belong to the target workspace (guards username changes,
corrupt entries, cross-workspace bleed).
Storage layer:
- Per CONVE-606, the persistence is its own $effect with a clean
dependency list (wsSlug + pathname) — combining with the title
sync above would re-run on async workspace-name resolution.
- Storage failures (private mode, disabled storage) swallowed; the
feature degrades to the previous dashboard-only behavior.
UX:
- Direct Dashboard navigation (sidebar + mobile header use plain
`<a href>` to the workspace root) is unaffected — only the
switcher takes the last-route path.
- Initial page load is unchanged (URL-driven).
- Stale targets (deleted item) take the user to the existing 404
surface; subsequent navs overwrite the bad entry.
Implements IDEA-753.
Parent: IDEA-753.
* fix(web): persist query string + clear cache on item-fetch error per Codex review (round 1)
Round 1 Codex findings (TASK-754):
- MEDIUM: Storing only `pathname` dropped URL-carried collection state
(?view, ?sort, ?group-by, filters, ?q). Now persist
`pathname + search`. Switcher splits on '?' before validating the
path-portion against the target workspace prefix.
- LOW: A restored route to a since-deleted item became a sticky
re-entry target — the leaf page renders an inline error and the
+layout effect re-saves the same dead URL on every visit. Now the
item-detail catch path clears `pad-last-route-{wsSlug}` so the next
switcher click falls back to the dashboard. The cache repopulates
on the user's next nav.
Parent: IDEA-753.
* fix(web): stale-request guard + path canonicalization per Codex review (round 2)
Round 2 Codex findings (TASK-754):
- LOW: The item-page catch path cleared 'pad-last-route-{wsSlug}' with
no stale-request guard. If the user opened a deleted item then
navigated away in the same workspace before the fetch rejected, the
+layout effect would save the new valid route first, then the old
rejected catch would clobber it. Now we capture (username, wsSlug,
collSlug, itemSlug) at loadData entry and only clear the cache if
its current value still points at THAT failed URL. Comparison
strips ?query / #hash before checking.
- LOW: WorkspaceSwitcher's split-on-'?' prefix check could be bypassed
by encoded traversal (e.g. /owner/ws/%2e%2e/other?q=1) — passes
startsWith(fallback + '/') textually but goto() normalizes outside
the workspace path. Now we canonicalize via URL(saved, origin) and
require: same origin, workspace prefix on the normalized pathname,
and no '/..' / '/./' / '//' / percent-encoded chars in the path
(the app never generates any of those).
Parent: IDEA-753.
|
||
|
|
fe4ff887a0 |
fix(web): truncate long parent titles on item cards (BUG-630) (#238)
`item.parent_title` is populated by `enrichItemForResponse()` (via
`GetParentForItem()`) for both `parent` AND `implements` link types
— see `childLinkTypes` in `internal/store/items.go:17`. So when a task
implements an idea (a common pattern via the Implements relationship),
the idea's title becomes the task's `parent_title` and renders in the
`.meta-parent` chip on the item card.
That chip had `white-space: nowrap` and no width cap, so a long idea
title (e.g. an idea recorded as a full sentence — "we should add a
'pad info' cli command that provides information about the local
instance" is 89 chars) pushed the card past its column bounds on
Board view.
Fix:
- `.meta-parent`: add `overflow: hidden; text-overflow: ellipsis;
max-width: 100%; min-width: 0;` alongside the existing `nowrap`,
so the chip truncates with an ellipsis at the card-content edge.
- `.card-meta`: add `min-width: 0` so flex children with intrinsic
content wider than the card can actually shrink instead of forcing
the parent to grow.
- Template: bind a single `parentLabel` `@const` and pass it through
to a `title={parentLabel}` attribute on the chip so the full label
is still accessible via hover tooltip after truncation.
Affects both Board and List views (ItemCard is shared); the original
report focused on Board where columns are narrowest.
Verified manually on the running server with the known offending
item (`add-pad-server-info-for-local-and-remote-connection-status`
in docapp/tasks, parent IDEA-322, 89-char title): card now stays
within its column on Board view, chip truncates with ellipsis,
tooltip shows full text on hover.
Verified: web/npm run build clean, go test ./... green.
|
||
|
|
fd0ace48ff |
fix(web): long-press delay on mobile status-header drag (BUG-641) (#237)
ListView's outer dndzone for status groups was missing `delayTouchStart`, so any touch on a group header was immediately interpreted as the start of a group-reorder drag. On mobile this meant trying to scroll the page by touching a header instead grabbed the header and dragged it with the finger — the page wouldn't scroll and the user couldn't reach content below the visible status bands. Mirror the inner item dndzone's `delayTouchStart: touchDragDelayMs` (500ms) on the outer group dndzone so the same long-press gesture is required to start a group reorder. Quick taps (collapse toggle) and short touch-drags (page scroll) now pass through unmolested; the existing drag-to-reorder behaviour is preserved behind the long-press, matching what already works for items inside a group. The `touchDragDelayMs` constant (line 46) was already in scope and already used for the inner dndzone, so this is a one-line addition. Verified manually on iOS at the running server: status headers no longer hijack scroll; long-press still reorders groups; tap-to-collapse unaffected. Verified: web/npm run build clean, go test ./... green. |
||
|
|
190d589afe |
fix(web): render markdown in timeline comments via .prose class (BUG-748) (#235)
* fix(web): render markdown in timeline comments via .prose class (BUG-748)
TimelineCommentCard tagged comment + reply bodies with `markdown-body`,
a class with no rules anywhere in the codebase. The global
`* { margin: 0; padding: 0 }` reset in app.css then stripped list
padding, heading margins, code-block backgrounds, blockquote borders,
and table styling — so any comment containing markdown (bullet lists,
headings, fenced code, quotes) rendered as run-on text without its
visual structure.
Switch both bodies to the existing `.prose` class (same one used by
the item-detail content view), and override `max-width: none` in the
scoped style so comments still fill the timeline column instead of
shrinking to the 960px content width that .prose pins for long-form
item bodies.
Comments are sanitized through DOMPurify in renderMarkdown (TASK-647);
this change is purely styling.
Verified: web/npm run build clean, go test ./... green.
* fix(web): explicit font-family + table overflow on comment-body (Codex round 1)
Address two LOW findings from Codex review of #235:
1. `.prose` pins `font-family: var(--font-content)`. The scoped
`.comment-body, .reply-body` rule didn't override font-family, so
comments inherited the .prose font. Currently identical to --font-ui,
but make the relationship explicit (`font-family: inherit`) so a
future divergence between --font-ui and --font-content doesn't
silently change comment typography.
2. `.prose table { width: 100% }` plus padded cells can produce a wider-
than-column table inside the indented `.reply-card` (which sits
inside `.replies` with an extra padding-left + border-left, so its
inner width is significantly narrower than a top-level comment).
Add `overflow-x: auto` to .comment-body/.reply-body so wide tables
scroll horizontally instead of overflowing the card.
Verified: web/npm run build clean, go test ./... green.
|
||
|
|
cf00eeba84 |
feat(web): add Support + Status links on auth pages and user menu (TASK-713) (#230)
Implements TASK-713 bullets 1+2 for Pad Cloud: a support@getpad.dev mailto and a https://status.getpad.dev link users can reach before signing in and from inside the app. Discord link deferred — spawn an HT follow-up once the server URL is known. Changes: - New SupportFooter.svelte — Support · Status row, gated on cloudMode, styled consistently with LegalFooter (underlined, focus-visible outline). Rendered below LegalFooter on login, register, and forgot-password. - TopBar user dropdown (desktop + mobile branches): Support and Status entries between the theme toggle and Sign out, grouped by a divider, gated on cloudMode so self-hosted installs do not advertise links that are not theirs to offer. Bullets 3+4 of TASK-713 (admin impersonation, MRR/churn dashboard) remain out of scope for Chunk 1 per the PLAN-645 audit decision; they will be tracked in a follow-up plan after beta launch. Parent: PLAN-645 (Pad Cloud Beta Readiness). Second PR in Chunk 1. |
||
|
|
cf3e64caf4 |
feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229)
* feat(web): add legal footer + consent notice on auth pages (TASK-714) Pad Cloud (cloudMode=true) needs visible Terms / Privacy / Sub-processors links so Stripe Live-mode compliance is defensible and users know what they're agreeing to. Self-hosted installs (cloudMode=false) don't need these — the legal docs at getpad.dev are Perpetual Software LLC's TOS for the hosted service, not the user's own instance. Changes: - New LegalFooter.svelte component: renders Terms · Privacy · Sub-processors links to https://getpad.dev/{terms,privacy,subprocessors}. Gated on a cloudMode prop so self-hosted installs see nothing. - login, register, forgot-password pages: render <LegalFooter> below the card. Each page already fetches session; register/forgot-password now persist session.cloud_mode into a local $state so the footer can be reused consistently. - register page adds a "By creating an account, you agree to Terms and Privacy Policy" consent notice directly under the Create account button (only when cloudMode=true). This is the signup touchpoint for Stripe Live mode. - Auth-page containers switched to flex-direction: column so the card and footer stack cleanly centered. Cookie banner intentionally deferred: the privacy policy already asserts "strictly-necessary cookies only, no banner required" and app.getpad.dev only sets first-party session/CSRF cookies. Stripe Checkout runs on billing.stripe.com (separate origin) so its cookies don't apply here. Parent: PLAN-645 (Pad Cloud Beta Readiness). This covers the main launch-blocking legal bullet (Stripe Live mode compliance); the pad-web marketing site already hosts the actual legal content. * fix(web): read cloudMode from authStore; add link affordance (Codex round 1) Addresses PR #229 review findings: MEDIUM — register and forgot-password pages were fetching session via api.auth.session() specifically to derive cloudMode, duplicating the root layout's authStore.load() and adding a silent failure path (if the extra request failed, the legal footer/consent would vanish even on Pad Cloud). Switched to authStore.cloudMode in both pages. register still calls api.auth.session() for its pre-existing setup_required + authenticated checks, but no longer reads cloud_mode from that call. forgot-password no longer needs onMount at all — its only addition was the cloudMode fetch. LOW — legal footer links had no affordance until hover: muted color, no underline, nothing for keyboard users. Added persistent subtle underline (1px, 2px offset) and :focus-visible outline so the links are discoverable for touch and keyboard users. Also: login page intentionally left untouched. It has a pre-existing local cloudMode state used for OAuth buttons + sign-up link + legal footer; unifying it with authStore is a separate refactor and outside this PR's scope. * fix(auth): add authStore.ensureLoaded for post-logout auth nav (Codex round 2) Addresses PR #229 round 2 finding: MEDIUM — After logout, authStore.session is cleared and the root layout doesn't re-run onMount on SPA navigation, so subsequent visits to /register or /forgot-password would read authStore.cloudMode=false and silently hide the legal footer/consent on Pad Cloud. Fix: add authStore.ensureLoaded() which returns the cached session when present or fetches it otherwise. register's onMount now routes its pre-existing session fetch through ensureLoaded (so the same call populates authStore for downstream components like LegalFooter). forgot-password calls ensureLoaded() on mount — cheap no-op when the store is already populated, one fetch when it's been cleared. This keeps the single-source-of-truth benefit from round 1 while handling the logout-then-navigate case Codex flagged. * fix(auth): coalesce concurrent session loads (Codex round 3) Addresses PR #229 round 3 finding: MEDIUM — authStore.ensureLoaded() did a bare 'if (session)' check, so a hard page load that fired both the root layout's authStore.load() and the page's ensureLoaded() could issue two /auth/session requests. If one succeeded and the other failed, the later catch path (session = null) would overwrite the good session, leaving cloudMode=false after a successful fetch. Fix: add an inflight Promise in auth.svelte.ts. load() returns the inflight promise when one exists; the then/catch/finally chain runs exactly once per fetch and clears inflight in finally. ensureLoaded() keeps its cached-session short-circuit and otherwise delegates to load(), so concurrent callers all await the same underlying request. * fix(auth): guard stale session fetches with generation counter (Codex round 4) Addresses PR #229 round 4 findings: MEDIUM — clear() did not invalidate a pending inflight load, so a pre-logout /auth/session call could resolve after logout and resurrect the logged-out user's session. Subsequent ensureLoaded() calls would also attach to that stale promise. LOW — inflight was only cleared in the promise's finally, so a permanently-hanging fetch wedged loading=true and every subsequent load()/ensureLoaded() returned the same never-settling promise. Fix: introduce a 'generation' counter that bumps on clear(). load() captures the current generation at fetch start and only writes session / clears loading / clears inflight when the generation is still current. clear() now also drops the inflight reference and resets loading=false, so the next ensureLoaded() fires a fresh request and the UI is not left hanging. Late callbacks from pre-logout fetches still resolve in the background but cannot mutate authStore state. |
||
|
|
5b14c2e35f |
fix(a11y): BottomSheet tabindex + roles page label associations (TASK-685) (#221)
Closes the four a11y warnings svelte-check surfaces today.
- BottomSheet.svelte: the <div role="dialog"> needs tabindex so screen
readers can focus it programmatically. Add tabindex="-1" — activates
when explicitly focused without putting it in the tab order (matches
the ARIA APG dialog pattern).
- roles/+page.svelte: three <label> elements for Icon & Name,
Description, and Tools had no associated control. Give each target
<input> a stable id (role-name, role-description, role-tools) and
point each <label for={id}>. The dialog renders a single instance at
a time so hardcoded ids are safe.
svelte-check before: 10 warnings (4 target + 6 pre-existing)
svelte-check after: 6 warnings (pre-existing only; no regressions)
Parent: PLAN-644.
|
||
|
|
9909d7b7c6 |
fix(web): resolve 9 svelte-check errors blocking CI (TASK-674) (#200)
svelte-check was reporting 9 errors on main, blocking the CI gate.
All fixed:
1. EditCollectionModal: make `open` prop bindable ($bindable()). This
unblocks `bind:open={editCollectionOpen}` in two call sites:
- routes/[username]/[workspace]/[collection]/+page.svelte:1153
- routes/[username]/[workspace]/[collection]/[slug]/+page.svelte:1101
2. [slug]/+page.svelte: narrow `item` inside callback-bound expressions:
- Line 684 (.find closure) now uses a local @const for the slug
rather than re-reading item.parent_collection_slug inside the
callback (TS cannot narrow across the closure).
- Line 744 star toggle handler now short-circuits on item presence,
so both `item.slug` and `item.id` are safe.
3. auth/cli/[code]/+page.svelte: guard against `$page.params.code`
being `undefined` in both onMount and handleApprove.
4. console/settings/+page.svelte: add @types/qrcode dev dependency
so the dynamic `import('qrcode')` calls have proper typings.
`cd web && npx svelte-check` now reports 0 errors (warnings were
out of scope — addressed separately in TASK-685). `go build/vet/test`
and `cd web && npm run build` are green.
Parent: PLAN-644.
|
||
|
|
69262c3b53 |
feat(server): periodically revalidate SSE subscriber membership (TASK-670) (#194)
* feat(server): periodically revalidate SSE subscriber membership (TASK-670)
handleSSE checked workspace access only at connection time. A removed
member kept receiving live events until they manually disconnected —
or, more commonly, indefinitely, because browser EventSource auto-
reconnects and the replay buffer filled any gaps. An owner who revoked
access had no way to stop the leak without restarting the server.
- New 60s membership revalidation ticker inside the SSE select loop.
- Store.sseSubscriberStillHasAccess mirrors RequireWorkspaceAccess's
access matrix: fresh install bypass, admin role, direct membership,
guest grants, legacy workspace-scoped API token. DB errors fail
OPEN (keep connection) so a transient blip doesn't bounce every
open tab; membership-absent fails CLOSED.
- On revocation we send the client a well-known {type:"unauthorized"}
event with a human-readable reason BEFORE closing the stream, so
frontend EventSource handlers can route to login / dismiss the
workspace instead of tight-looping to reconnect.
- sseMembershipRevalInterval is a package-level var so tests can
shrink it; pinned to the 30-300s reasonable range.
- Unit test exercises every branch: admin, active member, outsider,
removed member, guest-grant (skipped when default collections aren't
seeded), unauthenticated, legacy token scoped to same workspace, and
legacy token scoped to a different workspace.
Parent: PLAN-643 (OSS Security Hardening).
* fix(web): handle server-emitted 'unauthorized' SSE event in client (TASK-670)
Addresses Codex P2 on PR #194: the server emits `{type:"unauthorized"}`
before closing a revoked stream, but the Svelte SSE service only listened
for "connected", "sync_required", and item events. Without a handler,
the default `EventSource.onerror` would auto-reconnect indefinitely on
the next /api/v1/events request — exactly the tight-loop the server
event was meant to prevent.
- New 'unauthorized' SSEStatus so surrounding UI can react (e.g.
redirect to workspace list or show a "revoked" toast).
- Dedicated listener: on unauthorized, set status to 'unauthorized',
close the EventSource explicitly (this prevents browser auto-
reconnect), and null out currentWorkspace so a later connect()
doesn't treat the closed connection as "already connected".
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): recompute SSE visibility on each revalidation tick (TASK-670)
Addresses Codex P1 on PR #194: previous revision only rebuilt the
filter maps at connect time, so a user whose scope was NARROWED
mid-stream (role downgraded to viewer, collection access tightened
to "specific", item grants revoked) kept receiving events from
collections they no longer had access to. Revocation-of-membership
was caught, but scope-tightening was not.
- Extract the filter-map computation into a new sseVisibility struct
+ (*Server).computeSSEVisibility method. Same logic as before,
just reentrant so it can be re-run on a live connection.
- Store the snapshot in a local `vis` variable captured by the
sseEventVisible closure (reads the CURRENT snapshot, so the next
event dispatched after a tick sees the new permissions).
- On every revalidation tick where the subscriber still has access,
call computeSSEVisibility again and reassign `vis`. The cost is
one GetCollection + one GuestVisibleResources + friends per tick
per connection — acceptable at the 60s cadence.
- New TestComputeSSEVisibility_ReflectsCurrentGrants verifies that
a second call after membership revocation returns a different
snapshot (isGuest flip), pinning the regression.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): jitter first SSE revalidation tick to avoid stampedes (TASK-670)
Addresses Codex P2 on PR #194: the earlier comment promised jitter but
the implementation wired a plain time.NewTicker(revalInterval). Every
stream then revalidated on a cadence tied to its connect time, which
synchronizes whenever a wave of clients connects close together (post-
deploy reconnect storm, login wave, cron-driven dashboard refresh).
The resulting periodic :00/:60 DB load spike is the exact anti-pattern
the comment warned about.
- Swap the Ticker for a Timer. First fire is delayed by a random
uniform [0, revalInterval) window using math/rand so connect-time
coincidence doesn't translate to revalidation-time coincidence.
- After the first fire, Timer.Reset(revalInterval) re-arms at the
regular cadence — the jitter from connect-time is persistent for
the lifetime of the connection, no need to re-jitter every tick.
- math/rand is fine here: this is load-spreading, not a security
primitive, so a deterministic-at-boot PRNG is acceptable.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): re-fetch user during SSE revalidation to catch admin demotion (TASK-670)
Addresses Codex P1 on PR #194: sseSubscriberStillHasAccess early-
returned on currentUser(r).Role == "admin", but currentUser(r) is the
user snapshot cached in request context at SSE connect time. An admin
demoted mid-stream via /api/v1/admin/users/{userID} would keep the
admin short-circuit forever — the exact "admin forever" bug the
revalidation loop was meant to close.
- Re-fetch the user via s.store.GetUser(cachedUser.ID) at the start of
each revalidation pass so role changes, disabled flags, and account
deletions take effect on the next tick.
- User deleted → revoke.
- User disabled (IsDisabled) → revoke. Previously a disabled admin's
stream also leaked.
- All downstream checks (admin short-circuit, membership lookup, grant
check) use the fresh copy.
Tests:
- TestSSESubscriberStillHasAccess_AdminDemotion: bootstrap admin, hand
it to the request context, then demote to "member" in the DB and
verify access flips to false. Without the fresh fetch, this test
passes even though the real system leaks — pins the regression.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): use fresh user for SSE visibility computation too (TASK-670)
Addresses Codex P1 on PR #194 (companion to the previous commit):
computeSSEVisibility called visibleCollectionIDs(r, ...), which reads
currentUser(r).Role — the cached snapshot placed in request context
when the SSE connection opened. A global admin demoted to "member"
mid-stream while keeping workspace membership would keep the admin
short-circuit forever — visibleCollectionIDs returned nil (all access)
based on the stale Role="admin", so events from collections outside
the user's new collection_access="specific" scope would keep flowing.
- computeSSEVisibility now re-fetches the user via s.store.GetUser
before computing visibility. Transient DB errors fall back to the
cached snapshot so a blip doesn't accidentally widen visibility.
- The admin short-circuit (visibleIDs nil) now comes from the fresh
user.Role, so demotion immediately trips the "no, actually filter"
path on the next revalidation tick.
Tests:
- TestComputeSSEVisibility_DemotedAdminGetsFilter: set up a global
admin who is a workspace member with collection_access="specific"
and NO granted collections. Before demotion the admin gets nil
(unrestricted). Demote to "member" → the snapshot must flip to a
non-nil visibleSlugSet (system collections only). The cached-role
bug would keep returning nil here.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
42cc220024 |
fix(web): sanitize rendered markdown through DOMPurify (TASK-647) (#170)
* fix(web): sanitize rendered markdown through DOMPurify (TASK-647)
Comments (and any other caller of renderMarkdown) piped marked() output
straight to {@html}. A malicious comment could inject <script> / <img
onerror> / javascript: links that executed on every viewer's page —
stored XSS with full session takeover.
Wrap renderMarkdown's output in DOMPurify.sanitize with a strict
allowlist of markdown-produced tags and attributes. Also HTML-escape
the wiki-link title before interpolating it into the <a>/<span> so the
intermediate HTML is well-formed even for pathological titles.
Sanitization runs client-side only (adapter-static SPA mode has no
runtime SSR of user content). In SSR/prerender contexts we return ""
rather than emit unsanitized HTML — markdown-bearing views fetch their
data at runtime anyway, so the empty fallback is a no-op.
Parent: PLAN-643 (OSS Security Hardening).
* fix(web): allow ol start attribute in markdown sanitizer per Codex review
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|