@changfenhuang/dsh-genui 0.9.2 → 0.9.6

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
@@ -667,7 +667,7 @@ function repairNode(value, ctx, depth) {
667
667
  }
668
668
  case "quiz": {
669
669
  const question = str(v.question, GENUI_LIMITS.maxString);
670
- const options = repairQuizOptions(v.options);
670
+ const options = repairQuizOptions(v.options, v.answer);
671
671
  if (question === void 0 || options === void 0) return null;
672
672
  return {
673
673
  type: "quiz",
@@ -1128,13 +1128,13 @@ function walkTree(v, cap, depthLeft) {
1128
1128
  }
1129
1129
  return out;
1130
1130
  }
1131
- function repairQuizOptions(v) {
1131
+ function repairQuizOptions(v, answer) {
1132
1132
  if (!Array.isArray(v)) return void 0;
1133
1133
  const out = [];
1134
1134
  for (const optItem of v) {
1135
1135
  if (out.length >= GENUI_LIMITS.maxQuizOptions) break;
1136
1136
  const o = obj(optItem);
1137
- const label = o === void 0 ? void 0 : str(o.label, 512);
1137
+ const label = typeof optItem === "string" ? str(optItem, 512) : o === void 0 ? void 0 : str(o.label, 512);
1138
1138
  if (label === void 0) continue;
1139
1139
  out.push({
1140
1140
  label,
@@ -1142,7 +1142,14 @@ function repairQuizOptions(v) {
1142
1142
  ...opt("feedback", o === void 0 ? void 0 : str(o.feedback, GENUI_LIMITS.maxString))
1143
1143
  });
1144
1144
  }
1145
- return out;
1145
+ if (out.length === 0) return void 0;
1146
+ if (out.some((option) => option.correct === true)) return out;
1147
+ const answerIndex = typeof answer === "number" && Number.isFinite(answer) ? Math.trunc(answer) : typeof answer === "string" ? out.findIndex((option) => option.label === answer.slice(0, 512)) : -1;
1148
+ if (answerIndex < 0 || answerIndex >= out.length) return out;
1149
+ return out.map((option, index) => index === answerIndex ? {
1150
+ ...option,
1151
+ correct: true
1152
+ } : option);
1146
1153
  }
1147
1154
  /**
1148
1155
  * Patterns that indicate HTML/script injection in a string field. ECharts
@@ -0,0 +1,7 @@
1
+ export interface TemplateDrawerProps {
2
+ /** 试用:把模板指令插入当前输入框草稿。 */
3
+ onUse: (instruction: string) => void;
4
+ /** 当前面板(模板中心/成就页),由面板 header 按钮控制。 */
5
+ tab: 'templates' | 'achievements';
6
+ }
7
+ export declare function TemplateDrawer({ onUse, tab }: TemplateDrawerProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * achievement-store.ts — 成就状态存储与埋点入口(模块级单例)。
3
+ *
4
+ * 只存计数与解锁时间戳;绝不含消息/内容。localStorage 持久化,
5
+ * 订阅接口供 toast 与成就页消费。埋点入口:
6
+ * - recordFence(spec):一次「内容不重复」的界面渲染(按 spec 指纹去重,
7
+ * LRU 指纹表跨刷新防重放重复计数)
8
+ * - recordPanel():面板 dock 出现
9
+ * - recordInteraction():组件动作回传(去抖后)
10
+ * - recordTemplateUse():模板中心试用
11
+ */
12
+ import type { GenuiSpec } from './spec.ts';
13
+ import { type AchieveState, type AchievementDef } from './achievements.ts';
14
+ /** 记录一次「内容不重复」的 fence 渲染。 */
15
+ export declare function recordFence(spec: GenuiSpec): void;
16
+ /** 记录一次面板 dock 出现(仅会话从无到有时)。 */
17
+ export declare function recordPanel(): void;
18
+ /** 记录一次组件交互动作回传。 */
19
+ export declare function recordInteraction(): void;
20
+ /** 记录一次模板试用。 */
21
+ export declare function recordTemplateUse(): void;
22
+ /** 当前状态快照(订阅者读取)。 */
23
+ export declare function getAchievementSnapshot(): {
24
+ state: AchieveState;
25
+ unlocked: Record<string, number>;
26
+ };
27
+ /** 取走新解锁(toast 消费;一次性)。 */
28
+ export declare function consumeUnlocks(): AchievementDef[];
29
+ /** 订阅状态变化(toast 与成就页)。 */
30
+ export declare function subscribeAchievements(listener: () => void): () => void;
31
+ /** 成就页 spec 数据源(面板内渲染用)。 */
32
+ export declare function achievementCount(): {
33
+ total: number;
34
+ unlocked: number;
35
+ };
@@ -0,0 +1,2 @@
1
+ /** 挂载 toast 栈(apply 调用;返回卸载函数)。 */
2
+ export declare function mountAchievementToasts(): () => void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * achievements.ts — GenUI 探索成就(0.9.5)。
3
+ *
4
+ * 轻量本地成就:只统计「使用事件计数」(渲染过的 fence、面板出现、
5
+ * 交互回传、模板试用),绝不读消息/内容;状态存 localStorage,跨会话
6
+ * 保留。解锁时进 toast 队列(achievement-toast 消费),面板「成就」
7
+ * tab 用 dsh-ui 渲染自己的成就页(dogfooding)。
8
+ *
9
+ * 设计与 dsh-achievements 的分层快照思路一致,但零宿主改动:埋点全部
10
+ * 在本包的渲染/交互路径上(GenuiBlock/TemplateDrawer/GenuiPanel)。
11
+ */
12
+ import type { GenuiSpec } from './spec.ts';
13
+ /** 累积使用计数(成就的输入)。 */
14
+ export interface AchieveState {
15
+ /** 渲染过的不重复 fence 数。 */
16
+ fences: number;
17
+ /** 面板(panel dock)出现过的会话数。 */
18
+ panels: number;
19
+ /** 交互组件动作回传次数(去抖后)。 */
20
+ interactions: number;
21
+ /** 模板试用次数。 */
22
+ templates: number;
23
+ /** 图表节点(chart/plot/echart)出现过的 fence 数。 */
24
+ charts: number;
25
+ /** 高级节点(scene3d/mermaid/diagram)出现过的 fence 数。 */
26
+ advanced: number;
27
+ }
28
+ export declare function emptyState(): AchieveState;
29
+ export interface AchievementDef {
30
+ id: string;
31
+ name: string;
32
+ description: string;
33
+ /** 解锁前隐藏名称/描述(彩蛋)。 */
34
+ hidden?: boolean;
35
+ /** 稀有度。 */
36
+ rarity: 'common' | 'rare' | 'legendary';
37
+ check: (s: AchieveState) => boolean;
38
+ }
39
+ export declare const ACHIEVEMENTS: readonly AchievementDef[];
40
+ /** 统计一个 spec 里出现的相关节点类别(与 guard 同口径遍历)。 */
41
+ export declare function countSpecKinds(spec: GenuiSpec): {
42
+ charts: number;
43
+ advanced: number;
44
+ };
45
+ /** 生成成就页 spec(dsh-ui 渲染):进度 stat + 解锁列表 + 稀有度徽标。 */
46
+ export declare function buildAchievementsSpec(state: AchieveState, unlocked: Record<string, number>): GenuiSpec;
47
+ /** 检查给定状态下的新解锁(不受 hidden 限制——规则只管阈值)。 */
48
+ export declare function checkAchievements(state: AchieveState, unlocked: Record<string, number>): AchievementDef[];
@@ -4,6 +4,9 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
4
4
  export interface GenuiPanelInjected {
5
5
  sessionId: string;
6
6
  sendGenuiAction: GenuiActionHandler;
7
+ /** Template center "try it": insert the template instruction into the
8
+ * current composer draft (standard conversation.input.for channel). */
9
+ insertTemplate: (text: string) => void;
7
10
  }
8
11
  export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPanelInjected;
9
12
  /**
@@ -12,4 +15,4 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
12
15
  * default so the dock never steals the message flow's scroll room; the
13
16
  * header always shows the current panel title.
14
17
  */
15
- export declare function GenuiPanel({ sessionId, sendGenuiAction }: GenuiPanelProps): import("react").JSX.Element | null;
18
+ export declare function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps): import("react").JSX.Element | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * templates.ts — GenUI 模板中心数据(0.9.4)。
3
+ *
4
+ * 每个模板 = 一段自然语言 `instruction`(试用时插入输入框,模型按 genui
5
+ * skill 生成对应界面)+ 一份**合法**的 `demo` spec(预览时由 GenuiBlock
6
+ * 直接渲染,用户先看到效果再决定试用)。字段对齐 spec.ts / guard.ts 的
7
+ * 实际 schema(tests/templates.spec.ts 用 validateGenuiSpec 逐条校验)。
8
+ *
9
+ * 模板即说明书:覆盖布局/数据/图表/交互/测验/高级各能力面。
10
+ */
11
+ import type { GenuiSpec } from './spec.ts';
12
+ export interface GenuiTemplate {
13
+ id: string;
14
+ category: '仪表盘' | '数据' | '流程' | '图表' | '交互' | '测验' | '高级';
15
+ name: string;
16
+ description: string;
17
+ /** 试用时插入输入框的自然语言指令。 */
18
+ instruction: string;
19
+ /** 预览用的合法 spec(≤200 节点、嵌套 ≤8 层)。 */
20
+ demo: GenuiSpec;
21
+ }
22
+ export declare const GENUI_TEMPLATES: readonly GenuiTemplate[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@changfenhuang/dsh-genui",
3
3
  "description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
4
- "version": "0.9.2",
4
+ "version": "0.9.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/plugin/index.d.ts",
@@ -16,8 +16,13 @@
16
16
  literals here are only last-resort fallbacks for hosts without the token
17
17
  sheets. */
18
18
 
19
- .block {
20
- /* ── design tokens (design system v2) ── */
19
+ .block,
20
+ .panel {
21
+ /* ── design tokens (design system v2) ──
22
+ Declared on both the inline fence root and the session panel root: the
23
+ panel header sits OUTSIDE the inner .block, so without the shared
24
+ declaration its --dsl-g-* references resolved to undefined (the badge
25
+ gap/padding collapsed to zero). */
21
26
  --dsl-g-radius-surface: 12px;
22
27
  --dsl-g-radius-control: 8px;
23
28
  --dsl-g-radius-pill: 999px;
@@ -46,7 +51,9 @@
46
51
  --dsl-g-tint: 8%;
47
52
  --dsl-g-tint-strong: 14%;
48
53
  --dsl-g-font-mono: var(--ds-font-family-code, ui-monospace, 'SF Mono', monospace);
54
+ }
49
55
 
56
+ .block {
50
57
  /* Inline in the conversation: no canvas, no border, no padding — the
51
58
  components themselves (card/quiz/plot/…) carry the surfaces. A slab
52
59
  around every fence was tried and rejected: a lone stat or text node
@@ -637,7 +644,11 @@
637
644
  .gradeList { display: flex; flex-direction: column; gap: var(--dsl-g-gap-sm); }
638
645
  .gradeItem {
639
646
  display: grid;
640
- grid-template-columns: auto auto 1fr;
647
+ /* Question on its own full row (see .gradeQ), then tag | answer. The
648
+ answer column must be minmax(0,1fr): a bare 1fr child defaults to
649
+ min-width:auto, so a long question in an auto column squeezes the
650
+ answer down to one CJK glyph per line (#62). */
651
+ grid-template-columns: auto minmax(0, 1fr);
641
652
  gap: 6px 10px;
642
653
  align-items: baseline;
643
654
  font-size: var(--dsl-g-font-title);
@@ -649,13 +660,13 @@
649
660
  }
650
661
  .gradeItemOk { border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #3ecf8e) 30%, transparent); }
651
662
  .gradeItemNo { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ff6b6b) 30%, transparent); }
652
- .gradeQ { font-weight: 600; color: var(--dsw-alias-label-primary); }
663
+ .gradeQ { grid-column: 1 / -1; margin-bottom: 8px; overflow-wrap: anywhere; font-weight: 600; color: var(--dsw-alias-label-primary); }
653
664
  .gradeTag { font-weight: 700; }
654
665
  .gradeItemOk .gradeTag { color: var(--dsw-alias-state-success-primary, #3ecf8e); }
655
666
  .gradeItemNo .gradeTag { color: var(--dsw-alias-state-error-primary, #ff6b6b); }
656
- .gradeAns { color: var(--dsw-alias-label-secondary); }
667
+ .gradeAns { overflow-wrap: anywhere; color: var(--dsw-alias-label-secondary); }
657
668
  .gradeRight { color: var(--dsw-alias-state-error-primary, #ffb3b3); }
658
- .gradeExp { grid-column: 1 / -1; font-size: var(--dsl-g-font-meta); color: var(--dsw-alias-label-tertiary); line-height: 1.5; }
669
+ .gradeExp { grid-column: 1 / -1; overflow-wrap: anywhere; font-size: var(--dsl-g-font-meta); color: var(--dsw-alias-label-tertiary); line-height: 1.5; }
659
670
 
660
671
  .switchRow { display: flex; align-items: center; justify-content: space-between; gap: var(--dsl-g-gap-md); cursor: pointer; }
661
672
  .switchRow:focus-within .switch { box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsl-g-accent) var(--dsl-g-tint-strong), transparent); }
@@ -948,12 +959,15 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
948
959
  }
949
960
 
950
961
  .reveal {
951
- animation: genuiReveal 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
962
+ animation: genuiReveal 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards;
952
963
  }
953
964
 
954
965
  /* "✓ 已触发" confirmation chip: appears next to the button without layout
955
- shift risk (it inherits the flex row's gap). */
956
- .btnSent { animation: genuiPopIn 0.2s ease both; }
966
+ shift risk (it inherits the flex row's gap). `backwards`, not `both`: the
967
+ chip's natural (post-animation) style is already visible/no-transform, so
968
+ pinning the end keyframe would only keep a translateY(0) transform layer
969
+ alive forever — the exact mechanism behind issues #55/#69. */
970
+ .btnSent { animation: genuiPopIn 0.2s ease backwards; }
957
971
 
958
972
  /* 尊重用户的减弱动效偏好 */
959
973
  @media (prefers-reduced-motion: reduce) {
@@ -1145,9 +1159,16 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1145
1159
  .panel {
1146
1160
  /* Distinct from ordinary inline fences: accent border + badge header so a
1147
1161
  session panel is recognizable at a glance. */
1148
- margin: 10px 14px 2px;
1162
+ /* Align with the host's composer width axis (same geometry as the TodoDock
1163
+ cards it shares conversation.input.dock with): centered, inset by the
1164
+ side clearance and the dock inset chain, capped at the composer card
1165
+ width. Falls back to safe literals when the host does not define the
1166
+ --dsh-composer-* tokens. */
1167
+ margin: 10px auto 2px;
1168
+ width: calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4);
1169
+ max-width: calc(var(--dsh-composer-card-max-width, 780px) - var(--dsh-composer-dock-inset, 8px) * 4);
1149
1170
  border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
1150
- border-radius: 14px;
1171
+ border-radius: 12px;
1151
1172
  background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.04));
1152
1173
  overflow: hidden;
1153
1174
  }
@@ -1155,12 +1176,18 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1155
1176
  .panelHeader {
1156
1177
  display: flex;
1157
1178
  align-items: center;
1179
+ /* Header/body divider so the panel reads as a card with a title bar, not a
1180
+ slab with text glued to it (user report: "body 缺少内边框"). */
1181
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, 0.25));
1158
1182
  }
1159
1183
 
1160
1184
  .panelToggle {
1161
1185
  display: flex;
1162
1186
  align-items: center;
1163
- gap: var(--dsl-g-gap-sm);
1187
+ /* 10px matches the TodoDock header rhythm (gap: 10px) — the badge and the
1188
+ title no longer collapse together once the shared --dsl-g-gap-sm token
1189
+ reaches this element. */
1190
+ gap: 10px;
1164
1191
  flex: 1 1 auto;
1165
1192
  min-width: 0;
1166
1193
  padding: 9px 14px;
@@ -1207,6 +1234,112 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1207
1234
  opacity: 1;
1208
1235
  }
1209
1236
 
1237
+ /* ---- 0.9.4 template center & onboarding ---- */
1238
+ .panelTpl {
1239
+ flex: 0 0 auto;
1240
+ height: 26px;
1241
+ margin-right: 4px;
1242
+ padding: 0 10px;
1243
+ display: flex;
1244
+ align-items: center;
1245
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1246
+ border-radius: 8px;
1247
+ background: transparent;
1248
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1249
+ font-size: 12px;
1250
+ line-height: 1.5;
1251
+ cursor: pointer;
1252
+ opacity: 0.85;
1253
+ }
1254
+
1255
+ .panelTpl:hover {
1256
+ background: rgba(255, 255, 255, 0.08);
1257
+ opacity: 1;
1258
+ }
1259
+
1260
+ .panelTplActive {
1261
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
1262
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1263
+ }
1264
+
1265
+ .panelOnboard .panelHeader {
1266
+ cursor: default;
1267
+ }
1268
+
1269
+ .panelOnboardText {
1270
+ flex: 1;
1271
+ min-width: 0;
1272
+ padding: 0 8px;
1273
+ font-size: 12px;
1274
+ line-height: 1.5;
1275
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1276
+ }
1277
+
1278
+ .panelFlash {
1279
+ padding: 6px 12px;
1280
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1281
+ font-size: 12px;
1282
+ line-height: 1.5;
1283
+ color: var(--dsw-alias-state-success-primary, #6ccb5f);
1284
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #6ccb5f) 8%, transparent);
1285
+ }
1286
+
1287
+ .panelHint {
1288
+ padding: 6px 14px;
1289
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
1290
+ font-size: 12px;
1291
+ line-height: 1.5;
1292
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1293
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 8%, transparent);
1294
+ }
1295
+
1296
+ /* ---- 0.9.5 achievement toasts ---- */
1297
+ .achToasts {
1298
+ position: fixed;
1299
+ right: 16px;
1300
+ bottom: 16px;
1301
+ display: flex;
1302
+ flex-direction: column;
1303
+ gap: 8px;
1304
+ z-index: 9999;
1305
+ pointer-events: none;
1306
+ }
1307
+
1308
+ .achToast {
1309
+ display: flex;
1310
+ align-items: flex-start;
1311
+ gap: 10px;
1312
+ width: 300px;
1313
+ padding: 10px 12px;
1314
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 35%, transparent);
1315
+ border-radius: var(--dsl-g-radius-surface, 12px);
1316
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.85));
1317
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
1318
+ }
1319
+
1320
+ .achToastBadge {
1321
+ flex: none;
1322
+ font-size: 18px;
1323
+ line-height: 1.2;
1324
+ }
1325
+
1326
+ .achToastBody {
1327
+ min-width: 0;
1328
+ }
1329
+
1330
+ .achToastName {
1331
+ font-size: 13px;
1332
+ font-weight: 600;
1333
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
1334
+ }
1335
+
1336
+ .achToastDesc {
1337
+ margin-top: 2px;
1338
+ font-size: 12px;
1339
+ line-height: 1.5;
1340
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
1341
+ }
1342
+
1210
1343
  .panelBadge {
1211
1344
  flex: none;
1212
1345
  font-size: 11px;
@@ -1231,9 +1364,13 @@ button.ftNameBtn[aria-expanded] { cursor: pointer; }
1231
1364
  }
