Skip to content

Commit 4358228

Browse files
[feat](copilot): interactive guided tour + gate AI chat behind a flag (Phase 3)
Add a pluggable, 100% client-side Interactive Copilot (`src/features/copilot/`): a "Guide me" launcher runs a bilingual, step-by-step walkthrough of the four-step Advisor journey — spotlighting each target with Do/Don't guidance. - Adapter Pattern (`getCopilotService()`), closed `data-tour-id` whitelist, typed leak-free event bus, React-portal overlay (spotlight + card) with live tracking (ResizeObserver + MutationObserver + scroll/resize/visualViewport). Missing target degrades to a silent fallback; every observer/listener/timer is cleaned up. - Pre-Flight Check: navigate to the step's view, wait for paint, scroll the target BELOW the pinned chrome (app bar + step rail) so the spotlight is never hidden, and keep it ABOVE the mobile bottom-sheet card (pinned header/footer, scroll-only body) so a full step — title, body, Do/Don't — is never clipped. The app's sticky step-rail hides while the tour runs (the Copilot is the guide). Step 1 anchors the compact "Build custom system" card so the spotlight fits small screens. - Dependency-free + XSS-safe-by-construction (reuses `lib/markdown`), lazy-loaded so the initial JS budget is untouched; engine is unit-tested, overlay verified in-browser. Gate the AI Advisor chat OFF behind a single flag (`FEATURES.chat`, `src/config/features.ts`) while its UX is finalized: App skips mounting the FAB and the tour filters out its chat step, so nothing points at a control that isn't on screen. Re-enabling is a one-line change. README + DECISIONS updated to reflect both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c301c6e commit 4358228

22 files changed

Lines changed: 1215 additions & 14 deletions

DECISIONS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,13 @@ Both are chosen precisely to preserve the Zero-Mismatch invariant.
383383

384384
## Phase 3 — "AI Advisor" chat (Context-Aware, Client-Side), 2026-07-19
385385

386+
> **Update (2026-07-19): shipped OFF.** The chat is gated behind `FEATURES.chat`
387+
> (`src/config/features.ts`), currently `false`, while its UX is finalized — the deploy goes out
388+
> without it. Because the feature was already fully code-split, the gate is a single flag: `App`
389+
> skips mounting the FAB, and the Copilot tour filters out its chat step, so nothing points at a
390+
> control that isn't on screen. Flipping the flag to `true` restores everything with zero other
391+
> edits. The design below stands.
392+
386393
Implements the Phase-3 blueprint while honoring two enforced invariants (building it literally
387394
would have tripped both — the very regressions the mandate forbids):
388395

