quiver-cli 0.8.0 → 1.0.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.
Files changed (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +546 -458
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -21,6 +21,7 @@
21
21
 
22
22
  const TOKEN = window.__IMPECCABLE_TOKEN__;
23
23
  const PORT = window.__IMPECCABLE_PORT__;
24
+ const APP_ROOT = window.__IMPECCABLE_APP_ROOT__ || null;
24
25
  if (!TOKEN || !PORT) {
25
26
  window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
26
27
  return;
@@ -57,6 +58,7 @@
57
58
  const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
58
59
  const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
59
60
  const PREFIX = 'impeccable-live';
61
+ const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
60
62
  const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
61
63
  const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
62
64
  const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
@@ -97,7 +99,7 @@
97
99
 
98
100
  const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
99
101
  const LIVE_UI_SURFACES = [
100
- { key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice'] },
102
+ { key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
101
103
  { key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
102
104
  { key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
103
105
  { key: 'action-picker', ids: [PREFIX + '-picker'] },
@@ -109,7 +111,7 @@
109
111
  { key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
110
112
  { key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
111
113
  { key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
112
- { key: 'toasts-and-errors', ids: [PREFIX + '-toast'] },
114
+ { key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
113
115
  { key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
114
116
  ];
115
117
  const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
@@ -125,16 +127,69 @@
125
127
  let expectedVariants = 0;
126
128
  let arrivedVariants = 0;
127
129
  let visibleVariant = 0;
130
+ let generationPhase = null;
131
+ // Ascending order of the agent-generation lifecycle. The visible progress bar
132
+ // must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
133
+ // earlier phase (the server regresses the snapshot phase to `generating` on a
134
+ // behind checkpoint), and without this the bar jumps backward mid-generation.
135
+ // Unranked phases always pass so we never block a phase we do not model.
136
+ //
137
+ // Every `agent_phase` name here is emitted by recordAgentPhase() in
138
+ // live-server.mjs and listed in AGENT_PHASES in live/vocabulary.mjs, which the
139
+ // event validator enforces. This file is served raw and injected as an IIFE,
140
+ // so it cannot import that list; adding a phase means adding it in both.
141
+ // `queued`, `generating`, `variants_progress`, and `variants_ready` are set
142
+ // locally by this file and never arrive over the wire.
143
+ const PHASE_RANK = {
144
+ queued: 0,
145
+ picked_up: 1,
146
+ scaffolding: 2,
147
+ scaffold_fallback: 3,
148
+ source_ready: 4,
149
+ generation_ready: 5,
150
+ generating: 5,
151
+ variants_progress: 5,
152
+ first_reviewable: 8,
153
+ second_reviewable: 11,
154
+ all_variants_ready: 12,
155
+ variants_ready: 12,
156
+ };
157
+ function shouldAdvancePhase(current, next) {
158
+ if (!next || next === current) return false;
159
+ const nextRank = PHASE_RANK[next];
160
+ const currentRank = PHASE_RANK[current];
161
+ // Only block a known-lower phase from overwriting a known-higher one.
162
+ if (nextRank === undefined || currentRank === undefined) return true;
163
+ return nextRank >= currentRank;
164
+ }
165
+ let parameterGenerationState = 'idle';
166
+ let parameterReadyAnnouncedSession = null;
128
167
  let svelteComponentSession = null;
129
168
  let svelteRuntimePromise = null;
130
169
  let pendingSvelteComponentRetryObserver = null;
170
+ // The persistent mount-error card. A failed import/mount used to wipe local
171
+ // session state and flash a 5s toast, which destroyed the only handle the
172
+ // user had on a session the server still considered live. The card stays up
173
+ // until the variant mounts, the user retries, or a new cycle starts.
174
+ let mountErrorEl = null;
175
+ let mountErrorState = null;
176
+ let lastReportedMountFailure = null;
131
177
  let currentSourceFile = null;
132
178
  let currentPreviewFile = null;
133
179
  let currentPreviewMode = null;
134
180
  let recoveryWaitingForAnchor = false;
135
181
  let pickedAnchorSnapshot = null;
182
+ let pickedAnchorViewportTop = null;
136
183
  let pendingVariantAnchorRetryObserver = null;
137
184
  let pendingAcceptedSession = null;
185
+ // Survives cleanupAcceptedSession on purpose: the id of an accept whose
186
+ // POST was acknowledged (intent durable, epoch fenced) but whose actual
187
+ // source promotion hasn't reported back yet. Accept is optimistic, so the
188
+ // teardown nulls pendingAcceptedSession long before live-accept.mjs runs;
189
+ // this marker is what lets the SSE 'error' branch still recognize a late
190
+ // accept failure and say the variant was not saved (issue #384). Released
191
+ // when the real accept result arrives or a new session starts.
192
+ let awaitingAcceptResult = null;
138
193
  let variantObserver = null;
139
194
  let variantSelectionInFlight = false;
140
195
  let variantSelectionPromise = null;
@@ -150,9 +205,12 @@
150
205
  // startScrollLock / stopScrollLock below.
151
206
  let scrollLockObserver = null;
152
207
  let scrollLockTargetY = null;
208
+ let scrollLockAnchorTop = null;
153
209
  let scrollLockRaf = null;
154
210
  let scrollLockAbort = null;
155
211
  const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
212
+ const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
213
+ const DISCARD_STATE_STYLE_ID = 'impeccable-discard-state';
156
214
 
157
215
  // Dedicated key for scroll position - SEPARATE from LS_KEY so that
158
216
  // saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -250,6 +308,13 @@
250
308
  barConnected: !!barEl?.isConnected,
251
309
  hasSvelteComponentSession: !!svelteComponentSession,
252
310
  mountedSvelteVariant: svelteComponentSession?.mountedVariant || 0,
311
+ pickActive,
312
+ pendingApplyInFlight,
313
+ hoveredElement: hoveredElement ? {
314
+ tag: hoveredElement.tagName,
315
+ classes: hoveredElement.className,
316
+ pickable: pickable(hoveredElement),
317
+ } : null,
253
318
  pendingSvelteComponentRetry: !!pendingSvelteComponentRetryObserver,
254
319
  recoveryWaitingForAnchor,
255
320
  evtSourceReadyState: evtSource ? evtSource.readyState : null,
@@ -1112,14 +1177,15 @@
1112
1177
  });
1113
1178
  }
1114
1179
 
1115
- function hideBar() {
1180
+ function hideBar(instant) {
1116
1181
  if (!barEl) return;
1117
1182
  const hideSeq = ++barHideSeq;
1118
1183
  stopVoice({ suppressSubmit: true });
1119
1184
  if (configureKind === 'insert') clearInsertPicking();
1120
1185
  barEl.style.opacity = '0';
1121
- barEl.style.transform = 'translateY(6px)';
1122
- setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250);
1186
+ barEl.style.transform = instant ? 'translateY(0)' : 'translateY(6px)';
1187
+ if (instant) barEl.style.display = 'none';
1188
+ else setTimeout(() => { if (barEl && hideSeq === barHideSeq) barEl.style.display = 'none'; }, 250);
1123
1189
  hideActionPicker();
1124
1190
  closeTunePopover();
1125
1191
  hideConfigureBarTooltip();
@@ -1964,7 +2030,11 @@
1964
2030
  */
1965
2031
  function setLiveState(next) {
1966
2032
  state = next;
2033
+ window.__IMPECCABLE_LIVE_STATE__ = next;
1967
2034
  syncPageInteractionCursor();
2035
+ // Whether a queued steer is still behind a generation is a function of this
2036
+ // state, so the hint has to move with it, not only with the 5s poll.
2037
+ syncSteerQueueHint();
1968
2038
  }
1969
2039
 
1970
2040
  /** Element used to position the floating bar / shader during a session. */
@@ -2514,22 +2584,53 @@
2514
2584
  fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
2515
2585
  marginLeft: 'auto',
2516
2586
  });
2517
- // Variants currently arrive atomically in a single file edit, so a
2518
- // per-variant counter would lie. Say what's true.
2519
2587
  status.textContent = recoveryWaitingForAnchor
2520
2588
  ? 'Variants ready. Reveal the selected element to resume.'
2521
- : (arrivedVariants < expectedVariants
2522
- ? 'Generating ' + expectedVariants + ' variants...'
2523
- : 'Done');
2589
+ : generationStatusText();
2524
2590
  row.appendChild(status);
2525
2591
 
2526
2592
  return row;
2527
2593
  }
2528
2594
 
2595
+ function generationStatusText() {
2596
+ if (arrivedVariants >= expectedVariants && expectedVariants > 0) return 'Done';
2597
+ if (generationPhase === 'picked_up') return 'Agent picked up the request...';
2598
+ if (generationPhase === 'scaffolding') return 'Finding the source...';
2599
+ if (generationPhase === 'source_ready') return 'Source ready. Generating...';
2600
+ if (generationPhase === 'scaffold_fallback') return 'Agent is locating the source...';
2601
+ if (generationPhase === 'first_reviewable') return 'First variant is ready. Exploring more...';
2602
+ if (generationPhase === 'second_reviewable') return 'Checking the remaining variants...';
2603
+ return 'Generating ' + expectedVariants + ' variants...';
2604
+ }
2605
+
2529
2606
  // Cycling row
2530
2607
 
2531
2608
  const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
2532
2609
 
2610
+ /**
2611
+ * Which variant the user is actually looking at. For component previews the
2612
+ * mounted component is the truth; `visibleVariant` is the intent, and the two
2613
+ * differ while a mount is in flight.
2614
+ */
2615
+ function cyclingShownVariant() {
2616
+ return svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0
2617
+ ? svelteComponentSession.mountedVariant
2618
+ : visibleVariant;
2619
+ }
2620
+
2621
+ /**
2622
+ * The single counter string. It is built here rather than at each call site
2623
+ * because the row builder and the incremental sync used to disagree on the
2624
+ * denominator: one showed the planned count, the other the arrived count, so
2625
+ * "2/3" turned into "2/2" on the next sync without anything changing on
2626
+ * screen. Arrived wins once anything has arrived; expected covers the window
2627
+ * before the first variant lands.
2628
+ */
2629
+ function cyclingCounterText() {
2630
+ const total = arrivedVariants > 0 ? arrivedVariants : expectedVariants;
2631
+ return cyclingShownVariant() + '/' + total;
2632
+ }
2633
+
2533
2634
  function buildCyclingRow() {
2534
2635
  if (!ensureCyclingRenderable('build-cycling-row')) {
2535
2636
  return el('div', { display: 'none' });
@@ -2543,7 +2644,7 @@
2543
2644
  const prev = navBtn('\u2190');
2544
2645
  prev.id = PREFIX + '-variant-prev';
2545
2646
  prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
2546
- if (visibleVariant <= 1) prev.style.opacity = '0.3';
2647
+ if (cyclingShownVariant() <= 1) prev.style.opacity = '0.3';
2547
2648
  row.appendChild(prev);
2548
2649
 
2549
2650
  // Dots (clickable)
@@ -2555,20 +2656,22 @@
2555
2656
  color: BP.textDim, minWidth: '24px', textAlign: 'center',
2556
2657
  });
2557
2658
  counter.id = PREFIX + '-variant-counter';
2558
- counter.textContent = visibleVariant + '/' + arrivedVariants;
2659
+ counter.textContent = cyclingCounterText();
2559
2660
  row.appendChild(counter);
2560
2661
 
2561
2662
  // Next
2562
2663
  const next = navBtn('\u2192');
2563
2664
  next.id = PREFIX + '-variant-next';
2564
2665
  next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });
2565
- if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
2666
+ if (cyclingShownVariant() >= arrivedVariants) next.style.opacity = '0.3';
2566
2667
  row.appendChild(next);
2567
2668
 
2568
- // Tune chip - only when the visible variant exposes params
2669
+ // Tune chip stays visible while the deferred parameter phase is running,
2670
+ // then becomes interactive as soon as this variant exposes controls.
2569
2671
  const visParams = parseVariantParams(getVisibleVariantEl());
2570
2672
  const hasParams = visParams.length > 0;
2571
- if (hasParams) {
2673
+ const paramsPending = !hasParams && (parameterGenerationState === 'pending' || parameterGenerationState === 'loading');
2674
+ if (hasParams || paramsPending) {
2572
2675
  const tune = el('button', {
2573
2676
  display: 'inline-flex', alignItems: 'center', gap: '6px',
2574
2677
  padding: '4px 10px', borderRadius: '5px',
@@ -2576,35 +2679,54 @@
2576
2679
  background: tuneOpen ? BP.accentSoft : 'transparent',
2577
2680
  color: tuneOpen ? BP.accent : BP.text,
2578
2681
  fontFamily: FONT, fontSize: '11px', fontWeight: '500',
2579
- cursor: 'pointer',
2682
+ cursor: paramsPending ? 'wait' : 'pointer',
2580
2683
  transition: 'color 0.12s ease, background 0.12s ease',
2581
2684
  whiteSpace: 'nowrap',
2582
2685
  });
2583
- tune.innerHTML = TUNE_ICON_SVG;
2686
+ if (paramsPending) {
2687
+ const spinner = el('span', {
2688
+ width: '11px', height: '11px', borderRadius: '50%',
2689
+ border: '1.5px solid ' + BP.hairline,
2690
+ borderTopColor: BP.accent,
2691
+ animation: 'impeccable-spin 0.6s linear infinite',
2692
+ boxSizing: 'border-box', flexShrink: '0',
2693
+ });
2694
+ spinner.setAttribute('aria-hidden', 'true');
2695
+ tune.appendChild(spinner);
2696
+ } else {
2697
+ tune.innerHTML = TUNE_ICON_SVG;
2698
+ }
2584
2699
  const tuneLabel = document.createElement('span');
2585
2700
  tuneLabel.textContent = 'Tune';
2586
2701
  tune.appendChild(tuneLabel);
2587
- const tuneBadge = document.createElement('span');
2588
- Object.assign(tuneBadge.style, {
2589
- display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2590
- minWidth: '16px', height: '16px', padding: '0 4px',
2591
- borderRadius: '999px',
2592
- background: tuneOpen ? C.brand : BP.hairline,
2593
- color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
2594
- fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
2595
- lineHeight: '1',
2596
- boxSizing: 'border-box',
2597
- });
2598
- tuneBadge.textContent = String(visParams.length);
2599
- tune.appendChild(tuneBadge);
2600
- tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
2601
- tune.addEventListener('mouseenter', () => {
2602
- if (!tuneOpen) tune.style.background = BP.accentSoft;
2603
- });
2604
- tune.addEventListener('mouseleave', () => {
2605
- if (!tuneOpen) tune.style.background = 'transparent';
2606
- });
2607
- tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
2702
+ if (hasParams) {
2703
+ const tuneBadge = document.createElement('span');
2704
+ Object.assign(tuneBadge.style, {
2705
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
2706
+ minWidth: '16px', height: '16px', padding: '0 4px',
2707
+ borderRadius: '999px',
2708
+ background: tuneOpen ? C.brand : BP.hairline,
2709
+ color: tuneOpen ? C.ink : 'inherit',
2710
+ fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
2711
+ lineHeight: '1',
2712
+ boxSizing: 'border-box',
2713
+ });
2714
+ tuneBadge.textContent = String(visParams.length);
2715
+ tune.appendChild(tuneBadge);
2716
+ tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
2717
+ tune.addEventListener('mouseenter', () => {
2718
+ if (!tuneOpen) tune.style.background = BP.accentSoft;
2719
+ });
2720
+ tune.addEventListener('mouseleave', () => {
2721
+ if (!tuneOpen) tune.style.background = 'transparent';
2722
+ });
2723
+ tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
2724
+ } else {
2725
+ tune.disabled = true;
2726
+ tune.setAttribute('aria-label', 'Tune controls are still being prepared');
2727
+ tune.title = 'Tune controls are still being prepared';
2728
+ tune.style.opacity = '0.72';
2729
+ }
2608
2730
  tune.dataset.iceqTune = '1';
2609
2731
  row.appendChild(tune);
2610
2732
  }
@@ -2612,6 +2734,15 @@
2612
2734
  // Spacer
2613
2735
  row.appendChild(el('div', { flex: '1' }));
2614
2736
 
2737
+ if (arrivedVariants < expectedVariants) {
2738
+ const remaining = expectedVariants - arrivedVariants;
2739
+ const progress = el('span', {
2740
+ fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
2741
+ });
2742
+ progress.textContent = remaining + ' more arriving...';
2743
+ row.appendChild(progress);
2744
+ }
2745
+
2615
2746
  // Accept - primary action, kinpaku gold + lacquer-deep (matches demo .live-demo-ctx-accept)
2616
2747
  const accept = el('button', {
2617
2748
  padding: '5px 14px', borderRadius: '5px',
@@ -2626,7 +2757,11 @@
2626
2757
  accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
2627
2758
  accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
2628
2759
  accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
2629
- if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
2760
+ if (arrivedVariants === 0) {
2761
+ accept.style.opacity = '0.3';
2762
+ accept.style.pointerEvents = 'none';
2763
+ accept.title = 'Accept becomes available when the first variant arrives';
2764
+ }
2630
2765
  row.appendChild(accept);
2631
2766
 
2632
2767
  // Discard
@@ -2893,7 +3028,7 @@
2893
3028
  console.warn('[impeccable] Refusing to render empty variant cycling state:', reason);
2894
3029
  const message = 'No variants were mounted. Please try again.';
2895
3030
  if (svelteComponentSession?.sessionId === currentSessionId) {
2896
- abortSvelteComponentInjection(currentSessionId, message);
3031
+ resetSvelteComponentSession(currentSessionId, message);
2897
3032
  return;
2898
3033
  }
2899
3034
  cleanup();
@@ -3035,16 +3170,26 @@
3035
3170
  function applyParamValue(variantEl, param, value) {
3036
3171
  if (!variantEl) return;
3037
3172
  const attr = 'data-p-' + param.id;
3038
- if (param.kind === 'range') {
3039
- variantEl.style.setProperty('--p-' + param.id, String(value));
3040
- } else if (param.kind === 'toggle') {
3173
+ if (param.kind === 'toggle') {
3041
3174
  const on = !!value;
3042
- variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
3043
3175
  if (on) variantEl.setAttribute(attr, 'on');
3044
3176
  else variantEl.removeAttribute(attr);
3045
3177
  } else if (param.kind === 'steps') {
3046
3178
  variantEl.setAttribute(attr, String(value));
3047
3179
  }
3180
+ // Svelte component variants are client-mounted into
3181
+ // [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
3182
+ // wrapper for the state stylesheet to target, and the element is not SSR'd,
3183
+ // so there is no React hydration to mismatch. Drive range/toggle --p-* inline
3184
+ // on the mounted element so scoped preview CSS resolves them.
3185
+ if (svelteComponentSession?.sessionId === currentSessionId) {
3186
+ if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
3187
+ else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
3188
+ return;
3189
+ }
3190
+ // range/toggle --p-* custom properties are driven through the injected
3191
+ // variant-state stylesheet so we never mutate inline style on SSR'd divs.
3192
+ updateVariantStateStylesheet(currentSessionId, visibleVariant);
3048
3193
  }
3049
3194
 
3050
3195
  function applyParamDefaults(variantEl, params) {
@@ -3119,7 +3264,7 @@
3119
3264
  position: 'absolute', top: '2px',
3120
3265
  left: initial ? '18px' : '2px',
3121
3266
  width: '16px', height: '16px', borderRadius: '50%',
3122
- background: 'oklch(98% 0 0)',
3267
+ background: C.ink,
3123
3268
  transition: 'left 0.18s ' + EASE,
3124
3269
  boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
3125
3270
  });
@@ -3153,7 +3298,7 @@
3153
3298
  const b = el('button', {
3154
3299
  padding: '5px 4px', border: 'none', borderRadius: '3px',
3155
3300
  background: active ? C.brand : 'transparent',
3156
- color: active ? 'oklch(98% 0 0)' : P.text,
3301
+ color: active ? C.ink : P.text,
3157
3302
  fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
3158
3303
  cursor: 'pointer', whiteSpace: 'nowrap',
3159
3304
  transition: 'background 0.1s ease, color 0.1s ease',
@@ -3166,7 +3311,7 @@
3166
3311
  segBtns.forEach(({ btn, val }) => {
3167
3312
  const on = val === o.value;
3168
3313
  btn.style.background = on ? C.brand : 'transparent';
3169
- btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
3314
+ btn.style.color = on ? C.ink : P.text;
3170
3315
  });
3171
3316
  applyParamValue(variantEl, p, o.value);
3172
3317
  queueCheckpoint('param_changed');
@@ -4714,6 +4859,7 @@
4714
4859
  paramsCurrentValues = {};
4715
4860
  tuneOpen = false;
4716
4861
  hideParamsPanel();
4862
+ if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
4717
4863
  return;
4718
4864
  }
4719
4865
  applyParamDefaults(variantEl, params);
@@ -4730,6 +4876,32 @@
4730
4876
  }
4731
4877
  }
4732
4878
 
4879
+ function mountedParameterCount() {
4880
+ if (svelteComponentSession?.sessionId === currentSessionId) {
4881
+ return Object.values(svelteComponentSession.paramsByVariant || {})
4882
+ .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
4883
+ }
4884
+ const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
4885
+ if (!wrapper) return 0;
4886
+ return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
4887
+ .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
4888
+ }
4889
+
4890
+ function completeParameterPublication() {
4891
+ if (!currentSessionId) return;
4892
+ const ready = mountedParameterCount() > 0;
4893
+ parameterGenerationState = ready ? 'ready' : 'none';
4894
+ if (ready && parameterReadyAnnouncedSession !== currentSessionId) {
4895
+ parameterReadyAnnouncedSession = currentSessionId;
4896
+ showToast('Tune controls are ready.', 3000);
4897
+ }
4898
+ if (state === 'CYCLING') {
4899
+ refreshParamsPanel();
4900
+ showOrUpdateCyclingBar();
4901
+ }
4902
+ saveSession();
4903
+ }
4904
+
4733
4905
  function toggleTunePopover() {
4734
4906
  if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
4735
4907
  if (tuneOpen) { closeTunePopover(); return; }
@@ -4771,20 +4943,7 @@
4771
4943
 
4772
4944
  function isVariantShown(el) {
4773
4945
  if (!el) return false;
4774
- if (el.hidden) return false;
4775
- if (el.style?.display === 'none') return false;
4776
- return true;
4777
- }
4778
-
4779
- function setVariantShown(el, shown) {
4780
- if (!el) return;
4781
- if (shown) {
4782
- el.removeAttribute('hidden');
4783
- el.style.display = '';
4784
- } else {
4785
- el.setAttribute('hidden', '');
4786
- el.style.display = 'none';
4787
- }
4946
+ return getComputedStyle(el).display !== 'none';
4788
4947
  }
4789
4948
 
4790
4949
  function scheduleCyclingBarSync(sessionId, variantNum) {
@@ -4799,11 +4958,9 @@
4799
4958
  }
4800
4959
 
4801
4960
  function syncCyclingControls() {
4802
- const shown = svelteComponentSession?.sessionId === currentSessionId && svelteComponentSession.mountedVariant > 0
4803
- ? svelteComponentSession.mountedVariant
4804
- : visibleVariant;
4961
+ const shown = cyclingShownVariant();
4805
4962
  const counter = uiGetById(PREFIX + '-variant-counter');
4806
- if (counter && arrivedVariants > 0) counter.textContent = shown + '/' + arrivedVariants;
4963
+ if (counter) counter.textContent = cyclingCounterText();
4807
4964
  const prev = uiGetById(PREFIX + '-variant-prev');
4808
4965
  const next = uiGetById(PREFIX + '-variant-next');
4809
4966
  if (prev) prev.style.opacity = shown <= 1 ? '0.3' : '1';
@@ -4823,11 +4980,7 @@
4823
4980
  }
4824
4981
  const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
4825
4982
  if (!wrapper) return false;
4826
- for (const child of wrapper.children) {
4827
- const v = child.dataset ? child.dataset.impeccableVariant : null;
4828
- if (!v) continue;
4829
- setVariantShown(child, v === String(num));
4830
- }
4983
+ updateVariantStateStylesheet(sessionId, num);
4831
4984
  // Unconditional refresh - covers first-reveal (no-op if state isn't
4832
4985
  // CYCLING yet, the subsequent CYCLING transition triggers its own
4833
4986
  // refresh) and every cycle step.
@@ -4839,6 +4992,10 @@
4839
4992
  return String(filePath || '').endsWith('manifest.json');
4840
4993
  }
4841
4994
 
4995
+ function isFrameworkComponentPreviewMode(mode) {
4996
+ return mode === 'svelte-component';
4997
+ }
4998
+
4842
4999
  function parseOriginalMarkupElement(originalMarkup) {
4843
5000
  const parser = new DOMParser();
4844
5001
  const doc = parser.parseFromString('<div id="impeccable-anchor">' + originalMarkup + '</div>', 'text/html');
@@ -5033,11 +5190,74 @@
5033
5190
  }
5034
5191
  }
5035
5192
 
5036
- function loadSvelteRuntime(runtimeModule) {
5193
+ // The dev server may serve under a non-root base (vite `base`) or a root
5194
+ // that differs from where the helper wrote the preview tree. Root-relative
5195
+ // URLs are tried against the detected base first; the /@fs/ absolute form
5196
+ // is the fallback that works regardless of base and root, as long as the
5197
+ // path is inside the server's fs.allow.
5198
+ let detectedDevBase = null;
5199
+ function detectDevServerBase() {
5200
+ if (detectedDevBase !== null) return detectedDevBase;
5201
+ detectedDevBase = '/';
5202
+ const scripts = document.querySelectorAll('script[type="module"][src]');
5203
+ for (const script of scripts) {
5204
+ const src = script.getAttribute('src') || '';
5205
+ const idx = src.indexOf('/@vite/client');
5206
+ if (idx > 0) { detectedDevBase = src.slice(0, idx) + '/'; break; }
5207
+ if (idx === 0) { detectedDevBase = '/'; break; }
5208
+ }
5209
+ return detectedDevBase;
5210
+ }
5211
+
5212
+ function componentModuleCandidates(manifest, modulePath, absPath) {
5213
+ const base = detectDevServerBase();
5214
+ const rel = String(modulePath || '').replace(/^\/+/, '');
5215
+ const candidates = [new URL(base + rel, location.origin).href];
5216
+ if (base !== '/') candidates.push(new URL('/' + rel, location.origin).href);
5217
+ if (absPath) {
5218
+ const fsRel = '@fs/' + String(absPath).replace(/^\/+/, '');
5219
+ candidates.push(new URL(base + fsRel, location.origin).href);
5220
+ // Vite versions differ on whether @fs is served under base or at the
5221
+ // server root; with a non-root base, try both.
5222
+ if (base !== '/') candidates.push(new URL('/' + fsRel, location.origin).href);
5223
+ }
5224
+ return candidates;
5225
+ }
5226
+
5227
+ async function importFirstReachable(candidates, bust) {
5228
+ let lastErr = null;
5229
+ for (const candidate of candidates) {
5230
+ try {
5231
+ const url = bust ? candidate + (candidate.includes('?') ? '&' : '?') + 't=' + Date.now() : candidate;
5232
+ const mod = await import(/* @vite-ignore */ url);
5233
+ return { mod, url: candidate };
5234
+ } catch (err) {
5235
+ lastErr = err;
5236
+ }
5237
+ }
5238
+ throw Object.assign(lastErr || new Error('no module candidates'), {
5239
+ impeccableTriedUrls: candidates,
5240
+ });
5241
+ }
5242
+
5243
+ // Distinguishes "this variant is broken" from "the preview tree is not
5244
+ // reachable from the dev server at all" (wrong root, unserved directory).
5245
+ async function probePreviewTree(manifest) {
5246
+ if (!manifest?.probeModule) return { ok: true, skipped: true };
5247
+ const candidates = componentModuleCandidates(manifest, manifest.probeModule, manifest.probeModuleAbs);
5248
+ try {
5249
+ await importFirstReachable(candidates, false);
5250
+ return { ok: true };
5251
+ } catch (err) {
5252
+ return { ok: false, tried: err.impeccableTriedUrls || candidates };
5253
+ }
5254
+ }
5255
+
5256
+ function loadSvelteRuntime(runtimeModule, manifest) {
5037
5257
  const modulePath = runtimeModule || '/src/lib/impeccable/__runtime.js';
5038
- const url = new URL(modulePath, location.origin).href;
5039
5258
  if (!svelteRuntimePromise) {
5040
- svelteRuntimePromise = import(/* @vite-ignore */ url);
5259
+ const candidates = componentModuleCandidates(manifest, modulePath, manifest?.runtimeModuleAbs);
5260
+ svelteRuntimePromise = importFirstReachable(candidates, false).then((r) => r.mod);
5041
5261
  }
5042
5262
  return svelteRuntimePromise;
5043
5263
  }
@@ -5047,7 +5267,7 @@
5047
5267
  // attribute with JSON braces can't survive the Svelte compiler. Returns a map of
5048
5268
  // { "1": [...params], "2": [...] }; an empty object when the agent declared none.
5049
5269
  async function loadSvelteComponentParams(manifest) {
5050
- const dir = String(manifest?.componentDir || '').replace(/^\/+/, '');
5270
+ const dir = String(manifest?.revisionDir || manifest?.componentDir || '').replace(/^\/+/, '');
5051
5271
  if (!dir) return {};
5052
5272
  const paramsPath = dir + '/params.json';
5053
5273
  const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(paramsPath);
@@ -5066,41 +5286,14 @@
5066
5286
  }
5067
5287
  }
5068
5288
 
5069
- async function loadSvelteComponentVariantSource(manifest, variantNum) {
5070
- const dir = String(manifest?.componentDir || '').replace(/^\/+/, '');
5071
- if (!dir || !variantNum) return '';
5072
- const sourcePath = dir + '/v' + variantNum + '.svelte';
5073
- const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(sourcePath);
5074
- try {
5075
- const res = await fetch(url);
5076
- if (!res.ok) return '';
5077
- return await res.text();
5078
- } catch {
5079
- return '';
5080
- }
5081
- }
5082
5289
 
5083
- function extractSvelteComponentStyle(source) {
5084
- const match = String(source || '').match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i);
5085
- return match ? match[1].trim() : '';
5086
- }
5087
5290
 
5088
- async function applySvelteComponentVariantStyle(variantNum) {
5089
- if (!svelteComponentSession || !variantNum) return;
5090
- const { manifest, sessionId } = svelteComponentSession;
5091
- const source = await loadSvelteComponentVariantSource(manifest, variantNum);
5092
- const css = extractSvelteComponentStyle(source);
5093
- removeSvelteComponentVariantStyle(svelteComponentSession);
5094
- if (!css) return;
5095
- const scopedCss = scopeCssToSveltePreview(css, sessionId);
5096
- if (!scopedCss) return;
5097
- const style = document.createElement('style');
5098
- style.dataset.impeccableSvelteComponentStyle = sessionId;
5099
- style.dataset.impeccableVariant = String(variantNum);
5100
- style.textContent = scopedCss;
5101
- document.head.appendChild(style);
5102
- svelteComponentSession.styleEl = style;
5103
- }
5291
+ // NOTE: the compiled component imported from the dev server already carries
5292
+ // its own scoped styles (vite-plugin-svelte injects them on module
5293
+ // evaluation). The old second injection re-fetched the raw source through
5294
+ // the helper and re-prefixed every selector un-hashed, so the same rules
5295
+ // applied twice with different specificity: preview and accepted cascades
5296
+ // disagreed. The single compiled copy is the truth now.
5104
5297
 
5105
5298
  function removeSvelteComponentVariantStyle(session = svelteComponentSession) {
5106
5299
  const style = session?.styleEl;
@@ -5108,10 +5301,6 @@
5108
5301
  if (session) session.styleEl = null;
5109
5302
  }
5110
5303
 
5111
- function scopeCssToSveltePreview(css, sessionId) {
5112
- const prefix = '[data-impeccable-variants="' + String(sessionId).replace(/"/g, '\\"') + '"] ';
5113
- return scopeCssBlock(String(css || ''), prefix).trim();
5114
- }
5115
5304
 
5116
5305
  function scopeCssBlock(css, prefix) {
5117
5306
  let out = '';
@@ -5221,26 +5410,212 @@
5221
5410
  const contract = manifest?.propContract || [];
5222
5411
  const values = {};
5223
5412
  if (!liveEl || contract.length === 0) return values;
5413
+ if (Number(manifest.contractVersion) === 2) {
5414
+ return buildSveltePropValuesV2(liveEl, manifest);
5415
+ }
5224
5416
  const sourceOriginal = parseOriginalMarkupElement(manifest.originalMarkup || '');
5225
5417
  if (!sourceOriginal) return values;
5226
5418
  const map = buildSvelteExpressionTextMap(sourceOriginal, liveEl);
5227
5419
  for (const entry of contract) {
5228
- const token = '{' + entry.expr + '}';
5420
+ const token = entry.previewToken || ('{' + entry.expr + '}');
5229
5421
  values[entry.prop] = map.get(token) || '';
5230
5422
  }
5231
5423
  return values;
5232
5424
  }
5233
5425
 
5426
+ // Contract v2 hydration. The scaffolder preserved control flow, so props
5427
+ // come in kinds: `collection` hydrates from the live DOM's rendered items
5428
+ // (count by the item root selector, texts by slot order), `condition` from
5429
+ // whether the branch's probe element is currently rendered, `text` from the
5430
+ // v1 index-zip run over the markup WITH control-flow regions stripped and
5431
+ // the live tree WITH item elements excluded, so loop tokens can never shift
5432
+ // slots again. `handler` props keep their no-op defaults.
5433
+ function buildSveltePropValuesV2(liveEl, manifest) {
5434
+ const contract = manifest.propContract || [];
5435
+ const values = {};
5436
+ const itemElsByProp = new Map();
5437
+
5438
+ for (const entry of contract) {
5439
+ if (entry.kind === 'collection' && entry.item && entry.item.rootTag) {
5440
+ const selector = entry.item.rootTag + (entry.item.rootClasses || []).map((c) => '.' + cssEscapeIdent(c)).join('');
5441
+ let matches = [];
5442
+ try { matches = Array.from(liveEl.querySelectorAll(selector)); } catch { matches = []; }
5443
+ itemElsByProp.set(entry.prop, matches);
5444
+ const statics = new Set((entry.item.staticTexts || []).map((t) => String(t).trim()));
5445
+ const slots = entry.item.textSlots || [];
5446
+ values[entry.prop] = matches.map((itemEl, index) => {
5447
+ const texts = collectVisibleTexts(itemEl).filter((t) => !statics.has(t));
5448
+ const item = {};
5449
+ slots.forEach((slot, i) => { item[slot.key] = texts[i] != null ? texts[i] : ''; });
5450
+ // Attribute-bound values (href={link.href}) hydrate from the
5451
+ // rendered attribute on the live item element or a descendant.
5452
+ for (const slot of entry.item.attrSlots || []) {
5453
+ if (item[slot.key] != null || !slot.tag) continue;
5454
+ const sel = slot.tag + (slot.classes || []).map((c) => '.' + cssEscapeIdent(c)).join('');
5455
+ let el = null;
5456
+ try { el = itemEl.matches(sel) ? itemEl : itemEl.querySelector(sel); } catch { el = null; }
5457
+ const value = el ? el.getAttribute(slot.attr) : null;
5458
+ if (value != null) item[slot.key] = value;
5459
+ }
5460
+ // Keyed each: the key field is never rendered, so hydrate it with a
5461
+ // unique per-index value or Svelte throws each_key_duplicate.
5462
+ if (entry.item.keyField && item[entry.item.keyField] == null) {
5463
+ item[entry.item.keyField] = 'impeccable-live-' + index;
5464
+ }
5465
+ return item;
5466
+ });
5467
+ } else if (entry.kind === 'condition') {
5468
+ if (entry.probe && entry.probe.tag) {
5469
+ const selector = entry.probe.tag + (entry.probe.classes || []).map((c) => '.' + cssEscapeIdent(c)).join('');
5470
+ try { values[entry.prop] = !!liveEl.querySelector(selector); } catch { /* keep default */ }
5471
+ } else if (entry.probe && entry.probe.className) {
5472
+ // class:name directive: the live DOM answers directly, either on
5473
+ // the picked element itself or on a descendant carrying the class.
5474
+ try {
5475
+ values[entry.prop] = liveEl.classList.contains(entry.probe.className)
5476
+ || !!liveEl.querySelector('.' + cssEscapeIdent(entry.probe.className));
5477
+ } catch { /* keep default */ }
5478
+ }
5479
+ }
5480
+ }
5481
+
5482
+ // Text props outside control flow: strip block regions from the source
5483
+ // markup, exclude live text nodes inside any hydrated item element, then
5484
+ // run the existing zip.
5485
+ const textEntries = contract.filter((e) => e.kind === 'text' || e.kind === 'raw');
5486
+ if (textEntries.length > 0) {
5487
+ const strippedMarkup = stripSvelteBlockRegions(manifest.originalMarkup || '');
5488
+ const sourceOriginal = parseOriginalMarkupElement(strippedMarkup);
5489
+ if (sourceOriginal) {
5490
+ const excluded = [];
5491
+ for (const els of itemElsByProp.values()) excluded.push(...els);
5492
+ const filteredLive = cloneWithoutElements(liveEl, excluded);
5493
+ const map = buildSvelteExpressionTextMap(sourceOriginal, filteredLive);
5494
+ for (const entry of textEntries) {
5495
+ const token = '{' + entry.expr + '}';
5496
+ if (map.has(token)) values[entry.prop] = map.get(token) || '';
5497
+ }
5498
+ }
5499
+ }
5500
+ return values;
5501
+ }
5502
+
5503
+ function cssEscapeIdent(value) {
5504
+ try { return CSS.escape(value); } catch { return String(value).replace(/[^a-zA-Z0-9_-]/g, ''); }
5505
+ }
5506
+
5507
+ function collectVisibleTexts(rootEl) {
5508
+ const texts = [];
5509
+ const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT);
5510
+ let node;
5511
+ while ((node = walker.nextNode())) {
5512
+ const trimmed = String(node.textContent || '').trim();
5513
+ if (trimmed) texts.push(trimmed);
5514
+ }
5515
+ return texts;
5516
+ }
5517
+
5518
+ // Remove balanced {#each}...{/each} and {#if}...{/if} regions (including
5519
+ // the delimiters) from a markup string. Nesting-aware. {#key} blocks keep
5520
+ // their CONTENT (it always renders) but lose their delimiter tokens, which
5521
+ // would otherwise consume live text slots in the zip and shift every
5522
+ // following expression.
5523
+ function stripSvelteBlockRegions(markup) {
5524
+ let out = String(markup || '');
5525
+ out = stripSvelteKeyDelimiters(out);
5526
+ for (const kind of ['each', 'if']) {
5527
+ const open = '{#' + kind;
5528
+ const close = '{/' + kind + '}';
5529
+ for (;;) {
5530
+ const start = out.indexOf(open);
5531
+ if (start === -1) break;
5532
+ let depth = 0;
5533
+ let i = start;
5534
+ let end = -1;
5535
+ while (i < out.length) {
5536
+ if (out.startsWith(open, i)) { depth++; i += open.length; continue; }
5537
+ if (out.startsWith(close, i)) {
5538
+ depth--;
5539
+ i += close.length;
5540
+ if (depth === 0) { end = i; break; }
5541
+ continue;
5542
+ }
5543
+ i++;
5544
+ }
5545
+ if (end === -1) break;
5546
+ out = out.slice(0, start) + out.slice(end);
5547
+ }
5548
+ }
5549
+ return out;
5550
+ }
5551
+
5552
+ function stripSvelteKeyDelimiters(markup) {
5553
+ let out = String(markup || '');
5554
+ for (;;) {
5555
+ const start = out.indexOf('{#key');
5556
+ if (start === -1) break;
5557
+ // The opening tag runs to its matching close brace (expressions inside
5558
+ // may nest braces).
5559
+ let depth = 0;
5560
+ let i = start;
5561
+ let openEnd = -1;
5562
+ while (i < out.length) {
5563
+ if (out[i] === '{') depth++;
5564
+ else if (out[i] === '}') {
5565
+ depth--;
5566
+ if (depth === 0) { openEnd = i + 1; break; }
5567
+ }
5568
+ i++;
5569
+ }
5570
+ if (openEnd === -1) break;
5571
+ out = out.slice(0, start) + out.slice(openEnd);
5572
+ }
5573
+ return out.split('{/key}').join('');
5574
+ }
5575
+
5576
+ function cloneWithoutElements(rootEl, excludedEls) {
5577
+ if (!excludedEls || excludedEls.length === 0) return rootEl;
5578
+ const excludedSet = new Set(excludedEls);
5579
+ // Mark originals, clone, then strip marked clones: identity does not
5580
+ // survive cloneNode, attributes do.
5581
+ const MARK = 'data-impeccable-hydration-excluded';
5582
+ for (const el of excludedSet) { try { el.setAttribute(MARK, '1'); } catch { /* detached */ } }
5583
+ let clone;
5584
+ try {
5585
+ clone = rootEl.cloneNode(true);
5586
+ clone.querySelectorAll('[' + MARK + ']').forEach((el) => el.remove());
5587
+ } finally {
5588
+ for (const el of excludedSet) { try { el.removeAttribute(MARK); } catch { /* detached */ } }
5589
+ }
5590
+ return clone || rootEl;
5591
+ }
5592
+
5234
5593
  async function mountSvelteComponentVariant(variantNum) {
5235
5594
  if (!svelteComponentSession || !variantNum) return false;
5236
5595
  const { manifest, mountTargetEl, sessionId } = svelteComponentSession;
5596
+ // Resolved before the first await so the failure report can name the module
5597
+ // the browser could not reach, whichever step threw.
5598
+ const extension = manifest.componentExtension || 'svelte';
5599
+ // Prefer the server-stamped revision dir: its path changes on every
5600
+ // publish, which is what defeats stale transform caches for files the
5601
+ // dev server does not watch.
5602
+ const dirRel = manifest.revisionDir || manifest.componentDir || '';
5603
+ const dirAbs = manifest.revisionDirAbs || manifest.componentDirAbs || null;
5604
+ const moduleBase = manifest.componentModuleBase
5605
+ || ('/' + String(dirRel).replace(/^\/+/, ''));
5606
+ const modulePath = String(moduleBase).replace(/\/+$/, '') + '/v' + variantNum + '.' + extension;
5607
+ const moduleAbs = dirAbs
5608
+ ? String(dirAbs).replace(/\/+$/, '') + '/v' + variantNum + '.' + extension
5609
+ : null;
5610
+ const candidates = componentModuleCandidates(manifest, modulePath, moduleAbs);
5611
+ let moduleUrl = candidates[0];
5237
5612
  try {
5238
5613
  const previousAnchor = getMountedSvelteComponentAnchor(svelteComponentSession) || selectedElement;
5239
5614
  svelteComponentSession.swapAnchor = makeFrozenAnchor(previousAnchor) || svelteComponentSession.swapAnchor || null;
5240
- const runtime = await loadSvelteRuntime(manifest.runtimeModule);
5241
- const modulePath = '/' + String(manifest.componentDir || '').replace(/^\/+/, '') + '/v' + variantNum + '.svelte';
5242
- const moduleUrl = new URL(modulePath, location.origin).href + '?t=' + Date.now();
5243
- const mod = await import(/* @vite-ignore */ moduleUrl);
5615
+ const runtime = await loadSvelteRuntime(manifest.runtimeModule, manifest);
5616
+ const imported = await importFirstReachable(candidates, true);
5617
+ moduleUrl = imported.url;
5618
+ const mod = imported.mod;
5244
5619
  const Component = mod.default;
5245
5620
  if (svelteComponentSession.mountedInstance && runtime.unmount) {
5246
5621
  await runtime.unmount(svelteComponentSession.mountedInstance);
@@ -5253,7 +5628,7 @@
5253
5628
  });
5254
5629
  svelteComponentSession.mountedVariant = variantNum;
5255
5630
  svelteComponentSession.runtime = runtime;
5256
- await applySvelteComponentVariantStyle(variantNum);
5631
+ removeSvelteComponentVariantStyle(svelteComponentSession);
5257
5632
  if (state === 'CYCLING') syncCyclingControls();
5258
5633
  const nextAnchor = getMountedSvelteComponentAnchor(svelteComponentSession);
5259
5634
  if (nextAnchor) {
@@ -5274,16 +5649,44 @@
5274
5649
  selectedElement = settledAnchor;
5275
5650
  });
5276
5651
  }
5652
+ // Render truth, not publish truth: this is the only point in the whole
5653
+ // pipeline that proves the user can see variant N.
5654
+ reportVariantMounted(sessionId, variantNum, moduleUrl);
5655
+ if (mountErrorState?.sessionId === sessionId && mountErrorState.variant === variantNum) {
5656
+ clearMountErrorCard();
5657
+ }
5277
5658
  return true;
5278
5659
  } catch (err) {
5279
5660
  if (svelteComponentSession?.sessionId === sessionId) {
5280
5661
  svelteComponentSession.swapAnchor = null;
5281
5662
  }
5282
- console.error('[impeccable] Failed to mount Svelte variant ' + variantNum + ' for ' + sessionId + ':', err);
5663
+ console.error('[impeccable] Failed to mount component variant ' + variantNum + ' for ' + sessionId + ':', err);
5664
+ reportVariantMountFailed(sessionId, variantNum, moduleUrl, err);
5665
+ // Every mount failure gets the card, so the variant-switch path (which
5666
+ // used to revert with no feedback whatsoever) says what broke too.
5667
+ showMountErrorCard(sessionId, {
5668
+ variant: variantNum,
5669
+ url: moduleUrl,
5670
+ message: await describeMountFailure(manifest, err),
5671
+ });
5283
5672
  return false;
5284
5673
  }
5285
5674
  }
5286
5675
 
5676
+ // Distinguishes a broken variant from an unreachable preview tree; the
5677
+ // recovery differs (fix the component vs fix the root/dev-server pair).
5678
+ async function describeMountFailure(manifest, err) {
5679
+ try {
5680
+ const probe = await probePreviewTree(manifest);
5681
+ if (probe.ok === false) {
5682
+ return 'The preview tree is not reachable from the dev server (probe failed on '
5683
+ + (probe.tried || []).join(', ')
5684
+ + '). The resolved app root and the dev server root likely disagree; restart live from the app the dev server serves.';
5685
+ }
5686
+ } catch { /* probe is best-effort */ }
5687
+ return 'The compiled component could not be imported or mounted. ' + (err?.message || 'Unknown error');
5688
+ }
5689
+
5287
5690
  function teardownSvelteComponentSession(restoreOriginal) {
5288
5691
  if (!svelteComponentSession) return;
5289
5692
  const { wrapperEl, detachedOriginal, runtime, mountedInstance } = svelteComponentSession;
@@ -5336,48 +5739,78 @@
5336
5739
  }
5337
5740
 
5338
5741
  async function injectSvelteComponentsFromManifest(manifestPath, sessionId) {
5742
+ // Every (re)injection is a fresh attempt: reset the failure dedupe so a
5743
+ // republish that is STILL broken at the same URL reports again instead of
5744
+ // being swallowed while the agent believes the repair landed.
5745
+ lastReportedMountFailure = null;
5339
5746
  const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(manifestPath);
5340
5747
  try {
5341
5748
  const res = await fetch(url);
5342
5749
  if (!res.ok) throw new Error(String(res.status));
5343
5750
  const manifest = JSON.parse(await res.text());
5344
- if (manifest.id !== sessionId) return;
5751
+ if (manifest.id !== sessionId) {
5752
+ // A manifest at the expected path belonging to a different session is
5753
+ // an agent-side publish error. Left as a bare return it stranded the
5754
+ // bar in GENERATING with no explanation and no event.
5755
+ const mismatch = 'Manifest at ' + manifestPath + ' belongs to session ' + (manifest.id || 'unknown') + ', not ' + sessionId + '.';
5756
+ reportVariantMountFailed(sessionId, visibleVariant || 1, manifestPath, mismatch);
5757
+ showMountErrorCard(sessionId, {
5758
+ variant: visibleVariant || 0,
5759
+ url: manifestPath,
5760
+ message: 'The variant manifest is for a different session. Ask the agent to republish.',
5761
+ previewFile: manifestPath,
5762
+ });
5763
+ return;
5764
+ }
5345
5765
 
5346
5766
  const paramsByVariant = await loadSvelteComponentParams(manifest);
5767
+ const availableVariants = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
5768
+ const componentPreviewMode = isFrameworkComponentPreviewMode(manifest.previewMode)
5769
+ ? manifest.previewMode
5770
+ : 'svelte-component';
5347
5771
  currentSessionId = sessionId;
5348
5772
  expectedVariants = Number(manifest.count) || expectedVariants || 1;
5349
5773
  rememberSessionFileMeta({
5350
5774
  sourceFile: manifest.sourceFile,
5351
5775
  previewFile: manifestPath,
5352
- previewMode: 'svelte-component',
5776
+ previewMode: componentPreviewMode,
5353
5777
  });
5354
5778
  if (state !== 'CYCLING') setLiveState('GENERATING');
5355
5779
 
5356
5780
  const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
5357
5781
  if (existingWrapper && svelteComponentSession?.sessionId === sessionId) {
5358
5782
  recoveryWaitingForAnchor = false;
5783
+ svelteComponentSession.manifest = manifest;
5359
5784
  svelteComponentSession.paramsByVariant = paramsByVariant;
5360
- arrivedVariants = Number(manifest.count) || expectedVariants || 1;
5361
- expectedVariants = arrivedVariants;
5785
+ arrivedVariants = availableVariants;
5786
+ expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
5362
5787
  visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants ? visibleVariant : 1;
5363
- await mountSvelteComponentVariant(visibleVariant || 1);
5788
+ const remounted = await mountSvelteComponentVariant(visibleVariant || 1);
5789
+ if (!remounted) {
5790
+ // The mount already reported the failure and raised the card.
5791
+ // Advancing to CYCLING here would show a bar claiming variants are
5792
+ // ready over a page where nothing rendered.
5793
+ saveSession();
5794
+ return;
5795
+ }
5364
5796
  setLiveState('CYCLING');
5365
5797
  showOrUpdateCyclingBar();
5366
5798
  saveSession();
5799
+ if (parameterGenerationState === 'loading') completeParameterPublication();
5367
5800
  return;
5368
5801
  }
5369
5802
 
5370
5803
  const liveEl = findLiveElementForSvelteManifest(manifest);
5371
5804
  if (!liveEl?.parentElement) {
5372
5805
  console.warn('[impeccable] Could not find original element in live DOM.');
5373
- arrivedVariants = Number(manifest.count) || expectedVariants || 1;
5374
- expectedVariants = arrivedVariants;
5806
+ arrivedVariants = availableVariants;
5807
+ expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
5375
5808
  const saved = loadSession();
5376
5809
  const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
5377
5810
  visibleVariant = visibleVariant > 0 && visibleVariant <= arrivedVariants
5378
5811
  ? visibleVariant
5379
5812
  : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
5380
- enterRecoveryWaitingForAnchor({ checkpointReason: 'svelte_component_anchor_missing', trackScroll: true });
5813
+ enterRecoveryWaitingForAnchor({ checkpointReason: 'component_preview_anchor_missing', trackScroll: true });
5381
5814
  waitForSvelteComponentTargetAndRetry({ manifestPath, sessionId, manifest });
5382
5815
  return;
5383
5816
  }
@@ -5385,7 +5818,7 @@
5385
5818
  const wrapper = document.createElement('div');
5386
5819
  wrapper.dataset.impeccableVariants = sessionId;
5387
5820
  wrapper.dataset.impeccableVariantCount = String(manifest.count || expectedVariants || 1);
5388
- wrapper.dataset.impeccablePreview = 'svelte-component';
5821
+ wrapper.dataset.impeccablePreview = componentPreviewMode;
5389
5822
  wrapper.style.display = 'contents';
5390
5823
 
5391
5824
  const mountTarget = document.createElement('div');
@@ -5423,8 +5856,8 @@
5423
5856
  recoveryWaitingForAnchor = false;
5424
5857
 
5425
5858
  const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
5426
- arrivedVariants = Number(manifest.count) || expectedVariants || 1;
5427
- expectedVariants = arrivedVariants;
5859
+ arrivedVariants = availableVariants;
5860
+ expectedVariants = Number(manifest.count) || expectedVariants || arrivedVariants;
5428
5861
  const saved = loadSession();
5429
5862
  const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
5430
5863
  visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
@@ -5434,9 +5867,11 @@
5434
5867
  const mounted = await mountSvelteComponentVariant(visibleVariant);
5435
5868
  if (!mounted) {
5436
5869
  // The compiled component threw (e.g. a Svelte compile error in the
5437
- // variant file). Don't strand the bar in an empty CYCLING state; restore
5438
- // the original element and reset to PICKING so the user can retry.
5439
- abortSvelteComponentInjection(sessionId, 'A variant failed to compile. Fix the component and re-run.');
5870
+ // variant file). mountSvelteComponentVariant already reported the
5871
+ // failure and raised the card; tear the half-built preview down but
5872
+ // keep the session so Retry and a republish still have something to
5873
+ // act on.
5874
+ abortSvelteComponentInjection(sessionId);
5440
5875
  return;
5441
5876
  }
5442
5877
 
@@ -5449,10 +5884,19 @@
5449
5884
  refreshParamsPanel();
5450
5885
  positionBar();
5451
5886
  saveSession();
5452
- console.log('[impeccable] Mounted ' + arrivedVariants + ' Svelte component variants.');
5887
+ if (parameterGenerationState === 'loading') completeParameterPublication();
5888
+ console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
5453
5889
  } catch (err) {
5454
- console.error('[impeccable] Failed to mount Svelte component variants:', err);
5455
- abortSvelteComponentInjection(sessionId, 'Could not load variants. Fix the error and re-run.');
5890
+ console.error('[impeccable] Failed to mount component-preview variants:', err);
5891
+ // Report the manifest PATH, never the fetch URL: that URL carries the
5892
+ // live helper token and this string is journaled.
5893
+ reportVariantMountFailed(sessionId, visibleVariant || 1, manifestPath, err);
5894
+ abortSvelteComponentInjection(sessionId, {
5895
+ variant: visibleVariant || 0,
5896
+ url: manifestPath,
5897
+ message: 'Could not read the variant manifest. ' + (err?.message || 'Unknown error'),
5898
+ previewFile: manifestPath,
5899
+ });
5456
5900
  }
5457
5901
  }
5458
5902
 
@@ -5473,10 +5917,187 @@
5473
5917
  pendingSvelteComponentRetryObserver.observe(document.body, { childList: true, subtree: true });
5474
5918
  }
