dsh-diagnostic-tutor 0.1.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/LICENSE +21 -0
- package/README.md +658 -0
- package/cordis.patch.yml +23 -0
- package/lib/api.js +413 -0
- package/lib/api.js.map +1 -0
- package/lib/client.js +2029 -0
- package/lib/client.js.map +1 -0
- package/lib/contract.js +14 -0
- package/lib/contract.js.map +1 -0
- package/lib/diagnosis.js +224 -0
- package/lib/diagnosis.js.map +1 -0
- package/lib/handoff.js +194 -0
- package/lib/handoff.js.map +1 -0
- package/lib/index.js +186 -0
- package/lib/index.js.map +1 -0
- package/lib/lesson.js +285 -0
- package/lib/lesson.js.map +1 -0
- package/lib/prompt.js +96 -0
- package/lib/prompt.js.map +1 -0
- package/lib/state.js +500 -0
- package/lib/state.js.map +1 -0
- package/lib/tools.js +994 -0
- package/lib/tools.js.map +1 -0
- package/lib/trust-fence.js +101 -0
- package/lib/trust-fence.js.map +1 -0
- package/lib/types/api.d.ts +62 -0
- package/lib/types/api.d.ts.map +1 -0
- package/lib/types/contract.d.ts +147 -0
- package/lib/types/contract.d.ts.map +1 -0
- package/lib/types/diagnosis.d.ts +116 -0
- package/lib/types/diagnosis.d.ts.map +1 -0
- package/lib/types/handoff.d.ts +141 -0
- package/lib/types/handoff.d.ts.map +1 -0
- package/lib/types/index.d.ts +71 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/lesson.d.ts +295 -0
- package/lib/types/lesson.d.ts.map +1 -0
- package/lib/types/prompt.d.ts +85 -0
- package/lib/types/prompt.d.ts.map +1 -0
- package/lib/types/state.d.ts +627 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/tools.d.ts +38 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/trust-fence.d.ts +53 -0
- package/lib/types/trust-fence.d.ts.map +1 -0
- package/lib/types/udt.d.ts +95 -0
- package/lib/types/udt.d.ts.map +1 -0
- package/lib/types/vocabulary.d.ts +162 -0
- package/lib/types/vocabulary.d.ts.map +1 -0
- package/lib/udt.js +141 -0
- package/lib/udt.js.map +1 -0
- package/lib/vocabulary.js +182 -0
- package/lib/vocabulary.js.map +1 -0
- package/package.json +104 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,2029 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-diagnostic-tutor",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
//#region src/client/blocks.tsx
|
|
10
|
+
/**
|
|
11
|
+
* Block renderers.
|
|
12
|
+
*
|
|
13
|
+
* The registry is the whole extension story: a block type is a key in
|
|
14
|
+
* `BLOCK_RENDERERS`, so Formula, Code, Comparison, Practice or Resource arrive
|
|
15
|
+
* as one more entry and never as a rewrite of the lesson renderer.
|
|
16
|
+
*
|
|
17
|
+
* Unknown types are the interesting case. A lesson authored by a newer host
|
|
18
|
+
* will contain blocks this build has never heard of, and that must degrade —
|
|
19
|
+
* the block is announced by type and left legible, rather than throwing inside
|
|
20
|
+
* React and blanking the whole panel.
|
|
21
|
+
*
|
|
22
|
+
* Note what is absent: no percentage, no score, no stars, no progress bar.
|
|
23
|
+
* State is always a word from the skill's vocabulary plus a coloured mark.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Inline formatting: `**bold**`, `` `code` ``, and inline math.
|
|
27
|
+
*
|
|
28
|
+
* Math is handled because the teaching brain writes it by convention —
|
|
29
|
+
* `\(...\)` inline and `\[...\]` display are the skill's own rule — and a
|
|
30
|
+
* STEM surface that prints the delimiters verbatim is unreadable.
|
|
31
|
+
*
|
|
32
|
+
* This is *styling*, not typesetting: the span is set apart and given a
|
|
33
|
+
* monospace face so the expression is legible. Real math rendering needs a
|
|
34
|
+
* typesetter (KaTeX or MathML), which is a later decision, not a silent gap.
|
|
35
|
+
*/
|
|
36
|
+
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\\\([^)]*\\\))/g;
|
|
37
|
+
function inline(text) {
|
|
38
|
+
return text.split(INLINE_PATTERN).filter(Boolean).map((part, index) => {
|
|
39
|
+
if (part.startsWith("**") && part.endsWith("**")) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: part.slice(2, -2) }, index);
|
|
40
|
+
if (part.startsWith("`") && part.endsWith("`")) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: part.slice(1, -1) }, index);
|
|
41
|
+
if (part.startsWith("\\(") && part.endsWith("\\)")) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
42
|
+
className: "dt-math",
|
|
43
|
+
children: part.slice(2, -2)
|
|
44
|
+
}, index);
|
|
45
|
+
return part;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classify one line.
|
|
50
|
+
*
|
|
51
|
+
* The teaching brain writes ordinary light markdown — `###` headings, `-`
|
|
52
|
+
* bullets, `1.` steps — because that is how a person writes an explanation.
|
|
53
|
+
* Printing the markers verbatim is what makes a lesson look like a Markdown
|
|
54
|
+
* renderer instead of a lesson, so they are recognised here.
|
|
55
|
+
*
|
|
56
|
+
* This adds **no block type**: it is how the existing text and check blocks are
|
|
57
|
+
* displayed, which is the part of the surface the learner actually reads.
|
|
58
|
+
*/
|
|
59
|
+
function classify(line) {
|
|
60
|
+
const heading = /^(#{2,3})\s+(.*)$/.exec(line);
|
|
61
|
+
if (heading) return {
|
|
62
|
+
kind: "heading",
|
|
63
|
+
level: heading[1] === "##" ? 2 : 3,
|
|
64
|
+
text: heading[2]
|
|
65
|
+
};
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (trimmed.startsWith("\\[") && trimmed.endsWith("\\]")) return {
|
|
68
|
+
kind: "math",
|
|
69
|
+
text: trimmed.slice(2, -2).trim()
|
|
70
|
+
};
|
|
71
|
+
const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
|
|
72
|
+
if (bullet) return {
|
|
73
|
+
kind: "bullet",
|
|
74
|
+
text: bullet[1]
|
|
75
|
+
};
|
|
76
|
+
const ordered = /^\s*(\d+)[.)]\s+(.*)$/.exec(line);
|
|
77
|
+
if (ordered) return {
|
|
78
|
+
kind: "ordered",
|
|
79
|
+
marker: ordered[1],
|
|
80
|
+
text: ordered[2]
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
kind: "text",
|
|
84
|
+
text: line
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Group consecutive lines into runs that render as one element. */
|
|
88
|
+
function toRuns(md) {
|
|
89
|
+
const runs = [];
|
|
90
|
+
let current = [];
|
|
91
|
+
let currentKind = null;
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (current.length > 0) runs.push(current);
|
|
94
|
+
current = [];
|
|
95
|
+
currentKind = null;
|
|
96
|
+
};
|
|
97
|
+
for (const raw of md.split(/\n/)) {
|
|
98
|
+
const line = raw.trimEnd();
|
|
99
|
+
if (line.trim().length === 0) {
|
|
100
|
+
flush();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const parsed = classify(line);
|
|
104
|
+
if (!(parsed.kind === "text" ? currentKind === "text" : (parsed.kind === "bullet" || parsed.kind === "ordered") && currentKind === parsed.kind)) flush();
|
|
105
|
+
current.push(parsed);
|
|
106
|
+
currentKind = parsed.kind;
|
|
107
|
+
}
|
|
108
|
+
flush();
|
|
109
|
+
return runs;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Render a text body as a lesson rather than as source.
|
|
113
|
+
*
|
|
114
|
+
* Blank-line separated runs become paragraphs, `###` becomes a real heading,
|
|
115
|
+
* `-` and `1.` become real lists, and `\[...\]` becomes a display-math panel.
|
|
116
|
+
*/
|
|
117
|
+
function Paragraphs({ md }) {
|
|
118
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: toRuns(md).map((run, index) => {
|
|
119
|
+
const first = run[0];
|
|
120
|
+
if (first.kind === "heading") {
|
|
121
|
+
const Tag = first.level === 2 ? "h3" : "h4";
|
|
122
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Tag, {
|
|
123
|
+
className: `dt-md-h${first.level}`,
|
|
124
|
+
children: inline(first.text)
|
|
125
|
+
}, index);
|
|
126
|
+
}
|
|
127
|
+
if (first.kind === "math") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
128
|
+
className: "dt-math-block",
|
|
129
|
+
children: first.text
|
|
130
|
+
}, index);
|
|
131
|
+
if (first.kind === "bullet") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
132
|
+
className: "dt-md-ul",
|
|
133
|
+
children: run.map((line, inner) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: inline(line.text) }, inner))
|
|
134
|
+
}, index);
|
|
135
|
+
if (first.kind === "ordered") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
|
|
136
|
+
className: "dt-md-ol",
|
|
137
|
+
children: run.map((line, inner) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: inline(line.text) }, inner))
|
|
138
|
+
}, index);
|
|
139
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
140
|
+
className: "dt-block-md",
|
|
141
|
+
children: inline(run.map((line) => line.text).join(" "))
|
|
142
|
+
}, index);
|
|
143
|
+
}) });
|
|
144
|
+
}
|
|
145
|
+
function TextBlockView({ block }) {
|
|
146
|
+
if (block.type !== "text") return null;
|
|
147
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
148
|
+
className: "dt-block dt-block-text",
|
|
149
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Paragraphs, { md: block.content.md })
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function ExampleBlockView({ block }) {
|
|
153
|
+
if (block.type !== "example") return null;
|
|
154
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
155
|
+
className: "dt-block dt-block-example",
|
|
156
|
+
children: [
|
|
157
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
158
|
+
className: "dt-block-label",
|
|
159
|
+
children: "Worked example"
|
|
160
|
+
}),
|
|
161
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", { children: block.content.title }),
|
|
162
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", { children: block.content.steps.map((step, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: inline(step) }, index)) }),
|
|
163
|
+
block.content.takeaway !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
164
|
+
className: "dt-takeaway",
|
|
165
|
+
children: inline(block.content.takeaway)
|
|
166
|
+
})
|
|
167
|
+
]
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function DiagramBlockView({ block }) {
|
|
171
|
+
if (block.type !== "diagram") return null;
|
|
172
|
+
const isMermaid = block.content.format === "mermaid";
|
|
173
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
174
|
+
className: "dt-block dt-block-diagram",
|
|
175
|
+
children: [
|
|
176
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
177
|
+
className: "dt-block-label",
|
|
178
|
+
children: "Diagram"
|
|
179
|
+
}),
|
|
180
|
+
isMermaid && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
181
|
+
className: "dt-caption",
|
|
182
|
+
children: [
|
|
183
|
+
"Source shown as text — this build ships no ",
|
|
184
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "mermaid" }),
|
|
185
|
+
" renderer."
|
|
186
|
+
]
|
|
187
|
+
}),
|
|
188
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
189
|
+
className: "dt-pre",
|
|
190
|
+
children: block.content.spec
|
|
191
|
+
}),
|
|
192
|
+
block.content.caption !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
193
|
+
className: "dt-caption",
|
|
194
|
+
children: block.content.caption
|
|
195
|
+
})
|
|
196
|
+
]
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The check.
|
|
201
|
+
*
|
|
202
|
+
* Rendered as an invitation rather than a field: this surface has no input box,
|
|
203
|
+
* so the block has to make it obvious that the next move is the learner's and
|
|
204
|
+
* that it happens in the chat. The wording says so instead of leaving a dead
|
|
205
|
+
* question sitting in a panel.
|
|
206
|
+
*/
|
|
207
|
+
function CheckBlockView({ block }) {
|
|
208
|
+
if (block.type !== "check") return null;
|
|
209
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
210
|
+
className: "dt-block dt-block-check",
|
|
211
|
+
children: [
|
|
212
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
213
|
+
className: "dt-check-head",
|
|
214
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
215
|
+
className: "dt-check-tag",
|
|
216
|
+
children: "Your turn"
|
|
217
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
218
|
+
className: "dt-check-where",
|
|
219
|
+
children: "answer in the chat →"
|
|
220
|
+
})]
|
|
221
|
+
}),
|
|
222
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
223
|
+
className: "dt-check-body",
|
|
224
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Paragraphs, { md: block.content.prompt })
|
|
225
|
+
}),
|
|
226
|
+
block.content.hint !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
227
|
+
className: "dt-check-hint",
|
|
228
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Hint" }), inline(block.content.hint)]
|
|
229
|
+
})
|
|
230
|
+
]
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The registry. Adding a block type means adding one entry here.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately a plain object rather than a switch: a `Map`/record makes the
|
|
237
|
+
* extension point visible and keeps the fallback impossible to forget.
|
|
238
|
+
*/
|
|
239
|
+
const BLOCK_RENDERERS = {
|
|
240
|
+
text: TextBlockView,
|
|
241
|
+
example: ExampleBlockView,
|
|
242
|
+
diagram: DiagramBlockView,
|
|
243
|
+
check: CheckBlockView
|
|
244
|
+
};
|
|
245
|
+
/** Shown for a block type this build does not know. Never throws. */
|
|
246
|
+
function UnknownBlockView({ block }) {
|
|
247
|
+
const type = block.type;
|
|
248
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
249
|
+
className: "dt-block dt-block-unknown",
|
|
250
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: typeof type === "string" ? type : "unknown" }), " block — this build has no renderer for it. Update the plugin to see it."]
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Render one block, degrading for anything unrecognised.
|
|
255
|
+
*
|
|
256
|
+
* @param props.block - the block to render.
|
|
257
|
+
* @returns the rendered block.
|
|
258
|
+
*/
|
|
259
|
+
function BlockView({ block }) {
|
|
260
|
+
const renderer = BLOCK_RENDERERS[block.type] ?? UnknownBlockView;
|
|
261
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
262
|
+
"data-block-type": block.type,
|
|
263
|
+
children: renderer({ block })
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Render a whole lesson's blocks in order.
|
|
268
|
+
*
|
|
269
|
+
* @param props.blocks - the blocks to render.
|
|
270
|
+
* @returns the rendered list.
|
|
271
|
+
*/
|
|
272
|
+
function LessonBody({ blocks }) {
|
|
273
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: blocks.map((block) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BlockView, { block }, block.id)) });
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The recommendation card.
|
|
277
|
+
*
|
|
278
|
+
* The learner reads *why* before they move, and nothing moves until they press
|
|
279
|
+
* the button — the tutor decides, the runtime stores, the learner chooses. That
|
|
280
|
+
* ordering is the whole point of the card existing rather than an automatic
|
|
281
|
+
* jump.
|
|
282
|
+
*
|
|
283
|
+
* Wording follows the decision: a move names where it goes, a stay says so
|
|
284
|
+
* plainly. No percentage, no score, no completion estimate — how far along the
|
|
285
|
+
* learner is lives in the node's state and evidence, and nowhere else.
|
|
286
|
+
*/
|
|
287
|
+
function NextStepCard({ nextStep, onContinue, busy }) {
|
|
288
|
+
const moves = nextStep.targetNodeId !== null;
|
|
289
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
290
|
+
className: "dt-next",
|
|
291
|
+
children: [
|
|
292
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
293
|
+
className: "dt-next-label",
|
|
294
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
295
|
+
className: "dt-next-arrow",
|
|
296
|
+
"aria-hidden": "true",
|
|
297
|
+
children: "↓"
|
|
298
|
+
}), "Next best step"]
|
|
299
|
+
}),
|
|
300
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
301
|
+
className: "dt-next-from",
|
|
302
|
+
children: [
|
|
303
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
304
|
+
className: "dt-next-tick",
|
|
305
|
+
"aria-hidden": "true",
|
|
306
|
+
children: "✓"
|
|
307
|
+
}),
|
|
308
|
+
nextStep.fromNodeTitle,
|
|
309
|
+
" — ",
|
|
310
|
+
nextStep.action.replace(/-/g, " ")
|
|
311
|
+
]
|
|
312
|
+
}),
|
|
313
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
314
|
+
className: "dt-next-target",
|
|
315
|
+
children: moves ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: ["Next: ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: nextStep.targetNodeTitle })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: ["Next: ", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("b", { children: ["Stay on ", nextStep.fromNodeTitle] })] })
|
|
316
|
+
}),
|
|
317
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
318
|
+
className: "dt-next-why",
|
|
319
|
+
children: [
|
|
320
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
321
|
+
className: "dt-next-why-label",
|
|
322
|
+
children: "Why:"
|
|
323
|
+
}),
|
|
324
|
+
" ",
|
|
325
|
+
nextStep.reason
|
|
326
|
+
]
|
|
327
|
+
}),
|
|
328
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
329
|
+
type: "button",
|
|
330
|
+
className: "dt-primary",
|
|
331
|
+
onClick: onContinue,
|
|
332
|
+
disabled: busy === true,
|
|
333
|
+
children: busy === true ? "Starting…" : moves ? "Continue learning" : "Continue"
|
|
334
|
+
})
|
|
335
|
+
]
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The handoff progress line.
|
|
340
|
+
*
|
|
341
|
+
* A model turn is not instant, and a silent wait is indistinguishable from a
|
|
342
|
+
* broken button — so the wait is narrated: which stage it is in, how long it
|
|
343
|
+
* has been, and how many attempts. When it goes quiet the learner gets a retry
|
|
344
|
+
* rather than a dead end, and retrying **never touches the focus**: the record
|
|
345
|
+
* is a statement about the wait, never about where they are.
|
|
346
|
+
*
|
|
347
|
+
* The elapsed time is shown because it is honest. A tutor that takes ninety
|
|
348
|
+
* seconds should look like a tutor that takes ninety seconds, not like a hang.
|
|
349
|
+
*/
|
|
350
|
+
function HandoffLine({ handoff, onRetry }) {
|
|
351
|
+
const retryable = handoff.phase === "failed" || handoff.phase === "stalled";
|
|
352
|
+
const seconds = Math.round(handoff.elapsedMs / 1e3);
|
|
353
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
354
|
+
className: "dt-handoff",
|
|
355
|
+
"data-phase": handoff.phase,
|
|
356
|
+
children: [
|
|
357
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
358
|
+
className: "dt-handoff-dot",
|
|
359
|
+
"aria-hidden": "true"
|
|
360
|
+
}),
|
|
361
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
362
|
+
className: "dt-handoff-label",
|
|
363
|
+
children: handoff.label
|
|
364
|
+
}),
|
|
365
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
366
|
+
className: "dt-handoff-time",
|
|
367
|
+
children: [seconds, "s"]
|
|
368
|
+
}),
|
|
369
|
+
handoff.attempts > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
370
|
+
className: "dt-handoff-try",
|
|
371
|
+
children: ["attempt ", handoff.attempts]
|
|
372
|
+
}),
|
|
373
|
+
handoff.detail !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
374
|
+
className: "dt-handoff-detail",
|
|
375
|
+
children: handoff.detail
|
|
376
|
+
}),
|
|
377
|
+
retryable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
378
|
+
type: "button",
|
|
379
|
+
className: "dt-handoff-retry",
|
|
380
|
+
onClick: onRetry,
|
|
381
|
+
children: "Ask again"
|
|
382
|
+
})
|
|
383
|
+
]
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The learner's controls over their own data.
|
|
388
|
+
*
|
|
389
|
+
* Both promises this project makes about state are kept here: that it is
|
|
390
|
+
* **visible and exportable**, and that it can be **deleted**. An export that
|
|
391
|
+
* only existed as an API route would not be a promise kept, and a delete that
|
|
392
|
+
* happened on one click would be a trap — so the second click is the
|
|
393
|
+
* confirmation, inline, with no dialog to dismiss by reflex.
|
|
394
|
+
*/
|
|
395
|
+
function DataFooter({ onExport, onReset, busy }) {
|
|
396
|
+
const [confirming, setConfirming] = (0, react.useState)(false);
|
|
397
|
+
if (confirming) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
398
|
+
className: "dt-data dt-data-confirm",
|
|
399
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
400
|
+
className: "dt-data-ask",
|
|
401
|
+
children: "Delete your goal, your map, every lesson and all the evidence behind it? This cannot be undone."
|
|
402
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
403
|
+
className: "dt-data-row",
|
|
404
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
405
|
+
type: "button",
|
|
406
|
+
className: "dt-danger",
|
|
407
|
+
onClick: () => {
|
|
408
|
+
setConfirming(false);
|
|
409
|
+
onReset();
|
|
410
|
+
},
|
|
411
|
+
disabled: busy === true,
|
|
412
|
+
children: busy === true ? "Deleting…" : "Yes, delete everything"
|
|
413
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
414
|
+
type: "button",
|
|
415
|
+
className: "dt-quiet",
|
|
416
|
+
onClick: () => setConfirming(false),
|
|
417
|
+
children: "Cancel"
|
|
418
|
+
})]
|
|
419
|
+
})]
|
|
420
|
+
});
|
|
421
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
422
|
+
className: "dt-data",
|
|
423
|
+
children: [
|
|
424
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
425
|
+
type: "button",
|
|
426
|
+
className: "dt-quiet",
|
|
427
|
+
onClick: onExport,
|
|
428
|
+
disabled: busy === true,
|
|
429
|
+
children: "Export my data"
|
|
430
|
+
}),
|
|
431
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
432
|
+
type: "button",
|
|
433
|
+
className: "dt-quiet",
|
|
434
|
+
onClick: () => setConfirming(true),
|
|
435
|
+
disabled: busy === true,
|
|
436
|
+
children: "Delete everything"
|
|
437
|
+
}),
|
|
438
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
439
|
+
className: "dt-data-note",
|
|
440
|
+
children: "Stored on this machine only. Nothing is sent anywhere."
|
|
441
|
+
})
|
|
442
|
+
]
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/client/model.ts
|
|
447
|
+
/**
|
|
448
|
+
* Build the map tree from a flat node list.
|
|
449
|
+
*
|
|
450
|
+
* Ordering is insertion order within each level, which is the order the nodes
|
|
451
|
+
* were diagnosed in — the map reads as a history, not as a syllabus.
|
|
452
|
+
*
|
|
453
|
+
* Nodes whose parent is missing are promoted to roots rather than dropped: a
|
|
454
|
+
* dangling reference is a data problem, and silently hiding the node would be
|
|
455
|
+
* worse than showing it at the top level.
|
|
456
|
+
*
|
|
457
|
+
* @param nodes - the flat node views.
|
|
458
|
+
* @returns the roots, each with its subtree.
|
|
459
|
+
*/
|
|
460
|
+
function buildMapTree(nodes) {
|
|
461
|
+
const byId = /* @__PURE__ */ new Map();
|
|
462
|
+
for (const node of nodes) byId.set(node.id, node);
|
|
463
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
464
|
+
for (const node of nodes) {
|
|
465
|
+
const parent = node.parentId !== null && byId.has(node.parentId) ? node.parentId : null;
|
|
466
|
+
const bucket = childrenOf.get(parent) ?? [];
|
|
467
|
+
bucket.push(node);
|
|
468
|
+
childrenOf.set(parent, bucket);
|
|
469
|
+
}
|
|
470
|
+
const rendered = /* @__PURE__ */ new Set();
|
|
471
|
+
const build = (parentId, depth, ancestors) => (childrenOf.get(parentId) ?? []).filter((node) => !ancestors.has(node.id)).map((node) => {
|
|
472
|
+
rendered.add(node.id);
|
|
473
|
+
const nextAncestors = new Set(ancestors).add(node.id);
|
|
474
|
+
return {
|
|
475
|
+
node,
|
|
476
|
+
depth,
|
|
477
|
+
children: build(node.id, depth + 1, nextAncestors)
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
const roots = build(null, 0, /* @__PURE__ */ new Set());
|
|
481
|
+
for (const node of nodes) {
|
|
482
|
+
if (rendered.has(node.id)) continue;
|
|
483
|
+
rendered.add(node.id);
|
|
484
|
+
roots.push({
|
|
485
|
+
node,
|
|
486
|
+
depth: 0,
|
|
487
|
+
children: build(node.id, 1, /* @__PURE__ */ new Set([node.id]))
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return roots;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Flatten a tree back into render order, carrying depth.
|
|
494
|
+
*
|
|
495
|
+
* @param roots - the tree.
|
|
496
|
+
* @returns every node in top-down, left-to-right order.
|
|
497
|
+
*/
|
|
498
|
+
function flattenTree(roots) {
|
|
499
|
+
const out = [];
|
|
500
|
+
const walk = (nodes) => {
|
|
501
|
+
for (const entry of nodes) {
|
|
502
|
+
out.push(entry);
|
|
503
|
+
walk(entry.children);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
walk(roots);
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
/** Human label for a relation, in the map's own terms. */
|
|
510
|
+
function relationLabel(relation) {
|
|
511
|
+
switch (relation) {
|
|
512
|
+
case "goal": return "goal";
|
|
513
|
+
case "part-of": return "part of";
|
|
514
|
+
case "prerequisite": return "prerequisite";
|
|
515
|
+
case "related": return "related";
|
|
516
|
+
default: return relation;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Only `confirmed` reads as a filled mark; every other state is an outline.
|
|
521
|
+
*
|
|
522
|
+
* There are deliberately no numeric or graded progressions here — the seven
|
|
523
|
+
* words are the entire vocabulary.
|
|
524
|
+
*/
|
|
525
|
+
function isFilledState(state) {
|
|
526
|
+
return state === "confirmed";
|
|
527
|
+
}
|
|
528
|
+
/** Short, non-learner-facing explanation of what a state means, for the detail pane. */
|
|
529
|
+
function stateExplanation(state) {
|
|
530
|
+
switch (state) {
|
|
531
|
+
case "unconfirmed": return "No evidence yet — nothing has been observed for this node.";
|
|
532
|
+
case "explained": return "It has been explained, but that alone does not prove understanding.";
|
|
533
|
+
case "practiced": return "Something was attempted at least once.";
|
|
534
|
+
case "checked": return "A check or near-transfer question was asked.";
|
|
535
|
+
case "weak": return "Partial or unstable — worth repairing before moving on.";
|
|
536
|
+
case "blocked": return "Cannot proceed: something here is missing or misunderstood.";
|
|
537
|
+
case "confirmed": return "Supported by a check or transfer observation. Re-checked, not assumed.";
|
|
538
|
+
default: return "Unknown state.";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/client/api.ts
|
|
543
|
+
/** Route prefix owned by this plugin; must match the host's `API_PREFIX`. */
|
|
544
|
+
const BASE = "/diagnostic-tutor/api";
|
|
545
|
+
/** Requests are local; anything slower than this is a problem worth surfacing. */
|
|
546
|
+
const TIMEOUT_MS = 8e3;
|
|
547
|
+
async function request(path, init) {
|
|
548
|
+
const controller = new AbortController();
|
|
549
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
550
|
+
try {
|
|
551
|
+
const response = await fetch(`${BASE}${path}`, {
|
|
552
|
+
...init,
|
|
553
|
+
signal: controller.signal,
|
|
554
|
+
headers: {
|
|
555
|
+
accept: "application/json",
|
|
556
|
+
...init?.headers ?? {}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
let body;
|
|
560
|
+
try {
|
|
561
|
+
body = await response.json();
|
|
562
|
+
} catch {
|
|
563
|
+
throw new Error(`unreadable response (HTTP ${response.status})`);
|
|
564
|
+
}
|
|
565
|
+
const envelope = body;
|
|
566
|
+
if (!response.ok || envelope.ok === false) throw new Error(envelope.error?.message ?? envelope.error?.code ?? `HTTP ${response.status}`);
|
|
567
|
+
return body;
|
|
568
|
+
} finally {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** The current course and its whole diagnosis map. */
|
|
573
|
+
function fetchOverview() {
|
|
574
|
+
return request("/overview");
|
|
575
|
+
}
|
|
576
|
+
/** One node, its evidence, its parent and its children. */
|
|
577
|
+
function fetchNode(nodeId) {
|
|
578
|
+
return request(`/node?id=${encodeURIComponent(nodeId)}`);
|
|
579
|
+
}
|
|
580
|
+
/** The lesson the tutor has written for a node; `lesson: null` until it has. */
|
|
581
|
+
function fetchLesson(nodeId) {
|
|
582
|
+
return request(`/lesson?nodeId=${encodeURIComponent(nodeId)}`);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Save everything the learner owns to a file.
|
|
586
|
+
*
|
|
587
|
+
* Deliberately a full download rather than a call that returns data: the point
|
|
588
|
+
* of an export is that the learner ends up holding it, and a file they can move,
|
|
589
|
+
* read and keep is the only version of that which survives this app.
|
|
590
|
+
*
|
|
591
|
+
* @returns the filename the browser was asked to save.
|
|
592
|
+
*/
|
|
593
|
+
async function downloadExport() {
|
|
594
|
+
const response = await fetch(`${BASE}/export`, { headers: { accept: "application/json" } });
|
|
595
|
+
if (!response.ok) throw new Error(`export failed (HTTP ${response.status})`);
|
|
596
|
+
const text = await response.text();
|
|
597
|
+
const name = `dsh-diagnostic-tutor-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`;
|
|
598
|
+
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
|
599
|
+
try {
|
|
600
|
+
const anchor = document.createElement("a");
|
|
601
|
+
anchor.href = url;
|
|
602
|
+
anchor.download = name;
|
|
603
|
+
document.body.appendChild(anchor);
|
|
604
|
+
anchor.click();
|
|
605
|
+
anchor.remove();
|
|
606
|
+
} finally {
|
|
607
|
+
URL.revokeObjectURL(url);
|
|
608
|
+
}
|
|
609
|
+
return name;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Delete everything and return to first run.
|
|
613
|
+
*
|
|
614
|
+
* @returns nothing; the caller re-reads the overview.
|
|
615
|
+
*/
|
|
616
|
+
function resetState() {
|
|
617
|
+
return request("/reset", { method: "POST" });
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Tell the host a surface has rendered the lesson.
|
|
621
|
+
*
|
|
622
|
+
* The last leg of the timing chain: everything before it is measured on the
|
|
623
|
+
* host, and this is the only part only the browser can answer.
|
|
624
|
+
*/
|
|
625
|
+
function reportObserved(nodeId) {
|
|
626
|
+
return request("/handoff/observed", {
|
|
627
|
+
method: "POST",
|
|
628
|
+
headers: { "content-type": "application/json" },
|
|
629
|
+
body: JSON.stringify({ nodeId })
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* What the Start learning button does.
|
|
634
|
+
*
|
|
635
|
+
* Records the focus and asks the host to wake the tutor. `sessionId` is the
|
|
636
|
+
* session the panel is showing; without it the focus is still recorded and the
|
|
637
|
+
* response says the tutor was not reached.
|
|
638
|
+
*/
|
|
639
|
+
function startFocus(nodeId, sessionId) {
|
|
640
|
+
return request("/focus", {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: { "content-type": "application/json" },
|
|
643
|
+
body: JSON.stringify(sessionId === void 0 ? { nodeId } : {
|
|
644
|
+
nodeId,
|
|
645
|
+
sessionId
|
|
646
|
+
})
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/client/use-learning.ts
|
|
651
|
+
/**
|
|
652
|
+
* Shared learning state for both surfaces.
|
|
653
|
+
*
|
|
654
|
+
* The plugin renders the same runtime in two places — the full-page panel and
|
|
655
|
+
* the docked right-sidebar tab — and both need identical behaviour: select a
|
|
656
|
+
* node, start learning, follow the tutor. Duplicating that would mean two
|
|
657
|
+
* implementations of the focus hand-off and two copies of the polling loop,
|
|
658
|
+
* which is exactly how two surfaces drift apart.
|
|
659
|
+
*
|
|
660
|
+
* So the behaviour lives here once, and the components are presentation.
|
|
661
|
+
*/
|
|
662
|
+
const defaultClient = {
|
|
663
|
+
fetchOverview,
|
|
664
|
+
fetchNode,
|
|
665
|
+
fetchLesson,
|
|
666
|
+
startFocus,
|
|
667
|
+
reportObserved,
|
|
668
|
+
downloadExport,
|
|
669
|
+
resetState
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* How often the surfaces re-read state while a focus is active.
|
|
673
|
+
*
|
|
674
|
+
* Polling rather than a push channel: DSH exposes no generic host→client push
|
|
675
|
+
* for third-party plugins, and the published UI plugins poll for the same
|
|
676
|
+
* reason. The interval only runs while something is being learned, so an idle
|
|
677
|
+
* surface makes no requests at all.
|
|
678
|
+
*/
|
|
679
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
680
|
+
/**
|
|
681
|
+
* Read and follow the learning runtime.
|
|
682
|
+
*
|
|
683
|
+
* @param options.client - the API to talk to.
|
|
684
|
+
* @param options.sessionId - the session the tutor should be woken in.
|
|
685
|
+
* @param options.initialOverview - pre-supplied state, so the preview and tests
|
|
686
|
+
* skip the first fetch.
|
|
687
|
+
* @returns the state and the two actions the surfaces offer.
|
|
688
|
+
*/
|
|
689
|
+
function useLearning(options) {
|
|
690
|
+
const { client, sessionId, initialOverview, onStarted } = options;
|
|
691
|
+
const [overview, setOverview] = (0, react.useState)(initialOverview ?? null);
|
|
692
|
+
const [selectedId, setSelectedId] = (0, react.useState)(null);
|
|
693
|
+
const [detail, setDetail] = (0, react.useState)(null);
|
|
694
|
+
const [lesson, setLesson] = (0, react.useState)(null);
|
|
695
|
+
const [note, setNote] = (0, react.useState)(null);
|
|
696
|
+
const [error, setError] = (0, react.useState)(null);
|
|
697
|
+
const [starting, setStarting] = (0, react.useState)(false);
|
|
698
|
+
const [loading, setLoading] = (0, react.useState)(initialOverview === void 0);
|
|
699
|
+
const focus = overview?.focus ?? null;
|
|
700
|
+
const liveNodeId = focus?.nodeId ?? null ?? selectedId;
|
|
701
|
+
const liveRef = (0, react.useRef)(liveNodeId);
|
|
702
|
+
liveRef.current = liveNodeId;
|
|
703
|
+
/** The node whose lesson has already been reported as seen. */
|
|
704
|
+
const observedRef = (0, react.useRef)(null);
|
|
705
|
+
(0, react.useEffect)(() => {
|
|
706
|
+
if (initialOverview !== void 0) return;
|
|
707
|
+
let live = true;
|
|
708
|
+
client.fetchOverview().then((data) => {
|
|
709
|
+
if (live) setOverview(data);
|
|
710
|
+
}).catch((cause) => {
|
|
711
|
+
if (live) setError(cause.message);
|
|
712
|
+
}).finally(() => {
|
|
713
|
+
if (live) setLoading(false);
|
|
714
|
+
});
|
|
715
|
+
return () => {
|
|
716
|
+
live = false;
|
|
717
|
+
};
|
|
718
|
+
}, [client, initialOverview]);
|
|
719
|
+
const select = (0, react.useCallback)((nodeId) => {
|
|
720
|
+
setSelectedId(nodeId);
|
|
721
|
+
setError(null);
|
|
722
|
+
client.fetchNode(nodeId).then(setDetail).catch((cause) => setError(cause.message));
|
|
723
|
+
}, [client]);
|
|
724
|
+
const beginFocus = (0, react.useCallback)((nodeId) => {
|
|
725
|
+
setStarting(true);
|
|
726
|
+
setError(null);
|
|
727
|
+
setNote(null);
|
|
728
|
+
client.startFocus(nodeId, sessionId).then(async (result) => {
|
|
729
|
+
setNote(result.prompted ? "Started. The tutor is teaching this node in the chat — the surface updates as it writes." : `Focus recorded, but the tutor was not woken (${result.promptReason ?? "unknown reason"}). Say anything in the chat to continue.`);
|
|
730
|
+
const [nextOverview, nextLesson] = await Promise.all([client.fetchOverview(), client.fetchLesson(result.focus.nodeId)]);
|
|
731
|
+
setOverview(nextOverview);
|
|
732
|
+
setLesson(nextLesson.lesson);
|
|
733
|
+
onStarted?.();
|
|
734
|
+
}).catch((cause) => setError(cause.message)).finally(() => setStarting(false));
|
|
735
|
+
}, [
|
|
736
|
+
client,
|
|
737
|
+
sessionId,
|
|
738
|
+
onStarted
|
|
739
|
+
]);
|
|
740
|
+
const start = (0, react.useCallback)(() => {
|
|
741
|
+
if (selectedId === null) return;
|
|
742
|
+
beginFocus(selectedId);
|
|
743
|
+
}, [beginFocus, selectedId]);
|
|
744
|
+
const [dataNote, setDataNote] = (0, react.useState)(null);
|
|
745
|
+
/** Re-read everything, the same way the initial load does. */
|
|
746
|
+
const refresh = (0, react.useCallback)(() => client.fetchOverview().then((data) => {
|
|
747
|
+
setOverview(data);
|
|
748
|
+
setError(null);
|
|
749
|
+
return data;
|
|
750
|
+
}).catch((cause) => {
|
|
751
|
+
setError(cause.message);
|
|
752
|
+
return null;
|
|
753
|
+
}), [client]);
|
|
754
|
+
const exportData = (0, react.useCallback)(() => {
|
|
755
|
+
setDataNote(null);
|
|
756
|
+
client.downloadExport?.().then((name) => setDataNote(`Saved ${name}.`)).catch((cause) => setDataNote(`Could not export: ${cause.message}`));
|
|
757
|
+
}, [client]);
|
|
758
|
+
const resetAll = (0, react.useCallback)(() => {
|
|
759
|
+
setDataNote(null);
|
|
760
|
+
setDetail(null);
|
|
761
|
+
setLesson(null);
|
|
762
|
+
client.resetState?.().then(refresh).then(() => setDataNote("Everything was deleted.")).catch((cause) => setDataNote(`Could not delete: ${cause.message}`));
|
|
763
|
+
}, [client, refresh]);
|
|
764
|
+
(0, react.useEffect)(() => {
|
|
765
|
+
if (focus === null) return;
|
|
766
|
+
const nodeId = focus.nodeId;
|
|
767
|
+
let live = true;
|
|
768
|
+
const tick = async () => {
|
|
769
|
+
try {
|
|
770
|
+
const [nextOverview, nextLesson] = await Promise.all([client.fetchOverview(), client.fetchLesson(nodeId)]);
|
|
771
|
+
if (!live) return;
|
|
772
|
+
setOverview(nextOverview);
|
|
773
|
+
setLesson(nextLesson.lesson);
|
|
774
|
+
const watched = liveRef.current;
|
|
775
|
+
if (watched !== null) setDetail(await client.fetchNode(watched));
|
|
776
|
+
if (nextLesson.lesson !== null && observedRef.current !== nodeId) {
|
|
777
|
+
observedRef.current = nodeId;
|
|
778
|
+
client.reportObserved?.(nodeId)?.catch(() => {});
|
|
779
|
+
}
|
|
780
|
+
} catch {}
|
|
781
|
+
};
|
|
782
|
+
tick();
|
|
783
|
+
const timer = setInterval(() => void tick(), POLL_INTERVAL_MS);
|
|
784
|
+
return () => {
|
|
785
|
+
live = false;
|
|
786
|
+
clearInterval(timer);
|
|
787
|
+
};
|
|
788
|
+
}, [client, focus]);
|
|
789
|
+
return {
|
|
790
|
+
overview,
|
|
791
|
+
focus,
|
|
792
|
+
nextStep: overview?.nextStep ?? null,
|
|
793
|
+
handoff: overview?.handoff ?? null,
|
|
794
|
+
teachingBrain: overview?.teachingBrain ?? null,
|
|
795
|
+
selectedId,
|
|
796
|
+
detail,
|
|
797
|
+
lesson,
|
|
798
|
+
note,
|
|
799
|
+
error,
|
|
800
|
+
starting,
|
|
801
|
+
loading,
|
|
802
|
+
select,
|
|
803
|
+
start,
|
|
804
|
+
continueTo: beginFocus,
|
|
805
|
+
exportData,
|
|
806
|
+
resetAll,
|
|
807
|
+
dataNote,
|
|
808
|
+
dismissLesson: (0, react.useCallback)(() => setLesson(null), [])
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/client/app.tsx
|
|
813
|
+
/** The status pill plus its mark. Never a number. */
|
|
814
|
+
function StateMark({ state }) {
|
|
815
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
816
|
+
className: `dt-dot dt-state-${state}`,
|
|
817
|
+
"data-filled": isFilledState(state)
|
|
818
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
819
|
+
className: `dt-state dt-state-${state}`,
|
|
820
|
+
children: state
|
|
821
|
+
})] });
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* The panel.
|
|
825
|
+
*
|
|
826
|
+
* @param props - injectable client, optional pre-supplied overview, session id.
|
|
827
|
+
* @returns the three-pane learning surface.
|
|
828
|
+
*/
|
|
829
|
+
function LearningPanel({ client, initialOverview, sessionId, onOpenChat, onFocusStarted }) {
|
|
830
|
+
const state = useLearning({
|
|
831
|
+
client: client ?? defaultClient,
|
|
832
|
+
sessionId,
|
|
833
|
+
initialOverview,
|
|
834
|
+
onStarted: onFocusStarted
|
|
835
|
+
});
|
|
836
|
+
const { overview, focus, nextStep, handoff, teachingBrain, dataNote, selectedId, detail, lesson, note, error, starting, loading } = state;
|
|
837
|
+
const rows = flattenTree(buildMapTree(overview?.nodes ?? []));
|
|
838
|
+
const confirmed = (overview?.nodes ?? []).filter((node) => node.state === "confirmed").length;
|
|
839
|
+
if (overview?.course == null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
840
|
+
className: "dt-root dt-root-welcome",
|
|
841
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
842
|
+
className: "dt-welcome",
|
|
843
|
+
children: [
|
|
844
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
845
|
+
className: "dt-welcome-eyebrow",
|
|
846
|
+
children: "Universal Diagnostic Tutor"
|
|
847
|
+
}),
|
|
848
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
|
|
849
|
+
className: "dt-welcome-title",
|
|
850
|
+
children: "What do you want to learn?"
|
|
851
|
+
}),
|
|
852
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
853
|
+
className: "dt-welcome-body",
|
|
854
|
+
children: "Works with the Universal Diagnostic Tutor skill. Start by telling the tutor what you want to learn — in your own words, in the chat. Then this page fills in: a map of what you actually know, the lesson, and the evidence behind every status."
|
|
855
|
+
}),
|
|
856
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
857
|
+
className: "dt-welcome-sample",
|
|
858
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
859
|
+
className: "dt-welcome-sample-label",
|
|
860
|
+
children: "Try"
|
|
861
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
862
|
+
className: "dt-welcome-sample-text",
|
|
863
|
+
children: "I want to learn machine learning. I know some Python, but my math is weak."
|
|
864
|
+
})]
|
|
865
|
+
}),
|
|
866
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
867
|
+
className: "dt-welcome-note",
|
|
868
|
+
children: "If tutoring doesn't start, make sure the UDT skill is available to your active DSH agent."
|
|
869
|
+
}),
|
|
870
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
871
|
+
className: "dt-welcome-note",
|
|
872
|
+
children: "No account, no scores, no streak. Your goal, your map and the evidence behind it stay on this machine, and are yours to keep or delete."
|
|
873
|
+
}),
|
|
874
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataFooter, {
|
|
875
|
+
onExport: state.exportData,
|
|
876
|
+
onReset: state.resetAll
|
|
877
|
+
}),
|
|
878
|
+
teachingBrain === false && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
879
|
+
className: "dt-notice",
|
|
880
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: "The Universal Diagnostic Tutor skill was not found." }), " This panel still records and shows your learning state, but no lesson will be written until the skill is available to your active DSH agent."]
|
|
881
|
+
})
|
|
882
|
+
]
|
|
883
|
+
})
|
|
884
|
+
});
|
|
885
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
886
|
+
className: "dt-root",
|
|
887
|
+
children: [
|
|
888
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
889
|
+
className: "dt-pane",
|
|
890
|
+
children: [
|
|
891
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
892
|
+
className: "dt-eyebrow",
|
|
893
|
+
children: "Current course"
|
|
894
|
+
}),
|
|
895
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
|
|
896
|
+
className: "dt-course-title",
|
|
897
|
+
children: overview?.course?.title ?? "No goal yet"
|
|
898
|
+
}),
|
|
899
|
+
overview?.course != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
900
|
+
className: "dt-goal",
|
|
901
|
+
children: [
|
|
902
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: "Goal:" }),
|
|
903
|
+
" ",
|
|
904
|
+
overview.course.goal
|
|
905
|
+
]
|
|
906
|
+
}),
|
|
907
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
908
|
+
className: "dt-section-title",
|
|
909
|
+
children: ["Diagnosis map ", /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [rows.length, " nodes"] })]
|
|
910
|
+
}),
|
|
911
|
+
rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
912
|
+
className: "dt-empty",
|
|
913
|
+
children: "Nothing on the map yet. State a goal in the chat and the runtime will record it."
|
|
914
|
+
}),
|
|
915
|
+
rows.map(({ node, depth }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
916
|
+
type: "button",
|
|
917
|
+
className: "dt-node",
|
|
918
|
+
style: { marginLeft: `${depth * 12}px` },
|
|
919
|
+
"aria-current": node.id === selectedId,
|
|
920
|
+
"data-focused": node.id === focus?.nodeId,
|
|
921
|
+
onClick: () => state.select(node.id),
|
|
922
|
+
children: [
|
|
923
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
924
|
+
className: `dt-dot dt-state-${node.state}`,
|
|
925
|
+
"data-filled": isFilledState(node.state)
|
|
926
|
+
}),
|
|
927
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
928
|
+
className: "dt-node-title",
|
|
929
|
+
children: [node.title, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
930
|
+
className: "dt-node-rel",
|
|
931
|
+
children: [
|
|
932
|
+
relationLabel(node.relation),
|
|
933
|
+
" · ",
|
|
934
|
+
node.evidenceCount,
|
|
935
|
+
" evidence"
|
|
936
|
+
]
|
|
937
|
+
})]
|
|
938
|
+
}),
|
|
939
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
940
|
+
className: `dt-state dt-state-${node.state}`,
|
|
941
|
+
children: node.state
|
|
942
|
+
})
|
|
943
|
+
]
|
|
944
|
+
}, node.id)),
|
|
945
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
946
|
+
className: "dt-caption",
|
|
947
|
+
children: [
|
|
948
|
+
confirmed,
|
|
949
|
+
" of ",
|
|
950
|
+
rows.length,
|
|
951
|
+
" confirmed by evidence."
|
|
952
|
+
]
|
|
953
|
+
})
|
|
954
|
+
]
|
|
955
|
+
}),
|
|
956
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
957
|
+
className: "dt-pane dt-detail",
|
|
958
|
+
children: detail === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
959
|
+
className: "dt-empty",
|
|
960
|
+
children: loading ? "Loading…" : "Pick a node on the map to see why it is there."
|
|
961
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
962
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
963
|
+
className: "dt-eyebrow",
|
|
964
|
+
children: "Selected node"
|
|
965
|
+
}),
|
|
966
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: detail.node.title }),
|
|
967
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
968
|
+
className: "dt-meta",
|
|
969
|
+
children: [
|
|
970
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(StateMark, { state: detail.node.state }),
|
|
971
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
972
|
+
className: "dt-chip",
|
|
973
|
+
children: relationLabel(detail.node.relation)
|
|
974
|
+
}),
|
|
975
|
+
detail.parent !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
976
|
+
className: "dt-chip",
|
|
977
|
+
children: ["parent: ", detail.parent.title]
|
|
978
|
+
}),
|
|
979
|
+
detail.children.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
980
|
+
className: "dt-chip",
|
|
981
|
+
children: [detail.children.length, " children"]
|
|
982
|
+
})
|
|
983
|
+
]
|
|
984
|
+
}),
|
|
985
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
986
|
+
className: "dt-why",
|
|
987
|
+
children: stateExplanation(detail.node.state)
|
|
988
|
+
}),
|
|
989
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
990
|
+
className: "dt-section-title",
|
|
991
|
+
children: ["Evidence ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: detail.node.evidence.length })]
|
|
992
|
+
}),
|
|
993
|
+
detail.node.evidence.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
994
|
+
className: "dt-empty",
|
|
995
|
+
children: "Nothing recorded yet."
|
|
996
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
997
|
+
className: "dt-evidence",
|
|
998
|
+
children: detail.node.evidence.map((entry, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
|
|
999
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1000
|
+
className: "kind",
|
|
1001
|
+
children: [entry.kind, entry.readiness !== void 0 && ` · ${entry.readiness}`]
|
|
1002
|
+
}),
|
|
1003
|
+
entry.note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: entry.note }),
|
|
1004
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1005
|
+
className: "when",
|
|
1006
|
+
children: entry.at
|
|
1007
|
+
})
|
|
1008
|
+
] }, index))
|
|
1009
|
+
}),
|
|
1010
|
+
note !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1011
|
+
className: "dt-caption",
|
|
1012
|
+
children: note
|
|
1013
|
+
}),
|
|
1014
|
+
error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1015
|
+
className: "dt-empty",
|
|
1016
|
+
children: error
|
|
1017
|
+
}),
|
|
1018
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1019
|
+
type: "button",
|
|
1020
|
+
className: "dt-primary",
|
|
1021
|
+
onClick: state.start,
|
|
1022
|
+
disabled: starting,
|
|
1023
|
+
children: starting ? "Starting…" : focus !== null && focus.nodeId === selectedId ? "Learning in progress" : "Start learning"
|
|
1024
|
+
})
|
|
1025
|
+
] })
|
|
1026
|
+
}),
|
|
1027
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1028
|
+
className: "dt-pane",
|
|
1029
|
+
children: [
|
|
1030
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1031
|
+
className: "dt-lesson-head",
|
|
1032
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1033
|
+
className: "dt-eyebrow",
|
|
1034
|
+
children: "Learning surface"
|
|
1035
|
+
}), lesson !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1036
|
+
className: "dt-origin",
|
|
1037
|
+
children: lesson.origin
|
|
1038
|
+
})]
|
|
1039
|
+
}),
|
|
1040
|
+
lesson === null ? focus !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1041
|
+
className: "dt-empty",
|
|
1042
|
+
children: "The tutor is preparing this node…"
|
|
1043
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1044
|
+
className: "dt-caption",
|
|
1045
|
+
children: "The teaching appears here as it is written, while the conversation continues in the chat."
|
|
1046
|
+
})] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1047
|
+
className: "dt-empty",
|
|
1048
|
+
children: [
|
|
1049
|
+
"Choose a node and press ",
|
|
1050
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: "Start learning" }),
|
|
1051
|
+
". The teaching appears here, and the conversation stays in the chat."
|
|
1052
|
+
]
|
|
1053
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: lesson.title }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1054
|
+
style: { marginTop: 14 },
|
|
1055
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LessonBody, { blocks: lesson.blocks })
|
|
1056
|
+
})] }),
|
|
1057
|
+
handoff !== null && lesson === null && focus !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffLine, {
|
|
1058
|
+
handoff,
|
|
1059
|
+
onRetry: () => state.continueTo(handoff.targetNodeId)
|
|
1060
|
+
}),
|
|
1061
|
+
nextStep !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NextStepCard, {
|
|
1062
|
+
nextStep,
|
|
1063
|
+
busy: starting,
|
|
1064
|
+
onContinue: () => state.continueTo(nextStep.targetNodeId ?? nextStep.fromNodeId)
|
|
1065
|
+
}),
|
|
1066
|
+
onOpenChat !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1067
|
+
type: "button",
|
|
1068
|
+
className: "dt-primary",
|
|
1069
|
+
onClick: onOpenChat,
|
|
1070
|
+
children: "Answer in the chat"
|
|
1071
|
+
}),
|
|
1072
|
+
lesson !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1073
|
+
type: "button",
|
|
1074
|
+
className: "dt-secondary",
|
|
1075
|
+
onClick: state.dismissLesson,
|
|
1076
|
+
children: "Back to the node"
|
|
1077
|
+
}),
|
|
1078
|
+
dataNote !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1079
|
+
className: "dt-caption",
|
|
1080
|
+
children: dataNote
|
|
1081
|
+
}),
|
|
1082
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataFooter, {
|
|
1083
|
+
onExport: state.exportData,
|
|
1084
|
+
onReset: state.resetAll
|
|
1085
|
+
})
|
|
1086
|
+
]
|
|
1087
|
+
})
|
|
1088
|
+
]
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/client/tab.tsx
|
|
1093
|
+
/**
|
|
1094
|
+
* The docked Learning tab.
|
|
1095
|
+
*
|
|
1096
|
+
* This is the surface that makes the loop usable. The full-page panel occupies
|
|
1097
|
+
* the main column, which is also where the conversation lives — so with only
|
|
1098
|
+
* that panel, answering a check means leaving the lesson. The right sidebar is
|
|
1099
|
+
* a *separate column*, so the same runtime sits beside the chat and the learner
|
|
1100
|
+
* never has to switch.
|
|
1101
|
+
*
|
|
1102
|
+
* Layout is deliberately narrow-first and ordered by what the learner needs
|
|
1103
|
+
* next: **the lesson, then the recommendation, then the map, then the
|
|
1104
|
+
* evidence.** The lesson leads because it is the teaching; the map and the
|
|
1105
|
+
* evidence are reference material the learner consults, not the main event.
|
|
1106
|
+
*
|
|
1107
|
+
* It runs on the same `useLearning` state as the full panel, so the two cannot
|
|
1108
|
+
* disagree about what is focused or what the tutor wrote.
|
|
1109
|
+
*/
|
|
1110
|
+
/** The tab chip's text. */
|
|
1111
|
+
function LearningTabTitle() {
|
|
1112
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: "Learning" });
|
|
1113
|
+
}
|
|
1114
|
+
function CompactMap({ nodes, selectedId, focusNodeId, onSelect }) {
|
|
1115
|
+
const rows = (0, react.useMemo)(() => flattenTree(buildMapTree(nodes)), [nodes]);
|
|
1116
|
+
const confirmed = nodes.filter((node) => node.state === "confirmed").length;
|
|
1117
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1118
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1119
|
+
className: "dt-tab-section",
|
|
1120
|
+
children: ["Diagnosis map ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: nodes.length })]
|
|
1121
|
+
}),
|
|
1122
|
+
rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1123
|
+
className: "dt-empty",
|
|
1124
|
+
children: "No map yet — state a goal in the chat."
|
|
1125
|
+
}),
|
|
1126
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1127
|
+
className: "dt-tree",
|
|
1128
|
+
children: rows.map(({ node, depth }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1129
|
+
type: "button",
|
|
1130
|
+
className: "dt-tab-node",
|
|
1131
|
+
style: { paddingLeft: `${6 + depth * 14}px` },
|
|
1132
|
+
"data-depth": depth > 0 ? Math.min(depth, 3) : void 0,
|
|
1133
|
+
"data-attention": node.state === "blocked" || node.state === "weak" ? "true" : void 0,
|
|
1134
|
+
"aria-current": node.id === selectedId,
|
|
1135
|
+
"data-focused": node.id === focusNodeId,
|
|
1136
|
+
onClick: () => onSelect(node.id),
|
|
1137
|
+
children: [
|
|
1138
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1139
|
+
className: `dt-dot dt-state-${node.state}`,
|
|
1140
|
+
"data-filled": node.state === "confirmed"
|
|
1141
|
+
}),
|
|
1142
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1143
|
+
className: "dt-tab-node-title",
|
|
1144
|
+
children: node.title
|
|
1145
|
+
}),
|
|
1146
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1147
|
+
className: `dt-tab-state dt-state-${node.state}`,
|
|
1148
|
+
children: node.state
|
|
1149
|
+
})
|
|
1150
|
+
]
|
|
1151
|
+
}, node.id))
|
|
1152
|
+
}),
|
|
1153
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1154
|
+
className: "dt-caption",
|
|
1155
|
+
children: [
|
|
1156
|
+
confirmed,
|
|
1157
|
+
" of ",
|
|
1158
|
+
nodes.length,
|
|
1159
|
+
" confirmed by evidence."
|
|
1160
|
+
]
|
|
1161
|
+
})
|
|
1162
|
+
] });
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* The docked learning surface.
|
|
1166
|
+
*
|
|
1167
|
+
* @param props - injectable client and the session the tab belongs to.
|
|
1168
|
+
* @returns the stacked node / map / lesson column.
|
|
1169
|
+
*/
|
|
1170
|
+
function LearningTab({ client, sessionId, initialOverview }) {
|
|
1171
|
+
const state = useLearning({
|
|
1172
|
+
client: client ?? defaultClient,
|
|
1173
|
+
sessionId,
|
|
1174
|
+
initialOverview
|
|
1175
|
+
});
|
|
1176
|
+
const { overview, focus, nextStep, handoff, teachingBrain, dataNote, selectedId, detail, lesson, note, error, starting, loading, select } = state;
|
|
1177
|
+
(0, react.useEffect)(() => {
|
|
1178
|
+
if (selectedId !== null) return;
|
|
1179
|
+
const first = overview?.nodes?.[0]?.id;
|
|
1180
|
+
if (first !== void 0) select(focus?.nodeId ?? first);
|
|
1181
|
+
}, [
|
|
1182
|
+
overview,
|
|
1183
|
+
focus,
|
|
1184
|
+
selectedId,
|
|
1185
|
+
select
|
|
1186
|
+
]);
|
|
1187
|
+
const shownId = focus?.nodeId ?? selectedId;
|
|
1188
|
+
const hasCourse = overview?.course != null;
|
|
1189
|
+
const rows = overview?.nodes ?? [];
|
|
1190
|
+
if (!hasCourse) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1191
|
+
className: "dt-tab",
|
|
1192
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1193
|
+
className: "dt-welcome",
|
|
1194
|
+
children: [
|
|
1195
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1196
|
+
className: "dt-welcome-eyebrow",
|
|
1197
|
+
children: "Universal Diagnostic Tutor"
|
|
1198
|
+
}),
|
|
1199
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
1200
|
+
className: "dt-welcome-title",
|
|
1201
|
+
children: "What do you want to learn?"
|
|
1202
|
+
}),
|
|
1203
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1204
|
+
className: "dt-welcome-body",
|
|
1205
|
+
children: "Works with the Universal Diagnostic Tutor skill. Start by telling the tutor what you want to learn — in your own words, in the chat."
|
|
1206
|
+
}),
|
|
1207
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1208
|
+
className: "dt-welcome-sample",
|
|
1209
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1210
|
+
className: "dt-welcome-sample-label",
|
|
1211
|
+
children: "Try"
|
|
1212
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1213
|
+
className: "dt-welcome-sample-text",
|
|
1214
|
+
children: "I want to learn machine learning. I know some Python, but my math is weak."
|
|
1215
|
+
})]
|
|
1216
|
+
}),
|
|
1217
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1218
|
+
className: "dt-welcome-note",
|
|
1219
|
+
children: "If tutoring doesn't start, make sure the UDT skill is available to your active DSH agent."
|
|
1220
|
+
}),
|
|
1221
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1222
|
+
className: "dt-welcome-note",
|
|
1223
|
+
children: "No account, no scores, no streak. Your goal, your map and the evidence behind it stay on this machine, and are yours to keep or delete."
|
|
1224
|
+
}),
|
|
1225
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataFooter, {
|
|
1226
|
+
onExport: state.exportData,
|
|
1227
|
+
onReset: state.resetAll
|
|
1228
|
+
}),
|
|
1229
|
+
teachingBrain === false && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1230
|
+
className: "dt-notice",
|
|
1231
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: "The Universal Diagnostic Tutor skill was not found." }), " This panel still records and shows your learning state, but no lesson will be written until the skill is available to your active DSH agent."]
|
|
1232
|
+
})
|
|
1233
|
+
]
|
|
1234
|
+
})
|
|
1235
|
+
});
|
|
1236
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1237
|
+
className: "dt-tab",
|
|
1238
|
+
children: [
|
|
1239
|
+
detail === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
1240
|
+
className: "dt-now",
|
|
1241
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1242
|
+
className: "dt-now-eyebrow",
|
|
1243
|
+
children: "Now learning"
|
|
1244
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1245
|
+
className: "dt-empty",
|
|
1246
|
+
children: loading ? "Loading…" : "Pick a node on the map below to begin."
|
|
1247
|
+
})]
|
|
1248
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
1249
|
+
className: "dt-now",
|
|
1250
|
+
"data-state": detail.node.state,
|
|
1251
|
+
children: [
|
|
1252
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1253
|
+
className: "dt-now-eyebrow",
|
|
1254
|
+
children: "Now learning"
|
|
1255
|
+
}),
|
|
1256
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
1257
|
+
className: "dt-now-title",
|
|
1258
|
+
children: detail.node.title
|
|
1259
|
+
}),
|
|
1260
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1261
|
+
className: "dt-now-meta",
|
|
1262
|
+
children: [
|
|
1263
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1264
|
+
className: `dt-tab-state dt-state-${detail.node.state}`,
|
|
1265
|
+
children: detail.node.state
|
|
1266
|
+
}),
|
|
1267
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1268
|
+
className: "dt-now-rel",
|
|
1269
|
+
children: relationLabel(detail.node.relation)
|
|
1270
|
+
}),
|
|
1271
|
+
detail.parent !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1272
|
+
className: "dt-now-rel",
|
|
1273
|
+
children: ["in ", detail.parent.title]
|
|
1274
|
+
})
|
|
1275
|
+
]
|
|
1276
|
+
}),
|
|
1277
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1278
|
+
className: "dt-now-note",
|
|
1279
|
+
children: stateExplanation(detail.node.state)
|
|
1280
|
+
}),
|
|
1281
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1282
|
+
type: "button",
|
|
1283
|
+
className: "dt-primary dt-now-action",
|
|
1284
|
+
onClick: state.start,
|
|
1285
|
+
disabled: starting || shownId === null,
|
|
1286
|
+
children: starting ? "Starting…" : focus !== null && focus.nodeId === shownId ? "Keep going" : "Start learning"
|
|
1287
|
+
}),
|
|
1288
|
+
note !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1289
|
+
className: "dt-caption",
|
|
1290
|
+
children: note
|
|
1291
|
+
}),
|
|
1292
|
+
error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1293
|
+
className: "dt-empty",
|
|
1294
|
+
children: error
|
|
1295
|
+
})
|
|
1296
|
+
]
|
|
1297
|
+
}),
|
|
1298
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1299
|
+
className: "dt-lesson",
|
|
1300
|
+
children: [
|
|
1301
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1302
|
+
className: "dt-tab-section",
|
|
1303
|
+
children: "Learning surface"
|
|
1304
|
+
}),
|
|
1305
|
+
handoff !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffLine, {
|
|
1306
|
+
handoff,
|
|
1307
|
+
onRetry: () => state.continueTo(handoff.targetNodeId)
|
|
1308
|
+
}),
|
|
1309
|
+
lesson === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1310
|
+
className: "dt-empty",
|
|
1311
|
+
children: focus !== null ? "The tutor is preparing this node…" : "Press Start learning and the teaching appears here, while the chat stays open beside it."
|
|
1312
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
|
|
1313
|
+
className: "dt-lesson-body",
|
|
1314
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1315
|
+
className: "dt-lesson-head",
|
|
1316
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1317
|
+
className: "dt-lesson-title",
|
|
1318
|
+
children: lesson.title
|
|
1319
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1320
|
+
className: "dt-origin",
|
|
1321
|
+
children: lesson.origin
|
|
1322
|
+
})]
|
|
1323
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LessonBody, { blocks: lesson.blocks })]
|
|
1324
|
+
})
|
|
1325
|
+
]
|
|
1326
|
+
}),
|
|
1327
|
+
nextStep !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NextStepCard, {
|
|
1328
|
+
nextStep,
|
|
1329
|
+
busy: starting,
|
|
1330
|
+
onContinue: () => {
|
|
1331
|
+
const target = nextStep.targetNodeId ?? nextStep.fromNodeId;
|
|
1332
|
+
select(target);
|
|
1333
|
+
state.continueTo(target);
|
|
1334
|
+
}
|
|
1335
|
+
}),
|
|
1336
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactMap, {
|
|
1337
|
+
nodes: rows,
|
|
1338
|
+
selectedId: shownId,
|
|
1339
|
+
focusNodeId: focus?.nodeId ?? null,
|
|
1340
|
+
onSelect: select
|
|
1341
|
+
}),
|
|
1342
|
+
dataNote !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1343
|
+
className: "dt-caption",
|
|
1344
|
+
children: dataNote
|
|
1345
|
+
}),
|
|
1346
|
+
detail !== null && detail.node.evidence.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1347
|
+
className: "dt-tab-section",
|
|
1348
|
+
children: ["Evidence ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: detail.node.evidence.length })]
|
|
1349
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
1350
|
+
className: "dt-evidence",
|
|
1351
|
+
children: detail.node.evidence.map((entry, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
|
|
1352
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1353
|
+
className: "dt-ev-kind",
|
|
1354
|
+
children: entry.kind
|
|
1355
|
+
}),
|
|
1356
|
+
entry.readiness !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1357
|
+
className: "dt-ev-readiness",
|
|
1358
|
+
children: entry.readiness.replace(/-/g, " ")
|
|
1359
|
+
}),
|
|
1360
|
+
entry.note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1361
|
+
className: "dt-ev-note",
|
|
1362
|
+
children: entry.note
|
|
1363
|
+
})
|
|
1364
|
+
] }, index))
|
|
1365
|
+
})] }),
|
|
1366
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataFooter, {
|
|
1367
|
+
onExport: state.exportData,
|
|
1368
|
+
onReset: state.resetAll
|
|
1369
|
+
})
|
|
1370
|
+
]
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
//#endregion
|
|
1374
|
+
//#region src/client/styles.ts
|
|
1375
|
+
/**
|
|
1376
|
+
* The panel's stylesheet.
|
|
1377
|
+
*
|
|
1378
|
+
* Injected as a `<style data-plugin>` element rather than imported as CSS,
|
|
1379
|
+
* because styles never travel through the client bundler: the module table
|
|
1380
|
+
* serves one JavaScript file per plugin, so a separate CSS artifact has nowhere
|
|
1381
|
+
* to live. This is what the published UI plugins do too.
|
|
1382
|
+
*
|
|
1383
|
+
* The web styling guide forbids Tailwind and component libraries here, and
|
|
1384
|
+
* recommends the design system's semantic custom properties. Every one of those
|
|
1385
|
+
* tokens is used with a fallback, so the same stylesheet renders correctly in
|
|
1386
|
+
* the standalone preview where no DSH theme is present.
|
|
1387
|
+
*/
|
|
1388
|
+
/** Marker attribute so the sheet is injected exactly once, and is findable. */
|
|
1389
|
+
const STYLE_ATTR = "data-diagnostic-tutor-style";
|
|
1390
|
+
const CSS = `.dt-root {
|
|
1391
|
+
--dt-bg: var(--dsw-alias-bg-base, #ffffff);
|
|
1392
|
+
--dt-surface: var(--dsw-alias-bg-elevated, #f7f8fa);
|
|
1393
|
+
--dt-sunken: var(--dsw-alias-bg-sunken, #f1f3f6);
|
|
1394
|
+
--dt-border: var(--dsw-alias-border-secondary, #e3e6ea);
|
|
1395
|
+
--dt-border-strong: var(--dsw-alias-border-primary, #cfd4dc);
|
|
1396
|
+
--dt-text: var(--dsw-alias-label-primary, #1c1f23);
|
|
1397
|
+
--dt-muted: var(--dsw-alias-label-secondary, #6b7280);
|
|
1398
|
+
--dt-accent: var(--dsw-alias-brand-primary, #4f46e5);
|
|
1399
|
+
--dt-good: #1f8b4c;
|
|
1400
|
+
--dt-warn: #b7791f;
|
|
1401
|
+
--dt-stop: #c0392b;
|
|
1402
|
+
--dt-radius: 10px;
|
|
1403
|
+
|
|
1404
|
+
display: grid;
|
|
1405
|
+
grid-template-columns: minmax(260px, 300px) minmax(280px, 1fr) minmax(320px, 1.15fr);
|
|
1406
|
+
gap: 0;
|
|
1407
|
+
height: 100%;
|
|
1408
|
+
min-height: 0;
|
|
1409
|
+
background: var(--dt-bg);
|
|
1410
|
+
color: var(--dt-text);
|
|
1411
|
+
font-size: 13px;
|
|
1412
|
+
line-height: 1.6;
|
|
1413
|
+
text-align: left;
|
|
1414
|
+
}
|
|
1415
|
+
@media (max-width: 1080px) {
|
|
1416
|
+
.dt-root { grid-template-columns: 1fr; height: auto; }
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
.dt-pane { min-width: 0; min-height: 0; overflow: auto; padding: 16px 18px 40px; }
|
|
1420
|
+
.dt-pane + .dt-pane { border-left: 1px solid var(--dt-border); }
|
|
1421
|
+
@media (max-width: 1080px) {
|
|
1422
|
+
.dt-pane + .dt-pane { border-left: 0; border-top: 1px solid var(--dt-border); }
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/* ---- shared type ------------------------------------------------------- */
|
|
1426
|
+
.dt-eyebrow,
|
|
1427
|
+
.dt-tab-section,
|
|
1428
|
+
.dt-now-eyebrow,
|
|
1429
|
+
.dt-welcome-eyebrow,
|
|
1430
|
+
.dt-block-label,
|
|
1431
|
+
.dt-next-label {
|
|
1432
|
+
font-size: 10px;
|
|
1433
|
+
letter-spacing: .1em;
|
|
1434
|
+
text-transform: uppercase;
|
|
1435
|
+
color: var(--dt-muted);
|
|
1436
|
+
margin: 0;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
.dt-course-title { font-size: 17px; font-weight: 640; margin: 0 0 6px; letter-spacing: -.01em; }
|
|
1440
|
+
.dt-goal {
|
|
1441
|
+
margin: 0 0 16px; padding: 9px 11px; border-radius: var(--dt-radius);
|
|
1442
|
+
background: var(--dt-surface); border: 1px solid var(--dt-border);
|
|
1443
|
+
color: var(--dt-muted); font-size: 12px;
|
|
1444
|
+
}
|
|
1445
|
+
.dt-goal b { color: var(--dt-text); font-weight: 560; }
|
|
1446
|
+
|
|
1447
|
+
.dt-section-title {
|
|
1448
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
1449
|
+
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
|
1450
|
+
color: var(--dt-muted); margin: 22px 0 8px;
|
|
1451
|
+
}
|
|
1452
|
+
.dt-section-title span { text-transform: none; letter-spacing: 0; font-size: 11px; }
|
|
1453
|
+
|
|
1454
|
+
.dt-caption { color: var(--dt-muted); font-size: 11px; margin: 6px 0 0; }
|
|
1455
|
+
.dt-empty { color: var(--dt-muted); font-size: 12.5px; margin: 8px 0; }
|
|
1456
|
+
.dt-cta-hint { margin-top: 14px; line-height: 1.6; }
|
|
1457
|
+
|
|
1458
|
+
/* ---- first use --------------------------------------------------------- */
|
|
1459
|
+
/* The full-page variant has no columns to fill, so the question centres. */
|
|
1460
|
+
.dt-root-welcome { grid-template-columns: 1fr; place-items: center; }
|
|
1461
|
+
.dt-root-welcome .dt-welcome { max-width: 520px; padding: 8px 24px; }
|
|
1462
|
+
.dt-welcome { padding: 28px 4px 8px; }
|
|
1463
|
+
.dt-welcome-eyebrow { color: var(--dt-accent); }
|
|
1464
|
+
.dt-welcome-title {
|
|
1465
|
+
font-size: 21px; font-weight: 660; letter-spacing: -.015em;
|
|
1466
|
+
margin: 8px 0 10px; line-height: 1.25;
|
|
1467
|
+
}
|
|
1468
|
+
.dt-welcome-body { margin: 0 0 18px; color: var(--dt-muted); font-size: 13px; line-height: 1.65; }
|
|
1469
|
+
.dt-welcome-sample {
|
|
1470
|
+
border: 1px solid var(--dt-accent); border-radius: var(--dt-radius);
|
|
1471
|
+
background: var(--dt-surface); padding: 11px 13px;
|
|
1472
|
+
}
|
|
1473
|
+
.dt-welcome-sample-label {
|
|
1474
|
+
display: block; font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
|
1475
|
+
color: var(--dt-accent); margin-bottom: 6px;
|
|
1476
|
+
}
|
|
1477
|
+
.dt-welcome-sample-text { font-size: 13px; line-height: 1.55; }
|
|
1478
|
+
.dt-welcome-note {
|
|
1479
|
+
margin: 18px 0 0; padding-top: 14px; border-top: 1px solid var(--dt-border);
|
|
1480
|
+
color: var(--dt-muted); font-size: 11.5px; line-height: 1.6;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/* Shown when nothing will teach: honest, actionable, and not a crash. */
|
|
1484
|
+
.dt-notice {
|
|
1485
|
+
margin: 16px 0 0; padding: 11px 13px; border-radius: var(--dt-radius);
|
|
1486
|
+
border: 1px solid var(--dt-warn);
|
|
1487
|
+
background: color-mix(in srgb, var(--dt-warn) 8%, var(--dt-bg));
|
|
1488
|
+
color: var(--dt-text); font-size: 12px; line-height: 1.6;
|
|
1489
|
+
}
|
|
1490
|
+
.dt-notice b { font-weight: 640; }
|
|
1491
|
+
|
|
1492
|
+
/* ---- learner data controls --------------------------------------------- */
|
|
1493
|
+
/* Quiet by default: these are rights, not calls to action. They sit below the
|
|
1494
|
+
teaching so they are findable without competing with it. */
|
|
1495
|
+
.dt-data {
|
|
1496
|
+
margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--dt-border);
|
|
1497
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
|
1498
|
+
}
|
|
1499
|
+
.dt-quiet {
|
|
1500
|
+
padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dt-border);
|
|
1501
|
+
background: transparent; color: var(--dt-muted);
|
|
1502
|
+
font: inherit; font-size: 11.5px; cursor: pointer;
|
|
1503
|
+
}
|
|
1504
|
+
.dt-quiet:hover { background: var(--dt-surface); color: var(--dt-text); }
|
|
1505
|
+
.dt-quiet:disabled { opacity: .5; cursor: default; }
|
|
1506
|
+
.dt-data-note { flex-basis: 100%; color: var(--dt-muted); font-size: 11px; line-height: 1.5; }
|
|
1507
|
+
.dt-data-confirm {
|
|
1508
|
+
border-top-color: var(--dt-stop);
|
|
1509
|
+
background: color-mix(in srgb, var(--dt-stop) 6%, transparent);
|
|
1510
|
+
border-radius: var(--dt-radius); padding: 12px 13px; margin-top: 26px;
|
|
1511
|
+
}
|
|
1512
|
+
.dt-data-ask { margin: 0 0 10px; font-size: 12px; line-height: 1.6; }
|
|
1513
|
+
.dt-data-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
1514
|
+
.dt-danger {
|
|
1515
|
+
padding: 5px 12px; border-radius: 7px; border: 1px solid var(--dt-stop);
|
|
1516
|
+
background: var(--dt-stop); color: #fff;
|
|
1517
|
+
font: inherit; font-size: 11.5px; font-weight: 550; cursor: pointer;
|
|
1518
|
+
}
|
|
1519
|
+
.dt-danger:disabled { opacity: .55; cursor: default; }
|
|
1520
|
+
|
|
1521
|
+
/* ---- now learning ------------------------------------------------------ */
|
|
1522
|
+
.dt-now {
|
|
1523
|
+
padding: 13px 14px 14px;
|
|
1524
|
+
border: 1px solid var(--dt-border);
|
|
1525
|
+
border-radius: var(--dt-radius);
|
|
1526
|
+
background: var(--dt-surface);
|
|
1527
|
+
}
|
|
1528
|
+
/* The state is the headline fact about the node, so it colours the card edge. */
|
|
1529
|
+
.dt-now[data-state="blocked"] { border-left: 3px solid var(--dt-stop); }
|
|
1530
|
+
.dt-now[data-state="weak"] { border-left: 3px solid var(--dt-warn); }
|
|
1531
|
+
.dt-now[data-state="confirmed"] { border-left: 3px solid var(--dt-good); }
|
|
1532
|
+
.dt-now[data-state="checked"] { border-left: 3px solid #0f8f8f; }
|
|
1533
|
+
.dt-now[data-state="practiced"] { border-left: 3px solid #5b53d6; }
|
|
1534
|
+
.dt-now[data-state="explained"] { border-left: 3px solid #2f6feb; }
|
|
1535
|
+
.dt-now[data-state="unconfirmed"] { border-left: 3px solid var(--dt-border-strong); }
|
|
1536
|
+
|
|
1537
|
+
.dt-now-eyebrow { color: var(--dt-muted); }
|
|
1538
|
+
.dt-now-title {
|
|
1539
|
+
font-size: 17px; font-weight: 650; letter-spacing: -.015em;
|
|
1540
|
+
margin: 6px 0 8px; line-height: 1.28; overflow-wrap: anywhere;
|
|
1541
|
+
}
|
|
1542
|
+
.dt-now-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 9px; }
|
|
1543
|
+
.dt-now-rel { color: var(--dt-muted); font-size: 11.5px; }
|
|
1544
|
+
.dt-now-note { margin: 0; color: var(--dt-muted); font-size: 12px; line-height: 1.6; }
|
|
1545
|
+
.dt-now-action { margin-top: 13px; width: 100%; }
|
|
1546
|
+
|
|
1547
|
+
/* ---- diagnosis map ----------------------------------------------------- */
|
|
1548
|
+
.dt-tree { display: flex; flex-direction: column; gap: 1px; }
|
|
1549
|
+
.dt-tab-node {
|
|
1550
|
+
display: grid; grid-template-columns: auto 1fr auto;
|
|
1551
|
+
align-items: center; gap: 8px; width: 100%;
|
|
1552
|
+
padding: 6px 8px; margin: 0; border-radius: 7px;
|
|
1553
|
+
border: 1px solid transparent; border-left: 3px solid transparent;
|
|
1554
|
+
background: transparent; color: inherit; font: inherit;
|
|
1555
|
+
text-align: left; cursor: pointer; position: relative;
|
|
1556
|
+
}
|
|
1557
|
+
/* Depth as a drawn guide rather than only as indentation: a nested node should
|
|
1558
|
+
read as nested at a glance. */
|
|
1559
|
+
.dt-tab-node[data-depth]::before {
|
|
1560
|
+
content: ''; position: absolute; left: 4px; top: 0; bottom: 0;
|
|
1561
|
+
width: 1px; background: var(--dt-border);
|
|
1562
|
+
}
|
|
1563
|
+
.dt-tab-node[data-depth="2"]::before { left: 18px; }
|
|
1564
|
+
.dt-tab-node[data-depth="3"]::before { left: 32px; }
|
|
1565
|
+
.dt-tab-node:hover { background: var(--dt-surface); }
|
|
1566
|
+
.dt-tab-node[aria-current="true"] {
|
|
1567
|
+
background: var(--dt-surface); border-color: var(--dt-border-strong);
|
|
1568
|
+
border-left-color: var(--dt-accent);
|
|
1569
|
+
}
|
|
1570
|
+
/* A blocker should be findable without reading every row. */
|
|
1571
|
+
.dt-tab-node[data-attention] { background: rgba(192, 57, 43, .05); }
|
|
1572
|
+
.dt-tab-node[data-attention]:hover { background: rgba(192, 57, 43, .09); }
|
|
1573
|
+
.dt-tab-node-title { min-width: 0; overflow-wrap: anywhere; line-height: 1.45; }
|
|
1574
|
+
.dt-tab-node[data-depth="0"] > .dt-tab-node-title { font-weight: 600; }
|
|
1575
|
+
|
|
1576
|
+
.dt-dot {
|
|
1577
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
1578
|
+
border: 1.5px solid currentColor; background: transparent; flex: none;
|
|
1579
|
+
}
|
|
1580
|
+
.dt-dot[data-filled="true"] { background: currentColor; }
|
|
1581
|
+
|
|
1582
|
+
.dt-state-unconfirmed { color: #8b93a1; }
|
|
1583
|
+
.dt-state-explained { color: #2f6feb; }
|
|
1584
|
+
.dt-state-practiced { color: #5b53d6; }
|
|
1585
|
+
.dt-state-checked { color: #0f8f8f; }
|
|
1586
|
+
.dt-state-weak { color: var(--dt-warn); }
|
|
1587
|
+
.dt-state-blocked { color: var(--dt-stop); }
|
|
1588
|
+
.dt-state-confirmed { color: var(--dt-good); }
|
|
1589
|
+
|
|
1590
|
+
/* A tinted chip scans far better than coloured words alone. */
|
|
1591
|
+
.dt-tab-state {
|
|
1592
|
+
font-size: 10.5px; font-weight: 560; letter-spacing: .01em;
|
|
1593
|
+
padding: 1px 7px; border-radius: 999px;
|
|
1594
|
+
border: 1px solid currentColor; white-space: nowrap;
|
|
1595
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
1596
|
+
}
|
|
1597
|
+
.dt-state { font-style: normal; }
|
|
1598
|
+
|
|
1599
|
+
/* ---- node detail (full panel) ------------------------------------------ */
|
|
1600
|
+
.dt-detail h2 { font-size: 16px; margin: 0 0 8px; font-weight: 650; letter-spacing: -.01em; }
|
|
1601
|
+
.dt-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 12px; }
|
|
1602
|
+
.dt-chip {
|
|
1603
|
+
font-size: 11px; color: var(--dt-muted);
|
|
1604
|
+
border: 1px solid var(--dt-border); border-radius: 999px; padding: 1px 8px;
|
|
1605
|
+
}
|
|
1606
|
+
.dt-why { margin: 0 0 14px; color: var(--dt-muted); font-size: 12.5px; line-height: 1.6; }
|
|
1607
|
+
.dt-node {
|
|
1608
|
+
display: grid; grid-template-columns: auto 1fr auto;
|
|
1609
|
+
align-items: center; gap: 8px; width: 100%;
|
|
1610
|
+
padding: 6px 8px; margin: 1px 0; border-radius: 7px;
|
|
1611
|
+
border: 1px solid transparent; background: transparent;
|
|
1612
|
+
color: inherit; font: inherit; text-align: left; cursor: pointer;
|
|
1613
|
+
}
|
|
1614
|
+
.dt-node:hover { background: var(--dt-surface); }
|
|
1615
|
+
.dt-node[aria-current="true"] { background: var(--dt-surface); border-color: var(--dt-border-strong); }
|
|
1616
|
+
.dt-node-title { min-width: 0; overflow-wrap: anywhere; }
|
|
1617
|
+
.dt-node-rel { display: block; color: var(--dt-muted); font-size: 11px; font-weight: 400; }
|
|
1618
|
+
|
|
1619
|
+
/* ---- evidence ---------------------------------------------------------- */
|
|
1620
|
+
/* Compact on purpose: this is a record, not a reading surface. One row per
|
|
1621
|
+
entry, with the note clamped so a long one cannot bury the next. */
|
|
1622
|
+
.dt-evidence { list-style: none; margin: 0; padding: 0; }
|
|
1623
|
+
.dt-evidence li {
|
|
1624
|
+
display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;
|
|
1625
|
+
padding: 7px 0 7px 10px; border-left: 2px solid var(--dt-border);
|
|
1626
|
+
font-size: 11.5px; line-height: 1.5;
|
|
1627
|
+
}
|
|
1628
|
+
.dt-evidence li + li { margin-top: 2px; }
|
|
1629
|
+
.dt-ev-kind {
|
|
1630
|
+
font-weight: 600; font-size: 11px; color: var(--dt-text);
|
|
1631
|
+
text-transform: capitalize;
|
|
1632
|
+
}
|
|
1633
|
+
.dt-ev-readiness {
|
|
1634
|
+
font-size: 10.5px; color: var(--dt-muted);
|
|
1635
|
+
border: 1px solid var(--dt-border); border-radius: 999px; padding: 0 6px;
|
|
1636
|
+
justify-self: start;
|
|
1637
|
+
}
|
|
1638
|
+
.dt-ev-note {
|
|
1639
|
+
grid-column: 1 / -1; color: var(--dt-muted);
|
|
1640
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
|
1641
|
+
overflow: hidden;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/* ---- buttons ----------------------------------------------------------- */
|
|
1645
|
+
.dt-primary {
|
|
1646
|
+
padding: 7px 14px; border-radius: 8px; border: 1px solid transparent;
|
|
1647
|
+
background: var(--dt-accent); color: #fff;
|
|
1648
|
+
font: inherit; font-weight: 550; cursor: pointer;
|
|
1649
|
+
}
|
|
1650
|
+
.dt-primary:hover { filter: brightness(1.07); }
|
|
1651
|
+
.dt-primary:disabled { opacity: .5; cursor: default; filter: none; }
|
|
1652
|
+
.dt-secondary {
|
|
1653
|
+
margin-top: 8px; padding: 6px 12px; border-radius: 8px;
|
|
1654
|
+
border: 1px solid var(--dt-border); background: transparent;
|
|
1655
|
+
color: inherit; font: inherit; font-size: 12px; cursor: pointer;
|
|
1656
|
+
}
|
|
1657
|
+
.dt-secondary:hover { background: var(--dt-surface); }
|
|
1658
|
+
|
|
1659
|
+
/* ---- lesson ------------------------------------------------------------ */
|
|
1660
|
+
.dt-lesson { margin-top: 20px; }
|
|
1661
|
+
.dt-lesson-body {
|
|
1662
|
+
border: 1px solid var(--dt-border); border-radius: var(--dt-radius);
|
|
1663
|
+
background: var(--dt-bg); padding: 16px 16px 6px;
|
|
1664
|
+
}
|
|
1665
|
+
.dt-lesson-head {
|
|
1666
|
+
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
|
|
1667
|
+
padding-bottom: 10px; margin-bottom: 14px;
|
|
1668
|
+
border-bottom: 1px solid var(--dt-border);
|
|
1669
|
+
}
|
|
1670
|
+
.dt-lesson-title { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: -.01em; line-height: 1.35; }
|
|
1671
|
+
.dt-tab-lesson-title { font-weight: 600; }
|
|
1672
|
+
.dt-origin {
|
|
1673
|
+
flex: none; font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
|
1674
|
+
color: var(--dt-muted); border: 1px solid var(--dt-border);
|
|
1675
|
+
border-radius: 999px; padding: 1px 7px;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/* ---- blocks ------------------------------------------------------------ */
|
|
1679
|
+
.dt-block { margin: 0 0 18px; }
|
|
1680
|
+
/* A readable measure: full-width prose in a docked panel is tiring. */
|
|
1681
|
+
.dt-block-md { margin: 0 0 11px; line-height: 1.66; max-width: 62ch; }
|
|
1682
|
+
.dt-block-md:last-child { margin-bottom: 0; }
|
|
1683
|
+
.dt-block-md strong { font-weight: 640; }
|
|
1684
|
+
|
|
1685
|
+
/* Headings the teaching brain writes inline as ### become real headings. */
|
|
1686
|
+
.dt-md-h2 {
|
|
1687
|
+
font-size: 15px; font-weight: 650; letter-spacing: -.01em;
|
|
1688
|
+
margin: 20px 0 9px; line-height: 1.35;
|
|
1689
|
+
}
|
|
1690
|
+
.dt-md-h2:first-child, .dt-md-h3:first-child { margin-top: 0; }
|
|
1691
|
+
.dt-md-h3 {
|
|
1692
|
+
font-size: 13.5px; font-weight: 650; color: var(--dt-text);
|
|
1693
|
+
margin: 18px 0 7px; line-height: 1.4;
|
|
1694
|
+
}
|
|
1695
|
+
.dt-md-ul, .dt-md-ol { margin: 0 0 12px; padding-left: 20px; max-width: 62ch; }
|
|
1696
|
+
.dt-md-ul li, .dt-md-ol li { margin-bottom: 5px; line-height: 1.6; }
|
|
1697
|
+
.dt-md-ul { list-style: none; padding-left: 4px; }
|
|
1698
|
+
.dt-md-ul li { position: relative; padding-left: 16px; }
|
|
1699
|
+
.dt-md-ul li::before {
|
|
1700
|
+
content: ''; position: absolute; left: 3px; top: .62em;
|
|
1701
|
+
width: 4px; height: 4px; border-radius: 50%; background: var(--dt-border-strong);
|
|
1702
|
+
}
|
|
1703
|
+
.dt-md-ol { list-style: decimal; }
|
|
1704
|
+
.dt-md-ol li::marker { color: var(--dt-muted); font-variant-numeric: tabular-nums; }
|
|
1705
|
+
|
|
1706
|
+
.dt-block-label {
|
|
1707
|
+
color: var(--dt-accent); margin-bottom: 7px;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/* Worked example: a distinct panel, because it is a different kind of reading
|
|
1711
|
+
from the prose around it. */
|
|
1712
|
+
.dt-block-example {
|
|
1713
|
+
border: 1px solid var(--dt-border); border-left: 3px solid var(--dt-border-strong);
|
|
1714
|
+
border-radius: var(--dt-radius); background: var(--dt-surface);
|
|
1715
|
+
padding: 12px 14px 13px;
|
|
1716
|
+
}
|
|
1717
|
+
.dt-block-example h4 { margin: 0 0 9px; font-size: 13px; font-weight: 620; line-height: 1.4; }
|
|
1718
|
+
.dt-block-example ol { margin: 0; padding-left: 19px; max-width: 62ch; }
|
|
1719
|
+
.dt-block-example li { margin-bottom: 6px; line-height: 1.6; }
|
|
1720
|
+
.dt-block-example li::marker { color: var(--dt-muted); font-variant-numeric: tabular-nums; }
|
|
1721
|
+
.dt-takeaway {
|
|
1722
|
+
margin: 11px 0 0; padding-top: 9px; border-top: 1px dashed var(--dt-border);
|
|
1723
|
+
font-size: 12px; color: var(--dt-text);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/* Diagram: monospace deserves its own frame so it reads as a figure. */
|
|
1727
|
+
.dt-block-diagram { padding: 0; }
|
|
1728
|
+
.dt-pre {
|
|
1729
|
+
margin: 0; padding: 12px 14px; overflow-x: auto;
|
|
1730
|
+
background: var(--dt-sunken); border: 1px solid var(--dt-border);
|
|
1731
|
+
border-radius: var(--dt-radius);
|
|
1732
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1733
|
+
font-size: 11.5px; line-height: 1.6; white-space: pre;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/* Check: this is the moment the lesson hands over to the learner, so it gets
|
|
1737
|
+
the strongest treatment on the surface. */
|
|
1738
|
+
.dt-block-check {
|
|
1739
|
+
border: 1px solid var(--dt-accent); border-radius: var(--dt-radius);
|
|
1740
|
+
background: color-mix(in srgb, var(--dt-accent) 5%, var(--dt-bg));
|
|
1741
|
+
padding: 0; overflow: hidden;
|
|
1742
|
+
}
|
|
1743
|
+
.dt-check-head {
|
|
1744
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
1745
|
+
padding: 9px 14px; background: var(--dt-accent); color: #fff;
|
|
1746
|
+
}
|
|
1747
|
+
.dt-check-tag {
|
|
1748
|
+
font-size: 10px; letter-spacing: .12em; text-transform: uppercase; font-weight: 650;
|
|
1749
|
+
}
|
|
1750
|
+
.dt-check-where { font-size: 11px; opacity: .9; }
|
|
1751
|
+
.dt-check-body { padding: 13px 14px 4px; }
|
|
1752
|
+
.dt-check-body .dt-block-md:last-child { margin-bottom: 11px; }
|
|
1753
|
+
.dt-check-hint {
|
|
1754
|
+
margin: 0; padding: 9px 14px 11px; border-top: 1px dashed var(--dt-border);
|
|
1755
|
+
font-size: 11.5px; color: var(--dt-muted);
|
|
1756
|
+
}
|
|
1757
|
+
.dt-check-hint span {
|
|
1758
|
+
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
|
1759
|
+
margin-right: 7px; color: var(--dt-accent);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* ---- math (styled, not typeset) ---------------------------------------- */
|
|
1763
|
+
.dt-math {
|
|
1764
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1765
|
+
font-size: .95em; padding: 0 3px; border-radius: 4px;
|
|
1766
|
+
background: var(--dt-sunken); white-space: nowrap;
|
|
1767
|
+
}
|
|
1768
|
+
.dt-math-block {
|
|
1769
|
+
margin: 0 0 12px; padding: 11px 13px; overflow-x: auto;
|
|
1770
|
+
background: var(--dt-sunken); border: 1px solid var(--dt-border);
|
|
1771
|
+
border-radius: var(--dt-radius);
|
|
1772
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1773
|
+
font-size: 12px; white-space: pre-wrap;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/* ---- handoff progress -------------------------------------------------- */
|
|
1777
|
+
.dt-handoff {
|
|
1778
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
1779
|
+
margin: 0 0 12px; padding: 8px 11px; border-radius: 8px;
|
|
1780
|
+
background: var(--dt-surface); border: 1px solid var(--dt-border);
|
|
1781
|
+
font-size: 12px;
|
|
1782
|
+
}
|
|
1783
|
+
.dt-handoff-dot {
|
|
1784
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--dt-accent);
|
|
1785
|
+
animation: dt-pulse 1.4s ease-in-out infinite;
|
|
1786
|
+
}
|
|
1787
|
+
.dt-handoff[data-phase="lesson-ready"] {
|
|
1788
|
+
border-color: color-mix(in srgb, var(--dt-good) 45%, var(--dt-border));
|
|
1789
|
+
background: color-mix(in srgb, var(--dt-good) 7%, var(--dt-bg));
|
|
1790
|
+
}
|
|
1791
|
+
.dt-handoff[data-phase="lesson-ready"] .dt-handoff-dot { background: var(--dt-good); animation: none; }
|
|
1792
|
+
.dt-handoff[data-phase="failed"] .dt-handoff-dot,
|
|
1793
|
+
.dt-handoff[data-phase="stalled"] .dt-handoff-dot { background: var(--dt-stop); animation: none; }
|
|
1794
|
+
@keyframes dt-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .3 } }
|
|
1795
|
+
.dt-handoff-label { font-weight: 550; }
|
|
1796
|
+
.dt-handoff-time, .dt-handoff-try, .dt-handoff-detail { color: var(--dt-muted); font-size: 11px; }
|
|
1797
|
+
.dt-handoff-detail { flex-basis: 100%; }
|
|
1798
|
+
.dt-handoff-retry {
|
|
1799
|
+
margin-left: auto; padding: 3px 10px; border-radius: 6px;
|
|
1800
|
+
border: 1px solid var(--dt-border); background: transparent;
|
|
1801
|
+
color: inherit; font: inherit; font-size: 11px; cursor: pointer;
|
|
1802
|
+
}
|
|
1803
|
+
.dt-handoff-retry:hover { background: var(--dt-bg); }
|
|
1804
|
+
@media (prefers-reduced-motion: reduce) { .dt-handoff-dot { animation: none } }
|
|
1805
|
+
|
|
1806
|
+
/* ---- next best step ---------------------------------------------------- */
|
|
1807
|
+
/* The conclusion of a node. It should be the most deliberate thing on the
|
|
1808
|
+
surface: what just finished, where it sends you, why, and one button. */
|
|
1809
|
+
.dt-next {
|
|
1810
|
+
margin: 20px 0 4px; padding: 14px 15px 15px; border-radius: var(--dt-radius);
|
|
1811
|
+
border: 1px solid var(--dt-accent);
|
|
1812
|
+
background: color-mix(in srgb, var(--dt-accent) 6%, var(--dt-bg));
|
|
1813
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
|
1814
|
+
}
|
|
1815
|
+
.dt-next-label {
|
|
1816
|
+
display: flex; align-items: center; gap: 6px;
|
|
1817
|
+
color: var(--dt-accent); font-weight: 650; margin-bottom: 10px;
|
|
1818
|
+
}
|
|
1819
|
+
.dt-next-arrow { font-size: 12px; }
|
|
1820
|
+
.dt-next-from { margin: 0 0 8px; color: var(--dt-muted); font-size: 11.5px; }
|
|
1821
|
+
.dt-next-tick { color: var(--dt-good); margin-right: 5px; }
|
|
1822
|
+
.dt-next-target {
|
|
1823
|
+
margin: 0 0 10px; font-size: 14.5px; line-height: 1.4; letter-spacing: -.01em;
|
|
1824
|
+
}
|
|
1825
|
+
.dt-next-target b { font-weight: 660; }
|
|
1826
|
+
.dt-next-why { margin: 0; font-size: 12px; line-height: 1.6; color: var(--dt-text); }
|
|
1827
|
+
.dt-next-why-label { color: var(--dt-muted); }
|
|
1828
|
+
.dt-next .dt-primary { margin-top: 14px; width: 100%; }
|
|
1829
|
+
|
|
1830
|
+
/* ---- docked tab -------------------------------------------------------- */
|
|
1831
|
+
.dt-tab { padding: 14px 14px 40px; font-size: 12.5px; color: var(--dt-text); text-align: left; }
|
|
1832
|
+
.dt-tab-head { display: flex; align-items: baseline; gap: 8px; justify-content: space-between; }
|
|
1833
|
+
.dt-tab-title { font-size: 15px; font-weight: 650; margin: 0; }
|
|
1834
|
+
.dt-tab-meta { color: var(--dt-muted); font-size: 11px; margin: 4px 0 8px; }
|
|
1835
|
+
.dt-tab-action { margin-top: 10px; }
|
|
1836
|
+
.dt-tab-section {
|
|
1837
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
1838
|
+
margin: 24px 0 8px; padding-top: 14px;
|
|
1839
|
+
border-top: 1px solid var(--dt-border);
|
|
1840
|
+
}
|
|
1841
|
+
.dt-tab-section span { text-transform: none; letter-spacing: 0; font-size: 11px; }
|
|
1842
|
+
|
|
1843
|
+
.dt-block-unknown {
|
|
1844
|
+
border: 1px dashed var(--dt-border); border-radius: 8px;
|
|
1845
|
+
padding: 10px 12px; color: var(--dt-muted);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
`;
|
|
1849
|
+
/**
|
|
1850
|
+
* Inject the stylesheet once.
|
|
1851
|
+
*
|
|
1852
|
+
* @returns a disposer that removes the element, so an unload leaves no residue.
|
|
1853
|
+
*/
|
|
1854
|
+
function injectStyles() {
|
|
1855
|
+
if (document.querySelector(`style[data-diagnostic-tutor-style]`)) return () => {};
|
|
1856
|
+
const element = document.createElement("style");
|
|
1857
|
+
element.setAttribute(STYLE_ATTR, "");
|
|
1858
|
+
element.textContent = CSS;
|
|
1859
|
+
document.head.appendChild(element);
|
|
1860
|
+
return () => element.remove();
|
|
1861
|
+
}
|
|
1862
|
+
//#endregion
|
|
1863
|
+
//#region src/client/index.tsx
|
|
1864
|
+
/** Client module name; distinct from the host plugin's name. */
|
|
1865
|
+
const name = "diagnostic-tutor-client";
|
|
1866
|
+
/**
|
|
1867
|
+
* `slots` is the only service needed.
|
|
1868
|
+
*
|
|
1869
|
+
* Declared here rather than in the host half because it is a *client* service:
|
|
1870
|
+
* the browser half runs in its own Cordis context against the page's registry.
|
|
1871
|
+
*/
|
|
1872
|
+
const inject = [
|
|
1873
|
+
"slots",
|
|
1874
|
+
"layout",
|
|
1875
|
+
"sidebarRightTabs"
|
|
1876
|
+
];
|
|
1877
|
+
/**
|
|
1878
|
+
* Identifier shared by the sidebar entry and the main panel.
|
|
1879
|
+
*
|
|
1880
|
+
* These two must match — the sidebar's list `id` is what the layout uses to
|
|
1881
|
+
* select the `main` key — so they are derived from one constant.
|
|
1882
|
+
*/
|
|
1883
|
+
const PANEL_ID = "diagnostic-tutor";
|
|
1884
|
+
/**
|
|
1885
|
+
* The docked tab's identity.
|
|
1886
|
+
*
|
|
1887
|
+
* A tab type has two names: `id` is the implementation's own identity and is
|
|
1888
|
+
* what its body and title register under; `kind` is the discriminator `openTab`
|
|
1889
|
+
* names. They are the same string here because this plugin ships exactly one
|
|
1890
|
+
* implementation of exactly one kind.
|
|
1891
|
+
*/
|
|
1892
|
+
const TAB_ID = "diagnostic-tutor";
|
|
1893
|
+
/** The sidebar icon: a small node graph, drawn inline so it needs no assets. */
|
|
1894
|
+
function PanelIcon({ size, active }) {
|
|
1895
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1896
|
+
width: size,
|
|
1897
|
+
height: size,
|
|
1898
|
+
viewBox: "0 0 16 16",
|
|
1899
|
+
fill: "none",
|
|
1900
|
+
stroke: "currentColor",
|
|
1901
|
+
strokeWidth: active ? 1.6 : 1.3,
|
|
1902
|
+
"aria-hidden": "true",
|
|
1903
|
+
children: [
|
|
1904
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1905
|
+
cx: "8",
|
|
1906
|
+
cy: "3",
|
|
1907
|
+
r: "2"
|
|
1908
|
+
}),
|
|
1909
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1910
|
+
cx: "3.5",
|
|
1911
|
+
cy: "12",
|
|
1912
|
+
r: "2"
|
|
1913
|
+
}),
|
|
1914
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1915
|
+
cx: "12.5",
|
|
1916
|
+
cy: "12",
|
|
1917
|
+
r: "2"
|
|
1918
|
+
}),
|
|
1919
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1920
|
+
d: "M8 5v2.2M8 7.2 4.6 10.3M8 7.2l3.4 3.1",
|
|
1921
|
+
strokeLinecap: "round"
|
|
1922
|
+
})
|
|
1923
|
+
]
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* The panel body, with the session the tutor should be woken in threaded
|
|
1928
|
+
* through, plus a way back to the conversation.
|
|
1929
|
+
*
|
|
1930
|
+
* That last part matters more than it looks. The map, the node detail and the
|
|
1931
|
+
* learning surface occupy the **main** column, which is the same column the
|
|
1932
|
+
* conversation lives in — so while the panel is open the learner cannot type.
|
|
1933
|
+
* Checks are answered in the chat, so the surface needs a door back to it, and
|
|
1934
|
+
* `ctx.layout.selectPanel(null)` is the documented way to show the conversation
|
|
1935
|
+
* again.
|
|
1936
|
+
*
|
|
1937
|
+
* `useSessions` is part of the standard kit every root-scoped slot receives, so
|
|
1938
|
+
* the panel can name the session it is looking at without any plumbing of its
|
|
1939
|
+
* own. The host needs it to reach the right agent when the learner presses
|
|
1940
|
+
* Start learning.
|
|
1941
|
+
*/
|
|
1942
|
+
function MainPanel({ useSessions, onOpenChat, onFocusStarted }) {
|
|
1943
|
+
const sessionId = useSessions?.((state) => state.ids[0]);
|
|
1944
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LearningPanel, {
|
|
1945
|
+
...typeof sessionId === "string" ? { sessionId } : {},
|
|
1946
|
+
...onOpenChat === void 0 ? {} : { onOpenChat },
|
|
1947
|
+
...onFocusStarted === void 0 ? {} : { onFocusStarted }
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Load the browser half.
|
|
1952
|
+
*
|
|
1953
|
+
* @param ctx - the client Cordis context.
|
|
1954
|
+
*/
|
|
1955
|
+
function apply(ctx) {
|
|
1956
|
+
ctx.effect(() => injectStyles());
|
|
1957
|
+
ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
|
|
1958
|
+
name: "sidebar.panellist",
|
|
1959
|
+
id: PANEL_ID,
|
|
1960
|
+
order: 40,
|
|
1961
|
+
label: "Learn"
|
|
1962
|
+
}, PanelIcon));
|
|
1963
|
+
const layout = ctx.get("layout");
|
|
1964
|
+
ctx.slots.inject("main", () => ctx.slots.register({
|
|
1965
|
+
name: "main",
|
|
1966
|
+
key: PANEL_ID
|
|
1967
|
+
}, function BoundMainPanel(props) {
|
|
1968
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MainPanel, {
|
|
1969
|
+
...props,
|
|
1970
|
+
onOpenChat: () => {
|
|
1971
|
+
layout?.selectPanel(null);
|
|
1972
|
+
openDockedTab();
|
|
1973
|
+
},
|
|
1974
|
+
onFocusStarted: () => openDockedTab()
|
|
1975
|
+
});
|
|
1976
|
+
}));
|
|
1977
|
+
ctx.sidebarRightTabs.register({
|
|
1978
|
+
id: TAB_ID,
|
|
1979
|
+
kind: TAB_ID,
|
|
1980
|
+
title: () => "Learning",
|
|
1981
|
+
priority: "extension"
|
|
1982
|
+
});
|
|
1983
|
+
/**
|
|
1984
|
+
* Dock the tab, retrying briefly.
|
|
1985
|
+
*
|
|
1986
|
+
* The right sidebar hosts **session-scoped** tabs, so it can only accept one
|
|
1987
|
+
* while a session surface is mounted — and while the full panel occupies the
|
|
1988
|
+
* main column, no session surface exists. Returning to the conversation
|
|
1989
|
+
* mounts one, so the useful moment to dock is just after that. Retrying
|
|
1990
|
+
* covers the gap between the click and the surface mounting.
|
|
1991
|
+
*/
|
|
1992
|
+
const openDockedTab = (attempt = 0) => {
|
|
1993
|
+
const sidebarRight = ctx.get("sidebarRight");
|
|
1994
|
+
if (!sidebarRight) return;
|
|
1995
|
+
try {
|
|
1996
|
+
sidebarRight.openTab(TAB_ID);
|
|
1997
|
+
} catch {
|
|
1998
|
+
if (attempt < 8) setTimeout(() => openDockedTab(attempt + 1), 400);
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
|
|
2002
|
+
name: "sidebar.right.pane.tab",
|
|
2003
|
+
key: TAB_ID
|
|
2004
|
+
}, LearningTab));
|
|
2005
|
+
ctx.slots.inject("sidebar.right.pane.tab.title", () => ctx.slots.register({
|
|
2006
|
+
name: "sidebar.right.pane.tab.title",
|
|
2007
|
+
key: TAB_ID
|
|
2008
|
+
}, LearningTabTitle));
|
|
2009
|
+
fetchOverview().then((overview) => {
|
|
2010
|
+
if (overview.course !== null && overview.focus !== null) openDockedTab();
|
|
2011
|
+
}).catch(() => {});
|
|
2012
|
+
}
|
|
2013
|
+
//#endregion
|
|
2014
|
+
exports.BLOCK_RENDERERS = BLOCK_RENDERERS;
|
|
2015
|
+
exports.BlockView = BlockView;
|
|
2016
|
+
exports.LearningPanel = LearningPanel;
|
|
2017
|
+
exports.LearningTab = LearningTab;
|
|
2018
|
+
exports.LearningTabTitle = LearningTabTitle;
|
|
2019
|
+
exports.LessonBody = LessonBody;
|
|
2020
|
+
exports.PANEL_ID = PANEL_ID;
|
|
2021
|
+
exports.TAB_ID = TAB_ID;
|
|
2022
|
+
exports.apply = apply;
|
|
2023
|
+
exports.inject = inject;
|
|
2024
|
+
exports.name = name;
|
|
2025
|
+
return module.exports;
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
//# sourceMappingURL=client.js.map
|