1232
1365
 
1233
1366
  .panelChevron {
1367
+ /* Same glyph cell + color as the TodoDock header chevron (grid-centered,
1368
+ label-tertiary), so the arrow reads as a host control, not typed text. */
1369
+ display: grid;
1370
+ place-items: center;
1371
+ flex: none;
1234
1372
  margin-left: auto;
1235
- color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.55));
1236
- font-size: var(--dsl-g-font-meta);
1373
+ color: var(--dsw-alias-label-tertiary);
1237
1374
  }
1238
1375
 
1239
1376
  .panelBody {
@@ -10,6 +10,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
10
10
  import { useGenuiAction } from './action-context.ts'
11
11
  import css from './GenuiBlock.module.css'
12
12
  import { loadBlockState, saveBlockState } from './interaction-store.ts'
13
+ import { recordFence, recordInteraction } from './achievement-store.ts'
13
14
  import { renderNode } from './blocks/render-node.tsx'
14
15
  import type { AnswersState, GenuiBlockProps, QuestionMeta } from './blocks/state.ts'
15
16
  import type { GenuiSpec } from './spec.ts'
@@ -127,6 +128,15 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
127
128
  }),
128
129
  [answers, fields, secretFields, meta, locked, round, setAnswer, setField, registerSecretField, registerMeta, clear],
