@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/CHANGELOG.md +23 -0
- package/README.md +11 -0
- package/README.zh-CN.md +17 -0
- package/lib/client.js +3 -3
- package/lib/index.js +11 -4
- package/lib/types/client/TemplateDrawer.d.ts +7 -0
- package/lib/types/client/achievement-store.d.ts +35 -0
- package/lib/types/client/achievement-toast.d.ts +2 -0
- package/lib/types/client/achievements.d.ts +48 -0
- package/lib/types/client/panel.d.ts +4 -1
- package/lib/types/client/templates.d.ts +22 -0
- package/package.json +1 -1
- package/src/client/GenuiBlock.module.css +151 -14
- package/src/client/GenuiBlock.tsx +16 -1
- package/src/client/PlotBlock.tsx +23 -10
- package/src/client/TemplateDrawer.module.css +152 -0
- package/src/client/TemplateDrawer.tsx +137 -0
- package/src/client/achievement-store.ts +151 -0
- package/src/client/achievement-toast.tsx +68 -0
- package/src/client/achievements.ts +111 -0
- package/src/client/blocks/advanced.tsx +36 -4
- package/src/client/guard.ts +20 -4
- package/src/client/index.tsx +13 -0
- package/src/client/panel.tsx +109 -10
- package/src/client/templates.ts +280 -0
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
|
|
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,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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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-
|
|
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,
|
|
175
|
+
{renderNode(c, i, trackedAction, 0, answersState)}
|
|
161
176
|
</div>
|
|
162
177
|
))}
|
|
163
178
|
</div>
|
package/src/client/PlotBlock.tsx
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|