@@ -412,3 +419,36 @@ would have tripped both — the very regressions the mandate forbids):
412419
**BroadcastChannels a reset** so other tabs clear silently. The FAB mounts **globally** (never
413420
per-view), so switching tabs closes the Guide but **never** unmounts the chat or disrupts an
414421
active stream. Verified in-browser: reply grounded to `/100`, survives nav, reset wipes, EN/ID.
422+
423+
## Phase 3 — Interactive Copilot & guided tour, 2026-07-19
424+
425+
A pluggable **"Guide me" walkthrough** (`src/features/copilot/`) that spotlights each of the four
426+
steps with Do/Don't guidance. Built to the Copilot blueprint while honoring the same invariants the
427+
chat did — the maximalist form would have tripped them:
428+
429+
- **Local structured-command service, Adapter Pattern (no external LLM).** `CopilotService` behind
430+
`getCopilotService()` is the single swap-point. Today it returns the **offline `localCopilotService`**
431+
— bilingual step content that weaves in the *live top pick* from the frozen engine (pure +
432+
unit-tested). A generated/LLM guide is a drop-in with **zero** UI changes. Keeps the 100%
433+
client-side / offline / no-keys / no-telemetry promise intact.
434+
- **Closed `data-tour-id` whitelist (no arbitrary selectors, no DOMPurify).** Targets are addressed
435+
only through `TOUR_IDS` + the type-safe `tourId()` helper; `isTourId()` rejects anything else and
436+
a unit test enforces every tour step maps to a whitelisted id. A missing target degrades to a
437+
**silent fallback** (centered card + "not on screen" note) — never hangs or throws. No
438+
`dangerouslySetInnerHTML`; step bodies use the same XSS-safe `lib/markdown.tsx` renderer.
439+
- **Portal overlay + live tracking (escapes every stacking context).** `CopilotOverlay` renders via
440+
`createPortal` to `document.body`; a box-shadow spotlight rings the target, tracked by
441+
`ResizeObserver` + `MutationObserver` + `scroll`/`resize`/`visualViewport` (all cleaned up — zero
442+
leaks). Z-index strategy: primary `z10` < utility `z20` < copilot overlay `z9999`.
443+
- **Goes to the location, never covers it (Pre-Flight Check + responsive card).** `useCopilot`
444+
navigates to the step's view, waits for the target to mount+paint (`waitForTarget` = MutationObserver
445+
bounded by a timeout), scrolls it to the **upper third** (`scrollBy … − vh*0.20`) and waits a
446+
paint before the first draw. On phones the card is a **bottom sheet** (`min(68vh, 100dvh−84px)`)
447+
with **pinned header/footer + a scroll-only body**, so a full step — title, body, **and** both
448+
Do/Don't cards — is always readable and **never clipped** (verified iPhone-SE 375×667: DON'T text
449+
fully visible, spotlight above the sheet). Bottom controls (launcher · action bar · chat FAB) are
450+
lifted on the Advisor view (`body.aa-view-advisor`) with `flex-shrink:0`/`nowrap` to prevent overlap.
451+
- **Nav-harmony, Start-Over, lean.** Switching tabs mid-tour stops it; "Start Over" resets the tour
452+
next to the chat (`registerReset`); the launcher **and** the whole overlay are `lazy()` — initial
453+
JS budget untouched. Engine (bus/whitelist/service/tour config) is unit-tested; the DOM overlay is
454+
verified in-browser.

README.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ flowchart LR
122122

123123
## AI Advisor chat (Phase 3)
124124

125+
> **Status: temporarily disabled.** The chat is gated **off** behind a single flag
126+
> (`FEATURES.chat` in [`src/config/features.ts`](src/config/features.ts)) while its UX is being
127+
> finalized, so the deployed build ships without it. The code is complete and code-split; flip the
128+
> flag to `true` to bring it back (the FAB mounts, "Start Over" resets it, and the Copilot tour
129+
> restores its chat step — no other change). The rest of this section describes it as designed.
130+
125131
A floating **AI Advisor** (bottom-right) answers questions grounded in *your* scenario —
126132
"what do you recommend?", "what is microservices?", "monolith vs microservices", "why?". It is a
127133
**client-side, offline, rule-based** assistant computed from the frozen engine + the same config
@@ -154,6 +160,44 @@ flowchart LR
154160
untouched); bubbles render with the dependency-free, XSS-safe-by-construction Markdown renderer
155161
(React elements, never `dangerouslySetInnerHTML`), each wrapped in a per-bubble error boundary.
156162

163+
## Interactive Copilot & guided tour (Phase 3)
164+
165+
A **"Guide me" launcher** (bottom-right, Advisor tab) starts a step-by-step walkthrough of the
166+
four-step journey. Each step **navigates to the right view, scrolls its target into the upper
167+
third, and rings it with a spotlight** while a card explains what the step does — with concrete
168+
**Do / Don't** guidance. Like the chat, it is **100% client-side, offline, and dependency-free**:
169+
no external tour library, no `dangerouslySetInnerHTML`, and targets are addressed through a
170+
**closed `data-tour-id` whitelist** (a command can never point the overlay at an arbitrary
171+
selector). It ships as a **pluggable `src/features/copilot/` module** behind a service adapter, so
172+
a smarter (even LLM-driven) guide is a drop-in later with zero UI changes.
173+
174+
```mermaid
175+
flowchart LR
176+
B["Guide me"] --> HK["useCopilot engine<br/>(step index · run token)"]
177+
HK -->|"describe(step, ctx)"| SVC["getCopilotService()<br/><b>CopilotService (Adapter)</b>"]
178+
SVC --> LOC["localCopilotService<br/>(bilingual · live top-pick)"]
179+
HK -->|"nav to step.view"| NAV["App view"]
180+
HK -->|"waitForTarget()<br/>MutationObserver + timeout"| DOM["data-tour-id target<br/>(closed whitelist)"]
181+
HK -->|"highlight | dismiss | stop"| BUS["Event Bus<br/>(typed · leak-free)"]
182+
BUS --> OV["CopilotOverlay (portal → body)"]
183+
DOM -->|"ResizeObserver · MutationObserver<br/>scroll · resize · visualViewport"| OV
184+
OV --> SPOT["Spotlight ring + Do/Don't card<br/>(pinned head/foot · scroll body)"]
185+
SVC -. "future" .-> LLM["llmCopilotService<br/>(drop-in, same contract)"]
186+
```
187+
188+
- **Goes to the location, never covers it** — a Pre-Flight Check scrolls the target to the upper
189+
third and waits a paint before the first draw; the ring then tracks it live via
190+
`ResizeObserver` + `MutationObserver` + `scroll`/`resize`/`visualViewport`. On phones the card is
191+
a **bottom sheet** (pinned header/footer, only the body scrolls) so a full step — title, body,
192+
**and** both Do/Don't cards — is always readable and **never clipped**.
193+
- **Safe by construction** — only whitelisted `data-tour-id`s can be highlighted; a missing target
194+
degrades to a **silent fallback** (card centers, "this control isn't on screen" note) instead of
195+
hanging or throwing. The overlay renders through a **React portal** to `document.body`, escaping
196+
every `z-index` stacking context (primary `z10` < utility `z20` < copilot overlay `z9999`).
197+
- **Nav-harmony & clean teardown** — switching tabs mid-tour stops it; "Start Over" resets the tour
198+
alongside the chat; every observer/listener/timer is cleaned up (zero leaks). The launcher and
199+
the whole overlay are **lazy-loaded**, so the initial JS budget is untouched.
200+
157201
## Run it locally
158202

