@decocms/start 2.20.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -369,6 +369,7 @@ For sites with 100+ sections:
369
369
  | Admin / CMS integration | `references/admin-cms.md` |
370
370
  | Gotchas index | `references/gotchas.md` |
371
371
  | Post-migration cleanup checklist | `references/post-migration-cleanup.md` |
372
+ | HTMX → React rewrite recipes | `references/htmx-rewrite.md` |
372
373
  | React hooks patterns | `references/react-hooks-patterns.md` |
373
374
  | React signals & state | `references/react-signals-state.md` |
374
375
  | JSX migration differences | `references/jsx-migration.md` |
@@ -0,0 +1,548 @@
1
+ # HTMX → React Rewrite Recipes
2
+
3
+ Some Deco storefronts on the Fresh stack picked up htmx as their
4
+ interactivity model — `hx-get`, `hx-post`, `hx-target`, `hx-swap`,
5
+ `hx-on:click`, etc. The TanStack Start / React stack does **not**
6
+ ship an htmx runtime, and per **D2** in the
7
+ [migration tooling policy](https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc)
8
+ we don't add one — every `hx-*` attribute gets rewritten to a React
9
+ equivalent on migration. There is no half-measure adapter package.
10
+
11
+ This reference is the per-pattern playbook the engineer (and the
12
+ Wave 14 codemods) follow.
13
+
14
+ ## Inventory the surface first
15
+
16
+ Before rewriting anything, run the analyzer (added in
17
+ `@decocms/start >= 2.20.0`):
18
+
19
+ ```bash
20
+ # From the SOURCE site directory (Fresh repo, before migration).
21
+ npx -p @decocms/start deco-htmx-analyze --top 20
22
+
23
+ # Or after migration to verify there's nothing left:
24
+ cd /path/to/migrated-site
25
+ npx -p @decocms/start deco-htmx-analyze --json | jq '.totalOccurrences'
26
+ ```
27
+
28
+ The analyzer groups every `hx-*` element into one of seven
29
+ **categories**, ordered roughly by rewrite difficulty:
30
+
31
+ | Category | Cluster | Difficulty |
32
+ |---|---|---|
33
+ | `event-handler` | `hx-on:*` / `hx-on-*`, no fetch attr | trivial — direct `onClick` |
34
+ | `boost` | `hx-boost="true"` | trivial — `<Link>` |
35
+ | `click-swap` | fetch + `hx-target` on a button-like element | medium — state + conditional render |
36
+ | `form-swap` | fetch on `<form>` with target/swap | medium — `useMutation` |
37
+ | `auto-fetch` | fetch on input or `hx-trigger=keyup\|intersect` | medium — debounced state + `useQuery` |
38
+ | `oob-swap` | `hx-swap-oob` / `hx-select-oob` | hard — manual, no 1:1 |
39
+ | `unmatched` | doesn't fit a known cluster | manual — read the call site |
40
+
41
+ **Order of attack**: walk the list top-down. Most sites' surface is
42
+ ~40 % event-handler, ~30 % click-swap, ~10 % each form-swap and
43
+ auto-fetch, ~5 % each oob-swap and unmatched. Knock out the trivial
44
+ ones first — they're often the easiest wins and clear the noise.
45
+
46
+ ## Two syntactic flavours of `hx-on`
47
+
48
+ htmx 2.x supports both colon and dash forms of event handler attrs.
49
+ HTML's spec doesn't allow `:` in attribute names, so the dash is
50
+ canonical:
51
+
52
+ ```tsx
53
+ <button hx-on:click={useScript(...)} /> // colon (older Fresh fixtures)
54
+ <button hx-on-click={useScript(...)} /> // dash (htmx 2.x canonical)
55
+ <div hx-on-htmx-after-request={...} /> // htmx event with prefix
56
+ ```
57
+
58
+ Both rewrite to the same React `onClick` / `onChange` etc.
59
+
60
+ ## Pattern 1 — `event-handler`
61
+
62
+ The biggest bucket. Almost always a `useScript`-wrapped function
63
+ attached as an event handler, with no fetch involved.
64
+
65
+ > **Codemod available** (since `@decocms/start >= 2.21.0`). The
66
+ > migration script's `transforms` pipeline now runs
67
+ > `htmx-on-event-rename`, which mechanically rewrites
68
+ > `hx-on:click={…}` → `onClick={…}` (and every other standard DOM
69
+ > event in the [STANDARD_EVENT_MAP](https://github.com/decocms/deco-start/blob/main/scripts/migrate/transforms/htmx-on-events.ts)
70
+ > table) for both colon and dash variants. Handler bodies are
71
+ > preserved verbatim; if the body references Fresh-only globals
72
+ > (`useScript(…)`, `globalThis.window.STOREFRONT`, `STOREFRONT.…`),
73
+ > the codemod injects a single MIGRATION TODO comment at the top
74
+ > of the file pointing back here. **htmx lifecycle events**
75
+ > (`hx-on:htmx-config-request`, `hx-on-htmx-before-request`, etc.)
76
+ > and unknown custom events (`hx-on:my-custom-thing`) are left
77
+ > alone — those need manual rewrite, and the `htmx-residue` audit
78
+ > rule catches them.
79
+ >
80
+ > Smoke result on als-storefront (754 files): codemod renames 98
81
+ > `hx-on:*` attributes across 71 files; 67 of those files (94 %)
82
+ > get the body-TODO. Engineers still own the body rewrite below;
83
+ > the codemod just removes the dead `hx-*` attribute name so the
84
+ > file compiles in React.
85
+
86
+ ### Before
87
+
88
+ ```tsx
89
+ <button
90
+ hx-on:click={useScript(
91
+ async (skuId: string, sellerId: string) => {
92
+ if (!skuId || !sellerId) return;
93
+ const button = this as HTMLButtonElement;
94
+ button.dataset.loading = "true";
95
+ await globalThis.window.STOREFRONT.CART.addToCart({
96
+ orderItems: [{ id: skuId, quantity: 1, seller: sellerId }],
97
+ });
98
+ button.dataset.loading = "false";
99
+ },
100
+ productSku,
101
+ sellerId ?? "1",
102
+ )}
103
+ >
104
+ Add to bag
105
+ </button>
106
+ ```
107
+
108
+ ### After
109
+
110
+ ```tsx
111
+ import { useState } from "react";
112
+ import { useCart } from "~/hooks/useCart";
113
+
114
+ export default function AddToBagButton({ productSku, sellerId }) {
115
+ const { addItems } = useCart();
116
+ const [loading, setLoading] = useState(false);
117
+
118
+ return (
119
+ <button
120
+ data-loading={loading}
121
+ onClick={async () => {
122
+ if (!productSku || !sellerId) return;
123
+ setLoading(true);
124
+ try {
125
+ await addItems({
126
+ orderItems: [{ id: productSku, quantity: 1, seller: sellerId ?? "1" }],
127
+ });
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ }}
132
+ >
133
+ Add to bag
134
+ </button>
135
+ );
136
+ }
137
+ ```
138
+
139
+ ### Gotchas
140
+
141
+ - `globalThis.window.STOREFRONT.CART.*` → the platform hook
142
+ (`useCart` from `~/hooks/useCart`, which delegates to `createUseCart`
143
+ from `@decocms/apps/vtex/hooks/createUseCart`). Do not reach for
144
+ globals.
145
+ - `this as HTMLButtonElement` → use a `useRef`, or read from the
146
+ React event (`(e) => e.currentTarget`).
147
+ - `button.dataset.loading = "true"` direct DOM mutation breaks
148
+ hydration on F5 and React Compiler optimisations. Use state.
149
+ - The `useScript` import (`@deco/deco/hooks` or
150
+ `@decocms/start/sdk/useScript`) becomes unused — remove it.
151
+
152
+ ## Pattern 2 — `boost`
153
+
154
+ Trivial. `hx-boost` enabled SPA-style navigation on regular `<a>`
155
+ elements. TanStack Router's `<Link>` does this by default.
156
+
157
+ ### Before
158
+
159
+ ```tsx
160
+ <a hx-boost="true" href="/produto/foo">Foo</a>
161
+ ```
162
+
163
+ ### After
164
+
165
+ ```tsx
166
+ import { Link } from "@tanstack/react-router";
167
+
168
+ <Link to="/produto/$slug" params={{ slug: "foo" }}>Foo</Link>;
169
+ ```
170
+
171
+ ### Gotchas
172
+
173
+ - For unparameterised links, `<Link to="/produto/foo">` works without
174
+ `params`.
175
+ - `<a>` tags that aren't `hx-boost`-ed should also become `<Link>`
176
+ if they target a route in the app — but that's covered by
177
+ `references/navigation.md`, not by this skill.
178
+
179
+ ## Pattern 3 — `click-swap`
180
+
181
+ The dominant button-driven shape: a button with `hx-get` (or
182
+ `hx-post`) + `hx-target` + `hx-swap` that fetches a server-rendered
183
+ section and swaps it into a target element.
184
+
185
+ ### Before
186
+
187
+ ```tsx
188
+ <button
189
+ type="button"
190
+ hx-target={`#${VIEW_CONTENT_ID}`}
191
+ hx-swap="innerHTML transition:true"
192
+ hx-trigger="click"
193
+ hx-get={useSection({
194
+ props: {
195
+ viewConfig: {
196
+ view: viewIds.RECEIVE_ACCESS_CODE_FOR_PASSWORD,
197
+ currentEmail: props?.currentEmail,
198
+ },
199
+ },
200
+ })}
201
+ hx-indicator="this"
202
+ >
203
+ Forgot password
204
+ </button>
205
+ ```
206
+
207
+ ### Two After options
208
+
209
+ **Option A — local state machine** (when the swap stays inside one
210
+ component tree, e.g. a multi-step login form). Each `view` becomes a
211
+ discriminator in component state, each section becomes a sub-component.
212
+
213
+ ```tsx
214
+ import { useState } from "react";
215
+
216
+ type View =
217
+ | { name: "EMAIL_PWD" }
218
+ | { name: "RECEIVE_CODE_FOR_PWD"; currentEmail?: string }
219
+ | { name: "FORGOTTEN_PWD" };
220
+
221
+ export function LoginFlow({ initialEmail }: { initialEmail?: string }) {
222
+ const [view, setView] = useState<View>({ name: "EMAIL_PWD" });
223
+
224
+ if (view.name === "EMAIL_PWD") {
225
+ return (
226
+ <EmailAndPassword
227
+ currentEmail={initialEmail}
228
+ onForgotPassword={() =>
229
+ setView({
230
+ name: "RECEIVE_CODE_FOR_PWD",
231
+ currentEmail: initialEmail,
232
+ })
233
+ }
234
+ />
235
+ );
236
+ }
237
+ if (view.name === "RECEIVE_CODE_FOR_PWD") {
238
+ return <ReceiveAccessCode currentEmail={view.currentEmail} />;
239
+ }
240
+ return <ForgottenPassword />;
241
+ }
242
+ ```
243
+
244
+ The "Forgot password" button becomes:
245
+
246
+ ```tsx
247
+ <button type="button" onClick={onForgotPassword}>Forgot password</button>
248
+ ```
249
+
250
+ **Option B — sub-route** (when the swap should be URL-addressable,
251
+ e.g. cart, address-book pages, account sections that benefit from
252
+ deep-links).
253
+
254
+ ```tsx
255
+ // routes/account/forgot-password.tsx
256
+ import { createFileRoute } from "@tanstack/react-router";
257
+
258
+ export const Route = createFileRoute("/account/forgot-password")({
259
+ component: ForgottenPasswordPage,
260
+ });
261
+ ```
262
+
263
+ Button becomes:
264
+
265
+ ```tsx
266
+ <Link to="/account/forgot-password">Forgot password</Link>
267
+ ```
268
+
269
+ ### Choose between A and B
270
+
271
+ - **B** if the new "view" is a meaningful URL (search filters, tabs,
272
+ multi-step flows worth bookmarking, account pages).
273
+ - **A** if the swap is incidental UI state (modals, dropdowns,
274
+ step-flows that should not survive reload).
275
+
276
+ ## Pattern 4 — `form-swap`
277
+
278
+ Form posts that swap the result back into a target. Same surface as
279
+ click-swap but on a `<form>` and triggered by `submit`.
280
+
281
+ ### Before
282
+
283
+ ```tsx
284
+ <form
285
+ hx-target={`#${VIEW_CONTENT_ID}`}
286
+ hx-swap="innerHTML transition:true show:window:top"
287
+ hx-trigger="submit"
288
+ hx-post={useSection({
289
+ props: {
290
+ viewConfig: { view: viewIds.EMAIL_AND_PASSWORD },
291
+ action: actionIds.CLASSIC_SIGN_IN,
292
+ },
293
+ })}
294
+ hx-indicator=".submit"
295
+ >
296
+ <input name="email" type="email" required />
297
+ <input name="password" type="password" required />
298
+ <button type="submit" data-test-id="login-submit">Sign In</button>
299
+ </form>
300
+ ```
301
+
302
+ ### After
303
+
304
+ ```tsx
305
+ import { useMutation } from "@tanstack/react-query";
306
+ import { invoke } from "~/server/invoke";
307
+ import { useState } from "react";
308
+
309
+ export function EmailAndPassword({ onSuccess }: { onSuccess: (user: User) => void }) {
310
+ const [error, setError] = useState<string | null>(null);
311
+
312
+ const signIn = useMutation({
313
+ mutationFn: (input: { email: string; password: string }) =>
314
+ invoke({ "vtex.actions.auth/classicSignIn": input }),
315
+ onSuccess: (user) => onSuccess(user),
316
+ onError: (e) => setError(e.message),
317
+ });
318
+
319
+ return (
320
+ <form
321
+ onSubmit={(e) => {
322
+ e.preventDefault();
323
+ const data = new FormData(e.currentTarget);
324
+ signIn.mutate({
325
+ email: String(data.get("email")),
326
+ password: String(data.get("password")),
327
+ });
328
+ }}
329
+ >
330
+ <input name="email" type="email" required />
331
+ <input name="password" type="password" required />
332
+ <button type="submit" disabled={signIn.isPending}>
333
+ {signIn.isPending ? "Signing in…" : "Sign In"}
334
+ </button>
335
+ {error && <p>{error}</p>}
336
+ </form>
337
+ );
338
+ }
339
+ ```
340
+
341
+ ### Gotchas
342
+
343
+ - `hx-post={useSection(...)}` posted to a Deco section that ran a
344
+ server-side handler. The TanStack equivalent is **either** a
345
+ server function (`createServerFn`) **or** an action exposed via
346
+ `invoke` over `@decocms/apps`. Pick the one whose business logic
347
+ already lives there.
348
+ - `hx-indicator=".submit"` → button `disabled={mutation.isPending}`
349
+ + visible "Signing in…" text. No CSS class plumbing.
350
+ - Validation errors that the section returned via re-render: now
351
+ surface via `mutation.onError` + state.
352
+
353
+ ## Pattern 5 — `auto-fetch`
354
+
355
+ A fetch attribute fires automatically on a non-click trigger:
356
+ `keyup`, `intersect`, `revealed`, `load`, or `every:Xs`. Most often
357
+ seen on `<input>` for search-as-you-type.
358
+
359
+ ### Before — search-as-you-type
360
+
361
+ ```tsx
362
+ <input
363
+ id={searchInputId}
364
+ name="q"
365
+ type="text"
366
+ hx-sync="this:replace"
367
+ hx-swap="innerHTML transition:true"
368
+ hx-target={`#${searchResultsId}`}
369
+ hx-post={useComponent(Suggestions, { id })}
370
+ hx-trigger="keyup changed delay:200ms"
371
+ class="…"
372
+ />
373
+ <div id={searchResultsId}></div>
374
+ ```
375
+
376
+ ### After
377
+
378
+ ```tsx
379
+ import { useState, useDeferredValue } from "react";
380
+ import { useQuery, keepPreviousData } from "@tanstack/react-query";
381
+ import { invoke } from "~/server/invoke";
382
+
383
+ export function SearchInput() {
384
+ const [term, setTerm] = useState("");
385
+ const debouncedTerm = useDeferredValue(term);
386
+
387
+ const suggestions = useQuery({
388
+ queryKey: ["search-suggestions", debouncedTerm],
389
+ queryFn: () =>
390
+ invoke({ "vtex.loaders.intelligentSearch/suggestions": { term: debouncedTerm } }),
391
+ enabled: debouncedTerm.length >= 2,
392
+ placeholderData: keepPreviousData,
393
+ staleTime: 30_000,
394
+ });
395
+
396
+ return (
397
+ <>
398
+ <input
399
+ type="text"
400
+ value={term}
401
+ onChange={(e) => setTerm(e.target.value)}
402
+ placeholder="Search"
403
+ />
404
+ <div>{suggestions.data?.map((s) => <SuggestionRow key={s.id} item={s} />)}</div>
405
+ </>
406
+ );
407
+ }
408
+ ```
409
+
410
+ ### Before — intersection-triggered (lazy load row)
411
+
412
+ ```tsx
413
+ <div hx-trigger="intersect once" hx-get={useComponent(MoreItems)} hx-swap="outerHTML"></div>
414
+ ```
415
+
416
+ ### After
417
+
418
+ ```tsx
419
+ import { useEffect, useRef, useState } from "react";
420
+
421
+ export function MoreItemsLoader() {
422
+ const ref = useRef<HTMLDivElement>(null);
423
+ const [loaded, setLoaded] = useState(false);
424
+ const [items, setItems] = useState<Item[] | null>(null);
425
+
426
+ useEffect(() => {
427
+ if (loaded || !ref.current) return;
428
+ const obs = new IntersectionObserver(
429
+ async ([entry]) => {
430
+ if (!entry.isIntersecting) return;
431
+ obs.disconnect();
432
+ setLoaded(true);
433
+ setItems(await invoke({ "site.loaders/moreItems": {} }));
434
+ },
435
+ { rootMargin: "100px" },
436
+ );
437
+ obs.observe(ref.current);
438
+ return () => obs.disconnect();
439
+ }, [loaded]);
440
+
441
+ if (items) return <>{items.map((i) => <Row key={i.id} item={i} />)}</>;
442
+ return <div ref={ref} />;
443
+ }
444
+ ```
445
+
446
+ ### Gotchas
447
+
448
+ - `hx-sync="this:replace"` — the htmx-side concurrency control —
449
+ becomes "useQuery dedupes identical query keys + replaces results
450
+ on key change" automatically. No equivalent flag needed.
451
+ - `keyup changed delay:200ms` → `useDeferredValue` gives concurrent
452
+ rendering, or fall back to a manual debounce hook if you want a
453
+ fixed delay.
454
+ - Single-`once` triggers should set a flag on first intersection
455
+ and disconnect the observer (see `loaded` above).
456
+
457
+ ## Pattern 6 — `oob-swap`
458
+
459
+ Out-of-band swaps don't have a clean React equivalent. They were
460
+ htmx's mechanism for a server response to update **multiple
461
+ disconnected DOM nodes** in one request — e.g. update the cart
462
+ indicator in the header AND the cart drawer body from a single
463
+ "add to cart" response.
464
+
465
+ There's no codemod here. Two refactor patterns:
466
+
467
+ ### Refactor A — global state
468
+ Lift the data the OOB nodes read into a shared store (`@tanstack/store`).
469
+ The mutation writes to the store; the consumers re-render naturally.
470
+ This is the right choice 90 % of the time.
471
+
472
+ ### Refactor B — broadcast event
473
+ For cases where one component must trigger a side effect in another
474
+ without a shared data shape: dispatch a custom event on `window`,
475
+ listen for it in the consumer's `useEffect`. This is the htmx
476
+ `hx-swap-oob` pattern, just done with browser primitives.
477
+
478
+ ```tsx
479
+ // Producer
480
+ window.dispatchEvent(new CustomEvent("cart:item-added", { detail: { itemId } }));
481
+
482
+ // Consumer
483
+ useEffect(() => {
484
+ const handler = (e: Event) => { /* react to e.detail */ };
485
+ window.addEventListener("cart:item-added", handler);
486
+ return () => window.removeEventListener("cart:item-added", handler);
487
+ }, []);
488
+ ```
489
+
490
+ Prefer A. Resort to B only when the producer/consumer can't share a
491
+ parent.
492
+
493
+ ## Pattern 7 — `unmatched`
494
+
495
+ Cases the analyzer didn't fit cleanly. Read each call site. Common
496
+ shapes:
497
+
498
+ - `<div hx-indicator="…">` standalone — passive marker that some
499
+ *other* element shows as a loading indicator. The other element
500
+ is the real interactivity site; rewrite there. The `hx-indicator`
501
+ div becomes plain JSX gated on a state boolean.
502
+ - `htmx-request:` Tailwind variants (`htmx-request:loading`,
503
+ `[.htmx-request>&]:hidden`) — these toggle styles based on
504
+ htmx's own request lifecycle. Replace with `data-[loading=true]:`
505
+ variants driven by your component state.
506
+ - `hx-confirm`, `hx-prompt` — synchronous browser dialogs. Use the
507
+ site's modal system, or `window.confirm()` if it was always a
508
+ trivial native dialog.
509
+ - Any `hx-*` attribute on a Deco component import path
510
+ (`<Accordion.Trigger hx-on-click={...}>`) — the component itself
511
+ spreads attrs onto a child element. Lift the handler into a prop.
512
+
513
+ ## Verification
514
+
515
+ After the rewrite, the post-cleanup audit's
516
+ **`htmx-residue`** rule (Wave 13-C) reports any remaining `hx-*` in
517
+ the migrated `src/`. A site is "rewrite-complete" when `htmx-residue
518
+ == 0` for non-test files.
519
+
520
+ ```bash
521
+ npx -p @decocms/start deco-post-cleanup --strict
522
+ # exit 2 if any hx-* attributes survive in src/
523
+ ```
524
+
525
+ You can also re-run the analyzer on the migrated tree to verify
526
+ zero hits:
527
+
528
+ ```bash
529
+ npx -p @decocms/start deco-htmx-analyze
530
+ # expects: ✓ No hx-* attributes found.
531
+ ```
532
+
533
+ ## Real-world signal — als-storefront
534
+
535
+ Initial inventory across 133 source files, 210 occurrences:
536
+
537
+ | Bucket | Count | % |
538
+ |---|---:|---:|
539
+ | event-handler | 88 | 42 % |
540
+ | click-swap | 64 | 30 % |
541
+ | form-swap | 20 | 10 % |
542
+ | auto-fetch | 9 | 4 % |
543
+ | oob-swap | 8 | 4 % |
544
+ | unmatched | 21 | 10 % |
545
+
546
+ 86 % of occurrences fall in codemod-able buckets (event-handler,
547
+ click-swap, form-swap, auto-fetch). Wave 14 ships codemods for those
548
+ four; the rest stay manual.
@@ -359,7 +359,46 @@ rm src/types/widgets.ts
359
359
  Confirm `tsc --noEmit` is still clean — the framework version is a
360
360
  strict superset of what the migration script generated.
361
361
 
362
- ## 7. Search for orphan `TODO: move into framework` comments
362
+ ## 7. Verify no leftover HTMX residue in `src/`
363
+
364
+ For sites that came from a Fresh codebase using HTMX (`@deco/htmx`,
365
+ `hx-*` attributes on JSX elements), the migration to TanStack Start
366
+ requires **rewriting** every HTMX interaction to React state +
367
+ event handlers + `useNavigate()`/sub-routes. The framework
368
+ intentionally ships **no** HTMX runtime — leaving `hx-*` attributes
369
+ in the migrated `src/` tree means the corresponding interaction is
370
+ silently dead.
371
+
372
+ The audit's `htmx-residue` rule scans every `*.{ts,tsx}` under `src/`
373
+ (excluding `*.test.tsx` / `*.spec.ts` / `__tests__/`) for any
374
+ remaining `hx-*` attribute, classifies each occurrence into one of
375
+ seven categories (`event-handler`, `form-swap`, `click-swap`,
376
+ `auto-fetch`, `oob-swap`, `boost`, `unmatched`), and emits one
377
+ warning per file with a category breakdown:
378
+
379
+ ```
380
+ [WARNING] src/components/AddToBagButton.tsx:14 — 1 hx-* element(s) — event-handler=1
381
+ fix: Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md
382
+ (run `deco-htmx-analyze` for the per-category breakdown)
383
+ ```
384
+
385
+ The rule is intentionally **detect-only**:
386
+
387
+ - The rewrites are non-mechanical — choosing between a local state
388
+ machine, a sub-route, a React form action, or a platform hook
389
+ (e.g. `useCart`) varies per call site and depends on the data
390
+ flow, not just the attribute cluster.
391
+ - The companion CLI `deco-htmx-analyze` produces a richer inventory
392
+ (top tags, sample line numbers, JSON output) when you need to
393
+ triage hundreds of occurrences across a large repo.
394
+ - The `references/htmx-rewrite.md` skill is the per-pattern
395
+ playbook with before/after code for each of the seven categories.
396
+
397
+ In `--strict` mode any residue exits 2 — wire that into CI once a
398
+ site has finished its HTMX rewrite to prevent regressions sneaking
399
+ back in via copy-paste from a Fresh source.
400
+
401
+ ## 8. Search for orphan `TODO: move into framework` comments
363
402
 
364
403
  Real sites accumulate `TODO` comments like `// TODO: move into decoVitePlugin
365
404
  in next @decocms/start release`. These are roadmap items the framework
@@ -376,7 +415,7 @@ For each hit, decide:
376
415
 
377
416
  ## Verification checklist
378
417
 
379
- After completing 1-7:
418
+ After completing 1-8:
380
419
 
381
420
  - [ ] `npm run typecheck` baseline matches pre-cleanup count (no new errors)
382
421
  - [ ] `npm run dev` starts and `/`, `/some-pdp/p`, `/s?q=foo` all render