129
130
  )
131
+ // Achievement telemetry: every emitted action counts as one interaction
132
+ // (the debounced emit fires once per real user action).
133
+ const trackedAction = useMemo(() => {
134
+ if (onAction === undefined) return undefined
135
+ return (action: string, payload: Record<string, unknown>): void => {
136
+ recordInteraction()
137
+ onAction(action, payload)
138
+ }
139
+ }, [onAction])
130
140
  // Durable save (debounced 300ms — typing in a field fires per keystroke).
131
141
  // Secret field values are stripped before writing: passwords never persist.
132
142
  useEffect(() => {
@@ -143,6 +153,11 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
143
153
  }, 300)
144
154
  return () => clearTimeout(timer)
145
155
  }, [stateKey, answers, locked, fields, secretFields])
156
+ // Achievement telemetry (0.9.5): the store dedupes by spec fingerprint, so
157
+ // streaming re-renders and replays count once per distinct content.
158
+ useEffect(() => {
159
+ recordFence(spec)
160
+ }, [spec])
146
161
  return (
147
162
  <div className={css.block} data-genui>
148
163
  {spec.title !== undefined && <div className={css.banner}>{spec.title}</div>}
@@ -157,7 +172,7 @@ export const GenuiBlock = memo(function GenuiBlock({ spec, stateKey }: GenuiBloc
157
172
  className={css.reveal}
158
173
  style={{ animationDelay: `${Math.min(i * 90, 720)}ms` }}
159
174
  >
160
- {renderNode(c, i, onAction, 0, answersState)}
175
+ {renderNode(c, i, trackedAction, 0, answersState)}
161
176
  </div>
162
177
  ))}
