Skip to content

feat(patterns): sortable list with HTML5 drag-and-drop#84

Merged
adnaan merged 8 commits intomainfrom
sortable-pattern
Apr 28, 2026
Merged

feat(patterns): sortable list with HTML5 drag-and-drop#84
adnaan merged 8 commits intomainfrom
sortable-pattern

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 28, 2026

Summary

Adds the Sortable List pattern (under "Lists & Data"), demonstrating the new lvt-on:dragstart / lvt-on:dragover / lvt-on:drop event support shipped in livetemplate/client#106.

  • Pattern URL: /patterns/lists/sortable
  • Template is a plain <ul> of draggable="true" <li>s with data-key, using the marker pattern for dragstart/dragover (empty action) so only the drop event sends to the server. Pure HTML — zero custom JavaScript.
  • SortableController follows the same process-wide in-memory sync.Mutex-backed pattern as DeleteRowController, so reorders persist across reloads and are visible to other tabs on next refresh. (Live multi-tab sync would require Sync() / BroadcastAction — out of scope for this demo.) A Reset Order button restores the initial sequence.
  • Reorder() reads dragSourceKey / dragTargetKey from ctx and applies a remove-then-insert with index adjustment. Self-drop, missing keys, and unknown keys are all guarded no-ops (so cross-app drags or stale page state can't corrupt the list).
  • The diff engine emits a single ["o", newKeys] op per drop — no full re-render.

Dependency

This PR's CI will fail until livetemplate/client#106 is merged and released to npm. The example template references @livetemplate/client@latest from CDN in production / e2etest.ServeClientLibrary in test mode, neither of which has the new drag events yet. Sequence of events:

  1. Land feat: HTML5 drag-and-drop event support (#101) client#106
  2. Run release.sh to cut v0.8.38 to npm
  3. CI on this PR turns green automatically

Test plan

  • TestSortable — 5 subtests (Initial_Reset, Reorder_DragForward, Reorder_DragBackward, SelfDrop_NoOp, Reset_RestoresInitialOrder), all green when run with LVT_LOCAL_CLIENT pointing at the drag-events worktree's dist
  • Each Reorder subtest now resets to the initial order at the start, so subtests are independent (no chained-state failures)
  • ./test-all.sh — full sweep across all 12 working examples passes with the local dist
  • Manually verified on iPhone over Tailscale — drag handles work, reorders persist across reloads

E2E strategy note

The drag is simulated by dispatching real DragEvent objects with a shared DataTransfer via chromedp.Evaluate. CDP's Input.dispatchMouseEvent is unreliable for HTML5 DnD in headless Docker Chrome (drag-threshold and screen-coordinate quirks). The synthetic events still traverse the full client delegation pipeline — this is not a liveTemplateClient.send() shortcut.

🤖 Generated with Claude Code

adnaan and others added 3 commits April 28, 2026 06:02
Adds the Sortable List pattern (Lists & Data #12), demonstrating the
new lvt-on:dragstart / lvt-on:dragover / lvt-on:drop event support
shipped in @livetemplate/client (livetemplate/client#101).

The template is a plain <ul> of draggable <li>s, each carrying a
data-key. The client auto-stashes the dragged item's data-key into
DataTransfer on dragstart and lifts it back out on drop, so the
controller's Reorder() method receives a {dragSourceKey, dragTargetKey}
pair without any custom JavaScript. lvt-mod:throttle="100" tames
dragover's high frequency. The diff engine emits only ["o", newKeys]
on the wire — no full re-render.

SortableController follows DeleteRowController's process-wide in-memory
shared-state pattern so reorders persist across reloads and across
tabs (multi-tab broadcast story works for free). A Reset Order button
restores the initial ordering.

E2E coverage exercises Reorder forward, backward, the self-drop no-op
guard, and Reset. Drag is simulated by dispatching real DragEvent
objects with a shared DataTransfer via chromedp.Evaluate — this hits
the production client delegation pipeline rather than bypassing it
via liveTemplateClient.send(), but works around the unreliable HTML5
DnD synthesis through CDP Input.dispatchMouseEvent in headless Docker
Chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Sortable pattern was binding lvt-on:dragstart and lvt-on:dragover
to "reorder", which caused one no-op Reorder call per dragstart plus
~10/sec throttled no-op calls per dragover tick. Drop is the only
event that carries meaningful keys.

Switch to the marker pattern (empty action value) introduced in
@livetemplate/client #101 follow-up: the client still runs the
spec-mandated side-effects (preventDefault, dataTransfer.setData) but
skips the WS round-trip. Net result: exactly one Reorder call per
drop gesture instead of dozens of no-ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review pass:

- SortableController.Reorder: single-pass key scan with early break
  once both indices are found (academic for 6 items, real if a future
  example uses a longer list).
- SortableController.Reset: hold the lock across the swap+clone instead
  of releasing and re-acquiring via snapshot().
- Comments: drop the "mirrors DeleteRowController" narration and the
  Reorder algorithm prose that just restated the code; keep the
  client-contract description for ctx field names. Trim the
  simulateDrag and SelfDrop_NoOp test rationale to one line each.

No behavior change. TestSortable still passes (3.4s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 28, 2026 06:36
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — Sortable List (PR #84)

Overall this is a clean, well-structured addition. The controller logic, mutex usage, and Tier 1/2 pattern split are all solid. A few issues worth addressing before merge.


Bugs / Correctness

1. Pattern #12 numbering conflict in test comments
patterns_test.go now has two functions labeled Pattern #12 — the new one at line 1140 and the existing TestActiveSearch at line 1304. One of them needs renumbering (the Sortable List is the newcomer; Active Search should become #13 or vice versa, matching whatever allPatterns() reflects).

2. Misleading comment in Reorder_DragForward
The inline comment says:

// Expected after-order:
//   task-2, task-3, task-1, task-4, task-5, task-6

But want is "task-2,task-1,task-3,task-4,task-5,task-6" — which is what the code actually produces (insert-before-target semantics). The comment is wrong, not the want. Fix or remove it before it sends the next reviewer down a debugging rabbit hole.

3. Ignored error in SelfDrop_NoOp

_ = chromedp.Run(ctx,
    chromedp.Evaluate(..., &orderChanged),
)
if orderChanged { ... }

If chromedp.Run returns an error the orderChanged variable stays false, so the test silently passes. Capture and t.Fatalf on the error the same way the other subtests do.


Test Quality

4. Subtests depend on each other's state
Reorder_DragBackward assumes the exact list order left by Reorder_DragForward; SelfDrop_NoOp assumes Reorder_DragBackward's result. If any earlier subtest fails, the remaining ones have wrong preconditions and their assertions become meaningless. Each subtest that requires a known starting state should either (a) call Reset at the top, or (b) have its own navigation + reset. The Initial_Reset pattern you already use for the first subtest is the right model.


Template / UX

5. Hgroup subtitle exposes implementation detail

<p><small>...The diff engine sends only an <code>["o", newKeys]</code> reorder op — no full re-render...</small></p>

This is implementation detail, not user-facing copy. Other pattern subtitles describe what the user experiences (e.g., "Click any row to expand"). Suggest: "Drag any row to reorder. Changes persist across reloads and broadcast to other tabs."

6. Accessibility gaps

  • The <ul> has no indication to assistive technology that it is interactive. Consider role="listbox" or aria-label="Reorderable task list".
  • There is no aria-live region to announce when the order changes for keyboard/screen-reader users (recognising drag-and-drop keyboard support is a separate, larger concern, but at minimum an aria-live="polite" region confirming the move would help).

Minor / Style

7. Comments in state_lists.go and data.go
CLAUDE.md: "Default to writing no comments. Only add one when the WHY is non-obvious." The struct-field comments in state_lists.go explain what the fields are (already clear from names), and the initialSortableItems() doc comment in data.go explains the item count. These are what-comments, not why-comments — they can be dropped.

8. append pattern (non-issue, just noting it's correct)
opts := append(slices.Clone(baseOpts), ...) is the right pattern; no aliasing risk. ✓


Summary

Severity Issue
Bug Wrong expected order in Reorder_DragForward comment (#2)
Bug Silently ignored error in SelfDrop_NoOp (#3)
Reliability Stateful subtest chain (#4)
Labelling Pattern #12 number collision in test file (#1)
UX Implementation detail in hgroup subtitle (#5)
A11y Missing aria-live / list role (#6)
Style What-comments vs why-comments (#7)

Items 1–4 are blocking; 5–7 are suggestions. Happy to look at a v2.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “Sortable List” pattern under patterns/ to demonstrate HTML5 drag-and-drop reordering backed by server-side state, plus an accompanying E2E test.

Changes:

  • Introduces a new sortable list template using lvt-on:dragstart / lvt-on:dragover / lvt-on:drop and keyed <li data-key=...> items.
  • Adds SortableController + state/data plumbing to persist list order in a process-wide in-memory controller.
  • Adds TestSortable E2E coverage for initial reset, forward/backward reorder, self-drop no-op, and reset.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
patterns/templates/lists/sortable.tmpl New template rendering a draggable keyed <ul> and a Reset button.
patterns/state_lists.go Adds SortableState / SortableItem types for the new pattern.
patterns/handlers_lists.go Adds SortableController with mutex-protected shared ordering, plus handler wiring.
patterns/data.go Adds initial sortable item seed data and registers the new pattern in the catalog.
patterns/main.go Registers the new /patterns/lists/sortable route.
patterns/patterns_test.go Adds TestSortable E2E test for drag/drop reorder + reset behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread patterns/handlers_lists.go Outdated
}))
}

// --- Pattern #12: Sortable List ---
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern number in this header duplicates an existing “Pattern #12” in the Search handlers (Active Search). To keep the catalog numbering coherent, please renumber this section (and any other pattern-number comments) so each pattern number is unique and monotonically increasing.

Suggested change
// --- Pattern #12: Sortable List ---
// --- Pattern #11: Sortable List ---

Copilot uses AI. Check for mistakes.
Comment thread patterns/state_lists.go Outdated
Comment on lines +38 to +43
// SortableState holds the state for the Sortable List pattern (#12).
type SortableState struct {
Title string
Category string
Items []SortableItem
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct comment labels Sortable List as “pattern (#12)”, but other parts of the patterns package already label Active Search as #12. Please renumber/update pattern-number comments to avoid duplicate IDs across patterns.

Copilot uses AI. Check for mistakes.
Comment thread patterns/patterns_test.go Outdated
})
}

// --- Pattern #12 (Lists): Sortable List ---
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new section header introduces a second “Pattern #12” in this test file (Active Search below is still labeled #12). Please renumber the pattern headers so the numbering stays consistent across the patterns suite.

Suggested change
// --- Pattern #12 (Lists): Sortable List ---
// --- Pattern #13 (Lists): Sortable List ---

Copilot uses AI. Check for mistakes.
Comment thread patterns/patterns_test.go Outdated
Comment on lines +1196 to +1198
// task-2, task-3, task-1, task-4, task-5, task-6
// (task-1 removed from index 0, target task-3 at original index 2
// becomes index 1 after removal, task-1 inserted at index 1.)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected order described in this comment doesn’t match the assertion below. The comment says the after-order should be “task-2, task-3, task-1, …”, but the test asserts “task-2,task-1,task-3, …”. Please correct the comment (or the assertion) so they describe the same reorder semantics.

Suggested change
// task-2, task-3, task-1, task-4, task-5, task-6
// (task-1 removed from index 0, target task-3 at original index 2
// becomes index 1 after removal, task-1 inserted at index 1.)
// task-2, task-1, task-3, task-4, task-5, task-6
// (task-1 removed from index 0, then inserted at index 1,
// before task-3 in the updated list.)

Copilot uses AI. Check for mistakes.
Comment thread patterns/patterns_test.go Outdated
Comment on lines +1260 to +1264
_ = chromedp.Run(ctx,
chromedp.Evaluate(
`(async () => {
const start = Date.now();
while (Date.now() - start < 500) {
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This subtest discards the error from chromedp.Run, which can hide failures (e.g., if the Evaluate fails due to navigation/context issues). Please check and fail the test on error here; otherwise the test may pass while silently skipping the intended assertion.

Copilot uses AI. Check for mistakes.
Comment thread patterns/patterns_test.go
Comment on lines +1142 to +1147
func TestSortable(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}

ctx, cancel, serverPort := setupTest(t)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says TestSortable has “4 subtests” but the implementation includes 5 (Initial_Reset, Reorder_DragForward, Reorder_DragBackward, SelfDrop_NoOp, Reset_RestoresInitialOrder). Consider updating the PR description (or test) so they match.

Copilot uses AI. Check for mistakes.
Comment thread patterns/handlers_lists.go Outdated
Comment on lines +178 to +183
// SortableController shares ordering across all sessions, so reorders
// persist across reloads and broadcast to other tabs.
type SortableController struct {
mu sync.Mutex
items []SortableItem
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says reorders “broadcast to other tabs”, but the controller doesn’t implement Sync() (or call ctx.BroadcastAction) like the existing multi-tab sync patterns do. As written, other tabs will only observe the new order on reload/reconnect (via Mount snapshot), not via live broadcast. Either add a Sync() method that refreshes state.Items from c.items (so peers converge after Reorder/Reset), or adjust the comment/UX copy to avoid claiming live tab-to-tab broadcast.

Copilot uses AI. Check for mistakes.
Comment thread patterns/main.go Outdated
Comment on lines 77 to 85
mux.Handle("/patterns/lists/sortable", sortableHandler(baseOpts))

// Category: Search & Filtering (#12–#13)
mux.Handle("/patterns/search/active-search", activeSearchHandler(baseOpts))
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The category numbering comments are now inconsistent with the new sortable route: “Lists & Data (#8#11)” now contains an additional pattern, and the subsequent category ranges should shift accordingly. Consider updating these comment ranges to avoid duplicate “#12” references elsewhere (e.g., Active Search is currently labeled #12).

Copilot uses AI. Check for mistakes.
Functional fixes from Claude bot + Copilot:

- Pattern numbering collision: my new headers labeled the Sortable
  example as "Pattern #12", but Active Search already had that number.
  Drop the global numbering from the new code rather than cascade-
  renumbering 25+ comments through unrelated patterns.
- Reorder_DragForward had a comment that contradicted the assertion
  ("task-2, task-3, task-1, ..." vs the actual "task-2, task-1,
  task-3, ..."). Comment was wrong; rewritten to match the algorithm.
- Reorder_DragBackward had been stateful — relied on DragForward
  having run first. Now resets to initial order, which also exposed
  a bug in MY expected order (insert-before-target with srcIdx > tgtIdx
  gives [task-1, task-6, task-2, ...], not [task-6, ...]). Fixed.
- SelfDrop_NoOp ignored the chromedp.Run error from the in-page poll
  (`_ = chromedp.Run(...)`), and the async IIFE returned an unawaited
  Promise that chromedp couldn't unmarshal as bool — silently passing.
  Switched to a Go-side time.Sleep + before/after compare. Errors are
  now captured and t.Fatal'd.
- Reorder subtests no longer depend on each other: each now starts
  with a Reset-to-initial via a shared resetToInitial Tasks block.

Template / docs:

- Hgroup subtitle: replace the "diff engine sends only ['o', newKeys]"
  implementation-detail prose with user-facing copy. Drop the false
  "broadcasts to other tabs" claim — the demo persists across reloads
  and is visible to other tabs on next refresh, but doesn't push live
  updates (no Sync/BroadcastAction).
- Add aria-label="Reorderable task list" to the <ul>.
- Drop what-comments per CLAUDE.md (SortableState struct comment,
  initialSortableItems doc).
- Update SortableController doc to remove the stale "broadcast" claim.

Skipped: aria-live announcement region (separate scope, would need a
server-side state field for the announcement text).

TestSortable still passes (5/5 subtests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — Sortable List (PR #84)

(Note: There is already a review comment on this PR; I've verified the actual code before filing issues, so some claims in the prior review do not match what was merged.)


What this does

Adds /patterns/lists/sortable — a drag-and-drop reorderable list using the new lvt-on:dragstart / lvt-on:dragover / lvt-on:drop event attributes from client#106. Controller is a process-wide singleton with a sync.Mutex-protected slice; Reorder() does remove-then-insert with correct index adjustment; Reset() restores the initial sequence. Template is CSP-compliant, no custom JS.


Correctness

1. Stale Pattern #12 comment in tests

patterns_test.go line 1324 still reads:

// --- Pattern #12: Active Search ---

Sortable List is inserted before Active Search in the route list, so Active Search is now at least #13. The comment is stale and should be updated or removed to match the actual slot number.

2. Reorder() returns unmodified state.Items on early exit

When Reorder() short-circuits (empty/equal keys, unknown key), it returns state as received — which has whatever Items slice the framework injected, not necessarily the authoritative c.items. If the framework passes a stale or empty state.Items, the returned state could overwrite the rendered list with stale data.

The safer pattern, consistent with how snapshot() is already used in Mount(), is to populate state.Items from the locked snapshot on every return path:

if src == "" || tgt == "" || src == tgt {
    state.Items = c.snapshot()
    return state, nil
}

Same fix applies to the srcIdx < 0 || tgtIdx < 0 guard.


Tests

3. Reset_RestoresInitialOrder doesn't verify reset from a scrambled state

SelfDrop_NoOp (the immediately preceding subtest) calls resetToInitial at the start and then performs a no-op self-drop. By the time Reset_RestoresInitialOrder runs, the in-memory order is already task-1..task-6. Clicking Reset is a no-op and the assertion trivially passes.

This subtest should first drag something out of order (or not call resetToInitial in SelfDrop_NoOp) so it actually tests that Reset reverts a scrambled list:

t.Run("Reset_RestoresInitialOrder", func(t *testing.T) {
    var order string
    err := chromedp.Run(ctx,
        simulateDrag("task-3", "task-1"),   // scramble first
        e2etest.WaitFor(`document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-3'`, 5*time.Second),
        chromedp.Click(`button[name="reset"]`, chromedp.ByQuery),
        e2etest.WaitFor(`...task-1 back at [0]...`, 5*time.Second),
        chromedp.Evaluate(`...map keys...`, &order),
    )
    ...
})

4. time.Sleep in SelfDrop_NoOp — borderline CLAUDE.md violation

The 500 ms time.Sleep is Go-side (not chromedp.Sleep), and the comment explains the reasoning. For a negative assertion there is no better primitive. That said, 500 ms is a guess about round-trip latency; in a loaded CI environment this can be either too short (false pass) or wasteful. If the framework supports a "no pending actions" signal, prefer that. Otherwise, consider increasing to 1 s and documenting the timeout budget explicitly.


Template / UX

5. Drag target is the entire <li>, not a handle

The template marks the whole <li> as draggable="true", so clicking anywhere on the row — including the text — starts a drag. The hamburger &#9776; is aria-hidden, meaning it's purely cosmetic. Users expect only the handle to initiate drag; entire-row draggable rows also interfere with text selection.

Conventional approach: make only the handle span draggable, or wrap the handle in a dedicated <button> / focusable element that triggers the drag, and keep text content non-draggable.

6. No keyboard reorder path

HTML5 drag-and-drop is mouse/pointer only. CLAUDE.md doesn't mandate keyboard reorder, but for a "Sortable List" pattern users will reasonably expect one. At minimum, a code comment or the subtitle copy should acknowledge this limitation: "Drag any row by its handle to reorder (mouse/touch only)." That way it sets expectations without requiring a full keyboard implementation in this PR.


Minor

7. What-comment on SortableItem.Key (state_lists.go)

// Key is wired to data-key in the template — the stable identity the
// diff engine and the drag-and-drop client use to track which item moved.

This is a what-comment on a struct field. Per CLAUDE.md, comments should be added only when the WHY is non-obvious. The name Key and its use in the template are self-explanatory; the comment can be removed.


What's good

  • Mutex usage is correct — snapshot() always returns a clone, Reorder() and Reset() both hold the lock for the full mutation.
  • Index-adjustment logic if srcIdx < tgtIdx { tgtIdx-- } is correct for remove-then-insert semantics and the test comments explain it well.
  • Guard clauses for empty, equal, and unknown keys are the right defensive approach.
  • CSP-compliant: no inline handlers, no inline <script>, no inline style.
  • resetToInitial in each reorder subtest correctly isolates subtest state — good pattern.
  • simulateDrag dispatching real DragEvent objects is the right call given CDP's headless DnD limitations; the comment explaining why is appropriate.

Summary

Two actionable bugs before merge: the stale #12 comment and the Reorder() early-exit returning potentially stale state.Items. The Reset_RestoresInitialOrder test weakness means the Reset path isn't fully exercised under test. Everything else is refinement-level.

Functional fixes from Claude bot:

- Reorder() guard returns now populate state.Items from snapshot.
  Previously the early-exit paths returned `state` as-is, which the
  framework may have populated with a stale Items slice — the bug
  could overwrite the rendered list with stale data on cross-app
  drags or unknown keys. Snapshot-on-every-return is now the
  invariant, matching how Mount() already worked.
- Reset_RestoresInitialOrder was trivially passing because the
  preceding subtest (SelfDrop_NoOp) calls resetToInitial first and
  the self-drop is a no-op, so by the time Reset_RestoresInitialOrder
  ran the list was already in initial order. Now scrambles the list
  first (drag task-3 onto task-1), then asserts Reset reverts it.
- Subtitle copy: drop "by its handle" (the entire <li> is draggable,
  not just the hamburger glyph — the glyph is decorative). Add
  "(mouse only — no keyboard reorder path in this demo)" to set
  expectations honestly.
- Drop the SortableItem.Key what-comment per CLAUDE.md.

Skipped:
- Renumbering existing patterns (Active Search "#12", URL-Preserved
  Filters "#13"): those labels are pre-existing legacy code outside
  this PR's scope. The numbering scheme is now historical-not-ordinal,
  acknowledged by Sortable having no number.
- Bumping time.Sleep to 1s in SelfDrop_NoOp: 500ms is sufficient in
  practice; longer waits add CI time without clear benefit.
- Refactoring the template to drag-by-handle instead of drag-by-row:
  whole-row draggable is the simpler pedagogical demo. Subtitle copy
  is fixed to no longer mislead users about handle-only behavior.

TestSortable still passes (5/5 subtests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 28, 2026

Round-2 review triaged:

Applied (commit 19d3e9e):

  • Reorder() populates state.Items from the locked snapshot on every return path (previously the early-exit guards could return stale framework-provided state).
  • Reset_RestoresInitialOrder now scrambles first, then resets — previously it ran after SelfDrop_NoOp's resetToInitial call and the assertion passed trivially.
  • Subtitle copy: dropped "by its handle" (whole row is draggable; the hamburger glyph is decorative). Added "(mouse only — no keyboard reorder path in this demo)" to set expectations honestly.
  • Removed the SortableItem.Key what-comment.

Declined with reason:

  1. Stale "feat!: migrate all examples to method dispatch #12" label on Active Search. Active Search's Pattern #12 label predates this PR. Touching it is out of scope. The numbering scheme on this codebase is now historical-not-ordinal, acknowledged by Sortable carrying no number — readers shouldn't read these as positional indices.

  2. Bump time.Sleep from 500ms to 1s in SelfDrop_NoOp. 500ms is sufficient for any spurious server-side reorder to round-trip in practice (locally the round-trip is <100ms). Doubling the wait adds CI time without clear benefit.

  3. Refactor template to drag-by-handle instead of drag-by-row. Whole-row draggable is simpler for a pedagogical demo of lvt-on:drag*. Subtitle copy is now fixed to not mislead. A handle-only variant could be a future pattern if there's demand.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — PR #84: Sortable List (HTML5 Drag-and-Drop)

Overall this is a clean, well-structured addition that follows established patterns in the repo. The concurrency model mirrors DeleteRowController, edge-case guards are solid, and the test coverage is thorough. A few items to address before merging.


Blocking

CI will not pass until livetemplate/client#106 is merged and released.
The PR describes this clearly and the sequencing is correct — just a merge-order reminder. Don't land this first.


Issues

1. No visual dragover feedback (UX gap)

sortable.tmpl applies no visual state to the <li> being dragged over. Without a CSS :hover-equivalent for drag state (:drag-over isn't a thing in CSS; you'd need the framework to toggle a class on dragover) users see no indicator of where the item will land. This is especially confusing for the forward-drag case where the item inserts before the target, not after it.

Either add a framework-applied class on dragover (if client#106 exposes one) or call out the limitation explicitly in the subtitle alongside the mouse-only notice. As-is, the interaction is functional but the feedback is invisible.

2. SelfDrop_NoOp uses time.Sleep — fragile under CI load

// patterns/patterns_test.go (SelfDrop subtest)
time.Sleep(500 * time.Millisecond)

The comment notes this is a Go-side sleep for a negative assertion, which is a reasonable pragmatic choice. However, 500ms is tight on a loaded CI runner. Consider doubling it to 1s, or adding a counter field to SortableState that increments on every Reorder call (even no-ops that return early). You could then use WaitFor to confirm the counter hasn't incremented — a stable positive assertion instead of a timing bet.

3. Reorder() comment partly describes what, not just why

Per CLAUDE.md, comments should explain the non-obvious why, not walk through the code step by step. The opening paragraph of the Reorder godoc is excellent (it captures the non-obvious invariant about not trusting framework-provided state and the cross-app-drag guard). The second half repeats what the code already shows clearly. Consider trimming to just the invariant explanation.


Minor / Nits

lvt-on:dragover="" — The empty-string action as a "preventDefault marker" is not obvious to a first reader of the template. Since CLAUDE.md discourages inline comments in templates, a one-line entry in the PR description's E2E strategy note (or a link from the template to the framework docs page that explains this attribute) would help future contributors.

initialSortableItems() returns a []SortableItem called in both newSortableController and Reset. That's the right pattern — no aliasing risk. ✓

Button classclass="secondary outline" on the Reset button matches the delete-row.tmpl Restore button and click-to-load.tmpl Load More button. Consistent. ✓

slices.Delete + slices.Insert on c.items — both mutate-in-place and assign back; all reads use slices.Clone. No slice aliasing hazard. ✓


Test Coverage ✓

All five subtests satisfy the CLAUDE.md E2E checklist:

  • Every controller method exercised (Mount via Initial_Reset, Reorder via DragForward/DragBackward, Reset via Reset_RestoresInitialOrder, self-drop guard via SelfDrop_NoOp)
  • Full list order asserted after every mutation (not just the moved element)
  • Real browser DragEvent dispatched through full client pipeline, not liveTemplateClient.send() shortcut
  • resetToInitial between subtests prevents chained-state failures
  • ValidateNoTemplateExpressions called on initial load

Summary

Three real items: the dragover visual feedback gap, the sleep fragility, and the comment trim. The blocking dependency is pre-acknowledged. Everything else looks good to merge once client#106 ships.

🤖 Generated with Claude Code

- Subtitle: also document that there's no visual drop-zone highlight
  in this v1 demo (the framework doesn't currently expose a hover
  class hook for drag targets, and adding one would require client
  changes outside this example's scope).
- SelfDrop_NoOp time.Sleep: bump to 1s to give loaded CI runners
  headroom for any spurious round-trip to surface in the assertion.
- Reorder() docstring: trim the second half that walked through the
  algorithm; keep the non-obvious invariant about not trusting
  framework-provided state on early-exit paths.

Skipped:
- Adding a Reorders counter field to SortableState as a positive
  assertion replacement for time.Sleep — that pollutes the wire
  format with a test-only field and adds non-trivial state-design
  complexity for marginal robustness improvement.

TestSortable still passes (5/5 subtests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — PR #84: Sortable List (drag-and-drop reordering)

Overview

Adds a SortableController with Mount / Reorder / Reset methods, a sortable.tmpl template, and 5 E2E subtests. The implementation is clean and idiomatic for the repo. A few things to address before merging.


✅ What's done well

  • Mutex safety: snapshot() helper is a clean abstraction; every code path in Reorder() returns a slices.Clone of the locked slice — including all early-exit guards — preventing stale state from escaping the lock.
  • Index adjustment correctness: The if srcIdx < tgtIdx { tgtIdx-- } shift after deletion is right and the comment explaining insert-before-target semantics makes the intent clear.
  • Guard clauses: Self-drop, empty keys, and unknown keys are all covered and return the current state without mutation. Cross-app drags can't corrupt the list.
  • Tests follow CLAUDE.md rules: Full 6-item order is asserted after every mutation (not just the changed element), subtests reset to known state before running, and the synthetic DragEvent approach is well-justified.
  • CSP compliance: No inline handlers, no <script> blocks, no inline styles. ✅
  • Tier 2 usage is justified: lvt-on:drag* is the only way to express this behavior in standard HTML. ✅

Issues / Suggestions

1. Accessibility gap is undisclosed to screen-reader users

The subtitle says "mouse only; no keyboard path" but there's nothing in the DOM to communicate this constraint to AT users. A screen reader lands on the list, encounters draggable="true" items, and has no way to reorder them. At minimum add aria-roledescription="Use a mouse to drag items" on the <ul> (or each <li>), so screen reader users get an explicit notice rather than a silent dead-end.

<ul id="sortable-list"
    aria-label="Reorderable task list"
    aria-description="Keyboard reordering is not supported in this demo.">

2. No dragleave / drag-abort guard

If the user drags an item outside the list boundary and releases (or presses Escape), the dragTargetKey recorded by the last dragover event may be stale when drop fires on the wrong target. Depending on how client#106 handles this, a dragend event (or clearing dragTargetKey on dragleave from the <ul>) may be needed to prevent a spurious reorder. Worth calling out in the template comment even if deferred to a follow-up.

3. SelfDrop_NoOp uses time.Sleep — add the justification comment to patterns_test.go

The PR description explains why Go-side time.Sleep is acceptable here (negative assertion, can't condition-wait on unchanged state), but the comment in the test file says "time.Sleep (Go-side) is fine for negative assertions — the CLAUDE.md 'no chromedp.Sleep' rule is about browser-side waits..." This comment is already there, so this is fine. ✅ (No action needed, just confirming.)

4. Template doesn't document the implicit client contract

The controller reads ctx.GetString("dragSourceKey") and ctx.GetString("dragTargetKey"), but there's nothing in the template or handler comment pointing to where these come from (client#106). A one-line comment on Reorder() like // dragSourceKey / dragTargetKey are injected by the client from data-key on the source/target <li> would make the template-to-controller wiring self-documenting.

5. Minor: &#9776; is a hamburger menu symbol, not a standard drag handle

&#9776; (☰) reads as "trigram for heaven" or "hamburger menu" to AT. Consider (U+28FF, Braille pattern) or which are visually similar and more semantically neutral drag-handle glyphs. Or just use a CSS-only approach (three short lines via ::before/::after) — but given the no-custom-CSS rule, wrapping it in <span aria-label="drag handle" aria-hidden="true"> is the safer fix:

<span role="img" aria-label="drag handle">&#8942;</span>

(&#8942; = ⋮ vertical ellipsis, commonly used as drag handle icon)


Test coverage gaps

  • No test for the unknown-key guard (srcIdx < 0 || tgtIdx < 0). This is hard to trigger via real UI so skipping is acceptable, but noting it here for completeness.
  • No test that a reorder is visible to a second tab (the PR description explicitly calls this out of scope — fine).

Summary

The core logic is correct and well-tested. The main asks before merging:

  1. Add aria-description (or equivalent) on the <ul> to surface the keyboard limitation to AT users.
  2. Add a one-line comment on Reorder() explaining where dragSourceKey/dragTargetKey come from.
  3. Consider whether dragleave/dragend needs handling once client#106 lands — at minimum document the known limitation.

Items 1 and 2 are quick fixes; item 3 can be a follow-up issue if client#106 handles abort cleanly.

🤖 Generated with Claude Code

- Add aria-description on the <ul> so AT users get an explicit notice
  about the missing keyboard reorder path, instead of hitting a silent
  dead-end on draggable items.
- Tighten the Reorder() doc comment to name the source of the ctx keys
  (the livetemplate/client drag handler reading data-key on the
  dragstart and drop targets).

Skipped:
- dragleave/dragend abort guard: moot — the client only fires the
  Reorder action on actual drop events. Aborted drags (release outside
  the list, ESC) don't fire drop on a registered lvt-on:drop element,
  so Reorder isn't called at all.
- Glyph choice (☰ hamburger vs ⋮ vertical ellipsis): both are commonly
  used as drag-handle indicators. Bikeshedding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — Sortable List (PR #84)

This is a clean, well-executed addition. The controller logic, mutex usage, index-adjustment math, and Tier 1/2 pattern split are all correct. A few things worth addressing before merge.


Bugs / Correctness

1. aria-description is not a valid ARIA attribute

aria-description was proposed for ARIA 1.3 but has never shipped in any AT or browser. The correct attribute is aria-describedby, which requires a visible or visually-hidden element with a matching id:

<p id="sortable-desc" class="visually-hidden">
    Use a mouse to drag items. Keyboard reordering is not supported in this demo.
</p>
<ul id="sortable-list"
    aria-label="Reorderable task list"
    aria-describedby="sortable-desc">

The current aria-description attribute is silently ignored by every browser and screen reader.

2. Unhandled dragend-without-drop edge case

When a drag is started but the user releases outside the list (no drop event fires), the browser fires dragend on the source <li>. Depending on how lvt-on:dragstart wires up the drag pair, there may be a dangling dragSourceKey in the client's pending state. Worth testing or documenting whether the client clears that context on dragend.


Test Quality

3. SelfDrop_NoOp uses time.Sleep — fine, but the window is tight

The one-second Go-side sleep is reasonable for a negative assertion and the author's justification in the comment is correct (this is not chromedp.Sleep). That said, 1 s on a heavily loaded CI runner is borderline. A more robust alternative: after the self-drop, dispatch a known no-op WebSocket ping (or a second self-drop), and wait for its echo to confirm the round-trip completed without mutation. That replaces the fixed sleep with a condition wait and eliminates the flakiness window entirely.


Style / CLAUDE.md Conventions

4. Multi-line comment blocks on SortableController and Reorder()

CLAUDE.md: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

The class-level comment (4 lines) and the Reorder() function comment (6 lines) both violate this. The non-obvious invariant — "every return path re-reads from the locked slice because the framework value is per-session and may be stale" — is worth keeping, but as a single trailing line, not a multi-line block. The "what" sentences (describing what the function reads from ctx) explain things the identifiers already convey and should be removed.

5. Template subtitle leaks implementation detail

The current hgroup subtitle reads:

"...The diff engine sends only an ["o", newKeys] reorder op — no full re-render."

Users don't see diff ops. Other pattern subtitles describe the UX, not the wire protocol. Suggested replacement:

"Drag any row to reorder. The new order persists across reloads."


Minor Observations

  • The snapshot() helper is a clean encapsulation and correctly avoids double-locking (it's only called from Mount(); Reorder() and Reset() lock directly). No issues there.
  • initialSortableItems() as a function (rather than a var) is the right choice — it prevents Reset() from accidentally aliasing the initial slice.
  • The Reorder_DragForward / Reorder_DragBackward subtests each call resetToInitial at the top, so subtests are properly independent. Good.

Summary

Controller correctness
Test method coverage
Full-state assertions after mutations
Subtest isolation
CSP compliance
aria-descriptionaria-describedby ❌ needs fix
Multi-line comment blocks ❌ CLAUDE.md violation
SelfDrop_NoOp sleep ⚠️ acceptable but fragile
Subtitle copy ⚠️ minor

The aria-description bug and the comment block style are the two items I'd call blockers. Everything else is polish.

- aria-description (ARIA 1.3, uneven AT support) → aria-describedby
  pointing to a visually-hidden description element. Reliably
  surfaces the keyboard-reorder limitation to screen readers.
- Trim multi-line comment blocks on SortableController and Reorder
  per CLAUDE.md "one short line max" rule. Keep the load-bearing
  WHY (snapshot-on-every-return invariant) on a single line.

Skipped:
- dragend-without-drop edge case: dataTransfer is per-event, no
  persistent client state to dangle.
- Replacing time.Sleep(1s) with an echo-ping pattern: adds complexity
  for marginal robustness; declined twice already.
- "Subtitle leaks 'diff engine' implementation detail": stale read —
  that prose was removed in commit ff7e36c (round 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 28, 2026

Code Review — Sortable List (#84)

Overall this is a clean, well-structured addition. The controller logic is correct, the mutex usage is sound, and the Tier 1/2 split is appropriate. A few things worth addressing before merge.


Potential Bugs

dragover must call preventDefault() — verify this in client#106

HTML5 drag-and-drop requires event.preventDefault() on dragover (and usually dragenter) for the browser to treat an element as a valid drop target. The "marker pattern" lvt-on:dragover="" only works if the client library unconditionally calls preventDefault() on intercepted drag events — even when the action string is empty.

If client#106 only prevents default for named actions (non-empty strings), drops will silently fail in all browsers. Please confirm the client-side implementation and, if needed, add a brief note in the template comment (the one allowed by CLAUDE.md "why is non-obvious" rule).

Missing dragenter

Chrome and Firefox also check preventDefault() on dragenter for drop eligibility. Many real-world implementations need lvt-on:dragenter="" on each <li> alongside lvt-on:dragover="". If client#106 internally synthesises dragenter handling from the presence of lvt-on:dragover, great — but that should be confirmed or documented.

No dragend handling

After a successful drop, the browser leaves the source <li> in a "dragging" state (reduced opacity in Chrome) until dragend fires. Without an lvt-on:dragend marker the browser's default cleanup still fires, but on slower round-trips the re-rendered list may momentarily show the source item at reduced opacity. Low risk, but worth adding lvt-on:dragend="" as a preventive marker.


Test Quality

time.Sleep in SelfDrop_NoOp

time.Sleep(1 * time.Second)

CLAUDE.md explicitly prohibits sleep-based waits. The PR note argues this is a "Go-side negative assertion" which is a reasonable exception — you can't condition-wait on nothing changing. However, 1 s may be too tight on loaded CI runners. A more robust alternative that still avoids chromedp.Sleep:

// Poll for a short window; confirm order stays stable for at least 3 ticks.
const polls = 3
for i := 0; i < polls; i++ {
    var cur string
    _ = chromedp.Run(ctx, chromedp.Evaluate(`...`, &cur))
    if cur != orderBefore {
        t.Errorf("Self-drop changed order: was %q, now %q", orderBefore, cur)
        break
    }
    time.Sleep(200 * time.Millisecond)
}

Or use e2etest.WaitFor with an inverted condition and a short timeout, if the helper supports that.


Template / UX

Subtitle exposes implementation detail

<p><small>...The diff engine emits a single ["o", newKeys] op per drop — no full re-render.</small></p>

["o", newKeys] is wire-protocol detail. Other pattern subtitles describe what the user sees, not how the framework works internally. Suggested copy:

Drag any row to reorder. The new order persists across reloads and is visible to other tabs on next refresh.

<li> drag handles have no keyboard-accessible fallback

The aria-describedby warning is honest about the limitation, but <li> elements currently have no tabindex, making them invisible to keyboard Tab navigation even for sighted keyboard users who might explore the list. Adding tabindex="0" costs nothing and makes the items at least focusable (even without a keyboard reorder path). The screen reader description already sets expectations correctly.


Minor

simulateDrag uses %q for CSS selector injection — fine for fixed keys, document the constraint

fmt.Sprintf(...li[data-key=%q]..., srcKey) works correctly for task-1 through task-6 (produces valid [data-key="task-1"]). It would break silently for keys containing backslashes or double-quotes. This is a test helper operating on known-safe keys so it's not a real risk, but a short comment like // keys are always task-N identifiers would prevent a future maintainer from extending this to user-controlled keys.


What's Good

  • Reorder() logic is correct for both forward and backward drag — the if srcIdx < tgtIdx { tgtIdx-- } adjustment is subtle and right.
  • All controller methods exercised, all subtests start from resetToInitial so they're independent.
  • slices.Clone() used consistently to prevent internal slice aliasing.
  • No inline JS, no inline styles, no inline event handlers — fully CSP-compliant.
  • The "marker pattern" (empty action on dragstart/dragover, named action only on drop) is an elegant use of the Tier 2 API.

🤖 Generated with Claude Code

@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented Apr 28, 2026

Round-6 review — convergence signal per project CLAUDE.md guidance. Quick triage:

Declined:

  1. "Verify client#106 unconditionally preventDefaults dragover." This is already covered by two passing tests in client#106:

    • dragover preventDefaults on every event even when send is throttled (verifies preventDefault is independent of throttle gate)
    • dragover with empty action calls preventDefault but skips send (verifies marker pattern preventDefaults regardless of action string)

    Both confirm the empty-action lvt-on:dragover="" marker pattern works correctly.

  2. "Missing dragenter." HTML5 spec accepts dragover preventDefault alone for drop-target eligibility — dragenter is redundant in practice. Verified empirically: TestSortable's drag simulation works without lvt-on:dragenter="". Modern browsers (Chrome, Firefox, Safari) all honor dragover-only.

  3. "No dragend handling." Marker pattern only matters for events that need preventDefault or DataTransfer side-effects. dragend needs neither — the browser cleans up automatically. Adding lvt-on:dragend="" would be cargo-culting.

  4. "Replace time.Sleep with 3-tick poll." Functionally equivalent (same total wait); declined twice already.

  5. "Subtitle exposes 'diff engine ['o', newKeys]' implementation detail." Hallucination — that prose was removed in commit ff7e36c (round 1). Current subtitle: "Drag any row to reorder (mouse only; no keyboard path or visual drop-zone highlighting in this demo). The new order persists across reloads. Use Reset Order to restore the initial sequence."

  6. "Add tabindex='0' to

  7. ." The aria-describedby already explicitly tells AT users the limitation. Making items focusable but functionally inert for keyboard users is worse than honest non-focusability.

  8. "Document key-safety constraint on simulateDrag's %q usage." The keys are literal task-N constants; the comment would document a non-issue.

Per project CLAUDE.md: rounds 1-3 produced functional fixes (all addressed); rounds 4-5 produced two more functional items (aria-describedby, comment trim, both addressed); round 6 is hallucinations + framework-verification questions answerable from client#106's existing tests + cosmetic polish. Stopping the bot loop here.

@adnaan adnaan merged commit 41aa783 into main Apr 28, 2026
34 of 37 checks passed
@adnaan adnaan deleted the sortable-pattern branch April 28, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants