dsh-smooth-stream 0.6.0 → 0.7.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.
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import { spawn } from "node:child_process";
7
7
  /** Defaults shared by the Host schema and the client-side fallback. */
8
8
  const DEFAULT_STREAM_CONFIG = {
9
9
  mode: "typewriter",
10
- preset: "balanced",
10
+ preset: "silky",
11
11
  revealCharsPerSec: 80,
12
12
  scrollSpeedPxPerSec: 48,
13
13
  maxScrollSpeedPxPerSec: 1e3
@@ -136,6 +136,214 @@ function updateNpmProfilePackage(profileDir, packageName) {
136
136
  });
137
137
  }
138
138
  //#endregion
139
+ //#region src/settings-channel.ts
140
+ /** Largest request body this channel buffers; settings payloads are tiny. */
141
+ const MAX_REQUEST_BYTES = 1 << 20;
142
+ /** Endpoint segment shape accepted by the Connection router, mirrored here. */
143
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
144
+ /** Envelope discriminator the browser caller sends. */
145
+ const CLIENT_REQUEST = "client-request";
146
+ /** Envelope discriminator the browser caller expects back. */
147
+ const SERVER_RESPONSE = "server-response";
148
+ /**
149
+ * Register the settings RPC channel on the most capable path the kernel has.
150
+ * The registration is an effect of `ctx`, so the caller's fiber owns the
151
+ * channel's lifetime on either path.
152
+ * @param ctx - Host context carrying `connection` and, when composed, `webServer`.
153
+ * @param channel - absolute channel prefix, e.g. `/smooth-stream`.
154
+ * @param handler - decoded endpoint handler for every endpoint on the channel.
155
+ * @returns Disposer that withdraws the channel.
156
+ */
157
+ function registerSettingsChannel(ctx, channel, handler) {
158
+ let connection;
159
+ try {
160
+ connection = ctx.get("connection");
161
+ } catch {
162
+ connection = void 0;
163
+ }
164
+ return ctx.effect(() => {
165
+ try {
166
+ const direct = mountDirectRoute(ctx, connection, channel, handler);
167
+ if (direct !== void 0) return direct;
168
+ } catch {}
169
+ const viaService = tryServiceChannel(connection, channel, handler);
170
+ if (viaService !== void 0) return viaService;
171
+ return mountDirectRoute(ctx, connection, channel, handler);
172
+ }, `dsh-smooth-stream: ${channel} RPC channel`);
173
+ }
174
+ /**
175
+ * Try the service-owned registration, which is the only path that also carries
176
+ * the kernel's own channel policy.
177
+ * @returns Disposer on success, `undefined` when the kernel cannot mount it.
178
+ */
179
+ function tryServiceChannel(connection, channel, handler) {
180
+ const rpc = connection?.rpc;
181
+ if (rpc === void 0 || typeof rpc.handle !== "function") return void 0;
182
+ try {
183
+ const release = rpc.handle(channel, handler, { authority: "loopback" });
184
+ if (typeof release !== "function") return void 0;
185
+ return () => {
186
+ release();
187
+ };
188
+ } catch (error) {
189
+ console.warn(`[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; falling back to a directly registered route`, error);
190
+ return;
191
+ }
192
+ }
193
+ /**
194
+ * Mount the channel prefix on the Web server.
195
+ *
196
+ * With the connection service reachable the route sits behind the connection
197
+ * trust fence. On dsh 0.2.x the connection service is unresolvable from a
198
+ * plugin scope, so the route is then served unfenced (loopback + the app's
199
+ * own token gate only) with a warning — the same trade-off the skill center
200
+ * plugin ships on 0.2.x.
201
+ * @throws fail-closed when the connection service is reachable but cannot
202
+ * fence, so the channel is never exposed without the trust policy.
203
+ */
204
+ function mountDirectRoute(ctx, connection, channel, handler) {
205
+ const webServer = ctx.get("webServer");
206
+ const reject = connection?.requestRejection;
207
+ if (webServer === void 0 || typeof webServer.register !== "function") throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`);
208
+ if (typeof reject !== "function") {
209
+ if (connection !== void 0) throw new Error(`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} cannot be mounted behind the connection trust fence`);
210
+ console.warn(`[dsh-smooth-stream] ${channel} mounted without the connection trust fence (dsh 0.2.x: the connection service is not injectable here); loopback and the app token gate remain the only protections`);
211
+ }
212
+ return webServer.register({
213
+ kind: "prefix",
214
+ path: channel,
215
+ handler: async (req, res) => {
216
+ const rejection = reject?.call(connection, req);
217
+ if (rejection !== void 0) {
218
+ res.writeHead(rejection);
219
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
220
+ return;
221
+ }
222
+ await answer(req, res, channel, handler);
223
+ }
224
+ });
225
+ }
226
+ /**
227
+ * Decode one request envelope, dispatch it, and write the decoded reply.
228
+ * Mirrors the Connection router's own carrier rules: method and endpoint must
229
+ * agree, the body must be JSON, and only endpoint failures are results while
230
+ * transport failures are statuses.
231
+ */
232
+ async function answer(req, res, channel, handler) {
233
+ const endpoint = endpointOf(channel, req.url);
234
+ if (req.method !== "POST" || endpoint === void 0) {
235
+ res.writeHead(404);
236
+ res.end("not found");
237
+ return;
238
+ }
239
+ if ((req.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
240
+ res.writeHead(415);
241
+ res.end("content type must be application/json");
242
+ return;
243
+ }
244
+ const body = await readBody(req);
245
+ if (body.kind === "too-large") {
246
+ res.writeHead(413);
247
+ res.end("request body too large");
248
+ return;
249
+ }
250
+ if (body.kind === "invalid") {
251
+ res.writeHead(400);
252
+ res.end("body is not JSON");
253
+ return;
254
+ }
255
+ const envelope = requestEnvelope(body.value);
256
+ if (envelope === void 0) {
257
+ write(res, rpcIdOf(body.value), {
258
+ ok: false,
259
+ error: {
260
+ code: "gateway/bad-request",
261
+ message: "invalid client-request message",
262
+ details: { issues: [] }
263
+ }
264
+ });
265
+ return;
266
+ }
267
+ if (envelope.method !== endpoint) {
268
+ write(res, envelope.rpcId, {
269
+ ok: false,
270
+ error: {
271
+ code: "gateway/bad-request",
272
+ message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
273
+ details: { issues: [] }
274
+ }
275
+ });
276
+ return;
277
+ }
278
+ const controller = new AbortController();
279
+ res.on("close", () => {
280
+ controller.abort();
281
+ });
282
+ try {
283
+ write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal));
284
+ } catch (error) {
285
+ res.writeHead(500);
286
+ res.end(`handler failure: ${String(error)}`);
287
+ }
288
+ }
289
+ /**
290
+ * Resolve the channel-relative endpoint for one request path.
291
+ * @returns The endpoint, or `undefined` when the path is outside the channel
292
+ * or carries a segment the Connection router would refuse.
293
+ */
294
+ function endpointOf(channel, rawUrl) {
295
+ const pathname = new URL(rawUrl ?? "/", "http://dsh.internal").pathname;
296
+ if (!pathname.startsWith(`${channel}/`)) return void 0;
297
+ const endpoint = pathname.slice(channel.length + 1);
298
+ if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT.test(segment))) return;
299
+ return endpoint;
300
+ }
301
+ /** Structure one decoded request, rejecting anything the caller could not have sent. */
302
+ function requestEnvelope(value) {
303
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
304
+ const record = value;
305
+ if (record.type !== CLIENT_REQUEST) return void 0;
306
+ if (typeof record.rpcId !== "string" || typeof record.method !== "string") return void 0;
307
+ return {
308
+ rpcId: record.rpcId,
309
+ method: record.method,
310
+ payload: record.payload
311
+ };
312
+ }
313
+ /** Recover the correlation id of a malformed envelope so the caller can match it. */
314
+ function rpcIdOf(value) {
315
+ const raw = value?.rpcId;
316
+ return typeof raw === "string" ? raw : "invalid-request";
317
+ }
318
+ /** Write one decoded reply in the envelope the browser caller parses. */
319
+ function write(res, rpcId, result) {
320
+ res.writeHead(200, { "content-type": "application/json" });
321
+ res.end(JSON.stringify({
322
+ type: SERVER_RESPONSE,
323
+ rpcId,
324
+ result
325
+ }));
326
+ }
327
+ /** Buffer one request body, refusing both malformed JSON and unbounded input. */
328
+ async function readBody(req) {
329
+ const chunks = [];
330
+ let bytes = 0;
331
+ for await (const chunk of req) {
332
+ const buffer = chunk;
333
+ bytes += buffer.length;
334
+ if (bytes > MAX_REQUEST_BYTES) return { kind: "too-large" };
335
+ chunks.push(buffer);
336
+ }
337
+ try {
338
+ return {
339
+ kind: "value",
340
+ value: JSON.parse(Buffer.concat(chunks).toString("utf8"))
341
+ };
342
+ } catch {
343
+ return { kind: "invalid" };
344
+ }
345
+ }
346
+ //#endregion
139
347
  //#region src/settings-api.ts
140
348
  /** Shared wire vocabulary for the plugin-owned settings RPC channel. */
141
349
  /** Dedicated, loopback-only RPC channel registered by the Host half. */
@@ -164,8 +372,10 @@ const STREAM_SETTINGS_NS = "smooth-stream";
164
372
  const DEFAULT_STREAM_SETTINGS = {
165
373
  enabled: true,
166
374
  controlScroll: true,
375
+ preset: "silky",
167
376
  motionPreference: "auto",
168
377
  thinkAutoExpand: true,
378
+ logarithmicFade: true,
169
379
  debugEnabled: false,
170
380
  debugTuning: {
171
381
  revealScale: 1,
@@ -201,12 +411,18 @@ const Config = Schema.object({
201
411
  const StreamSettingsSchema = Schema.object({
202
412
  enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
203
413
  controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
414
+ preset: Schema.union([
415
+ Schema.const("realtime"),
416
+ Schema.const("balanced"),
417
+ Schema.const("silky")
418
+ ]).default(DEFAULT_STREAM_SETTINGS.preset),
204
419
  motionPreference: Schema.union([
205
420
  Schema.const("auto"),
206
421
  Schema.const("force-smooth"),
207
422
  Schema.const("force-reduced")
208
423
  ]).default(DEFAULT_STREAM_SETTINGS.motionPreference),
209
424
  thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
425
+ logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
210
426
  debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
211
427
  debugTuning: Schema.object({
212
428
  revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
@@ -234,8 +450,68 @@ function apply(ctx, config) {
234
450
  httpCtx.effect(() => httpCtx.webServer.tapIndex((html) => injectStreamConfig(html, config)), "dsh-smooth-stream: boot config bridge");
235
451
  });
236
452
  ctx.inject(["settings"], (settingsCtx) => {
453
+ if (typeof settingsCtx.settings.register !== "function") {
454
+ const view02 = () => ({
455
+ version: STREAM_PACKAGE_VERSION,
456
+ installation: inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind,
457
+ writable: false,
458
+ enabled: DEFAULT_STREAM_SETTINGS.enabled,
459
+ controlScroll: DEFAULT_STREAM_SETTINGS.controlScroll,
460
+ preset: config.preset,
461
+ motionPreference: DEFAULT_STREAM_SETTINGS.motionPreference,
462
+ thinkAutoExpand: DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
463
+ logarithmicFade: DEFAULT_STREAM_SETTINGS.logarithmicFade,
464
+ canUpgrade: false
465
+ });
466
+ const handle02 = async (endpoint) => {
467
+ if (endpoint === STREAM_SETTINGS_RPC.read) return {
468
+ ok: true,
469
+ value: view02()
470
+ };
471
+ if (endpoint === STREAM_SETTINGS_RPC.debugRead) return {
472
+ ok: true,
473
+ value: {
474
+ debugEnabled: DEFAULT_STREAM_SETTINGS.debugEnabled,
475
+ tuning: DEFAULT_STREAM_SETTINGS.debugTuning
476
+ }
477
+ };
478
+ if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
479
+ if (inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind !== "npm") return {
480
+ ok: false,
481
+ error: {
482
+ code: "internal",
483
+ message: "smooth-stream is not an npm profile dependency",
484
+ details: {}
485
+ }
486
+ };
487
+ return {
488
+ ok: false,
489
+ error: {
490
+ code: "internal",
491
+ message: "upgrade is unavailable until the 0.2.x settings migration lands",
492
+ details: {}
493
+ }
494
+ };
495
+ }
496
+ return {
497
+ ok: false,
498
+ error: {
499
+ code: "settings-rejected",
500
+ message: "smooth-stream settings are read-only on dsh 0.2.x; durable storage moved to profile entry config",
501
+ details: { ns: STREAM_SETTINGS_NS }
502
+ }
503
+ };
504
+ };
505
+ ctx.inject(["webServer"], (webCtx) => {
506
+ registerSettingsChannel(webCtx, STREAM_SETTINGS_RPC_CHANNEL, handle02);
507
+ });
508
+ return;
509
+ }
237
510
  const settingsNamespace = STREAM_SETTINGS_NS;
238
- const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, { applies: "live" });
511
+ const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, {
512
+ base: { preset: config.preset },
513
+ applies: "live"
514
+ });
239
515
  settingsCtx.inject(["connection"], (connectionCtx) => {
240
516
  let upgrade;
241
517
  const view = () => {
@@ -247,8 +523,10 @@ function apply(ctx, config) {
247
523
  writable: connectionCtx.settings.writable,
248
524
  enabled: settings.enabled,
249
525
  controlScroll: settings.controlScroll,
526
+ preset: settings.preset ?? config.preset,
250
527
  motionPreference: settings.motionPreference,
251
528
  thinkAutoExpand: settings.thinkAutoExpand,
529
+ logarithmicFade: settings.logarithmicFade,
252
530
  canUpgrade: installation.kind === "npm"
253
531
  };
254
532
  };
@@ -288,6 +566,14 @@ function apply(ctx, config) {
288
566
  };
289
567
  try {
290
568
  const next = payload;
569
+ if (next.preset !== void 0 && next.preset !== "realtime" && next.preset !== "balanced" && next.preset !== "silky") return {
570
+ ok: false,
571
+ error: {
572
+ code: "settings-rejected",
573
+ message: "preset must be one of realtime | balanced | silky",
574
+ details: { ns: STREAM_SETTINGS_NS }
575
+ }
576
+ };
291
577
  if (next.motionPreference !== void 0 && next.motionPreference !== "auto" && next.motionPreference !== "force-smooth" && next.motionPreference !== "force-reduced") return {
292
578
  ok: false,
293
579
  error: {
@@ -296,6 +582,14 @@ function apply(ctx, config) {
296
582
  details: { ns: STREAM_SETTINGS_NS }
297
583
  }
298
584
  };
585
+ if (next.logarithmicFade !== void 0 && typeof next.logarithmicFade !== "boolean") return {
586
+ ok: false,
587
+ error: {
588
+ code: "settings-rejected",
589
+ message: "logarithmicFade must be a boolean",
590
+ details: { ns: STREAM_SETTINGS_NS }
591
+ }
592
+ };
299
593
  const hasDebug = next.debugEnabled !== void 0 || next.debugTuning !== void 0;
300
594
  if (hasDebug && (typeof next.debugEnabled !== "boolean" || !validDebugTuning(next.debugTuning))) return {
301
595
  ok: false,
@@ -308,8 +602,10 @@ function apply(ctx, config) {
308
602
  await scope.update({
309
603
  enabled: next.enabled,
310
604
  controlScroll: next.controlScroll,
605
+ ...next.preset === void 0 ? {} : { preset: next.preset },
311
606
  ...next.motionPreference === void 0 ? {} : { motionPreference: next.motionPreference },
312
607
  thinkAutoExpand: next.thinkAutoExpand,
608
+ ...next.logarithmicFade === void 0 ? {} : { logarithmicFade: next.logarithmicFade },
313
609
  ...hasDebug ? {
314
610
  debugEnabled: next.debugEnabled,
315
611
  debugTuning: next.debugTuning
@@ -427,7 +723,7 @@ function apply(ctx, config) {
427
723
  }
428
724
  };
429
725
  };
430
- connectionCtx.effect(() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: "loopback" }), "dsh-smooth-stream: settings RPC");
726
+ registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle);
431
727
  });
432
728
  });
433
729
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-smooth-stream",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,13 +10,12 @@
10
10
 
11
11
  .surface[data-entrance='active'] {
12
12
  animation: dsh-smooth-stream-agent-row-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
13
- will-change: opacity, transform;
13
+ will-change: opacity;
14
14
  }
15
15
 
16
16
  @keyframes dsh-smooth-stream-agent-row-in {
17
17
  0% {
18
18
  opacity: 0;
19
- transform: translate3d(0, 4px, 0);
20
19
  clip-path: inset(0 100% 0 0);
21
20
  }
22
21
 
@@ -27,7 +26,6 @@
27
26
 
28
27
  100% {
29
28
  opacity: 1;
30
- transform: translate3d(0, 0, 0);
31
29
  clip-path: inset(0 0 0 0);
32
30
  }
33
31
  }
@@ -36,7 +34,6 @@
36
34
  .surface[data-entrance='active'] {
37
35
  animation: none;
38
36
  opacity: 1;
39
- transform: none;
40
37
  clip-path: none;
41
38
  will-change: auto;
42
39
  }
@@ -8,7 +8,7 @@
8
8
  // row attribute and the Think chrome keep working unchanged.
9
9
 
10
10
  import { type KeyboardEvent, type ReactNode } from 'react'
11
- import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { IconChevronDown } from './harnessIcons.ts'
12
12
  import css from './TypewriterAssistantNodeView.module.css'
13
13
 
14
14
  /** Class-name join for optional overlay classes over the chrome defaults. */
@@ -74,11 +74,11 @@ export function AnimatedDisclosure({
74
74
  >
75
75
  <span className={cx(css.disclosureLeading, leadingClassName)}>
76
76
  {open
77
- ? <IconChevronDownOutline14 className={chevronClassName} />
77
+ ? <IconChevronDown className={chevronClassName} />
78
78
  : (
79
79
  <>
80
80
  <span className={css.disclosureIconIdle}>{icon}</span>
81
- <IconChevronDownOutline14 className={cx(chevronClassName, css.disclosureChevronHover)} />
81
+ <IconChevronDown className={cx(chevronClassName, css.disclosureChevronHover)} />
82
82
  </>
83
83
  )}
84
84
  </span>
@@ -2,20 +2,26 @@ import { useEffect, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
4
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
5
- import {
6
- IconCloseOutline16,
7
- IconCodeOutline16,
8
- IconCopyOutline16,
9
- IconQuestionOutline14,
10
- IconRefreshOutline16,
11
- Tooltip,
12
- writeClipboard,
13
- } from '@deepseek-ai/dsh-client-ui-primitives'
5
+ import { Tooltip, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import { IconClose, IconCode, IconCopy, IconQuestion, IconRefreshSmall } from './harnessIcons.ts'
14
7
  import type { StreamDebugTuning } from '../settings.ts'
15
- import type { DebugPanelFace, DebugRuntimeState } from './debugRuntime.ts'
8
+ import type { DebugPanelFace, DebugRuntimeState, FollowTerminalPhase } from './debugRuntime.ts'
16
9
  import type { NS } from './locales.ts'
17
10
  import css from './DebugPanel.module.css'
18
11
 
12
+ /**
13
+ * Locale key per terminal-follow phase. `terminal-drain` and `host-cascade`
14
+ * are the two that must never be confused: the first is the engine finishing
15
+ * ordinary text, the second is a hostile host commit that needs temporary
16
+ * credit.
17
+ */
18
+ const PHASE_LABEL: Record<FollowTerminalPhase, Parameters<PropsLocale<typeof NS>['t']>[0]> = {
19
+ 'live': 'debugPhaseLive',
20
+ 'terminal-drain': 'debugPhaseDrain',
21
+ 'host-cascade': 'debugPhaseCascade',
22
+ 'natural': 'debugPhaseNatural',
23
+ }
24
+
19
25
  export type DebugPanelProps =
20
26
  PropsRuntime<'conversation.session.header.utilities'>
21
27
  & PropsLocale<typeof NS>
@@ -93,7 +99,7 @@ function TuningField({
93
99
  aria-label={label}
94
100
  title={t(control.tip)}
95
101
  >
96
- <IconQuestionOutline14 />
102
+ <IconQuestion />
97
103
  </button>
98
104
  </Tooltip>
99
105
  </span>
@@ -165,7 +171,7 @@ export function DebugPanel(props: DebugPanelProps) {
165
171
  <span className={css.state}>{t(live ? 'debugLive' : 'debugIdle')}</span>
166
172
  {state.dirty ? <span className={css.unsaved}>{t('debugUnsaved')}</span> : null}
167
173
  <button className={css.iconButton} type="button" title={t('debugCopy')} aria-label={t('debugCopy')} onClick={() => { void copyDiagnostics() }}>
168
- <IconCopyOutline16 />
174
+ <IconCopy />
169
175
  </button>
170
176
  <button
171
177
  className={css.iconButton}
@@ -179,7 +185,7 @@ export function DebugPanel(props: DebugPanelProps) {
179
185
  props.save()
180
186
  }}
181
187
  >
182
- <IconCloseOutline16 />
188
+ <IconClose />
183
189
  </button>
184
190
  <span className={css.visuallyHidden} aria-live="polite">{copied ? t('debugCopied') : ''}</span>
185
191
  </header>
@@ -199,6 +205,19 @@ export function DebugPanel(props: DebugPanelProps) {
199
205
  <Metric label={t('debugVelocity')} value={`${fixed(metrics.followVelocityPxPerSec, 0)} px/s`} />
200
206
  <Metric label={t('debugReserve')} value={`${fixed(metrics.followReservePx)} px`} />
201
207
  <Metric label={t('debugCapacity')} value={`${fixed(metrics.followCapacityPx)} px`} />
208
+ <Metric
209
+ label={t('debugTerminalPhase')}
210
+ value={t(PHASE_LABEL[metrics.followTerminalPhase])}
211
+ tone={metrics.followTerminalPhase === 'host-cascade' ? 'warn' : undefined}
212
+ />
213
+ <Metric label={t('debugOwnedRunway')} value={`${fixed(metrics.followRunwayPx)} px`} />
214
+ <Metric label={t('debugTerminalBudget')} value={`${fixed(metrics.followTerminalBudgetPx)} px`} />
215
+ <Metric label={t('debugBaselineShift')} value={`${fixed(metrics.followBaselineShiftPx)} px`} />
216
+ <Metric
217
+ label={t('debugAnchorDelta')}
218
+ value={metrics.followAnchorDeltaPx === null ? '—' : `${fixed(metrics.followAnchorDeltaPx)} px`}
219
+ tone={(metrics.followAnchorDeltaPx ?? 0) > 0.35 ? 'warn' : undefined}
220
+ />
202
221
  <Metric label={t('debugAppliedScale')} value={`${fixed(metrics.followRevealScale, 2)}x`} />
203
222
  </dl>
204
223
  </section>
@@ -220,7 +239,7 @@ export function DebugPanel(props: DebugPanelProps) {
220
239
 
221
240
  <footer className={css.footer}>
222
241
  <button className={css.secondaryButton} type="button" disabled={!state.writable} onClick={props.reset}>
223
- <IconRefreshOutline16 />
242
+ <IconRefreshSmall />
224
243
  {t('debugReset')}
225
244
  </button>
226
245
  <span className={css.footerSpacer} />
@@ -240,7 +259,7 @@ export function DebugPanel(props: DebugPanelProps) {
240
259
  title={t('debugPanelToggle')}
241
260
  onClick={() => { setOpen(current => !current) }}
242
261
  >
243
- <IconCodeOutline16 />
262
+ <IconCode />
244
263
  </button>
245
264
  {typeof document === 'undefined' || panel === null ? null : createPortal(panel, document.body)}
246
265
  </>