feat/discovery
needs attentionviewing older commitab7c103 · incrementalPR #305reviewed 2026-07-20 15:52 UTC0H · 1M · 6L · 4I- Purpose
- Batu platform feature discovery system — spotlight coachmarks, Descubre hub checklist, and first-run guided tour for provider orgs
- Goal
- Shipping Feature Discovery v1: contextual capability coachmarks (server-decided), a Descubre hub with a capability checklist, and a first-run product tour; fixing the hub's pull→pull jam by replacing the floating pull card with a navigate+pulse cue
- Sub-goals
- SG-1: Core FCIS domain (discovery-state entity, decisions, shells, queries)
- SG-2: API contract + handler + mapper for GET /spotlight and POST /spotlight/:key/record
- SG-3: FeatureSpotlight ambient coachmark (flag-gated, desktop, server-decided)
- SG-4: DescubreMenu hub (checklist, progress badge, 'Volver al tour' entry)
- SG-5: GuidedTour + RpuSubTour (first-run linear tour, flag-gated)
- SG-6: FeatureHighlight pulse cue (hub navigate+pulse, replaces floating pull-card)
- What
- Replaced the hub's ?descubre-driven floating pull-card (server query + mounted coachmark) with a lighter FeatureHighlight component (DOM querySelector + ring animation + param strip). Removed ?revisit mode. Unified tutorialHref so both done and available rows use ?descubre. Simplified FeatureSpotlight to ambient-only (no requested/revisit params). Added two new analytics events (highlight_shown, highlight_anchor_missing).
- Why
- The floating pull-card pattern kept jamming on pull→pull: clicking one hub row then another left both cards vanishing and neither reopening. A pulse is a pure function of the URL param — no mounted identity to strand, no round-trip, self-clearing — so switching between hub items is a clean param change.
- Areas
- apps/platform/src/components/discovery+1200−50apps/platform/src/app+430−35apps/platform/src/api+280−10domains/core/src/discovery+310−0domains/cross-domain/src+835−0packages/analytics/src+115−0packages/api/src/schemas+99−0packages/database/drizzle+13370−0e2e/platform+61−0
- Blast
- 66 files, +18723/-65 whole-branch (dominated by drizzle meta snapshot); incremental window is 9 files, +273/-124 — limited to discovery UI components and analytics types
Findings · 13
correctness3
FeatureHighlight fires without the discovery feature flag
apps/platform/src/components/discovery/FeatureHighlight.tsx:34
FeatureSpotlight gates on `(flagEnabled || isPreviewDeployment()) && isDesktop && !!page && !!orgId`. FeatureHighlight has no such guard — it processes any `?descubre=<key>` URL unconditionally. DescubreMenu is flag-gated so ordinary users won't see the link, but a stale bookmark or hand-crafted URL will pulse the control even when `feature-discovery-spotlights` is disabled. Failure mode is benign (visual only, param is stripped), but the flag doesn't fully suppress the surface. Also: DescubreMenu is md-gated (768px) while FeatureSpotlight is lg-gated (1024px), so on tablets (768–1024px) the hub can generate ?descubre links whose visual cue has no breakpoint guard.
Stale ring position during rapid key A→B switch
apps/platform/src/components/discovery/FeatureHighlight.tsx:60
When the user clicks a second hub item before the first pulse expires, the locate effect cleanup clears A's timers but does not call `setRect(null)`. Because `key` is now B but `rect` still holds A's DOMRect, the ring briefly renders at A's position during the `setTimeout(0)` + retry window (up to 300ms if B's element is async). Adding `setRect(null)` as the first statement in the locate effect for truthy `key` eliminates the artefact.
Dead `typeof document === 'undefined'` guard after hooks
apps/platform/src/components/discovery/FeatureHighlight.tsx:116
This guard is dead code in a `'use client'` component — document is always defined. If ever reached in SSR, all the hooks above would have thrown first. Remove it or move the check before any hooks.
security2
querySelector key not CSS.escape()d — safe today, fragile by convention
apps/platform/src/components/discovery/FeatureHighlight.tsx:68
`[data-tour="${key}"]` is safe today because `key` is catalog-validated. But if a catalog key ever contains a CSS metacharacter (dot, bracket, quote), the selector silently misbehaves or throws. `CSS.escape(key)` would make the contract independent of catalog authors remembering to avoid those characters.
router.replace strip path is safe — no open redirect
apps/platform/src/components/discovery/FeatureHighlight.tsx:53
The strip path derives the query string from the browser's own URLSearchParams (not the raw param value) and calls next-intl's internal router.replace. No open-redirect surface.
conventions3
Multi-paragraph docstrings prohibited by CLAUDE.md
apps/platform/src/components/discovery/FeatureHighlight.tsx:3
The file-level JSDoc (lines 3–19) is an 18-line, 3-paragraph rationale block ('Why a pulse and not a floating card...'). CLAUDE.md prohibits multi-paragraph docstrings. Also: HIGHLIGHT_PARAM in spotlight-catalog.ts has a 10-line multi-paragraph JSDoc (lines 72–82). Architectural rationale belongs in ADRs or commit messages.
eslint-disable masking non-idiomatic deps expression
apps/platform/src/components/discovery/FeatureHighlight.tsx:113
The scroll/resize effect uses `[rect !== null, key]` with `// eslint-disable-line react-hooks/exhaustive-deps`. Passing a derived boolean into deps is non-idiomatic. Extracting `const pulsing = rect !== null` and using `[pulsing, key]` expresses the intent clearly with no suppression needed.
Redundant SSR guard in render return of 'use client' component
apps/platform/src/components/discovery/FeatureHighlight.tsx:116
`typeof document === 'undefined'` in the render return of a `'use client'` component is always false — client components never run server-side in App Router. The guard is dead code.
tests2
resolveSpotlightPage and pathForPage have no direct unit tests
apps/platform/src/components/discovery/spotlight-catalog.ts:34
Both are pure functions with branching logic (sub-path matching, fallback path) exercised only indirectly via tutorialHref tests. A handful of direct assertions would give complete coverage cheaply.
resolveHighlightKey coverage is complete
apps/platform/src/components/discovery/__tests__/highlight-key.test.ts
Full catalog enumeration, prototype-chain guards (constructor/toString), and absent/empty inputs are all covered. No gaps.
improvement3
Locate retry constants duplicated between FeatureHighlight and FeatureSpotlight
apps/platform/src/components/discovery/FeatureSpotlight.tsx:218
FeatureHighlight.tsx defines LOCATE_RETRY_MS=300 and LOCATE_MAX_ATTEMPTS=10 as named constants; FeatureSpotlight.tsx still uses bare magic numbers 300 and 10 in its locate loop. If the retry budget needs tuning, both files must be updated in sync. Centralise in spotlight-catalog.ts or a shared constants file.
Effect 3 re-queries DOM per scroll event instead of caching element ref
apps/platform/src/components/discovery/FeatureHighlight.tsx:103
Effect 3 runs `document.querySelector(...)` on every scroll/resize event. FeatureSpotlight avoids this by caching the element in `anchorRef.current` (with `.isConnected` guard). Caching the found element in a ref in Effect 2 and reading it in Effect 3 saves a DOM query per event and makes the element-gone case explicit.
Effect 1 no-dep-array pattern benefits from an inline clarifying comment
apps/platform/src/components/discovery/FeatureHighlight.tsx:48
The no-dep-array effect that keeps stripRef and pageRef current is correct and intentional, but maintainers may add a dep array as a 'fix'. An inline 'intentional: no dep array — kept current every render' note would make it unmissable.
History · 36 commits
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52current
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08