159203
> **Prerequisite:** Node **24** (LTS) — the version is pinned in [`.nvmrc`](.nvmrc) and used by all
@@ -309,13 +353,45 @@ export function getChatAdapter(): ChatAdapter {
309353
`buildChatContext()` deep-clones + validates the pipeline payload before it reaches any adapter, so
310354
a new backend can never mutate app state or receive an `undefined`.
311355

356+
### Add a Copilot step (or swap the guide backend)
357+
358+
The tour is data: add a step to `MAIN_TOUR` in
359+
[`src/features/copilot/tourConfig.ts`](src/features/copilot/tourConfig.ts). `target` must be one of
360+
the whitelisted `data-tour-id`s in [`dataTourId.ts`](src/features/copilot/dataTourId.ts) — tag the
361+
element with the type-safe `tourId()` helper and the test suite enforces the pairing:
362+
363+
```ts
364+
// 1) tag the element (closed whitelist — the only ids the overlay can point at)
365+
<section {...tourId('recommendation')}></section>
366+
367+
// 2) add a bilingual step with concrete Do / Don't guidance
368+
{ id: 'result', view: 'advisor', target: 'recommendation', placement: 'top',
369+
title: { en: 'Your recommended plan', id: 'Rencana yang disarankan' },
370+
body: { en: 'Ranked per dimension…', id: 'Diperingkat per dimensi…' },
371+
dos: [{ en: 'Read the "why"', id: 'Baca alasannya' }],
372+
donts: [{ en: 'Chase a single number', id: 'Mengejar satu angka' }] }
373+
```
374+
375+
The overlay depends only on the `CopilotService` contract;
376+
`getCopilotService()` in [`copilotService.ts`](src/features/copilot/copilotService.ts) is the
377+
**single** swap-point — return a smarter (even LLM-driven) service and nothing else changes:
378+
379+
```ts
380+
export function getCopilotService(): CopilotService {
381+
return localCopilotService; // today: offline, bilingual, weaves in your live top pick
382+
// return llmCopilotService; // future: a generated walkthrough — same contract, drop-in
383+
}
384+
```
385+
312386
### Component map (Advisor tab)
313387

314388
| Area | Component | Notes |
315389
|---|---|---|
316390
| Scenario gallery + wizard entry | [`components/advisor/PresetBar.tsx`](src/components/advisor/PresetBar.tsx) | search, tag filters, dominant custom card |
317-
| AI Advisor chat (lazy) | [`components/chat/ChatFab.tsx`](src/components/chat/ChatFab.tsx) · `ChatPanel.tsx` · [`hooks/useChat.ts`](src/hooks/useChat.ts) | launcher + panel + state bridge |
391+
| AI Advisor chat (lazy, flag-gated) | [`components/chat/ChatFab.tsx`](src/components/chat/ChatFab.tsx) · `ChatPanel.tsx` · [`hooks/useChat.ts`](src/hooks/useChat.ts) | launcher + panel + state bridge — off via `FEATURES.chat` |
318392
| Chat service (Adapter) | [`lib/chat/`](src/lib/chat/) | `getChatAdapter()` · `localAdvisorAdapter` · `buildChatContext()` |
393+
| Copilot tour (lazy, pluggable) | [`features/copilot/Copilot.tsx`](src/features/copilot/Copilot.tsx) · `useCopilot.ts` · `components/CopilotOverlay.tsx` | launcher + engine + portal overlay |
394+
| Copilot service (Adapter) | [`features/copilot/`](src/features/copilot/) | `getCopilotService()` · `MAIN_TOUR` · `tourId()` whitelist · event bus |
319395
| Custom wizard (lazy modal) | [`components/advisor/CustomWizard.tsx`](src/components/advisor/CustomWizard.tsx) | iterates the wizard config |
320396
| Wizard → engine bridge (pure) | [`lib/customWizard.ts`](src/lib/customWizard.ts) | `wizardToLevels()` — the only mapping |
321397
| ① Project factors | [`components/advisor/FactorInputs.tsx`](src/components/advisor/FactorInputs.tsx) · `FactorField.tsx` | 14 factors, per-level examples |

scripts/check-bundle-size.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import { gzipSync } from 'node:zlib';
1616
// This reads the real initial set from index.html rather than guessing by filename, so a new lazy or
1717
// shared async chunk can't silently be mis-counted. Headroom catches a real regression; raise the
1818
// budgets deliberately (with a note) if the app grows.
19-
const JS_INITIAL_BUDGET_KB = 120;
19+
const JS_INITIAL_BUDGET_KB = 121; // 120→121 for Phase 3 (chat + copilot) 2026-07-19: both features are LAZY chunks; the only initial delta is their dynamic-import stubs + a few refs of wiring (~0.3kB gzip, negligible FCP). NFR ceiling is far higher.
2020
const JS_TOTAL_BUDGET_KB = 268; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 AI Advisor chat 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched; NFR cap is 300)
21-
const CSS_BUDGET_KB = 27; // raised 25→27 for Fase 2g UI/UX polish 2026-07-18 (preset dropdown, uniform export buttons, 2×2 step-rail wrap grid, footer stacking, modern app-bar controls); still well under the ~30kB NFR ceiling
21+
const CSS_BUDGET_KB = 29; // 25→27 (Fase 2g polish 2026-07-18); 27→29 for Phase 3 2026-07-19 (chat panel + copilot overlay/launcher/Dos-Don'ts cards); still under the ~30kB NFR ceiling
2222