5475
5919
 
5476
- // Reset cleanly when a Svelte component session can't mount: tear the wrapper
5477
- // down (restoring the original element), clear persisted session state, and
5478
- // return the bar to PICKING. Avoids the stuck 0/0 CYCLING bar.
5479
- function abortSvelteComponentInjection(sessionId, message) {
5920
+ //
5921
+ // Mount acknowledgements
5922
+ //
5923
+ // The agent's `done` says it published files. Only the browser knows whether
5924
+ // the import resolved and the component reached the DOM. These two events
5925
+ // carry that answer back, so the journal, `live-status`, and `live-resume`
5926
+ // can tell "the user is comparing variants" from "nothing ever rendered".
5927
+
5928
+ // Mirror of the caps in live/event-validation.mjs. Trimming here keeps a
5929
+ // stack-trace-sized error from being rejected outright and lost.
5930
+ const MOUNT_URL_MAX = 2000;
5931
+ const MOUNT_ERROR_MAX = 1000;
5932
+
5933
+ function reportVariantMounted(sessionId, variantNum, moduleUrl) {
5934
+ const variant = Math.floor(Number(variantNum) || 0);
5935
+ if (!sessionId || variant < 1) return;
5936
+ sendEvent({
5937
+ type: 'variant_mounted',
5938
+ id: sessionId,
5939
+ variant,
5940
+ url: moduleUrl ? String(moduleUrl).slice(0, MOUNT_URL_MAX) : undefined,
5941
+ });
5942
+ }
5943
+
5944
+ function reportVariantMountFailed(sessionId, variantNum, moduleUrl, error) {
5945
+ if (!sessionId) return;
5946
+ const parsed = Math.floor(Number(variantNum) || 0);
5947
+ const variant = parsed >= 1 ? parsed : 1;
5948
+ const url = String(moduleUrl || 'unknown').slice(0, MOUNT_URL_MAX);
5949
+ const message = String(error?.message || error || 'Unknown mount error').slice(0, MOUNT_ERROR_MAX);
5950
+ // Progressive delivery and the Retry button both re-enter the same failure.
5951
+ // Report each distinct one once so the agent's poll queue and the journal
5952
+ // stay readable; a genuinely new failure (different variant, URL, or
5953
+ // message) still gets through.
5954
+ const key = sessionId + '|' + variant + '|' + url + '|' + message;
5955
+ if (lastReportedMountFailure === key) return;
5956
+ lastReportedMountFailure = key;
5957
+ sendEvent({ type: 'variant_mount_failed', id: sessionId, variant, url, error: message });
5958
+ }
5959
+
5960
+ function truncateMiddle(value, max) {
5961
+ const text = String(value || '');
5962
+ if (text.length <= max) return text;
5963
+ const head = Math.ceil((max - 1) / 2);
5964
+ const tail = max - 1 - head;
5965
+ return text.slice(0, head) + '…' + text.slice(text.length - tail);
5966
+ }
5967
+
5968
+ /**
5969
+ * Persistent failure surface. Replaces the old 5s toast: a toast that
5970
+ * disappears while the session is unusable is indistinguishable from no
5971
+ * feedback at all, and the wipe that came with it deleted the only handle on
5972
+ * a session the server still considered live.
5973
+ */
5974
+ function showMountErrorCard(sessionId, details) {
5975
+ mountErrorState = {
5976
+ sessionId: sessionId || currentSessionId || null,
5977
+ variant: Math.floor(Number(details?.variant) || 0),
5978
+ url: details?.url ? String(details.url) : '',
5979
+ message: details?.message || 'A variant failed to load.',
5980
+ previewFile: details?.previewFile || currentPreviewFile || null,
5981
+ };
5982
+ renderMountErrorCard();
5983
+ }
5984
+
5985
+ function clearMountErrorCard() {
5986
+ mountErrorState = null;
5987
+ if (mountErrorEl) {
5988
+ mountErrorEl.remove();
5989
+ mountErrorEl = null;
5990
+ }
5991
+ }
5992
+
5993
+ function mountErrorCardBottomOffset() {
5994
+ const barRect = globalBarEl?.getBoundingClientRect();
5995
+ return barRect && barRect.height > 0
5996
+ ? Math.max(16, window.innerHeight - barRect.top + 12)
5997
+ : 16;
5998
+ }
5999
+
6000
+ function renderMountErrorCard() {
6001
+ if (!mountErrorState) return;
6002
+ if (mountErrorEl) mountErrorEl.remove();
6003
+ const P = BP || barPaletteForTheme(detectPageTheme());
6004
+ const card = el('div', {
6005
+ position: 'fixed', bottom: mountErrorCardBottomOffset() + 'px', left: '50%',
6006
+ transform: 'translateX(-50%)',
6007
+ display: 'flex', flexDirection: 'column', gap: '6px',
6008
+ background: P.surface, color: P.text,
6009
+ border: '1px solid oklch(65% 0.18 30 / 0.55)',
6010
+ borderRadius: '8px', padding: '10px 12px',
6011
+ fontFamily: FONT, fontSize: '12px',
6012
+ boxShadow: P.shadow, zIndex: Z.toast,
6013
+ maxWidth: 'min(520px, calc(100vw - 32px))',
6014
+ pointerEvents: 'auto', textAlign: 'left',
6015
+ });
6016
+ card.id = PREFIX + '-mount-error';
6017
+
6018
+ const head = el('div', { display: 'flex', alignItems: 'center', gap: '8px' });
6019
+ const glyph = el('span', { fontSize: '13px', lineHeight: '1', color: 'oklch(62% 0.19 30)', flexShrink: '0' });
6020
+ glyph.textContent = '⚠';
6021
+ head.appendChild(glyph);
6022
+ const title = el('span', { fontWeight: '600', flex: '1' });
6023
+ title.textContent = mountErrorState.variant > 0
6024
+ ? 'Variant ' + mountErrorState.variant + ' failed to load'
6025
+ : 'Variants failed to load';
6026
+ head.appendChild(title);
6027
+ const dismiss = el('button', {
6028
+ border: 'none', background: 'transparent', color: P.textDim,
6029
+ cursor: 'pointer', fontFamily: FONT, fontSize: '14px', lineHeight: '1',
6030
+ padding: '0 2px', flexShrink: '0',
6031
+ });
6032
+ dismiss.textContent = '×';
6033
+ dismiss.setAttribute('aria-label', 'Dismiss');
6034
+ dismiss.addEventListener('click', (e) => {
6035
+ e.stopPropagation();
6036
+ clearMountErrorCard();
6037
+ // The card was the only recovery affordance while the bar is hidden;
6038
+ // dismissing it must hand the user back a usable surface. PICKING
6039
+ // reactivates the global mark and the picker. The saved session and
6040
+ // server truth survive, so a later republish (SSE `done`) still
6041
+ // resurrects the comparison through the normal handlers.
6042
+ if (state === 'GENERATING') setLiveState('PICKING');
6043
+ });
6044
+ head.appendChild(dismiss);
6045
+ card.appendChild(head);
6046
+
6047
+ const body = el('div', { color: P.textDim, lineHeight: '1.4' });
6048
+ body.textContent = mountErrorState.message;
6049
+ card.appendChild(body);
6050
+
6051
+ if (mountErrorState.url) {
6052
+ const urlLine = el('div', {
6053
+ fontFamily: MONO, fontSize: '11px', color: P.textDim,
6054
+ wordBreak: 'break-all', opacity: '0.85',
6055
+ });
6056
+ urlLine.textContent = truncateMiddle(mountErrorState.url, 72);
6057
+ urlLine.title = mountErrorState.url;
6058
+ card.appendChild(urlLine);
6059
+ }
6060
+
6061
+ const actions = el('div', { display: 'flex', gap: '8px', marginTop: '2px' });
6062
+ const retry = el('button', {
6063
+ border: '1px solid ' + P.hairline, background: 'transparent',
6064
+ color: P.text, fontFamily: FONT, fontSize: '12px', fontWeight: '500',
6065
+ borderRadius: '5px', padding: '4px 10px', cursor: 'pointer',
6066
+ });
6067
+ retry.textContent = 'Retry';
6068
+ retry.dataset.impeccableMountRetry = 'true';
6069
+ retry.addEventListener('click', (e) => { e.stopPropagation(); retryMountErrorCard(); });
6070
+ actions.appendChild(retry);
6071
+ card.appendChild(actions);
6072
+
6073
+ mountErrorEl = card;
6074
+ uiAppend(card);
6075
+ defangOutsideHandlers(card);
6076
+ }
6077
+
6078
+ function retryMountErrorCard() {
6079
+ const info = mountErrorState;
6080
+ if (!info) return;
6081
+ const sessionId = info.sessionId || currentSessionId;
6082
+ const manifestPath = info.previewFile || currentPreviewFile;
6083
+ clearMountErrorCard();
6084
+ if (!sessionId || !manifestPath) {
6085
+ showToast('No variant manifest to retry. Ask the agent to republish.', 5000);
6086
+ return;
6087
+ }
6088
+ // A retry must be able to report the same failure again, otherwise a second
6089
+ // attempt against an unchanged broken module would look silent.
6090
+ lastReportedMountFailure = null;
6091
+ if (state !== 'CYCLING') setLiveState('GENERATING');
6092
+ injectSvelteComponentsFromManifest(manifestPath, sessionId);
6093
+ }
6094
+
6095
+ // Tear down a component preview that could not mount, WITHOUT touching
6096
+ // session identity. The old version cleared localStorage, nulled
6097
+ // currentSessionId, and reset to PICKING, which orphaned a session the server
6098
+ // still had in its journal and made every recovery path unreachable. The DOM
6099
+ // teardown and observer cleanup are still right; the state wipe never was.
6100
+ function abortSvelteComponentInjection(sessionId, details) {
5480
6101
  try {
5481
6102
  if (svelteComponentSession?.sessionId === sessionId) {
5482
6103
  teardownSvelteComponentSession(true);
@@ -5488,14 +6109,52 @@
5488
6109
  console.warn('[impeccable] Svelte component abort cleanup failed:', err);
5489
6110
  }
5490
6111
  hideShaderOverlay();
6112
+ if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
6113
+ if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
6114
+ // The generate submit armed a scroll lock and a variant observer; a page
6115
+ // the user cannot scroll, watched by a stale observer, is exactly the
6116
+ // wrong place to show a card asking them to act.
6117
+ stopScrollLock();
6118
+ if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
6119
+ removeVariantStateStylesheet();
6120
+ hideBar(true);
6121
+ // currentSessionId, the saved session, and the file metadata all survive on
6122
+ // purpose: Retry, a republish from the agent, and a page reload all need
6123
+ // them. saveSession keeps the localStorage cache in step with the server.
6124
+ saveSession();
6125
+ if (details) showMountErrorCard(sessionId, details);
6126
+ else if (!mountErrorState) {
6127
+ showMountErrorCard(sessionId, { message: 'Variants could not be mounted. Retry, or ask the agent to republish.' });
6128
+ }
6129
+ }
6130
+
6131
+ // Hard reset for the one case that is not a mount failure: a cycling state
6132
+ // with nothing to cycle. There is no variant to retry and no URL to report,
6133
+ // so the session really is over.
6134
+ function resetSvelteComponentSession(sessionId, message) {
6135
+ try {
6136
+ if (svelteComponentSession?.sessionId === sessionId) {
6137
+ teardownSvelteComponentSession(true);
6138
+ } else {
6139
+ const orphan = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
6140
+ if (orphan) orphan.remove();
6141
+ }
6142
+ } catch (err) {
6143
+ console.warn('[impeccable] Svelte component reset cleanup failed:', err);
6144
+ }
6145
+ hideShaderOverlay();
5491
6146
  if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
5492
6147
  if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
5493
6148
  if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
5494
6149
  stopScrollLock();
6150
+ removeVariantStateStylesheet();
6151
+ clearMountErrorCard();
5495
6152
  clearSession();
5496
6153
  clearHandled();
5497
6154
  resetSessionFileMeta();
5498
6155
  currentSessionId = null;
6156
+ parameterGenerationState = 'idle';
6157
+ parameterReadyAnnouncedSession = null;
5499
6158
  expectedVariants = 0;
5500
6159
  arrivedVariants = 0;
5501
6160
  visibleVariant = 0;
@@ -5505,12 +6164,40 @@
5505
6164
  if (message) showToast(message, 5000);
5506
6165
  }
5507
6166
 
6167
+ // How many delayed re-reads a completion-driven source fallback gets when
6168
+ // the fetched source still shows only the preflight scaffold, before the
6169
+ // failure is surfaced via recoverEmptyCycling.
6170
+ const COMPLETED_SOURCE_FALLBACK_RETRIES = 3;
6171
+ const COMPLETED_SOURCE_FALLBACK_RETRY_MS = 1200;
6172
+
6173
+ /**
6174
+ * Terminal recovery for a session whose source-side scaffolding no longer
6175
+ * exists. The discard event is best-effort: with no agent polling it parks
6176
+ * the durable session in discard_requested, which no resume path adopts;
6177
+ * with an agent attached it triggers the normal discard finalization.
6178
+ */
6179
+ function discardOrphanedSession(reason) {
6180
+ const sessionId = currentSessionId;
6181
+ if (!sessionId) return;
6182
+ console.warn('[impeccable] Discarding orphaned session ' + sessionId + ': ' + reason);
6183
+ sendEvent({ type: 'discard', id: sessionId, orphaned: true }).catch(() => {});
6184
+ markSessionHandled();
6185
+ cleanup({ instantChrome: true });
6186
+ showToast('The previous live session no longer matches the source file, so it was discarded. Pick an element to start fresh.', 6000);
6187
+ }
6188
+
5508
6189
  /**
5509
6190
  * No-HMR fallback: fetch the raw source file from the live server,
5510
6191
  * parse it, extract the variant wrapper, and inject it into the live DOM.
5511
6192
  * This works even when the dev server caches HTML (Bun, static servers).
6193
+ *
6194
+ * opts.generationCompleted marks callers that KNOW the agent finished (a
6195
+ * `done` arrived or the server reported a completed generation). For them an
6196
+ * empty read is a stale source view and no further event is coming, so the
6197
+ * read retries a few times and then surfaces recovery. Callers without the
6198
+ * flag may be mid-generation and wait indefinitely for the real completion.
5512
6199
  */
5513
- function injectVariantsFromSource(filePath, sessionId) {
6200
+ function injectVariantsFromSource(filePath, sessionId, opts = {}) {
5514
6201
  if (isSvelteComponentManifestPath(filePath)) {
5515
6202
  injectSvelteComponentsFromManifest(filePath, sessionId);
5516
6203
  return;
@@ -5535,6 +6222,25 @@
5535
6222
  srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
5536
6223
  if (!srcWrapper) {
5537
6224
  console.warn('[impeccable] Variant wrapper not found in source file.');
6225
+ // A resumed cycling session whose wrapper is gone from source is an
6226
+ // ORPHAN: the file was edited or regenerated out from under it, so
6227
+ // no reload, HMR push, or server restart can ever complete it, and
6228
+ // the frozen picker it leaves behind used to need a manual
6229
+ // live-complete --discarded. Retry a few reads first (an agent
6230
+ // rewrite or HMR patch may be mid-flight), then self-discard and
6231
+ // hand the surface back to the picker.
6232
+ if (opts.orphanDiscard && sessionId === currentSessionId) {
6233
+ const attempt = opts._orphanAttempt || 0;
6234
+ if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
6235
+ setTimeout(() => {
6236
+ if (sessionId !== currentSessionId) return;
6237
+ if (state !== 'GENERATING' && state !== 'CYCLING') return;
6238
+ injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
6239
+ }, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
6240
+ } else {
6241
+ discardOrphanedSession('variant wrapper missing from source');
6242
+ }
6243
+ }
5538
6244
  return;
5539
6245
  }
5540
6246
 
@@ -5576,6 +6282,32 @@
5576
6282
  arrivedVariants = variants.length;
5577
6283
  expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
5578
6284
  if (arrivedVariants <= 0) {
6285
+ if (state === 'GENERATING') {
6286
+ // Mid-generation the source legitimately holds a scaffold wrapper
6287
+ // with no variants yet (the server-side preflight wraps before the
6288
+ // agent writes). Tearing the session down here would destroy an
6289
+ // in-flight generation; stay in GENERATING — the variant observer
6290
+ // is armed and the server re-delivers a missed `done`.
6291
+ if (!opts.generationCompleted) {
6292
+ console.log('[impeccable] Source has scaffold but no variants yet; still generating.');
6293
+ return;
6294
+ }
6295
+ // Generation finished, yet the read shows only the scaffold: the
6296
+ // source view is stale and no further event will fire. Re-read a
6297
+ // few times before surfacing recovery — a single silent return
6298
+ // here would strand the tab in GENERATING forever.
6299
+ const attempt = opts.attempt || 0;
6300
+ if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
6301
+ console.log('[impeccable] Generation is done but source shows no variants yet; retrying read ('
6302
+ + (attempt + 1) + '/' + COMPLETED_SOURCE_FALLBACK_RETRIES + ').');
6303
+ setTimeout(() => {
6304
+ if (state !== 'GENERATING' || currentSessionId !== sessionId) return;
6305
+ if (arrivedVariants > 0) return;
6306
+ injectVariantsFromSource(filePath, sessionId, { ...opts, attempt: attempt + 1 });
6307
+ }, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
6308
+ return;
6309
+ }
6310
+ }
5579
6311
  recoverEmptyCycling('source-fallback-empty');
5580
6312
  return;
5581
6313
  }
@@ -5597,6 +6329,7 @@
5597
6329
  refreshParamsPanel();
5598
6330
  positionBar();
5599
6331
  saveSession();
6332
+ if (parameterGenerationState === 'loading') completeParameterPublication();
5600
6333
  console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
5601
6334
  })
5602
6335
  .catch(err => {
@@ -5806,13 +6539,98 @@
5806
6539
  return variantDiv;
5807
6540
  }
5808
6541
 
6542
+ // Variant visibility and range/toggle params are expressed through ONE
6543
+ // injected stylesheet, never inline attributes on the variant divs. Those
6544
+ // divs are scaffolded into page source, so SSR frameworks (Next.js App
6545
+ // Router) server-render them; toggling their `hidden` / inline `style` /
6546
+ // `--p-*` client-side trips a React 19 hydration mismatch on the next
6547
+ // Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
6548
+ // and pick-cursor (#286) fixes address. A stylesheet rule has the same
6549
+ // computed effect without mutating any hydrated element's attributes.
6550
+ // (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
6551
+ const VARIANT_HIDE_DECL = 'display: none !important;';
6552
+ const VARIANT_SHOW_DECL = 'display: block !important;';
6553
+
6554
+ // Build a direct-child variant selector for a session. With `num`, targets a
6555
+ // single variant (`… > [data-impeccable-variant="N"]`); without it, targets
6556
+ // every variant via the bare `[data-impeccable-variant]` attribute.
6557
+ function variantStateSelector(sessionId, num) {
6558
+ const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
6559
+ const variant = num == null
6560
+ ? '[data-impeccable-variant]'
6561
+ : '[data-impeccable-variant="' + num + '"]';
6562
+ return wrapper + ' > ' + variant;
6563
+ }
6564
+
6565
+ // Serialize the visible variant's knob values into `--p-<id>` custom-property
6566
+ // declarations. Only range (number) and toggle (boolean) values become a
6567
+ // custom property; steps params drive `data-p-*` attributes instead.
6568
+ function variantParamDecls(values) {
6569
+ return Object.entries(values || {})
6570
+ .map(([id, val]) => {
6571
+ if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
6572
+ if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
6573
+ return '';
6574
+ })
6575
+ .join('');
6576
+ }
6577
+
6578
+ function updateVariantStateStylesheet(sessionId, num) {
6579
+ if (!sessionId || num == null || num < 1) return;
6580
+
6581
+ let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
6582
+ if (!styleEl) {
6583
+ styleEl = document.createElement('style');
6584
+ styleEl.id = VARIANT_STATE_STYLE_ID;
6585
+ (document.head || document.documentElement).appendChild(styleEl);
6586
+ }
6587
+
6588
+ // Hide every variant except the visible one (incl. the SSR'd "original").
6589
+ const hideOthers = variantStateSelector(sessionId)
6590
+ + ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
6591
+
6592
+ // Force-show the visible variant (beats the source inline display:none on
6593
+ // v2/v3) and apply its knob values as custom properties.
6594
+ const showVisible = variantStateSelector(sessionId, num)
6595
+ + ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
6596
+
6597
+ styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
6598
+ }
6599
+
6600
+ function removeVariantStateStylesheet() {
6601
+ document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
6602
+ }
6603
+
6604
+ function showOriginalDuringDiscard(sessionId) {
6605
+ if (!sessionId) return;
6606
+ let styleEl = document.getElementById(DISCARD_STATE_STYLE_ID);
6607
+ if (!styleEl) {
6608
+ styleEl = document.createElement('style');
6609
+ styleEl.id = DISCARD_STATE_STYLE_ID;
6610
+ (document.head || document.documentElement).appendChild(styleEl);
6611
+ }
6612
+ const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
6613
+ styleEl.textContent = wrapper + ' > [data-impeccable-variant]:not([data-impeccable-variant="original"]) { display:none !important; }\n'
6614
+ + wrapper + ' > [data-impeccable-variant="original"] { display:block !important; }';
6615
+ }
6616
+
6617
+ function resolveScrollLockAnchorTop() {
6618
+ const anchor = resolveBarAnchor();
6619
+ if (!anchor?.isConnected) return null;
6620
+ const top = anchor.getBoundingClientRect().top;
6621
+ return Number.isFinite(top) ? top : null;
6622
+ }
6623
+
5809
6624
  // Hold window.scrollY at a fixed value across DOM mutations inside the
5810
6625
  // session's wrapper (HMR patches, variant inserts, cycle swaps).
5811
- function startScrollLock(sessionId, initialTargetY) {
6626
+ function startScrollLock(sessionId, initialTargetY, initialAnchorTop) {
5812
6627
  stopScrollLock();
5813
6628
  scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
5814
6629
  ? initialTargetY
5815
6630
  : window.scrollY;
6631
+ scrollLockAnchorTop = typeof initialAnchorTop === 'number' && isFinite(initialAnchorTop)
6632
+ ? initialAnchorTop
6633
+ : resolveScrollLockAnchorTop();
5816
6634
 
5817
6635
  try { history.scrollRestoration = 'manual'; } catch {}
5818
6636
 
@@ -5836,6 +6654,17 @@
5836
6654
  const correct = (why) => {
5837
6655
  scrollLockRaf = null;
5838
6656
  if (scrollLockTargetY == null) return;
6657
+ const anchor = resolveBarAnchor();
6658
+ if (anchor?.isConnected && typeof scrollLockAnchorTop === 'number' && isFinite(scrollLockAnchorTop)) {
6659
+ const anchorTop = anchor.getBoundingClientRect().top;
6660
+ const anchorDelta = anchorTop - scrollLockAnchorTop;
6661
+ if (Math.abs(anchorDelta) >= 0.5) {
6662
+ window.scrollTo({ top: window.scrollY + anchorDelta, left: window.scrollX, behavior: 'instant' });
6663
+ scrollLockTargetY = window.scrollY;
6664
+ writeScrollY(scrollLockTargetY);
6665
+ return;
6666
+ }
6667
+ }
5839
6668
  const before = window.scrollY;
5840
6669
  const delta = before - scrollLockTargetY;
5841
6670
  if (Math.abs(delta) < 0.5) {
@@ -5880,6 +6709,7 @@
5880
6709
  if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
5881
6710
  const prevTarget = scrollLockTargetY;
5882
6711
  scrollLockTargetY = window.scrollY;
6712
+ scrollLockAnchorTop = resolveScrollLockAnchorTop();
5883
6713
  writeScrollY(scrollLockTargetY);
5884
6714
  };
5885
6715
  const markGesture = (why) => {
@@ -5917,6 +6747,7 @@
5917
6747
  if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
5918
6748
  if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
5919
6749
  scrollLockTargetY = null;
6750
+ scrollLockAnchorTop = null;
5920
6751
  // NOTE: do NOT clear the persistent scroll key here. startScrollLock
5921
6752
  // calls us as a reset, and clearing the key would nuke the Go-time
5922
6753
  // scrollY that the next resume needs to read.
@@ -5990,6 +6821,7 @@
5990
6821
 
5991
6822
  updating = true;
5992
6823
  arrivedVariants = count;
6824
+ generationPhase = arrivedVariants >= expectedVariants ? 'variants_ready' : 'variants_progress';
5993
6825
  if (visibleVariant === 0 && arrivedVariants > 0) {
5994
6826
  const saved = loadSession();
5995
6827
  const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
@@ -6005,7 +6837,7 @@
6005
6837
  const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
6006
6838
  if (expected > 0) expectedVariants = expected;
6007
6839
 
6008
- if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
6840
+ if (arrivedVariants > 0) {
6009
6841
  setLiveState('CYCLING');
6010
6842
  recoveryWaitingForAnchor = false;
6011
6843
  hideShaderOverlay();
@@ -6013,13 +6845,18 @@
6013
6845
  updateSelectedElement();
6014
6846
  showOrUpdateCyclingBar();
6015
6847
  disableInlineEdit();
6016
- refreshParamsPanel();
6848
+ if (arrivedVariants >= expectedVariants && expectedVariants > 0) refreshParamsPanel();
6849
+ else hideParamsPanel();
6017
6850
  positionBar();
6018
6851
  } else if (state === 'GENERATING') {
6019
6852
  updateBarContent('generating');
6020
6853
  }
6021
6854
  saveSession();
6022
- queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
6855
+ sendCheckpoint(
6856
+ arrivedVariants >= expectedVariants && expectedVariants > 0
6857
+ ? 'variants_ready'
6858
+ : 'variants_progress',
6859
+ );
6023
6860
  updating = false;
6024
6861
  });
6025
6862
 
@@ -6087,11 +6924,12 @@
6087
6924
  switch (msg.type) {
6088
6925
  case 'connected':
6089
6926
  hasProjectContext = !!msg.hasProjectContext;
6090
- if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
6927
+ if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
6091
6928
  console.log('[impeccable] Live mode connected.');
6092
6929
  syncAgentPollingUi(!!msg.agentPolling);
6093
6930
  startAgentStatusPoll();
6094
6931
  restoreFromActiveSessions(msg.activeSessions, 'sse_connected');
6932
+ recoverMissedGenerationCompletion(msg.activeSessions);
6095
6933
  if (state === 'IDLE' && (pickActive || insertActive)) setLiveState('PICKING');
6096
6934
  syncPageInteractionCursor();
6097
6935
  syncPageChatFocus('sse-connected');
@@ -6099,6 +6937,38 @@
6099
6937
  case 'agent_polling':
6100
6938
  syncAgentPollingUi(!!msg.connected);
6101
6939
  break;
6940
+ case 'agent_phase':
6941
+ if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
6942
+ // Advance the visible phase monotonically. A behind/resumed
6943
+ // checkpoint may carry an earlier phase for internal bookkeeping,
6944
+ // but the bar must not move backward.
6945
+ if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
6946
+ // The deferred parameter pass reports through `variant_progress`
6947
+ // with publicationKind 'params', not through agent_phase.
6948
+ updateBarContent(state === 'CYCLING' ? 'cycling' : 'generating');
6949
+ saveSession();
6950
+ }
6951
+ break;
6952
+ case 'variant_progress':
6953
+ if (msg.id === currentSessionId) {
6954
+ if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
6955
+ rememberSessionFileMeta(msg);
6956
+ if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
6957
+ // Component-preview (Svelte/Vue) progressive delivery: the browser
6958
+ // mounts compiled components, so there is no framework-owned DOM
6959
+ // to race. Keep streaming each checkpoint into the preview.
6960
+ injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
6961
+ }
6962
+ // Source-preview targets: do NOT source-inject per checkpoint.
6963
+ // Immediate injection races framework (React/Vue) ownership mid-
6964
+ // generation and triggers removeChild errors on the next HMR
6965
+ // commit. Let HMR own reconciliation while variants stream in;
6966
+ // source injection runs only on the final `done` (which keeps its
6967
+ // 750ms settle + retry ladder for non-HMR harnesses like Cursor).
6968
+ // The visible progress count still advances from the variant
6969
+ // MutationObserver as HMR lands each variant.
6970
+ }
6971
+ break;
6102
6972
  case 'steer_done':
6103
6973
  maybeCompleteSteer(msg);
6104
6974
  break;
@@ -6116,6 +6986,10 @@
6116
6986
  case 'done':
6117
6987
  if (maybeCompleteSteer(msg)) break;
6118
6988
  rememberSessionFileMeta(msg);
6989
+ if (msg.id === currentSessionId && isFrameworkComponentPreviewMode(currentPreviewMode) && currentPreviewFile) {
6990
+ injectSvelteComponentsFromManifest(currentPreviewFile, msg.id);
6991
+ break;
6992
+ }
6119
6993
  // Variants already arrived via HMR → normal transition.
6120
6994
  if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
6121
6995
  if (state === 'GENERATING') {
@@ -6131,7 +7005,7 @@
6131
7005
  setTimeout(() => {
6132
7006
  if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
6133
7007
  if (state !== 'GENERATING' || msg.id !== currentSessionId) return;
6134
- injectVariantsFromSource(msg.file, msg.id);
7008
+ injectVariantsFromSource(msg.file, msg.id, { generationCompleted: true });
6135
7009
  }, 750);
6136
7010
  break;
6137
7011
  }
@@ -6153,12 +7027,21 @@
6153
7027
  break;
6154
7028
  case 'complete':
6155
7029
  case 'accept':
7030
+ // The real accept result arrived: the awaited failure window closed.
7031
+ if (awaitingAcceptResult?.id && msg.id === awaitingAcceptResult.id) awaitingAcceptResult = null;
6156
7032
  if (maybeCompleteAcceptedSession(msg)) break;
6157
7033
  break;
6158
7034
  case 'agent_done':
6159
- // Carbonize accepts are not terminal until live-complete.mjs sends
6160
- // the final complete event. Keep the browser in its recoverable
6161
- // saving state while the source cleanup is still in flight.
7035
+ // The deterministic accept has already committed the reviewed DOM
7036
+ // and fenced generation. Carbonize may continue in the background;
7037
+ // it must not hold the foreground picker hostage.
7038
+ // Only a carbonize agent_done is provably accept-side: accept
7039
+ // unlocks at the first variant, so a late generation agent_done
7040
+ // for the same session id can still arrive after Accept and must
7041
+ // not close the awaited failure window early (the SSE broadcast
7042
+ // carries no sourceEventType to tell the two apart).
7043
+ if (msg.data?.carbonize === true && awaitingAcceptResult?.id && msg.id === awaitingAcceptResult.id) awaitingAcceptResult = null;
7044
+ if (msg.data?.carbonize === true && maybeCompleteAcceptedSession(msg)) break;
6162
7045
  break;
6163
7046
  case 'discarded':
6164
7047
  if (msg.id && msg.id === currentSessionId) {
@@ -6169,14 +7052,43 @@
6169
7052
  case 'error':
6170
7053
  if (pendingAcceptedSession?.id && msg.id === pendingAcceptedSession.id) {
6171
7054
  pendingAcceptedSession = null;
7055
+ awaitingAcceptResult = null;
6172
7056
  setLiveState('CYCLING');
6173
7057
  updateBarContent('cycling');
6174
7058
  showToast('Could not complete accept cleanup. Try Accept again.', 5000);
6175
7059
  break;
6176
7060
  }
7061
+ // The optimistic teardown already released the session, so the
7062
+ // CYCLING recovery above can no longer match; without this branch
7063
+ // the failure fell through to the generic toast and the user had
7064
+ // no hint their variant was never written (issue #384).
7065
+ if (awaitingAcceptResult?.id && msg.id === awaitingAcceptResult.id) {
7066
+ awaitingAcceptResult = null;
7067
+ console.error('[impeccable] Accept failed after teardown:', msg.message);
7068
+ // Hedged on purpose: a carbonize-phase failure raises this same
7069
+ // error after the source WAS promoted, so "was not saved" would
7070
+ // overclaim. Normalize the server message's terminal punctuation
7071
+ // so the two sentences don't run together.
7072
+ const acceptFailDetail = String(msg.message || 'unknown error').trim().replace(/[.!?]?$/, '.');
7073
+ showToast('Accept failed: ' + acceptFailDetail + ' The variant may not have been saved. If the change is missing, pick the element and generate again.', 8000);
7074
+ break;
7075
+ }
6177
7076
  if (maybeCompleteSteer(msg)) break;
6178
7077
  console.error('[impeccable] Error:', msg.message);
6179
7078
  showToast('Error: ' + msg.message, 5000);
7079
+ // An agent error reply is terminal for the session it names: tear
7080
+ // it down exactly like 'discarded' (cleanup includes clearSession),
7081
+ // or the durable localStorage checkpoint survives and every reload
7082
+ // resurrects a GENERATING bar for a session the server no longer
7083
+ // knows about (issue #362).
7084
+ if (msg.id && msg.id === currentSessionId) {
7085
+ markSessionHandled();
7086
+ cleanup();
7087
+ break;
7088
+ }
7089
+ // A stored-but-not-current checkpoint naming the errored session
7090
+ // (the error raced a reload) must not resurrect either.
7091
+ if (msg.id && loadSession()?.id === msg.id) clearSession();
6180
7092
  hideBar();
6181
7093
  renderEditBadge('hidden');
6182
7094
  setLiveState('PICKING');
@@ -6202,7 +7114,7 @@
6202
7114
  function handleServerLost() {
6203
7115
  const recoveryState = currentSessionId ? state : 'IDLE';
6204
7116
  if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
6205
- showToast('Live server disconnected. Session ended.', 5000);
7117
+ showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
6206
7118
  }
6207
7119
  hideBar();
6208
7120
  hideHighlight();
@@ -6221,6 +7133,13 @@
6221
7133
  if (currentSessionId) saveSession();
6222
7134
  }
6223
7135
 
7136
+ // Progress events must never overtake the event that CREATES their session:
7137
+ // the Go-time checkpoint and the generate POST are concurrent fetches, and
7138
+ // when the checkpoint lands first the server rightly refuses it as
7139
+ // unknown_session — which must mean "foreign leftovers", not "you raced
7140
+ // your own Go click". The gate serializes creation before progress.
7141
+ let sessionCreationGate = Promise.resolve();
7142
+
6224
7143
  function sendEvent(msg, opts) {
6225
7144
  msg.token = TOKEN;
6226
7145
  function handleFailure(err) {
@@ -6231,15 +7150,42 @@
6231
7150
  console.debug('[impeccable] Dropped optional live event:', err);
6232
7151
  return null;
6233
7152
  }
6234
- return fetch('http://localhost:' + PORT + '/events', {
7153
+ const doSend = () => fetch('http://localhost:' + PORT + '/events', {
6235
7154
  method: 'POST',
6236
7155
  headers: { 'Content-Type': 'application/json' },
6237
7156
  body: JSON.stringify(msg),
6238
7157
  }).then(async res => {
6239
7158
  if (res.ok) return res;
6240
7159
  const body = await res.json().catch(() => ({}));
7160
+ // The server refused to journal progress for a session it has never
7161
+ // seen: this browser is carrying state from another project or a
7162
+ // wiped store (two apps sharing a localhost port). Continuing to
7163
+ // report it would freeze the picker behind a session that can never
7164
+ // complete, so drop the local state and hand the surface back.
7165
+ if (body.error === 'unknown_session' && msg.type === 'checkpoint'
7166
+ && msg.id && msg.id === currentSessionId) {
7167
+ abandonForeignSession(msg.id);
7168
+ return null;
7169
+ }
6241
7170
  return handleFailure(new Error(body.error || ('HTTP ' + res.status + ' ' + res.statusText)));
6242
7171
  }).catch(handleFailure);
7172
+
7173
+ if (msg.type === 'generate' || msg.type === 'steer') {
7174
+ const creation = doSend();
7175
+ sessionCreationGate = creation.then(() => {}, () => {});
7176
+ return creation;
7177
+ }
7178
+ return sessionCreationGate.then(doSend);
7179
+ }
7180
+
7181
+ let abandonedForeignSessionId = null;
7182
+ function abandonForeignSession(sessionId) {
7183
+ if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
7184
+ abandonedForeignSessionId = sessionId;
7185
+ console.warn('[impeccable] The live server has no record of session ' + sessionId + '; clearing stale local state.');
7186
+ markSessionHandled();
7187
+ cleanup({ instantChrome: true });
7188
+ showToast('A saved live session belonged to a different project, so it was cleared. Pick an element to start fresh.', 6000);
6243
7189
  }
6244
7190
 
6245
7191
  function checkpointPayload(reason) {
@@ -6247,6 +7193,7 @@
6247
7193
  type: 'checkpoint',
6248
7194
  id: currentSessionId,
6249
7195
  revision: sessionState.nextCheckpointRevision(),
7196
+ revisionDomain: 'browser',
6250
7197
  owner: browserOwner,
6251
7198
  phase: String(state || '').toLowerCase(),
6252
7199
  reason,
@@ -6272,6 +7219,7 @@
6272
7219
  type: 'checkpoint',
6273
7220
  id,
6274
7221
  revision: sessionState.nextCheckpointRevision(),
7222
+ revisionDomain: 'browser',
6275
7223
  owner: browserOwner,
6276
7224
  phase: 'steer',
6277
7225
  reason,
@@ -6620,11 +7568,21 @@
6620
7568
  disableInlineEdit();
6621
7569
  stripManualEditRuntimeState(selectedElement);
6622
7570
 
7571
+ // A new cycle publishes new modules, so the previous cycle's mount failure
7572
+ // is about files that no longer matter.
7573
+ clearMountErrorCard();
7574
+ lastReportedMountFailure = null;
6623
7575
  pendingAcceptedSession = null;
7576
+ // A new session supersedes any accept still awaiting its result; a late
7577
+ // failure toast for the previous session would only mislead here.
7578
+ awaitingAcceptResult = null;
6624
7579
  currentSessionId = id8();
6625
7580
  expectedVariants = selectedCount;
6626
7581
  arrivedVariants = 0;
6627
7582
  visibleVariant = 0;
7583
+ generationPhase = 'queued';
7584
+ parameterGenerationState = 'pending';
7585
+ parameterReadyAnnouncedSession = null;
6628
7586
  resetSessionFileMeta();
6629
7587
 
6630
7588
  // Flip to GENERATING immediately so the bar morphs without waiting on
@@ -6634,6 +7592,7 @@
6634
7592
  const elForCapture = selectedElement;
6635
7593
  pickedAnchorSnapshot = buildPickedAnchorSnapshot(elForCapture);
6636
7594
  const captureRect = elForCapture.getBoundingClientRect();
7595
+ pickedAnchorViewportTop = captureRect.top;
6637
7596
  const snapshot = {
6638
7597
  comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
6639
7598
  strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
@@ -6665,7 +7624,7 @@
6665
7624
  writeScrollY(window.scrollY);
6666
7625
  if (variantObserver) variantObserver.disconnect();
6667
7626
  variantObserver = startVariantObserver(currentSessionId);
6668
- startScrollLock(currentSessionId);
7627
+ startScrollLock(currentSessionId, window.scrollY, pickedAnchorViewportTop);
6669
7628
 
6670
7629
  captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
6671
7630
  }
@@ -6695,17 +7654,28 @@
6695
7654
  if (!canCreateInsert({ prompt, comments: snapshot.comments, strokes: snapshot.strokes })) return;
6696
7655
 
6697
7656
  stopVoice({ suppressSubmit: true });
7657
+ // A new cycle publishes new modules, so the previous cycle's mount failure
7658
+ // is about files that no longer matter.
7659
+ clearMountErrorCard();
7660
+ lastReportedMountFailure = null;
6698
7661
  pendingAcceptedSession = null;
7662
+ // A new session supersedes any accept still awaiting its result; a late
7663
+ // failure toast for the previous session would only mislead here.
7664
+ awaitingAcceptResult = null;
6699
7665
  currentSessionId = id8();
6700
7666
  expectedVariants = selectedCount;
6701
7667
  arrivedVariants = 0;
6702
7668
  visibleVariant = 0;
7669
+ generationPhase = 'queued';
7670
+ parameterGenerationState = 'pending';
7671
+ parameterReadyAnnouncedSession = null;
6703
7672
  resetSessionFileMeta();
6704
7673
  selectedElement = placeholderElement;
6705
7674
  insertPlaceholderSnapshot = buildInsertPlaceholderSnapshotFromDom(insertAnchorElement, placeholderElement);
6706
7675
 
6707
7676
  const elForCapture = placeholderElement;
6708
7677
  const captureRect = elForCapture.getBoundingClientRect();
7678
+ pickedAnchorViewportTop = captureRect.top;
6709
7679
  const basePayload = {
6710
7680
  type: 'generate',
6711
7681
  mode: 'insert',
@@ -6736,7 +7706,7 @@
6736
7706
  writeScrollY(window.scrollY);
6737
7707
  if (variantObserver) variantObserver.disconnect();
6738
7708
  variantObserver = startVariantObserver(currentSessionId);
6739
- startScrollLock(currentSessionId);
7709
+ startScrollLock(currentSessionId, window.scrollY, pickedAnchorViewportTop);
6740
7710
  captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
6741
7711
  }
6742
7712
 
@@ -6916,9 +7886,9 @@
6916
7886
  // preview mounts are covered by the same shader regression checks.
6917
7887
  const adapter = String(window.__IMPECCABLE_LIVE_ADAPTER__ || '').toLowerCase();
6918
7888
  if (adapter === 'svelte' || adapter === 'sveltekit') return true;
6919
- if (currentPreviewMode === 'svelte-component' || svelteComponentSession) return true;
7889
+ if (isFrameworkComponentPreviewMode(currentPreviewMode) || svelteComponentSession) return true;
6920
7890
  const wrapper = el?.closest?.('[data-impeccable-variants]');
6921
- return wrapper?.dataset?.impeccablePreview === 'svelte-component';
7891
+ return isFrameworkComponentPreviewMode(wrapper?.dataset?.impeccablePreview);
6922
7892
  }
6923
7893
 
6924
7894
  function paintsShaderProxySurface(node) {
@@ -7046,6 +8016,17 @@
7046
8016
  }
7047
8017
 
7048
8018
  async function captureAndEmit(el, basePayload, snapshot, rect) {
8019
+ const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
8020
+
8021
+ // Plain requests do not send a screenshot to the agent, so capture is
8022
+ // presentation-only. Wait only for the helper to accept the event before
8023
+ // starting CPU-heavy capture; this yields the browser task and prevents
8024
+ // rasterization from delaying the fetch itself.
8025
+ if (!hasAnnotations) {
8026
+ basePayload.clientSentAt = Date.now();
8027
+ await sendEvent(basePayload);
8028
+ }
8029
+
7049
8030
  let screenshotPath;
7050
8031
  let blob;
7051
8032
  let paper;
@@ -7063,7 +8044,6 @@
7063
8044
  // are present. Without annotations the image is pure visual anchoring -
7064
8045
  // it biases the model toward the current rendering and works against the
7065
8046
  // three-distinct-directions brief.
7066
- const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
7067
8047
  if (blob && hasAnnotations) {
7068
8048
  try {
7069
8049
  const uploadRes = await fetch(
@@ -7081,7 +8061,12 @@
7081
8061
  console.warn('[impeccable] annotation upload failed:', err);
7082
8062
  }
7083
8063
  }
7084
- sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
8064
+ // Annotated requests must wait for capture + upload because the screenshot
8065
+ // is semantic input. Plain requests were already dispatched above.
8066
+ if (hasAnnotations) {
8067
+ basePayload.clientSentAt = Date.now();
8068
+ sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
8069
+ }
7085
8070
  }
7086
8071
 
7087
8072
  //
@@ -7461,7 +8446,6 @@ void main() {
7461
8446
  if (variantSelectionPromise) {
7462
8447
  try { await variantSelectionPromise; } catch { /* failed selection falls back below */ }
7463
8448
  }
7464
- if (!currentSessionId || arrivedVariants === 0) return;
7465
8449
  const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
7466
8450
  if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
7467
8451
  const acceptPayload = {
@@ -7469,7 +8453,9 @@ void main() {
7469
8453
  id: currentSessionId,
7470
8454
  variantId: String(visibleVariant),
7471
8455
  pageUrl: location.pathname,
8456
+ clientSentAt: Date.now(),
7472
8457
  };
8458
+ if (!currentSessionId || arrivedVariants === 0) return;
7473
8459
  const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
7474
8460
  if (Object.keys(paramsCurrentValues).length > 0) {
7475
8461
  acceptPayload.paramValues = { ...paramsCurrentValues };
@@ -7483,7 +8469,7 @@ void main() {
7483
8469
  const acceptedSessionId = currentSessionId;
7484
8470
  const acceptedVariant = visibleVariant;
7485
8471
  const acceptedIsSvelteComponent = svelteComponentSession?.sessionId === acceptedSessionId
7486
- || acceptWrapper?.dataset?.impeccablePreview === 'svelte-component';
8472
+ || isFrameworkComponentPreviewMode(acceptWrapper?.dataset?.impeccablePreview);
7487
8473
  const acceptedSnapshot = snapshotAcceptedVariantDom(acceptedSessionId, acceptedVariant);
7488
8474
 
7489
8475
  setLiveState('SAVING');
@@ -7498,7 +8484,18 @@ void main() {
7498
8484
  saveSession();
7499
8485
 
7500
8486
  sendEvent(acceptPayload, { throwOnError: true })
7501
- .then(() => {})
8487
+ .then(() => {
8488
+ const pending = pendingAcceptedSession;
8489
+ if (!pending || pending.id !== acceptedSessionId) return;
8490
+ // POST /events returns only after the accept intent is durable and the
8491
+ // generation epoch is fenced. Source promotion/carbonize can finish in
8492
+ // the background; the foreground picker is free immediately.
8493
+ markSessionHandled();
8494
+ setLiveState('CONFIRMED');
8495
+ document.documentElement.dataset.impeccableAcceptToPickingMs = String(Date.now() - acceptPayload.clientSentAt);
8496
+ awaitingAcceptResult = { id: acceptedSessionId };
8497
+ scheduleAcceptCleanup(pending);
8498
+ })
7502
8499
  .catch(() => {
7503
8500
  if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
7504
8501
  setLiveState('CYCLING');
@@ -7527,17 +8524,26 @@ void main() {
7527
8524
  }
7528
8525
 
7529
8526
  function scheduleAcceptCleanup(accepted) {
7530
- setTimeout(function() {
7531
- if (!accepted?.isSvelteComponent && !acceptedDomAlreadyClean(accepted)) {
7532
- setTimeout(function() {
7533
- if (pendingAcceptedSession?.id !== accepted?.id) return;
7534
- if (!accepted?.isSvelteComponent) ensureAcceptedDomClean(accepted);
7535
- cleanupAcceptedSession();
7536
- }, 1800);
7537
- return;
8527
+ queueMicrotask(function() {
8528
+ if (pendingAcceptedSession?.id !== accepted?.id) return;
8529
+ // Svelte previews live in an adapter-owned mount rather than in source
8530
+ // wrapper markup. Promote the mounted variant before releasing the
8531
+ // session so the old adapter instance cannot linger behind the next
8532
+ // Pick → Go loop while carbonize finishes in the background.
8533
+ if (accepted?.isSvelteComponent) {
8534
+ commitAcceptedSvelteComponentToDom(accepted.id);
7538
8535
  }
7539
8536
  cleanupAcceptedSession();
7540
- }, 1200);
8537
+ });
8538
+ // Let React/Vue/Svelte own the HMR reconciliation. Mutating their DOM in
8539
+ // the same turn as the source update causes removeChild/NotFoundError
8540
+ // races. Static servers still need a fallback, but it must not keep Live
8541
+ // in SAVING or block the user's next pick.
8542
+ if (!accepted?.isSvelteComponent) {
8543
+ setTimeout(function() {
8544
+ if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted);
8545
+ }, 1200);
8546
+ }
7541
8547
  }
7542
8548
 
7543
8549
  function snapshotAcceptedVariantDom(sessionId, variantId) {
@@ -7636,11 +8642,16 @@ void main() {
7636
8642
  stopScrollTracking();
7637
8643
  if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
7638
8644
  stopScrollLock();
8645
+ removeVariantStateStylesheet();
7639
8646
  clearScrollY();
7640
8647
  clearSession();
7641
8648
  resetSessionFileMeta();
7642
8649
  selectedElement = null;
8650
+ hoveredElement = null;
8651
+ pagePickSkipClick = false;
7643
8652
  currentSessionId = null;
8653
+ parameterGenerationState = 'idle';
8654
+ parameterReadyAnnouncedSession = null;
7644
8655
  selectedAction = 'impeccable';
7645
8656
  pendingAcceptedSession = null;
7646
8657
  renderEditBadge('hidden');
@@ -7675,7 +8686,7 @@ void main() {
7675
8686
  sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
7676
8687
  .then(() => {
7677
8688
  markSessionHandled();
7678
- cleanup();
8689
+ cleanup({ restoreOriginal: true, instantChrome: true });
7679
8690
  })
7680
8691
  .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
7681
8692
  }
@@ -7697,6 +8708,7 @@ void main() {
7697
8708
  currentPreviewMode = null;
7698
8709
  recoveryWaitingForAnchor = false;
7699
8710
  pickedAnchorSnapshot = null;
8711
+ pickedAnchorViewportTop = null;
7700
8712
  }
7701
8713
 
7702
8714
  function rememberSessionFileMeta(meta = {}) {
@@ -7705,8 +8717,8 @@ void main() {
7705
8717
  const previewFile = normalizeSessionPath(meta.previewFile);
7706
8718
  const previewMode = meta.previewMode || (isSvelteComponentManifestPath(previewFile || file) ? 'svelte-component' : null);
7707
8719
 
7708
- if (previewMode === 'svelte-component' || isSvelteComponentManifestPath(file)) {
7709
- currentPreviewMode = 'svelte-component';
8720
+ if (isFrameworkComponentPreviewMode(previewMode) || isSvelteComponentManifestPath(file)) {
8721
+ currentPreviewMode = isFrameworkComponentPreviewMode(previewMode) ? previewMode : 'svelte-component';
7710
8722
  currentPreviewFile = previewFile || (isSvelteComponentManifestPath(file) ? file : currentPreviewFile);
7711
8723
  currentSourceFile = sourceFile || currentSourceFile;
7712
8724
  return;
@@ -7722,12 +8734,15 @@ void main() {
7722
8734
  rememberSessionFileMeta(saved);
7723
8735
  if (saved.insertPlaceholder) insertPlaceholderSnapshot = saved.insertPlaceholder;
7724
8736
  if (saved.pickedAnchor) pickedAnchorSnapshot = saved.pickedAnchor;
8737
+ if (Number.isFinite(saved.pickedAnchorViewportTop)) pickedAnchorViewportTop = saved.pickedAnchorViewportTop;
7725
8738
  if (saved.action) selectedAction = saved.action;
7726
8739
  if (saved.count) selectedCount = saved.count;
7727
8740
  if (saved.previewMode) currentPreviewMode = saved.previewMode;
7728
8741
  if (saved.paramValues && typeof saved.paramValues === 'object') {
7729
8742
  paramsCurrentValues = { ...saved.paramValues };
7730
8743
  }
8744
+ if (saved.parameterState) parameterGenerationState = saved.parameterState;
8745
+ if (saved.generationPhase) generationPhase = saved.generationPhase;
7731
8746
  }
7732
8747
 
7733
8748
  function normalizePagePath(value) {
@@ -7765,8 +8780,62 @@ void main() {
7765
8780
  return Math.floor(num);
7766
8781
  }
7767
8782
 
8783
+ /**
8784
+ * A durable server session this page can adopt when the browser has no local
8785
+ * record of it. Requires an explicit pageUrl match: a summary with no page is
8786
+ * not evidence that it belongs to THIS page, and adopting it would hijack an
8787
+ * unrelated route.
8788
+ */
8789
+ // Phases in which the user is (or should be) comparing variants. Only these
8790
+ // are adoptable by a browser with no local record. Steer and manual-edit
8791
+ // sessions have no wrapper to restore, and accept/carbonize phases are
8792
+ // agent-side work: a reload mid-carbonize must not resurrect the bar over a
8793
+ // page whose comparison is already decided (a slow-CI reload hit exactly
8794
+ // that window and left the bar stranded after accept).
8795
+ const ADOPTABLE_SESSION_PHASES = new Set([
8796
+ 'generate_requested', 'variants_ready', 'generating', 'cycling',
8797
+ ]);
8798
+
8799
+ function findAdoptableServerSession(activeSessions) {
8800
+ if (!Array.isArray(activeSessions)) return null;
8801
+ return activeSessions.find((session) => (
8802
+ session?.id
8803
+ && !isTerminalSessionSummary(session)
8804
+ && !isSessionHandled(session.id)
8805
+ && session.pageUrl
8806
+ && pageMatchesCurrent(session.pageUrl)
8807
+ && (session.previewFile || session.sourceFile)
8808
+ && Number(session.expectedVariants) > 0
8809
+ && ADOPTABLE_SESSION_PHASES.has(String(session.phase || ''))
8810
+ )) || null;
8811
+ }
8812
+
8813
+ // Shape a server summary like a saved local session so one restore path
8814
+ // serves both. The server has no browser state machine, so an adopted session
8815
+ // always re-enters GENERATING and lets the injection settle the final state.
8816
+ function serverSessionAsSavedShape(session) {
8817
+ return {
8818
+ id: session.id,
8819
+ state: 'GENERATING',
8820
+ expected: Number(session.expectedVariants) || 0,
8821
+ arrived: Number(session.arrivedVariants) || 0,
8822
+ visible: Number(session.visibleVariant) || 0,
8823
+ sourceFile: session.sourceFile || undefined,
8824
+ previewFile: session.previewFile || undefined,
8825
+ previewMode: session.previewMode || undefined,
8826
+ pageUrl: session.pageUrl || undefined,
8827
+ paramValues: session.paramValues && typeof session.paramValues === 'object' ? session.paramValues : {},
8828
+ };
8829
+ }
8830
+
7768
8831
  function restoreSessionWithoutWrapper(reason, activeSessions) {
7769
- const saved = loadSession();
8832
+ const cached = loadSession();
8833
+ // localStorage is a cache, not a gate. A cleared tab, a second browser
8834
+ // profile, or a teardown that dropped local state all leave the durable
8835
+ // server session as the only record of work in progress; adopt it instead
8836
+ // of stranding a session the server still considers live.
8837
+ const adopted = cached?.id ? null : findAdoptableServerSession(activeSessions);
8838
+ const saved = cached?.id ? cached : (adopted ? serverSessionAsSavedShape(adopted) : null);
7770
8839
  if (!saved?.id || isSessionHandled(saved.id)) return false;
7771
8840
  const savedState = String(saved.state || '').toUpperCase();
7772
8841
  if (savedState !== 'GENERATING' && savedState !== 'CYCLING') return false;
@@ -7799,11 +8868,18 @@ void main() {
7799
8868
  saveSession();
7800
8869
  queueCheckpoint(reason || 'browser_restore_without_wrapper');
7801
8870
 
7802
- const restoreFile = currentPreviewMode === 'svelte-component'
8871
+ const restoreFile = isFrameworkComponentPreviewMode(currentPreviewMode)
7803
8872
  ? currentPreviewFile
7804
8873
  : (currentSourceFile || currentPreviewFile);
7805
8874
  if (restoreFile) {
7806
- injectVariantsFromSource(restoreFile, currentSessionId);
8875
+ // A restored CYCLING session promises variants already written into
8876
+ // source; if they are not there (after retries), the session is an
8877
+ // orphan and must self-discard instead of freezing the picker (#439).
8878
+ // GENERATING restores make no such promise: deferred-wrapper flows
8879
+ // legitimately have no wrapper in source until the agent's write lands.
8880
+ injectVariantsFromSource(restoreFile, currentSessionId, {
8881
+ orphanDiscard: savedState === 'CYCLING' && !isFrameworkComponentPreviewMode(currentPreviewMode),
8882
+ });
7807
8883
  return true;
7808
8884
  }
7809
8885
 
@@ -7812,17 +8888,48 @@ void main() {
7812
8888
 
7813
8889
  function restoreFromActiveSessions(activeSessions, reason) {
7814
8890
  const wrapper = document.querySelector('[data-impeccable-variants]');
7815
- if (wrapper && wrapper.dataset.impeccablePreview !== 'svelte-component') return false;
8891
+ if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
7816
8892
  if (svelteComponentSession?.sessionId === currentSessionId) return false;
7817
8893
  return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
7818
8894
  }
7819
8895
 
8896
+ // Self-heal on SSE (re)connect. The preflight scaffold write triggers a
8897
+ // framework full-reload (Astro reloads pages for any .astro edit); if the
8898
+ // agent's variant write + `done` broadcast land while this page is
8899
+ // mid-reload, both the done SSE and the second HMR reload are missed and
8900
+ // the resumed page would wait in GENERATING at 0/N forever. The server's
8901
+ // session summary carries the durable generationCompletedAt marker, so on
8902
+ // every connect compare it against our own progress and pull the finished
8903
+ // variants from source when behind. Mirrors the `done` handler's source
8904
+ // fallback, including its give-HMR-the-first-chance settle delay.
8905
+ function recoverMissedGenerationCompletion(activeSessions) {
8906
+ if (!currentSessionId || state !== 'GENERATING') return;
8907
+ if (!Array.isArray(activeSessions)) return;
8908
+ const summary = activeSessions.find((session) => session?.id === currentSessionId);
8909
+ if (!summary?.generationCompletedAt || summary.generationCanceled) return;
8910
+ if (isTerminalSessionSummary(summary)) return;
8911
+ if (arrivedVariants > 0 && arrivedVariants >= expectedVariants) return;
8912
+ rememberSessionFileMeta(summary);
8913
+ const sessionId = currentSessionId;
8914
+ const file = isFrameworkComponentPreviewMode(currentPreviewMode)
8915
+ ? currentPreviewFile
8916
+ : (summary.sourceFile || summary.previewFile || currentSourceFile || currentPreviewFile);
8917
+ if (!file) return;
8918
+ console.log('[impeccable] Reconnected after generation completed; recovering variants from source.');
8919
+ setTimeout(() => {
8920
+ if (sessionId !== currentSessionId || state !== 'GENERATING') return;
8921
+ if (arrivedVariants > 0 && arrivedVariants >= expectedVariants) return;
8922
+ injectVariantsFromSource(file, sessionId, { generationCompleted: true });
8923
+ }, 750);
8924
+ }
8925
+
7820
8926
  function saveSession() {
7821
8927
  if (!currentSessionId) return;
7822
8928
  // NOTE: scrollY is stored under a separate key (writeScrollY). Storing
7823
8929
  // it here would overwrite the Go-time value every time state changes.
7824
8930
  sessionState.saveSession({
7825
8931
  id: currentSessionId,
8932
+ appRoot: APP_ROOT || undefined,
7826
8933
  state,
7827
8934
  action: selectedAction,
7828
8935
  count: selectedCount,
@@ -7834,13 +8941,27 @@ void main() {
7834
8941
  previewMode: currentPreviewMode || undefined,
7835
8942
  pageUrl: location.pathname,
7836
8943
  paramValues: { ...paramsCurrentValues },
8944
+ parameterState: parameterGenerationState,
7837
8945
  insertPlaceholder: insertPlaceholderSnapshot || undefined,
7838
8946
  pickedAnchor: pickedAnchorSnapshot || undefined,
8947
+ pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
8948
+ pageHash: location.hash || undefined,
8949
+ pageSearch: location.search || undefined,
7839
8950
  });
7840
8951
  }
7841
8952
 
7842
8953
  function loadSession() {
7843
- return sessionState.loadSession();
8954
+ const saved = sessionState.loadSession();
8955
+ // localStorage is per-origin, and two projects routinely reuse the same
8956
+ // localhost port. A saved session stamped with another project's appRoot
8957
+ // is that project's leftover, never a session this server can complete;
8958
+ // resuming it freezes the picker behind an unfinishable banner.
8959
+ if (saved?.appRoot && APP_ROOT && saved.appRoot !== APP_ROOT) {
8960
+ console.warn('[impeccable] Ignoring saved live session from another project (' + saved.appRoot + ').');
8961
+ sessionState.clearSession();
8962
+ return null;
8963
+ }
8964
+ return saved;
7844
8965
  }
7845
8966
 
7846
8967
  function clearSession() {
@@ -7863,20 +8984,28 @@ void main() {
7863
8984
  sessionState.clearHandled();
7864
8985
  }
7865
8986
 
7866
- function cleanup() {
8987
+ function cleanup(options) {
8988
+ const restoreOriginal = options?.restoreOriginal === true;
8989
+ const instantChrome = options?.instantChrome === true;
7867
8990
  const cleanupSessionId = currentSessionId;
8991
+ clearMountErrorCard();
8992
+ lastReportedMountFailure = null;
7868
8993
  if (svelteComponentSession?.sessionId === cleanupSessionId) {
7869
8994
  teardownSvelteComponentSession(true);
7870
8995
  } else if (cleanupSessionId) {
7871
- // Hide the wrapper immediately so variants disappear. DON'T structurally
7872
- // mutate the DOM yet - HMR from the agent's source rewrite is on its way,
8996
+ // Switch visibility immediately without structurally mutating the DOM.
8997
+ // HMR from the agent's source rewrite may still be on its way,
7873
8998
  // and a manual replaceChild under React causes NotFoundError when the
7874
8999
  // reconciler later tries to remove a wrapper we already removed.
7875
9000
  // Schedule a 2s fallback that does the manual swap only if HMR hasn't
7876
9001
  // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
7877
9002
  const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
7878
- if (wrapper) wrapper.style.display = 'none';
9003
+ if (wrapper) {
9004
+ if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
9005
+ else wrapper.style.display = 'none';
9006
+ }
7879
9007
  setTimeout(function() {
9008
+ document.getElementById(DISCARD_STATE_STYLE_ID)?.remove();
7880
9009
  if (!cleanupSessionId) return;
7881
9010
  const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
7882
9011
  if (!lateWrapper) return;
@@ -7891,18 +9020,23 @@ void main() {
7891
9020
  lateWrapper.remove();
7892
9021
  }, 2000);
7893
9022
  }
7894
- hideBar();
9023
+ hideBar(instantChrome);
7895
9024
  hideHighlight();
7896
9025
  stopScrollTracking();
7897
9026
  if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
7898
9027
  if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
7899
9028
  stopScrollLock();
9029
+ removeVariantStateStylesheet();
7900
9030
  clearScrollY();
7901
9031
  finalizeInsertSession();
7902
9032
  clearSession();
7903
9033
  resetSessionFileMeta();
7904
9034
  selectedElement = null;
9035
+ hoveredElement = null;
9036
+ pagePickSkipClick = false;
7905
9037
  currentSessionId = null;
9038
+ parameterGenerationState = 'idle';
9039
+ parameterReadyAnnouncedSession = null;
7906
9040
  selectedAction = 'impeccable';
7907
9041
  renderEditBadge('hidden');
7908
9042
  setLiveState('PICKING');
@@ -7928,7 +9062,7 @@ void main() {
7928
9062
  const barTopFromBottom = barRect && barRect.height > 0
7929
9063
  ? Math.max(16, window.innerHeight - barRect.top + 12)
7930
9064
  : 16;
7931
- toastEl = el('div', {
9065
+ const currentToast = el('div', {
7932
9066
  position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
7933
9067
  transform: 'translateX(-50%) translateY(8px)',
7934
9068
  background: C.ink, color: C.white,
@@ -7938,19 +9072,24 @@ void main() {
7938
9072
  transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
7939
9073
  pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
7940
9074
  });
7941
- toastEl.id = PREFIX + '-toast';
7942
- toastEl.textContent = message;
7943
- uiAppend(toastEl);
9075
+ toastEl = currentToast;
9076
+ currentToast.id = PREFIX + '-toast';
9077
+ currentToast.textContent = message;
9078
+ uiAppend(currentToast);
7944
9079
  requestAnimationFrame(() => {
7945
- toastEl.style.opacity = '1';
7946
- toastEl.style.transform = 'translateX(-50%) translateY(0)';
9080
+ if (toastEl !== currentToast) return;
9081
+ currentToast.style.opacity = '1';
9082
+ currentToast.style.transform = 'translateX(-50%) translateY(0)';
7947
9083
  });
7948
9084
  setTimeout(() => {
7949
- if (toastEl) {
7950
- toastEl.style.opacity = '0';
7951
- toastEl.style.transform = 'translateX(-50%) translateY(8px)';
7952
- setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
7953
- }
9085
+ if (toastEl !== currentToast) return;
9086
+ currentToast.style.opacity = '0';
9087
+ currentToast.style.transform = 'translateX(-50%) translateY(8px)';
9088
+ setTimeout(() => {
9089
+ if (toastEl !== currentToast) return;
9090
+ currentToast.remove();
9091
+ toastEl = null;
9092
+ }, 250);
7954
9093
  }, duration);
7955
9094
  }
7956
9095
 
@@ -7981,7 +9120,7 @@ void main() {
7981
9120
  // would strand the bar in CYCLING at 0/0. If there's no live in-memory mount
7982
9121
  // for this wrapper, it's an orphan (reload / failed mount): drop it and let
7983
9122
  // the live-server's SSE re-inject the manifest if the session is still live.
7984
- if (wrapper.dataset.impeccablePreview === 'svelte-component'
9123
+ if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)
7985
9124
  && svelteComponentSession?.sessionId !== sessionId) {
7986
9125
  wrapper.remove();
7987
9126
  if (restoreSessionWithoutWrapper('browser_resumed_svelte_orphan_wrapper')) return true;
@@ -7990,7 +9129,7 @@ void main() {
7990
9129
  return false;
7991
9130
  }
7992
9131
 
7993
- if (wrapper.dataset.impeccablePreview === 'svelte-component') {
9132
+ if (isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) {
7994
9133
  if (!svelteComponentSession?.mountedVariant) {
7995
9134
  return true;
7996
9135
  }
@@ -8038,7 +9177,7 @@ void main() {
8038
9177
  insertPlaceholderSnapshot = saved.insertPlaceholder;
8039
9178
  }
8040
9179
 
8041
- const resumedState = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
9180
+ const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
8042
9181
 
8043
9182
  // Find the visible variant's content element for highlight positioning.
8044
9183
  const isInsert = wrapper.dataset.impeccableMode === 'insert';
@@ -8062,7 +9201,11 @@ void main() {
8062
9201
  // hid. Now that state is CYCLING, re-fire.
8063
9202
  if (state === 'CYCLING') refreshParamsPanel();
8064
9203
  saveSession();
8065
- queueCheckpoint('browser_resumed');
9204
+ if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
9205
+ sendCheckpoint('variants_progress');
9206
+ } else {
9207
+ queueCheckpoint('browser_resumed');
9208
+ }
8066
9209
 
8067
9210
  // Start observing for more variants AFTER initial setup
8068
9211
  if (variantObserver) variantObserver.disconnect();
@@ -8070,7 +9213,7 @@ void main() {
8070
9213
 
8071
9214
  // Hold the target at its saved viewport top through any subsequent
8072
9215
  // HMR patches, variant inserts, or cycle swaps.
8073
- startScrollLock(currentSessionId, readScrollY());
9216
+ startScrollLock(currentSessionId, readScrollY(), pickedAnchorViewportTop);
8074
9217
 
8075
9218
  // If we reloaded mid-generation (Bun's HTML HMR destroys the shader
8076
9219
  // canvas), re-capture the original's content and restart the shader so
@@ -8105,6 +9248,7 @@ void main() {
8105
9248
  let globalBarBrandEl = null;
8106
9249
  let agentPollTooltipEl = null;
8107
9250
  let agentPollingConnected = false;
9251
+ let agentStatusMessage = null;
8108
9252
  let agentStatusPollTimer = null;
8109
9253
  let steerFocusSuspended = false;
8110
9254
  let steerFocusPauseUntil = 0;
@@ -8188,6 +9332,8 @@ void main() {
8188
9332
  let pageChatInput = null;
8189
9333
  let pageChatHint = null;
8190
9334
  let pageChatVoiceBtn = null;
9335
+ let pageChatSendBtn = null;
9336
+ let pageChatQueueHintEl = null;
8191
9337
  let pageChatExpanded = false;
8192
9338
  let steerLocked = false;
8193
9339
  let steerRequestId = null;
@@ -8202,13 +9348,24 @@ void main() {
8202
9348
  /** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
8203
9349
  let voiceCtx = null;
8204
9350
  const PAGE_CHAT_COLLAPSED_W = '104px';
8205
- const PAGE_CHAT_PROCESSING_W = '76px';
9351
+ const PAGE_CHAT_QUEUED_W = '212px';
8206
9352
  const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
8207
9353
  const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
8208
9354
  const STEER_AWAIT_TIMEOUT_MS = 120000;
8209
9355
  const AGENT_STATUS_POLL_MS = 5000;
8210
9356
  const AGENT_DISCONNECTED_MARK = 'oklch(62% 0 0 / 0.78)';
8211
9357
  const AGENT_DISCONNECTED_TIP = 'Agent disconnected - run live-poll.mjs to connect';
9358
+ // The indicator tracks whether a poll is parked, which is what decides if
9359
+ // steering can reach the agent right now. That goes quiet two ways, and they
9360
+ // need different copy: nobody is polling at all, or the agent took the work
9361
+ // and is busy with it. Under one-shot foreground polling the second case is
9362
+ // every normal generation, and telling the user to start a poll loop then is
9363
+ // wrong advice about a healthy session.
9364
+ const AGENT_BUSY_TIP = 'Agent is working - steering resumes when it finishes';
9365
+ // Same distinction, said where the steer request is waiting. A submitted
9366
+ // steer that lands while a generate holds the poll lease is not stuck, it is
9367
+ // second in line, and the pulsing dots alone read as "nothing is happening".
9368
+ const STEER_QUEUED_HINT = 'Queued behind current generation';
8212
9369
  const GLOBAL_BAR_SECTION_GAP = 8;
8213
9370
  const GLOBAL_BAR_INNER_GAP = 2;
8214
9371
  const GLOBAL_BAR_INNER_PAD_LEFT = 2;
@@ -8361,10 +9518,85 @@ void main() {
8361
9518
  }
8362
9519
 
8363
9520
  function syncPageChatVisual() {
8364
- if (!pageChatInput || steerLocked) return;
9521
+ if (!pageChatInput || steerLocked) {
9522
+ syncPageChatSendButton();
9523
+ return;
9524
+ }
8365
9525
  const hasText = pageChatInput.value.length > 0;
8366
9526
  if (hasText && !pageChatExpanded) expandPageChat({ focus: false });
8367
9527
  else if (!hasText && pageChatExpanded) collapsePageChat();
9528
+ syncPageChatSendButton();
9529
+ }
9530
+
9531
+ /**
9532
+ * Send is visible once the pill is open for typing and enabled once there is
9533
+ * something to send. It disappears entirely while a steer is in flight, the
9534
+ * same way the mic does: a second submit during the lock has nowhere to go.
9535
+ */
9536
+ function syncPageChatSendButton() {
9537
+ if (!pageChatSendBtn) return;
9538
+ const P = pageChatPalette();
9539
+ const hasText = !!pageChatInput?.value.trim();
9540
+ const focused = pageChatInput && activeElementDeep() === pageChatInput;
9541
+ const visible = !steerLocked && (pageChatExpanded || hasText || focused);
9542
+ pageChatSendBtn.style.display = visible ? 'inline-flex' : 'none';
9543
+ pageChatSendBtn.disabled = !visible || !hasText;
9544
+ pageChatSendBtn.style.background = P.accent;
9545
+ pageChatSendBtn.style.color = C.ink;
9546
+ pageChatSendBtn.style.borderLeft = '1px solid ' + P.hairline;
9547
+ pageChatSendBtn.style.opacity = pageChatSendBtn.disabled ? '0.42' : '1';
9548
+ pageChatSendBtn.style.cursor = pageChatSendBtn.disabled ? 'not-allowed' : 'pointer';
9549
+ pageChatSendBtn.title = pageChatSendBtn.disabled ? 'Type what to change first' : 'Send (Enter)';
9550
+ }
9551
+
9552
+ /**
9553
+ * A submitted steer is queued, not ignored, whenever no poll is parked and
9554
+ * the browser knows a generation is in flight: the agent holds the lease and
9555
+ * will not see the steer until it finishes. Saying so beats three dots that
9556
+ * look identical to a lost request.
9557
+ */
9558
+ function steerQueuedBehindGeneration() {
9559
+ return steerLocked && !agentPollingConnected && agentHasWorkInFlight();
9560
+ }
9561
+
9562
+ function buildSteerQueueHint() {
9563
+ const P = pageChatPalette();
9564
+ const hint = el('span', {
9565
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'flex-end',
9566
+ flex: '1', minWidth: '0', marginLeft: 'auto',
9567
+ padding: '0 10px 0 6px',
9568
+ fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
9569
+ color: P.patinaPale,
9570
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
9571
+ pointerEvents: 'none',
9572
+ });
9573
+ hint.id = PREFIX + '-page-chat-queue';
9574
+ return hint;
9575
+ }
9576
+
9577
+ function syncSteerQueueHint() {
9578
+ if (!pageChatEl) return;
9579
+ const queued = steerQueuedBehindGeneration();
9580
+ if (queued) {
9581
+ if (!pageChatQueueHintEl) {
9582
+ pageChatQueueHintEl = buildSteerQueueHint();
9583
+ pageChatEl.appendChild(pageChatQueueHintEl);
9584
+ }
9585
+ pageChatQueueHintEl.textContent = STEER_QUEUED_HINT;
9586
+ if (pageChatDotsEl) pageChatDotsEl.style.display = 'none';
9587
+ pageChatEl.style.width = PAGE_CHAT_QUEUED_W;
9588
+ pageChatEl.setAttribute('aria-label', STEER_QUEUED_HINT);
9589
+ return;
9590
+ }
9591
+ if (pageChatQueueHintEl?.parentNode) {
9592
+ pageChatQueueHintEl.remove();
9593
+ pageChatQueueHintEl = null;
9594
+ if (steerLocked) {
9595
+ pageChatEl.style.width = pageChatExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
9596
+ pageChatEl.setAttribute('aria-label', 'Processing steer request');
9597
+ }
9598
+ }
9599
+ if (pageChatDotsEl) pageChatDotsEl.style.display = '';
8368
9600
  }
8369
9601
 
8370
9602
  function shouldFocusSteerChat() {
@@ -8536,6 +9768,7 @@ void main() {
8536
9768
 
8537
9769
  function syncPageChatFocusRing() {
8538
9770
  if (!pageChatEl || !pageChatInput) return;
9771
+ syncPageChatSendButton();
8539
9772
  const focused = activeElementDeep() === pageChatInput;
8540
9773
  const typingReady = focused && !steerLocked;
8541
9774
  pageChatEl.dataset.inputFocused = focused ? 'true' : 'false';
@@ -8612,9 +9845,9 @@ void main() {
8612
9845
  function buildSteerProcessingDots() {
8613
9846
  const P = pageChatPalette();
8614
9847
  const wrap = el('span', {
8615
- display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
8616
- gap: '5px', flex: '1', minWidth: '0',
8617
- padding: '0 12px 0 2px',
9848
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'flex-end',
9849
+ gap: '5px', flex: '0 0 auto', minWidth: '0', marginLeft: 'auto',
9850
+ padding: '0 12px 0 8px',
8618
9851
  pointerEvents: 'none',
8619
9852
  });
8620
9853
  wrap.setAttribute('aria-hidden', 'true');
@@ -8689,12 +9922,28 @@ void main() {
8689
9922
  steerAwaitTimer = setTimeout(() => {
8690
9923
  if (!steerLocked || steerRequestId !== id) return;
8691
9924
  unlockSteerChat({
8692
- error: 'Steer timed out waiting for the agent. Check that live-poll is running and replies with steer_done.',
9925
+ error: steerTimeoutMessage(),
8693
9926
  restoreMessage: steerPendingMessage,
8694
9927
  });
8695
9928
  }, STEER_AWAIT_TIMEOUT_MS);
8696
9929
  }
8697
9930
 
9931
+ /**
9932
+ * Two minutes of silence has three different causes and only one of them is
9933
+ * "live-poll is not running". Naming the wrong one sends the user to restart
9934
+ * a poll loop that was never the problem.
9935
+ */
9936
+ function steerTimeoutMessage() {
9937
+ const head = 'Steer timed out after 2 minutes. ';
9938
+ if (steerQueuedBehindGeneration()) {
9939
+ return head + 'The agent is still busy with the current generation - your message was not lost, but it never got picked up. Send it again once the variants land.';
9940
+ }
9941
+ if (!agentPollingConnected) {
9942
+ return head + 'No agent is polling right now. Run live-poll.mjs, then send it again.';
9943
+ }
9944
+ return head + 'The agent picked it up but never replied with steer_done. Check the agent session for a stalled or failed steer.';
9945
+ }
9946
+
8698
9947
  function lockSteerChat() {
8699
9948
  if (!pageChatEl || !pageChatInput) return;
8700
9949
  stopVoice({ suppressSubmit: true });
@@ -8718,6 +9967,7 @@ void main() {
8718
9967
  pageChatDotsEl = buildSteerProcessingDots();
8719
9968
  pageChatEl.appendChild(pageChatDotsEl);
8720
9969
  }
9970
+ syncSteerQueueHint();
8721
9971
  syncPageChatFocusRing();
8722
9972
  syncPageChatChrome();
8723
9973
  }
@@ -8759,6 +10009,10 @@ void main() {
8759
10009
  pageChatDotsEl.remove();
8760
10010
  pageChatDotsEl = null;
8761
10011
  }
10012
+ if (pageChatQueueHintEl?.parentNode) {
10013
+ pageChatQueueHintEl.remove();
10014
+ pageChatQueueHintEl = null;
10015
+ }
8762
10016
  steerPendingMessage = keepExpanded ? restoreMessage : '';
8763
10017
  steerInputWasFocused = false;
8764
10018
  syncPageChatChrome();
@@ -8997,10 +10251,11 @@ void main() {
8997
10251
  const id = id8();
8998
10252
  steerRequestId = id;
8999
10253
  steerPendingMessage = text;
9000
- if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true });
9001
10254
  lockSteerChat();
9002
10255
  scheduleSteerAwaitTimeout(id);
9003
- sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href });
10256
+ // Checkpoints follow the steer event, never precede it: the steer event
10257
+ // is what creates the session journal server-side, and a checkpoint for
10258
+ // a not-yet-created session is rejected as unknown_session.
9004
10259
  sendEvent({
9005
10260
  type: 'steer',
9006
10261
  id,
@@ -9008,9 +10263,11 @@ void main() {
9008
10263
  pageUrl: location.href,
9009
10264
  }).then((res) => {
9010
10265
  if (!res) {
9011
- sendSteerCheckpoint(id, 'steer_send_failed', { message: text });
9012
10266
  unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text });
10267
+ return;
9013
10268
  }
10269
+ if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true });
10270
+ sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href });
9014
10271
  });
9015
10272
  }
9016
10273
 
@@ -9126,10 +10383,41 @@ void main() {
9126
10383
  pageChatVoiceBtn.setAttribute('aria-label', 'Voice input');
9127
10384
  pageChatVoiceBtn.innerHTML = ICON_PAGE_VOICE;
9128
10385
 
10386
+ // Visible Send, same affordance the element-level Go bar gets from
10387
+ // buildConfigureSubmitButton. Enter still submits; the button exists so a
10388
+ // typed steer does not look like a dead-end text field.
10389
+ pageChatSendBtn = el('button', {
10390
+ display: 'none', alignItems: 'center', justifyContent: 'center',
10391
+ padding: '0', boxSizing: 'border-box',
10392
+ width: '28px', height: '28px', flexShrink: '0',
10393
+ border: 'none', borderLeft: '1px solid ' + P.hairline,
10394
+ borderRadius: '0',
10395
+ background: P.accent, color: C.ink,
10396
+ cursor: 'pointer',
10397
+ transition: 'filter 0.12s ease, opacity 0.12s ease',
10398
+ });
10399
+ pageChatSendBtn.id = PREFIX + '-page-chat-send';
10400
+ pageChatSendBtn.type = 'button';
10401
+ pageChatSendBtn.setAttribute('aria-label', 'Send steer message');
10402
+ pageChatSendBtn.innerHTML = ICON_CONFIGURE_SUBMIT;
10403
+ pageChatSendBtn.addEventListener('pointerdown', keepSteerPointerInside);
10404
+ pageChatSendBtn.addEventListener('mousedown', keepSteerPointerInside);
10405
+ pageChatSendBtn.addEventListener('mouseenter', () => {
10406
+ if (!pageChatSendBtn.disabled) pageChatSendBtn.style.filter = 'brightness(1.1)';
10407
+ });
10408
+ pageChatSendBtn.addEventListener('mouseleave', () => { pageChatSendBtn.style.filter = 'none'; });
10409
+ pageChatSendBtn.addEventListener('click', (e) => {
10410
+ e.stopPropagation();
10411
+ keepSteerPointerInside(e);
10412
+ if (steerLocked || pageChatSendBtn.disabled) return;
10413
+ submitSteerMessage();
10414
+ });
10415
+
9129
10416
  pageChatEl.appendChild(chatIcon);
9130
10417
  pageChatEl.appendChild(pageChatHint);
9131
10418
  pageChatEl.appendChild(pageChatInput);
9132
10419
  pageChatEl.appendChild(pageChatVoiceBtn);
10420
+ pageChatEl.appendChild(pageChatSendBtn);
9133
10421
 
9134
10422
  if (!uiGetById(PREFIX + '-page-chat-style')) {
9135
10423
  const s = document.createElement('style');
@@ -9152,14 +10440,14 @@ void main() {
9152
10440
 
9153
10441
  pageChatEl.addEventListener('pointerdown', (e) => {
9154
10442
  keepSteerPointerInside(e);
9155
- if (steerLocked || pageChatVoiceBtn.contains(e.target)) return;
10443
+ if (steerLocked || pageChatVoiceBtn.contains(e.target) || pageChatSendBtn.contains(e.target)) return;
9156
10444
  armPageChatForTyping({ expand: true, focus: false });
9157
10445
  });
9158
10446
  pageChatEl.addEventListener('mousedown', keepSteerPointerInside);
9159
10447
  pageChatEl.addEventListener('click', (e) => {
9160
10448
  keepSteerPointerInside(e);
9161
10449
  if (steerLocked) return;
9162
- if (pageChatVoiceBtn.contains(e.target)) return;
10450
+ if (pageChatVoiceBtn.contains(e.target) || pageChatSendBtn.contains(e.target)) return;
9163
10451
  armPageChatForTyping({ expand: true, focus: true });
9164
10452
  });
9165
10453
 
@@ -9180,6 +10468,7 @@ void main() {
9180
10468
 
9181
10469
  pageChatInput.addEventListener('input', () => {
9182
10470
  syncPageChatVisual();
10471
+ syncPageChatSendButton();
9183
10472
  });
9184
10473
 
9185
10474
  pageChatInput.addEventListener('focus', () => {
@@ -9229,24 +10518,47 @@ void main() {
9229
10518
  </svg>`;
9230
10519
  }
9231
10520
 
10521
+ /**
10522
+ * True while the browser is waiting on work it already handed to the agent.
10523
+ * In these states a quiet poll indicator means "busy", not "absent".
10524
+ */
10525
+ function agentHasWorkInFlight() {
10526
+ return state === 'GENERATING' || state === 'SAVING';
10527
+ }
10528
+
10529
+ /**
10530
+ * Derived at read time, not cached: which of the two reasons applies depends on
10531
+ * the live state, which moves between the 5s status polls. The truthiness is
10532
+ * the same either way, so the indicator's visuals can stay driven by the
10533
+ * cached value while the wording stays current.
10534
+ */
10535
+ function agentStatusText() {
10536
+ if (agentPollingConnected) return null;
10537
+ return agentHasWorkInFlight() ? AGENT_BUSY_TIP : AGENT_DISCONNECTED_TIP;
10538
+ }
10539
+
9232
10540
  function syncAgentPollingUi(connected) {
9233
10541
  agentPollingConnected = !!connected;
10542
+ syncSteerQueueHint();
9234
10543
  if (!globalBarBrandEl) return;
9235
10544
  const P = barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
10545
+ agentStatusMessage = agentStatusText();
9236
10546
  globalBarBrandEl.dataset.agentConnected = connected ? 'true' : 'false';
9237
- globalBarBrandEl.setAttribute('aria-label', connected
9238
- ? 'Impeccable live mode'
9239
- : 'Impeccable live mode - agent not polling');
10547
+ // The tooltip is mouse-only, so carry the same distinction in the label or
10548
+ // screen-reader users are left with the vaguer of the two readings.
10549
+ globalBarBrandEl.setAttribute('aria-label', agentStatusMessage
10550
+ ? 'Impeccable live mode - ' + (agentHasWorkInFlight() ? 'agent is working' : 'agent not polling')
10551
+ : 'Impeccable live mode');
9240
10552
  globalBarBrandEl.removeAttribute('title');
9241
- globalBarBrandEl.style.cursor = connected ? 'default' : 'help';
10553
+ globalBarBrandEl.style.cursor = agentStatusMessage ? 'help' : 'default';
9242
10554
  const mark = globalBarBrandEl.querySelector('[data-brand-mark]');
9243
10555
  if (mark) {
9244
10556
  mark.innerHTML = brandMarkSvg(connected ? P.accent : AGENT_DISCONNECTED_MARK, 18);
9245
10557
  mark.style.opacity = '1';
9246
10558
  }
9247
10559
  const dot = globalBarBrandEl.querySelector('[data-agent-dot]');
9248
- if (dot) dot.style.display = connected ? 'none' : 'block';
9249
- if (connected) hideAgentPollTooltip();
10560
+ if (dot) dot.style.display = agentStatusMessage ? 'block' : 'none';
10561
+ if (!agentStatusMessage) hideAgentPollTooltip();
9250
10562
  }
9251
10563
 
9252
10564
  function ensureAgentPollTooltip() {
@@ -9273,14 +10585,17 @@ void main() {
9273
10585
  whiteSpace: 'normal',
9274
10586
  });
9275
10587
  agentPollTooltipEl.id = PREFIX + '-agent-poll-tooltip';
9276
- agentPollTooltipEl.textContent = AGENT_DISCONNECTED_TIP;
10588
+ agentPollTooltipEl.textContent = agentStatusText() || AGENT_DISCONNECTED_TIP;
9277
10589
  uiAppend(agentPollTooltipEl);
9278
10590
  return agentPollTooltipEl;
9279
10591
  }
9280
10592
 
9281
10593
  function showAgentPollTooltip(anchor) {
9282
- if (agentPollingConnected || !anchor) return;
10594
+ if (!agentStatusMessage || !anchor) return;
9283
10595
  const tip = ensureAgentPollTooltip();
10596
+ // Re-derive rather than reuse the cached copy: the live state may have moved
10597
+ // since the last status poll set it.
10598
+ tip.textContent = agentStatusText() || AGENT_DISCONNECTED_TIP;
9284
10599
  tip.style.transition = 'none';
9285
10600
  tip.style.display = 'block';
9286
10601
  tip.style.opacity = '1';
@@ -9310,7 +10625,9 @@ void main() {
9310
10625
  fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
9311
10626
  .then((res) => (res.ok ? res.json() : null))
9312
10627
  .then((data) => {
9313
- if (data && typeof data.agentPolling === 'boolean') syncAgentPollingUi(data.agentPolling);
10628
+ if (data && typeof data.agentPolling === 'boolean') {
10629
+ syncAgentPollingUi(data.agentPolling);
10630
+ }
9314
10631
  })
9315
10632
  .catch(() => { /* server loss handled elsewhere */ });
9316
10633
  }
@@ -9984,6 +11301,7 @@ void main() {
9984
11301
  window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
9985
11302
  setLiveState('IDLE');
9986
11303
  document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
11304
+ removeVariantStateStylesheet();
9987
11305
  window.__IMPECCABLE_LIVE_INIT__ = false;
9988
11306
  console.log('[impeccable] Live mode exited.');
9989
11307
  }
@@ -10472,7 +11790,7 @@ void main() {
10472
11790
  if (designState.present === false) {
10473
11791
  const empty = document.createElement('div');
10474
11792
  empty.className = 'empty';
10475
- empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
11793
+ empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
10476
11794
  body.appendChild(empty);
10477
11795
  return;
10478
11796
  }
@@ -10502,7 +11820,7 @@ void main() {
10502
11820
  box.className = 'stale';
10503
11821
  box.innerHTML = `
10504
11822
  <span class="stale-dot"></span>
10505
- <span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
11823
+ <span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
10506
11824
  `;
10507
11825
  return box;
10508
11826
  }
@@ -10510,13 +11828,34 @@ void main() {
10510
11828
  function renderParsedMdCta() {
10511
11829
  const box = document.createElement('div');
10512
11830
  box.className = 'parsed-md-cta';
10513
- box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
11831
+ box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
10514
11832
  return box;
10515
11833
  }
10516
11834
 
10517
11835
  // Unified render: merge parsed DESIGN.md frontmatter with sidecar v2
10518
11836
 
11837
+ /**
11838
+ * The empty state has to say which emptiness it is. `present:false` (no
11839
+ * DESIGN.md at all) is handled upstream in renderDesignBody; everything here
11840
+ * means the helper found a design system and this panel found nothing in it
11841
+ * worth drawing. Telling that user "no design system data" reads as "your
11842
+ * DESIGN.md is missing" and sends them to write a file they already have.
11843
+ */
11844
+ function designEmptyMessage() {
11845
+ if (designState.hasMd && !designState.hasSidecar) {
11846
+ return 'DESIGN.md found, no structured tokens to display. Run ' + IMPECCABLE_COMMAND + ' document to generate the .impeccable/design.json sidecar.';
11847
+ }
11848
+ if (designState.hasMd) {
11849
+ return 'DESIGN.md and its sidecar were found, but neither carries colors, type, radii, or components to display.';
11850
+ }
11851
+ return 'No design system data available.';
11852
+ }
11853
+
10519
11854
  function renderDesignVisual(body, parsed, sidecar) {
11855
+ // Count only what this function draws: renderDesignBody may already have
11856
+ // appended a stale-sidecar hint or the basic-view CTA, and those must not
11857
+ // pass for token content.
11858
+ const beforeCount = body.childElementCount;
10520
11859
  const frontmatter = parsed?.frontmatter || {};
10521
11860
  const extensions = sidecar?.extensions || {};
10522
11861
  const proseColors = parsed?.colors || null;
@@ -10544,8 +11883,8 @@ void main() {
10544
11883
  body.appendChild(renderOverviewCollapsible(narrative));
10545
11884
  }
10546
11885
 
10547
- if (body.childElementCount === 0) {
10548
- body.appendChild(msgDiv('empty', 'No design system data available.'));
11886
+ if (body.childElementCount === beforeCount) {
11887
+ body.appendChild(msgDiv('empty', designEmptyMessage()));
10549
11888
  }
10550
11889
  }
10551
11890