Tags: drag-to-merge needs drag, drop-target and drop states #1187

Open
opened 2026-10-06 07:35:17 +00:00 by kayg · 10 comments
Owner

Owner report (2026-10-06): "dragging one tag to another has no drag state / preview and no drop state / preview"

Dragging a sidebar Tag onto another Tag (merge) must show:

  • Drag: the dragged row lifts (shared drag ghost: the Tag chip with its colour dot and count), the source row dims in place.
  • Drop target: hovering another Tag highlights it with the shared drop-target style and a short label "Merge into #target"; invalid targets (itself, a child into its own parent if that is invalid) show no highlight and a not-allowed cursor.
  • Drop: a confirmation popover "Merge #a into #b? 21 items · You can Undo" (existing merge confirm), then the optimistic result.
  • Keyboard: a "Merge into…" menu action with a Tag picker (same result). Touch: long-press drag with the same states.
    Reuse the shared drag primitives (Kanban #1118 / Notes tree) — no bespoke drag code. e2e like a User with pointer and touch; screenshots of drag, hover-target and drop states at 1440 and 390, light + dark, reviewed at ACTUAL size.
## Owner report (2026-10-06): "dragging one tag to another has no drag state / preview and no drop state / preview" Dragging a sidebar Tag onto another Tag (merge) must show: - **Drag:** the dragged row lifts (shared drag ghost: the Tag chip with its colour dot and count), the source row dims in place. - **Drop target:** hovering another Tag highlights it with the shared drop-target style and a short label "Merge into #target"; invalid targets (itself, a child into its own parent if that is invalid) show no highlight and a not-allowed cursor. - **Drop:** a confirmation popover "Merge #a into #b? 21 items · You can Undo" (existing merge confirm), then the optimistic result. - Keyboard: a "Merge into…" menu action with a Tag picker (same result). Touch: long-press drag with the same states. Reuse the shared drag primitives (Kanban #1118 / Notes tree) — no bespoke drag code. e2e like a User with pointer and touch; screenshots of drag, hover-target and drop states at 1440 and 390, light + dark, reviewed at ACTUAL size.
Author
Owner

Starting work on job/tagdnd-1187 from base f5fbced3c7. origin/dev is 5301e02085 (branch is 3 commits behind); I will incorporate origin/dev once before final gates.

Starting work on job/tagdnd-1187 from base f5fbced3c733370b5b37544d7816732628be2fd0. origin/dev is 5301e020859c1d61b4a7f812a5049a64241203e8 (branch is 3 commits behind); I will incorporate origin/dev once before final gates.
Author
Owner

Finding: the first Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css.
Text sizes and UI shape values use shared role tokens.
Keyboard focus rings use the shared focus tokens.
UI transitions and animation options use shared motion tokens or documented exceptions. stopped before source validation because this worktree has no node_modules (license check: 'node_modules not found'; perf-lint could not load TypeScript; svelte-kit and svelte-check were unavailable). I am installing the existing locked workspace dependencies with --frozen-lockfile, then I will rerun the gate.

Finding: the first Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css. Text sizes and UI shape values use shared role tokens. Keyboard focus rings use the shared focus tokens. UI transitions and animation options use shared motion tokens or documented exceptions. stopped before source validation because this worktree has no node_modules (license check: 'node_modules not found'; perf-lint could not load TypeScript; svelte-kit and svelte-check were unavailable). I am installing the existing locked workspace dependencies with --frozen-lockfile, then I will rerun the gate.
Author
Owner

Finding: the first web check stopped before source validation because this worktree had no node_modules. The license check reported “node_modules not found”, perf-lint could not load TypeScript, and svelte-kit/svelte-check were unavailable. I installed the existing locked workspace dependencies with bun install --frozen-lockfile; no dependency was added.

Finding: the first web check stopped before source validation because this worktree had no node_modules. The license check reported “node_modules not found”, perf-lint could not load TypeScript, and svelte-kit/svelte-check were unavailable. I installed the existing locked workspace dependencies with `bun install --frozen-lockfile`; no dependency was added.
Author
Owner

Finding: TagRow.svelte already had a Merge into… action and the durable rewrite confirmation, but tagDrag.svelte.ts only tracked a pointer target. It did not create the shared lifted clone or reject self/descendant targets before highlighting. The Kanban zone in TaskColumn.svelte uses the vendored svelte-dnd-action; I am extending that primitive for Tag rows. The server rename validator rejects moving a Tag below its own prefix, so the UI now applies the same boundary check before showing a valid target.

Finding: `TagRow.svelte` already had a Merge into… action and the durable rewrite confirmation, but `tagDrag.svelte.ts` only tracked a pointer target. It did not create the shared lifted clone or reject self/descendant targets before highlighting. The Kanban zone in `TaskColumn.svelte` uses the vendored `svelte-dnd-action`; I am extending that primitive for Tag rows. The server rename validator rejects moving a Tag below its own prefix, so the UI now applies the same boundary check before showing a valid target.
Author
Owner

Real-server e2e found that the compact TagPill drag ghost did not reliably activate a target when hit testing used the row clone center. The shared DnD zone now uses pointer hit testing; it keeps the shared ghost/zone and highlights the hovered valid Tag. Production pointer and held-touch flows now pass, including keyboard merge, 21-item confirmation, merge and Undo.

Real-server e2e found that the compact TagPill drag ghost did not reliably activate a target when hit testing used the row clone center. The shared DnD zone now uses pointer hit testing; it keeps the shared ghost/zone and highlights the hovered valid Tag. Production pointer and held-touch flows now pass, including keyboard merge, 21-item confirmation, merge and Undo.
Author
Owner

Real-server touch screenshot testing exposed a mobile confirmation hit-testing defect: during the 390 px sidebar flow, the shared ConfirmSheet surface and Cancel button inherited pointer-events: none from the body lock used by the sidebar Dialog. document.elementFromPoint() hit the Tag row behind Cancel. I added an explicit hit-testing reset to the shared OverlaySurface scrim and content surface. The production build and phone Cancel regression check are running now.

Real-server touch screenshot testing exposed a mobile confirmation hit-testing defect: during the 390 px sidebar flow, the shared ConfirmSheet surface and Cancel button inherited `pointer-events: none` from the body lock used by the sidebar Dialog. `document.elementFromPoint()` hit the Tag row behind Cancel. I added an explicit hit-testing reset to the shared OverlaySurface scrim and content surface. The production build and phone Cancel regression check are running now.
Author
Owner

Decision where DESIGN §§33, 34 and 59 are silent: reject a Tag drop onto itself or a descendant (for example, area/work → area/work/sub) to avoid a namespace prefix cycle. Allow a child Tag to merge into its parent (area/work/sub → area/work), which collapses one namespace level. The DnD adapter uses the shared primitive's cursor hit testing because the shared TagPill ghost is compact; it does not add a custom drop-zone implementation.

Decision where DESIGN §§33, 34 and 59 are silent: reject a Tag drop onto itself or a descendant (for example, `area/work` → `area/work/sub`) to avoid a namespace prefix cycle. Allow a child Tag to merge into its parent (`area/work/sub` → `area/work`), which collapses one namespace level. The DnD adapter uses the shared primitive's cursor hit testing because the shared TagPill ghost is compact; it does not add a custom drop-zone implementation.
Author
Owner

Mac-platform production screenshots for #1187. The browser uses macOS platform emulation. Input: 390 px and 820 px held touch; 1440 px pointer. Build ID: c9feb4ed30.

Mac-platform production screenshots for #1187. The browser uses macOS platform emulation. Input: 390 px and 820 px held touch; 1440 px pointer. Build ID: c9feb4ed302894860d4985cfc42be0000df56027. - light, 390 px (touch): [drag](https://git.kayg.org/attachments/563ec8d3-5332-4351-892a-8e07b5af553d) · [valid target](https://git.kayg.org/attachments/f6abf7b7-6bd2-4c15-997c-e4a1d177dd7f) · [confirmation](https://git.kayg.org/attachments/1aebb902-4491-46df-ada9-5227170dd478) - light, 820 px (touch): [drag](https://git.kayg.org/attachments/f3b39d48-ab16-4e91-9457-4208fb622499) · [valid target](https://git.kayg.org/attachments/e9437f5c-ba11-4c2f-8ef6-285de94c1b94) · [confirmation](https://git.kayg.org/attachments/7ee8c70d-9f99-4d27-83c1-36cf84f2902a) - light, 1440 px (pointer): [drag](https://git.kayg.org/attachments/43650bac-e178-4e0d-9a9a-0e5c64c68e56) · [valid target](https://git.kayg.org/attachments/023e911b-5acd-4cd5-a983-89c9efb9aa92) · [confirmation](https://git.kayg.org/attachments/e2f30b48-1289-48e8-aaff-b03c67c77876) - dark, 390 px (touch): [drag](https://git.kayg.org/attachments/2e81fcfd-b2c1-404c-a9b5-f18e1115bbbc) · [valid target](https://git.kayg.org/attachments/a5c5c49a-d32d-4558-a484-8ee3b6d3f6cc) · [confirmation](https://git.kayg.org/attachments/6ef56c5e-e0bd-458e-ac21-eb2296aa8e85) - dark, 820 px (touch): [drag](https://git.kayg.org/attachments/f6138c9d-2d8a-4bb2-9d09-25ba1f6ca417) · [valid target](https://git.kayg.org/attachments/0269a8f7-1653-4229-a1fe-81c51e02737e) · [confirmation](https://git.kayg.org/attachments/e05de0fb-b13f-4e20-a2e6-0c43e7745432) - dark, 1440 px (pointer): [drag](https://git.kayg.org/attachments/aed13514-8967-418c-ba4f-5b64d9177289) · [valid target](https://git.kayg.org/attachments/77e07b3d-9e61-4605-b9aa-90b3af282d07) · [confirmation](https://git.kayg.org/attachments/f9dea6cf-0ac8-4018-8e74-5e58a839e490)
Author
Owner

The mobile confirmation hit-testing issue is fixed and verified in the production build. The 390 px and 820 px screenshot flows now hit the real Cancel button, and the real-server acceptance flow passes keyboard merge, pointer merge, held-touch merge and Undo. The run reported zero CSP errors across its two pages.

The mobile confirmation hit-testing issue is fixed and verified in the production build. The 390 px and 820 px screenshot flows now hit the real Cancel button, and the real-server acceptance flow passes keyboard merge, pointer merge, held-touch merge and Undo. The run reported zero CSP errors across its two pages.
Author
Owner

#1187 complete

Implemented sidebar Tag drag-to-merge using the shared Kanban DnD action. Final branch head: 9994f807184a20f3bf25ce2fcd700be4557dfeb4.

What changed

  • A pointer or 450 ms long-press drag lifts a shared TagPill ghost with its colour dot and item count. The source stays in place and dims.
  • A valid target gets the shared drop outline and Merge into #target label. Invalid self and parent-to-descendant drops show no highlight and use a not-allowed cursor.
  • Drop opens the existing confirmation, then uses the existing Tag rewrite worker and Undo. The sidebar menu has a searchable “Merge into…” picker.
  • Shared DnD zones register only for the visible writable sidebar tree. Each row keeps one real item and at most one shadow item.
  • Fixed nested confirmation hit-testing on mobile by restoring pointer input on the shared overlay scrim and surface.

Shared components

Reused the vendored shared dndzone, TagPill, Pill, Menu, TextInput, ConfirmSheet, OverlaySurface, ItemActionMenu, RequestState, and shared DnD token classes. No feature-local drop paint or drag library was added.

Files

  • apps/web/src/lib/actions/tagDrag.svelte.ts and its Vitest file
  • apps/web/src/lib/components/TagRow.svelte, its Vitest file, and app-sidebar.svelte
  • apps/web/src/lib/tasks/TasksSidebar.svelte and TaskColumn.svelte
  • apps/web/e2e/tags-1110-1111.mjs
  • apps/web/src/lib/components/TagPill.svelte.test.ts
  • packages/ui/src/components/TagPill.svelte, OverlaySurface.svelte, tokens.css, and README.md
  • contracts/perf/adoption-1058.json, contracts/perf/exceptions.json, bench/tag-dnd-1187.mjs, and docs/perf/baseline.json

UX gaps closed

Pointer, touch, and keyboard paths use the same merge result. The real-server flow verified confirmation, durable writes, and Undo. The Tag picker uses real API data and has loading, error/retry, empty, and no-match states. Sidebar targets retain 44 px touch height. Screenshots cover drag, target hover, and confirmation at 390, 820, and 1440 px in light and dark with macOS platform emulation; 390/820 use touch and 1440 uses pointer. The 18 screenshots are attached in the earlier evidence comment.

UX gaps left

No feature behavior gap found in the exercised flows. Native Mac GUI review was not run; the attached screenshots use the required macOS emulation and remain for the orchestrator’s visual review.

Decisions

DESIGN does not define invalid namespace merges. A Tag cannot merge into itself or its descendant because that would create a prefix cycle; a child Tag can merge into its parent. The shared DnD hit test follows the pointer so the compact TagPill ghost does not change which row is targeted.

Performance

Expected drag work is O(N) registered zones per shared-library observation pass, where N is the number of rendered writable sidebar Tags; a merge preview tracks one source and one target. bench/tag-dnd-1187.mjs seeds real indexed Notes, then measures 20-Tag average and 500-Tag burst workloads. The local run reported average target-hover p50/p95 187.9/459.7 ms and worst-case 225.5/637.1 ms; warm activation p50/p95 was 355.8/672.2 ms and 818.8/1912.3 ms. At 500 Tags, browser mean/peak RSS was 760,753,752/802,746,368 bytes and server mean/peak RSS was 460,697,753/463,794,176 bytes.

There is no controlled prior or perf-VM baseline. The local host load average was 22.99, 40.54, 47.82 and the perf-VM lock was held when checked after the run. These values are directional only and are recorded as such in docs/perf/baseline.json.

Gates and evidence

apps/web bun run check passed. Relevant output, verbatim:

PASS production dependency licences: 759 locked package releases across apps/web, apps/docs and packages
perf-lint: PASS; 0 violations; 22359 scoped exceptions
User browser caches use userStorage; only documented device/public-link exceptions remain.
Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css.
Text sizes and UI shape values use shared role tokens.
Keyboard focus rings use the shared focus tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
svelte-check found 0 errors and 2 warnings in 2 files

The two warnings are empty CSS rulesets in AttachmentDeck.svelte and AgendaList.svelte.

bun run test -- --maxWorkers=2 passed. Summary, verbatim:

 Test Files  275 passed (275)
      Tests  1910 passed (1910)
   Start at  14:10:17
   Duration  680.40s (transform 34%, import 24%, environment 24%, tests 14%, setup 4%)

Real-server pointer, touch, keyboard, confirmation, and Undo flow, verbatim:

PASS durable Tag route boundaries
STAGE keyboard merge
STAGE sidebar pointer merge
STAGE sidebar touch merge
PASS tag rename, merge, Undo and touch flows
CSP REPORTS tags-1110-1111: 0 across 2 pages

No Rust source changed, so Rust format, clippy, and test gates were not run. cargo clean completed with Removed 8633 files, 7.3GiB total; apps/web/build and temporary local server copies were removed.

## #1187 complete Implemented sidebar Tag drag-to-merge using the shared Kanban DnD action. Final branch head: `9994f807184a20f3bf25ce2fcd700be4557dfeb4`. ### What changed - A pointer or 450 ms long-press drag lifts a shared TagPill ghost with its colour dot and item count. The source stays in place and dims. - A valid target gets the shared drop outline and `Merge into #target` label. Invalid self and parent-to-descendant drops show no highlight and use a not-allowed cursor. - Drop opens the existing confirmation, then uses the existing Tag rewrite worker and Undo. The sidebar menu has a searchable “Merge into…” picker. - Shared DnD zones register only for the visible writable sidebar tree. Each row keeps one real item and at most one shadow item. - Fixed nested confirmation hit-testing on mobile by restoring pointer input on the shared overlay scrim and surface. ### Shared components Reused the vendored shared `dndzone`, `TagPill`, `Pill`, `Menu`, `TextInput`, `ConfirmSheet`, `OverlaySurface`, `ItemActionMenu`, `RequestState`, and shared DnD token classes. No feature-local drop paint or drag library was added. ### Files - `apps/web/src/lib/actions/tagDrag.svelte.ts` and its Vitest file - `apps/web/src/lib/components/TagRow.svelte`, its Vitest file, and `app-sidebar.svelte` - `apps/web/src/lib/tasks/TasksSidebar.svelte` and `TaskColumn.svelte` - `apps/web/e2e/tags-1110-1111.mjs` - `apps/web/src/lib/components/TagPill.svelte.test.ts` - `packages/ui/src/components/TagPill.svelte`, `OverlaySurface.svelte`, `tokens.css`, and `README.md` - `contracts/perf/adoption-1058.json`, `contracts/perf/exceptions.json`, `bench/tag-dnd-1187.mjs`, and `docs/perf/baseline.json` ### UX gaps closed Pointer, touch, and keyboard paths use the same merge result. The real-server flow verified confirmation, durable writes, and Undo. The Tag picker uses real API data and has loading, error/retry, empty, and no-match states. Sidebar targets retain 44 px touch height. Screenshots cover drag, target hover, and confirmation at 390, 820, and 1440 px in light and dark with macOS platform emulation; 390/820 use touch and 1440 uses pointer. The 18 screenshots are attached in the earlier evidence comment. ### UX gaps left No feature behavior gap found in the exercised flows. Native Mac GUI review was not run; the attached screenshots use the required macOS emulation and remain for the orchestrator’s visual review. ### Decisions DESIGN does not define invalid namespace merges. A Tag cannot merge into itself or its descendant because that would create a prefix cycle; a child Tag can merge into its parent. The shared DnD hit test follows the pointer so the compact TagPill ghost does not change which row is targeted. ### Performance Expected drag work is O(N) registered zones per shared-library observation pass, where N is the number of rendered writable sidebar Tags; a merge preview tracks one source and one target. `bench/tag-dnd-1187.mjs` seeds real indexed Notes, then measures 20-Tag average and 500-Tag burst workloads. The local run reported average target-hover p50/p95 187.9/459.7 ms and worst-case 225.5/637.1 ms; warm activation p50/p95 was 355.8/672.2 ms and 818.8/1912.3 ms. At 500 Tags, browser mean/peak RSS was 760,753,752/802,746,368 bytes and server mean/peak RSS was 460,697,753/463,794,176 bytes. There is no controlled prior or perf-VM baseline. The local host load average was 22.99, 40.54, 47.82 and the perf-VM lock was held when checked after the run. These values are directional only and are recorded as such in `docs/perf/baseline.json`. ### Gates and evidence `apps/web` `bun run check` passed. Relevant output, verbatim: ```text PASS production dependency licences: 759 locked package releases across apps/web, apps/docs and packages perf-lint: PASS; 0 violations; 22359 scoped exceptions User browser caches use userStorage; only documented device/public-link exceptions remain. Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css. Text sizes and UI shape values use shared role tokens. Keyboard focus rings use the shared focus tokens. UI transitions and animation options use shared motion tokens or documented exceptions. svelte-check found 0 errors and 2 warnings in 2 files ``` The two warnings are empty CSS rulesets in `AttachmentDeck.svelte` and `AgendaList.svelte`. `bun run test -- --maxWorkers=2` passed. Summary, verbatim: ```text Test Files 275 passed (275) Tests 1910 passed (1910) Start at 14:10:17 Duration 680.40s (transform 34%, import 24%, environment 24%, tests 14%, setup 4%) ``` Real-server pointer, touch, keyboard, confirmation, and Undo flow, verbatim: ```text PASS durable Tag route boundaries STAGE keyboard merge STAGE sidebar pointer merge STAGE sidebar touch merge PASS tag rename, merge, Undo and touch flows CSP REPORTS tags-1110-1111: 0 across 2 pages ``` No Rust source changed, so Rust format, clippy, and test gates were not run. `cargo clean` completed with `Removed 8633 files, 7.3GiB total`; `apps/web/build` and temporary local server copies were removed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#1187
No description provided.