2323
const dir = 'dist/assets';
2424
if (!existsSync(dir)) {

src/App.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { usePersistedState } from './hooks/usePersistedState';
3939
import { useTheme } from './hooks/useTheme';
4040
import { useExportActions } from './hooks/useExportActions';
4141
import { DEFAULT_LEVELS } from './config/defaults';
42+
import { FEATURES } from './config/features';
4243
import { PRESETS } from './config/presets';
4344
import type { MigrationKey } from './config/migrationPaths';
4445
import { DIMENSION_ORDER } from './config/dimensions';
@@ -63,6 +64,10 @@ const LearnView = lazy(() => import('./components/insights/LearnView'));
6364
// is imported eagerly, for "Start Over".
6465
const ChatFab = lazy(() => import('./components/chat/ChatFab'));
6566

67+
// Interactive Copilot / guided tutorial (Phase 3) — the whole feature is a lazy plugin; only the
68+
// tiny `tourId()` helper is imported eagerly (to tag targets non-invasively).
69+
const Copilot = lazy(() => import('./features/copilot/Copilot'));
70+
6671
type Selections = Partial<Record<DimensionId, string>>;
6772

6873
export default function App() {
@@ -119,6 +124,8 @@ export default function App() {
119124
const undoRef = useRef<{ levels: Levels; selections: Selections; overrides: Overrides } | null>(null);
120125
// Registered by the chat panel when open, so "Start Over" can reset it in the same tab.
121126
const chatResetRef = useRef<(() => void) | null>(null);
127+
// Registered by the Copilot so "Start Over" hard-resets the guided tour (anti-contamination).
128+
const copilotResetRef = useRef<(() => void) | null>(null);
122129

123130
const scenario: ScenarioState = { v: 1, mode, lang, levels, selections, overrides };
124131
const exportInput: ExportInput = { levels, overrides, selections: effective, lang };
@@ -141,6 +148,8 @@ export default function App() {
141148
// reset if the panel was opened, else just the persistence + cross-tab broadcast.
142149
if (chatResetRef.current) chatResetRef.current();
143150
else resetChatPersistence();
151+
// ...and hard-resets the Copilot tour (anti-contamination, Phase 3.3).
152+
copilotResetRef.current?.();
144153
};
145154
const undoReset = () => {
146155
const snap = undoRef.current;
@@ -229,13 +238,33 @@ export default function App() {
229238
};
230239
}, []);
231240

241+
// Flag the Advisor view on <body> so the floating controls (chat FAB + copilot launcher) can lift
242+
// ABOVE the mobile action bar there — preventing the bottom-controls overlap (owner revision).
243+
useEffect(() => {
244+
document.body.classList.toggle('aa-view-advisor', mainView === 'advisor');
245+
return () => document.body.classList.remove('aa-view-advisor');
246+
}, [mainView]);
247+
232248
return (
233249
<>
234250
<AuroraBackground />
235-
{/* AI Advisor chat (Phase 3) — mounted GLOBALLY (never per-view) so navigating tabs closes the
236-
Guide but never unmounts the chat or disrupts an active stream (Phase 2.2 harmony). */}
251+
{/* AI Advisor chat (Phase 3) — gated behind FEATURES.chat (owner: disabled until finalized).
252+
When on, it mounts GLOBALLY (never per-view) so navigating tabs closes the Guide but never
253+
unmounts the chat or disrupts an active stream (Phase 2.2 harmony). */}
254+
{FEATURES.chat && (
255+
<Suspense fallback={null}>
256+
<ChatFab contextInput={{ levels, overrides, mode, lang }} registerReset={(fn) => (chatResetRef.current = fn)} />
257+
</Suspense>
258+
)}
259+
{/* Interactive Copilot / guided tutorial (Phase 3) — a lazy, pluggable feature module. */}
237260
<Suspense fallback={null}>
238-
<ChatFab contextInput={{ levels, overrides, mode, lang }} registerReset={(fn) => (chatResetRef.current = fn)} />
261+
<Copilot
262+
currentView={mainView}
263+
onRequestView={navigate}
264+
lang={lang}
265+
topPick={rankings.D1[0]?.name}
266+
registerReset={(fn) => (copilotResetRef.current = fn)}
267+
/>
239268
</Suspense>
240269
<MobileChrome mainView={mainView} onNavigate={navigate} theme={theme} onToggleTheme={toggleTheme} mode={mode} onSetMode={setMode} />
241270
{mainView === 'advisor' && <AdvisorMobileBar />}
@@ -324,7 +353,7 @@ export default function App() {
324353

325354
{/* Step 1 — project factors (its own dropdown section; owner feedback: factors and
326355
priorities must be SEPARATE so nobody gets confused). */}
327-
<StepSection id="aa-sec-1" n="1" titleG="step1.g" titleE="step1.e">
356+
<StepSection id="aa-sec-1" n="1" titleG="step1.g" titleE="step1.e" tourId="project-factors">
328357
<FactorInputs levels={levels} onChange={setLevels} />
329358
</StepSection>
330359

@@ -333,7 +362,7 @@ export default function App() {
333362
{/* Step 2 — derived quality priorities; the adjust editor opens right underneath.
334363
Ungated (Fase 2d rev.3, owner): guided users can customise weights too — the
335364
plain-language adjuster is newcomer-safe. */}
336-
<StepSection id="aa-sec-2" n="2" titleG="step2.g" titleE="step2.e">
365+
<StepSection id="aa-sec-2" n="2" titleG="step2.g" titleE="step2.e" tourId="quality-priorities">
337366
<div style={{ display: 'grid', gap: '14px' }}>
338367
<PrioritiesCard weights={weights} onAdjust={() => setEditWeights((v) => !v)} editing={editWeights} />
339368
{editWeights && <QaOverridePanel weights={weights} overrides={overrides} onChange={setOverrides} />}
@@ -344,7 +373,7 @@ export default function App() {
344373

345374
{/* Step 3 — recommendation across dimensions (collapsible card, Fase 2d). */}
346375
<div id="adv-plan" style={{ scrollMarginTop: '132px' }} />
347-
<StepSection id="aa-sec-3" n="3" titleG="results.title.g" titleE="results.title.e">
376+
<StepSection id="aa-sec-3" n="3" titleG="results.title.g" titleE="results.title.e" tourId="recommendation">
348377
<AnalysisStepper runKey={analysisRun} />
349378
<DimensionCards rankings={rankings} current={currentDim} onSelect={setCurrentDim} />
350379
<DimensionDetail dim={currentDim} ranked={rankings[currentDim]} weights={weights} />
@@ -361,7 +390,7 @@ export default function App() {
361390

362391
<div id="adv-save" className="f-div" style={{ scrollMarginTop: '132px' }} />
363392
{/* Step 4 — save & share (collapsible card, Fase 2d). */}
364-
<StepSection id="aa-sec-4" n="4" titleG="step4.g" titleE="step4.e">
393+
<StepSection id="aa-sec-4" n="4" titleG="step4.g" titleE="step4.e" tourId="strategic-output">
365394
<Toolbar run={run} status={exportStatus} setStatus={setExportStatus} mode={mode} onImport={importScenario} />
366395
</StepSection>
367396

src/components/advisor/PresetBar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function PresetBar({ activeId, onApply, onReset, onUndo }: Readonly<Props
8181
{t('presets.fill')}
8282
</div>
8383

84-
{/* Search + tag filters */}
84+
{/* Search + tag filters. */}
8585
<div className="aa-gallery-tools">
8686
<label className="aa-gallery-search">
8787
<IconSearch size={14} aria-hidden style={{ color: 'var(--color-text-tertiary)', flex: 'none' }} />
@@ -98,7 +98,9 @@ export function PresetBar({ activeId, onApply, onReset, onUndo }: Readonly<Props
9898

9999
{/* Card gallery — dominant custom card first, then matching presets */}
100100
<div className="aa-gallery-grid">
101-
<button type="button" className="aa-scard aa-scard-custom" onClick={() => setWizardOpen(true)}>
101+
{/* Dominant "start here" card — also the compact Copilot anchor for the "pick a scenario"
102+
step (a tight target that fits the spotlight band on small screens). */}
103+
<button type="button" className="aa-scard aa-scard-custom" data-tour-id="scenario-gallery" onClick={() => setWizardOpen(true)}>
102104
<span className="aa-scard-head">
103105
<IconTerminal2 size={16} aria-hidden />
104106
{t('wizard.open')}

0 commit comments

Comments
 (0)