163
178
  </div>
@@ -290,15 +290,28 @@ export const PlotBlock = memo(function PlotBlock({
290
290
  setView(prev => ({ xMin: d.xMin + dx, xMax: d.xMax + dx, yMin: prev.yMin, yMax: prev.yMax }))
291
291
  }
292
292
  const onPointerUp = (): void => { dragRef.current = null }
293
- const onWheel = (e: React.WheelEvent): void => {
294
- const span = xMax - xMin
295
- const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
296
- const next = span * factor
297
- const rect = (e.currentTarget as Element).getBoundingClientRect()
298
- const cx = fromX(((e.clientX - rect.left) / rect.width) * WIDTH)
299
- const left = cx - ((cx - xMin) / span) * next
300
- setView(prev => ({ xMin: left, xMax: left + next, yMin: prev.yMin, yMax: prev.yMax }))
301
- }
293
+
294
+ // Wheel-to-zoom must not scroll the enclosing dialog (#63): React attaches
295
+ // onWheel as a passive listener, where preventDefault() is ignored and the
296
+ // event bubbles up to the scroll container. Register natively with
297
+ // { passive: false } instead — the same pattern as the 3D scene viewer.
298
+ const svgRef = useRef<SVGSVGElement | null>(null)
299
+ useEffect(() => {
300
+ const el = svgRef.current
301
+ if (el === null) return
302
+ const onWheel = (e: WheelEvent): void => {
303
+ e.preventDefault()
304
+ const span = xMax - xMin
305
+ const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
306
+ const next = span * factor
307
+ const rect = el.getBoundingClientRect()
308
+ const cx = fromX(((e.clientX - rect.left) / rect.width) * WIDTH)
309
+ const left = cx - ((cx - xMin) / span) * next
310
+ setView(prev => ({ xMin: left, xMax: left + next, yMin: prev.yMin, yMax: prev.yMax }))
311
+ }
312
+ el.addEventListener('wheel', onWheel, { passive: false })
313
+ return () => el.removeEventListener('wheel', onWheel)
314
+ }, [xMin, xMax])
302
315
 
303
316
  const hasParams = series.some(s => (s.params?.length ?? 0) > 0)
304
317
 
@@ -312,11 +325,11 @@ export const PlotBlock = memo(function PlotBlock({
312
325
  role="img"
313
326
  aria-label={title ?? 'function plot'}
314
327
  className={css.surface}
328
+ ref={svgRef}
315
329
  onPointerDown={onPointerDown}
316
330
  onPointerMove={onPointerMove}
317
331
  onPointerUp={onPointerUp}
318
332
  onPointerLeave={onPointerUp}
319
- onWheel={onWheel}
320
333
  >
321
334
  {/* y grid */}
322
335
  {yTicks.map(t => (
@@ -0,0 +1,152 @@
1
+ /* TemplateDrawer: the GenUI template center inside the session panel body.
2
+ Tokens mirror GenuiBlock.module.css (--dsw-alias-* sheets, fallbacks last). */
3
+
4
+ .wrap {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 12px;
8
+ padding: 12px 14px;
9
+ min-height: 0;
10
+ overflow: auto;
11
+ }
12
+
13
+ /* ---- achievements tab ---- */
14
+ .achievements {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 8px;
18
+ min-height: 0;
19
+ overflow: auto;
20
+ }
21
+
22
+ /* ---- category chips ---- */
23
+ .cats {
24
+ display: flex;
25
+ flex-wrap: wrap;
26
+ gap: 8px;
27
+ }
28
+
29
+ .cat {
30
+ padding: 4px 12px;
31
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
32
+ border-radius: 999px;
33
+ background: transparent;
34
+ color: var(--dsw-alias-label-secondary, var(--dsl-g-font-muted-fallback, rgba(255, 255, 255, 0.6)));
35
+ font-size: 12px;
36
+ line-height: 1.5;
37
+ cursor: pointer;
38
+ }
39
+
40
+ .cat:hover {
41
+ background: var(--dsw-alias-fill-hover, rgba(255, 255, 255, 0.06));
42
+ }
43
+
44
+ .catActive {
45
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
46
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
47
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
48
+ }
49
+
50
+ /* ---- template cards ---- */
51
+ .list {
52
+ display: grid;
53
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
54
+ gap: 8px;
55
+ }
56
+
57
+ .card {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 4px;
61
+ padding: 10px 12px;
62
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
63
+ border-radius: var(--dsl-g-radius-surface, 12px);
64
+ background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.04));
65
+ color: var(--dsw-alias-label-primary);
66
+ text-align: left;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .card:hover {
71
+ border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.2));
72
+ }
73
+
74
+ .cardActive {
75
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
76
+ }
77
+
78
+ .cardName {
79
+ font-size: 13px;
80
+ font-weight: 600;
81
+ line-height: 1.5;
82
+ }
83
+
84
+ .cardMeta {
85
+ font-size: 11px;
86
+ color: var(--dsw-alias-label-tertiary, rgba(255, 255, 255, 0.45));
87
+ }
88
+
89
+ .cardDesc {
90
+ font-size: 12px;
91
+ line-height: 1.5;
92
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
93
+ }
94
+
95
+ /* ---- detail preview ---- */
96
+ .detail {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 8px;
100
+ border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
101
+ padding-top: 12px;
102
+ }
103
+
104
+ .toolbar {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 8px;
108
+ flex-wrap: wrap;
109
+ }
110
+
111
+ .toolbarTitle {
112
+ flex: 1;
113
+ min-width: 0;
114
+ font-size: 13px;
115
+ font-weight: 600;
116
+ color: var(--dsw-alias-label-primary);
117
+ }
118
+
119
+ .try,
120
+ .copy {
121
+ padding: 5px 12px;
122
+ border-radius: var(--dsl-g-radius-control, 8px);
123
+ border: 1px solid transparent;
124
+ font-size: 12px;
125
+ line-height: 1.5;
126
+ cursor: pointer;
127
+ }
128
+
129
+ .try {
130
+ border-color: var(--dsw-alias-state-business-primary, #4f8ef7);
131
+ color: var(--dsw-alias-state-business-primary, #4f8ef7);
132
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 14%, transparent);
133
+ }
134
+
135
+ .try:hover {
136
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4f8ef7) 22%, transparent);
137
+ }
138
+
139
+ .copy {
140
+ border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
141
+ background: transparent;
142
+ color: var(--dsw-alias-label-secondary, rgba(255, 255, 255, 0.6));
143
+ }
144
+
145
+ .preview {
146
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
147
+ border-radius: var(--dsl-g-radius-surface, 12px);
148
+ padding: 12px;
149
+ min-height: 0;
150
+ overflow: auto;
151
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.08));
152
+ }