@qnroa/qtype 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,124 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.2.1] — 2026-09-01
17
+
18
+ Discoverability pass for `qtype config`. Users no longer have to grep
19
+ the source (or the wiki) to find out what keys they can set.
20
+
21
+ ### Added
22
+ - **`qtype config --help` now lists every configurable key** at the
23
+ bottom of the help output — one line per key with its default, scope,
24
+ description, and enum options where applicable. Same source of truth
25
+ as the actual schema, so it stays honest.
26
+ - **`qtype config list --all`** — verbose per-key view: current value
27
+ (or default if unset), source (which file / default), scope, and
28
+ enum options. Complements `list` (only shows keys the user has
29
+ actually set).
30
+ - **`SchemaEntry.description` + `SchemaEntry.options`** fields, both
31
+ optional. Populated for every currently-registered key so the two
32
+ new help surfaces have something to show.
33
+ - **`CommandModule.afterHelp`** hook — commands can append reference
34
+ material after the standard help sections. `config` uses it; other
35
+ commands can add their own without touching the registry.
36
+
37
+ ### Changed
38
+ - Cleaned up in-line doc comments in the schema files (they've been
39
+ moved into the `description` field where possible, so they're
40
+ reachable at runtime).
41
+
42
+ ## [0.2.0] — 2026-08-31
43
+
44
+ Substantial rework of the typing page. The screen-flow is now a real
45
+ finite state machine (xstate), the ad-hoc `paused / stampKind /
46
+ awaitingAdvance / finished / isReview` booleans are gone, and every
47
+ screen state's UI is derived from one place rather than pieced together
48
+ by view code. Behaviour visible to the user shifts in a few spots —
49
+ see below.
50
+
51
+ ### Added
52
+ - **Session persistence for done state.** Marking a card done writes
53
+ to the persistent kv (`useDoneState`), so the completion streak
54
+ survives page reloads and shows up on Home right next to the
55
+ material title as `3 / 12`. Openng a material now lands on the
56
+ first card the user hasn't finished, not always card 1.
57
+ - **Completed-card review mode.** Coming back to a card you've already
58
+ finished shows the answer painted in green (via `engine.markAllHit`)
59
+ with the HUD still available; pressing ▶ / Alt+Space wipes the done
60
+ flag and lets you retype from scratch.
61
+ - **Card-jump list panel.** The quick-input's card submode now lists
62
+ every card in the queue with its title, done state, and a "current"
63
+ marker. Type text to filter by title, digits to jump by number.
64
+ - **After-pass advance mode.** New setting `advanceMode` — the default
65
+ is now **Confirm** (stay on the finished card until the user hits
66
+ the confirm key) rather than auto-advance after 700ms. Users who
67
+ prefer the old behaviour can flip it back to Auto in settings.
68
+ - **Confirm key setting.** Choose Enter or Space as the "next" key.
69
+ The chosen key is highlighted on the on-screen keyboard during the
70
+ confirmation window so mobile users can see where to tap.
71
+ - **Global keyboard channel.** Typing keeps working after clicks that
72
+ move focus out of the arena — a window-level `keydown` listener
73
+ forwards keystrokes to the engine so users don't have to click back
74
+ into the (invisible) editor after every misplaced tap.
75
+ - **Mobile virtual keyboard inset.** The layout reserves space at the
76
+ bottom equal to the soft-keyboard height (`visualViewport` API), so
77
+ the answer text no longer disappears under the keyboard on iOS /
78
+ Android.
79
+ - **Region-aware TTS default.** New `auto` TTS provider picks Youdao
80
+ for `zh-*` browsers and Google elsewhere, with the "browser" engine
81
+ as fallback. Explicit picks still take precedence.
82
+ - **`?path=` search param on the typing route.** The HUD "back to
83
+ library" button returns to the exact folder the user came from
84
+ instead of the library root.
85
+
86
+ ### Changed
87
+ - **Typing page UI reimplemented as an xstate machine** (`idle` /
88
+ `typing` / `paused` / `passed` / `completed` / `finished`), replacing
89
+ five ad-hoc `useState` booleans. Every screen state's mask / stamp /
90
+ pill / keyboard-hint / HUD-play-icon is derived from one `derived`
91
+ struct rather than assembled by the view. Fixes a class of races
92
+ where two effects would fight over the same visual (e.g. the pass
93
+ stamp being covered by the pause mask because a review-restore
94
+ effect fired half a frame too early).
95
+ - **`useTypingEngine` is now keyed on card id, not answer text.** Two
96
+ cards whose answers happened to be identical used to share one
97
+ engine, leaking cursor / hit-miss marks between them. Every card
98
+ now gets a fresh engine on entry.
99
+ - **First character of each card is now highlighted on the on-screen
100
+ keyboard.** `useHudBridge` derives the first keystroke from the
101
+ engine at mount instead of waiting for the arena to push one.
102
+ - **Backspaced characters render as untyped.** The engine still tags
103
+ them as `Recover` for downstream logic, but the dotted underline
104
+ UI decoration is removed — visually the slot goes back to "pending".
105
+ - **HUD ▶ / Alt+Space in `completed` state means "restart this card"**,
106
+ not "start typing". It clears the done marker + resets the engine +
107
+ moves the machine to typing.
108
+ - **Paused → next card lands in idle, not typing.** Advancing while
109
+ paused now shows the pause mask on the new card so the user still
110
+ has to explicitly start.
111
+ - **Backspace / stamp / advance cleanup are co-located with the user
112
+ action that triggers them**, not smuggled through state-transition
113
+ effects that could fire on incidental transitions (e.g. advancing
114
+ to an un-done card would previously have clobbered the destination's
115
+ done state).
116
+
117
+ ### Removed
118
+ - **`sessionPassed` snapshot store.** Reviewing a passed card no
119
+ longer replays the exact keystroke-by-keystroke record from
120
+ sessionStorage — a static "all hit" view is used instead. That
121
+ removed a whole storage layer and closed the quota concern for
122
+ users with thousands of cards.
123
+ - **`TypingInput.snapshot` / `restore`** (and the `TypingInputSnapshot`
124
+ type). Superseded by `markAllHit`.
125
+
126
+ ### Fixed
127
+ - **Focus loss no longer stops typing.** Clicking HUD buttons or
128
+ outside the arena used to silently drop keystrokes; the global
129
+ keydown fallback catches them now.
130
+ - **Mobile answer text no longer covered by the soft keyboard.**
131
+ - **HUD "back" button returns to origin folder** (see `?path=`
132
+ above), instead of always the library root.
133
+
16
134
  ## [0.1.4] — 2026-08-29
17
135
 
18
136
  Two small UX changes: a tappable resume overlay and a completion
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,95 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.2.1] — 2026-09-01
16
+
17
+ `qtype config` 的可发现性升级。用户不再需要翻源码或 wiki 才知道能设
18
+ 置哪些 key。
19
+
20
+ ### 新增
21
+ - **`qtype config --help` 现在会在底部列出所有可配置的 key**,一行一个,
22
+ 含默认值、作用域、描述、枚举选项(如果有)。数据源就是 schema 本身,
23
+ 不会脱节。
24
+ - **`qtype config list --all`** —— 详版:每个 key 显示当前值(或未设置
25
+ 时的默认)、来源(哪个文件 / 默认)、作用域、枚举选项。和 `list`(只
26
+ 显示用户设过的)互补。
27
+ - **`SchemaEntry.description` + `SchemaEntry.options`** 两个可选字段。
28
+ 现在每个已注册 key 都填了,给上面两个新的 help 出口提供内容。
29
+ - **`CommandModule.afterHelp` 钩子** —— 命令可以在标准 help 段落后追加
30
+ 参考内容。`config` 用它,其它命令想加也行,不用改 registry。
31
+
32
+ ### 变更
33
+ - 清理了 schema 文件里的行内文档注释(能放进 `description` 的都挪进
34
+ 去了,这样运行时就能读到)。
35
+
36
+ ## [0.2.0] — 2026-08-31
37
+
38
+ 打字页面的整体架构重写。屏幕流程改成了真正的有限状态机(xstate),
39
+ 之前散在各处的 `paused / stampKind / awaitingAdvance / finished /
40
+ isReview` 布尔位全部替换成一个状态机,每种状态下的 UI 都从一个统一
41
+ 的派生里读,而不是各处自己拼。用户可感知的行为变化见下面。
42
+
43
+ ### 新增
44
+ - **完成状态持久化。** 通过卡片会写到本地存储(`useDoneState`),
45
+ 刷新页面 / 重启浏览器都保留,Home 里的材料卡上会显示 `3 / 12`
46
+ 完成进度。打开材料时会跳到第一张未完成的卡,不再永远从第一张开始。
47
+ - **已完成卡片的回看模式。** 切回已通过的卡时,答案会整体显示为绿色
48
+ (`engine.markAllHit`),HUD 仍可用;按 ▶ / Alt+Space 会清掉这张
49
+ 卡的完成标记并重置引擎,回到从头开始的可打状态。
50
+ - **卡片跳转列表面板。** 命令面板的卡片跳转模式现在会列出 queue 中
51
+ 每一张卡的标题、完成状态和当前位置。输入文本按标题模糊过滤,输入
52
+ 数字按序号直接跳转。
53
+ - **通过后推进模式。** 新增设置 `advanceMode`,默认改为 **确认**(打
54
+ 完卡等待用户按确认键才进入下一张),不再是原本的 700ms 自动跳。
55
+ 想要旧行为的用户可以在设置里切回自动。
56
+ - **确认键设置。** 可选 Enter 或 Space 作为「进入下一张」的键。选中
57
+ 的键在等待确认时会在虚拟键盘上高亮,方便移动端用户看到点哪。
58
+ - **全局键盘通道。** 点击了 HUD 按钮 / 卡片之外的区域之后依然可以继续
59
+ 打字 —— 一个 window 级别的 keydown 监听会兜底转发到引擎,用户不用
60
+ 再回点隐形编辑器。
61
+ - **移动端虚拟键盘避让。** 布局底部会预留和虚拟键盘等高的空间
62
+ (`visualViewport` API),答题区不再被系统键盘挡住。
63
+ - **区域感知的 TTS 默认值。** 新增 `auto` 语音源,浏览器语言 `zh-*`
64
+ 用有道,其他用 Google,都失败降级到浏览器自带。用户手动选的会覆盖。
65
+ - **typing route 支持 `?path=` search 参数。** HUD 上的「返回」按钮
66
+ 会回到用户进入该材料前所在的文件夹,而不是永远回到根目录。
67
+
68
+ ### 变更
69
+ - **打字页 UI 用 xstate 状态机重写**(`idle` / `typing` / `paused` /
70
+ `passed` / `completed` / `finished` 六态),替换了原本的 5 个散乱
71
+ `useState` 布尔。所有状态的遮罩 / 笑脸 / pill / 键盘高亮 / HUD 播
72
+ 放按钮图标都从一个 `derived` 结构派生。修复了一类由两个副作用抢
73
+ 同一个视觉状态引起的 bug(比如笑脸被 review restore 副作用早触发
74
+ 盖住)。
75
+ - **`useTypingEngine` 现在按 cardId 缓存,不再按答案文本。** 之前两
76
+ 张答案文字相同的卡会共用一个引擎,光标 / 对错标记会串。现在每张
77
+ 卡进入时都是一个干净的新引擎。
78
+ - **每张卡的第一个字符现在会在键盘上高亮。** `useHudBridge` 在挂载
79
+ 时就从引擎派生出第一个 keystroke,不再等 arena push。
80
+ - **删除的字符按待打字状态渲染。** 引擎仍然会打 `Recover` 标记(下
81
+ 游还在用),但视觉上不再显示原本的点状下划线。
82
+ - **completed 状态下按 ▶ / Alt+Space 表示「重打这张卡」**,不是「开始
83
+ 打字」。会清完成标记 + 重置引擎 + 转到 typing。
84
+ - **暂停状态下切下一张会到 idle,不到 typing。** 新卡进去仍显示暂停
85
+ 遮罩,等用户主动开始。
86
+ - **Backspace / stamp / advance 的清理逻辑放到触发它们的用户动作里**,
87
+ 不再依赖状态过渡副作用。避免了「切到未通过的卡时误清目标卡完成状
88
+ 态」这类 bug。
89
+
90
+ ### 删除
91
+ - **`sessionPassed` 快照存储。** 回看已通过的卡不再从 sessionStorage
92
+ 回放当时逐键的完整状态,直接使用「全绿」静态视图。移除了一整层存
93
+ 储,消除了对几千张卡容量上限的顾虑。
94
+ - **`TypingInput.snapshot` / `restore` 方法**(以及 `TypingInputSnapshot`
95
+ 类型)。被 `markAllHit` 取代。
96
+
97
+ ### 修复
98
+ - **焦点丢失不再停止打字。** 点击 HUD 按钮 / 卡片之外会让隐形编辑器
99
+ 失焦,原来会静默吞掉按键。现在有全局 keydown 兜底。
100
+ - **移动端答题区不再被虚拟键盘遮挡。**
101
+ - **HUD「返回」按钮回到进入时的目录**(见上面 `?path=`),不再永远回
102
+ 到根目录。
103
+
15
104
  ## [0.1.4] — 2026-08-29
16
105
 
17
106
  两个小 UX 改动:暂停遮罩可以点击继续,卡片打完时会有一个短暂的
@@ -178,6 +178,12 @@ export async function configUnsetAction(key, opts) {
178
178
  /** `qtype config list [--global|--local] [--show-origin]` */
179
179
  export async function configListAction(opts) {
180
180
  const scope = pickScope(opts);
181
+ // --all: enumerate every schema-known key, filling in defaults for
182
+ // ones the user hasn't set. Useful for discovering what can be
183
+ // configured.
184
+ if (opts.all) {
185
+ return listAllKnown(scope);
186
+ }
181
187
  const userEntries = scope === 'repo' ? [] : await userKv.list();
182
188
  const repoEntries = scope === 'user' ? [] : (await repoKv.available()) ? await repoKv.list() : [];
183
189
  const rows = [];
@@ -220,6 +226,68 @@ export async function configListAction(opts) {
220
226
  // ═══════════════════════════════════════════════════════════════
221
227
  // helpers
222
228
  // ═══════════════════════════════════════════════════════════════
229
+ /**
230
+ * List every key known to the schema in the given scope (all if scope
231
+ * is undefined). For each key: current value + source, or default +
232
+ * `(default)` marker if unset, plus type / options / description.
233
+ */
234
+ async function listAllKnown(scope) {
235
+ const rows = [];
236
+ const seen = new Set();
237
+ if (scope !== 'repo') {
238
+ for (const k of userKv.knownKeys()) {
239
+ if (seen.has(k))
240
+ continue;
241
+ seen.add(k);
242
+ rows.push({ key: k, kv: userKv, scope: 'user' });
243
+ }
244
+ }
245
+ if (scope !== 'user') {
246
+ for (const k of repoKv.knownKeys()) {
247
+ if (seen.has(k))
248
+ continue;
249
+ seen.add(k);
250
+ rows.push({ key: k, kv: repoKv, scope: 'repo' });
251
+ }
252
+ }
253
+ rows.sort((a, b) => a.key.localeCompare(b.key));
254
+ const repoAvailable = scope === 'user' ? false : await repoKv.available();
255
+ for (const r of rows) {
256
+ const entry = r.kv.getSchema(r.key);
257
+ // Prefer the stored value (from user or repo scope); fall back to
258
+ // the schema default. `get` returns default when unset, so guard
259
+ // with `has` first to know whether the value is user-set.
260
+ let currentDisplay = '';
261
+ let sourceLabel = '';
262
+ const canRead = r.scope === 'user' || repoAvailable;
263
+ if (canRead && (await r.kv.has(r.key))) {
264
+ const raw = await r.kv.get(r.key);
265
+ currentDisplay = formatValue(raw);
266
+ sourceLabel = `${r.scope}:${r.kv.origin}`;
267
+ }
268
+ else {
269
+ currentDisplay = formatValue(entry.default);
270
+ sourceLabel = 'default';
271
+ }
272
+ const parts = [`${r.key}=${currentDisplay}`];
273
+ if (entry.description)
274
+ parts.push(dim(entry.description));
275
+ console.log(parts.join(' '));
276
+ const meta = [];
277
+ if (entry.options && entry.options.length > 0) {
278
+ meta.push(`options: ${entry.options.join(' | ')}`);
279
+ }
280
+ if (entry.default !== undefined && sourceLabel === 'default') {
281
+ // already shown as current; keep meta uncluttered
282
+ }
283
+ else if (entry.default !== undefined) {
284
+ meta.push(`default: ${formatValue(entry.default)}`);
285
+ }
286
+ meta.push(`scope: ${r.scope}`);
287
+ meta.push(`source: ${sourceLabel}`);
288
+ console.log(' ' + dim(meta.join(' · ')));
289
+ }
290
+ }
223
291
  /** Format a value as a single-line string. Objects / arrays use JSON. */
224
292
  function formatValue(v) {
225
293
  if (v == null)
@@ -7,8 +7,10 @@ export const messages = {
7
7
  'config.opt.global': '操作 user 层(~/.config/qtype/config.json)',
8
8
  'config.opt.local': '操作 repo 层(<repo>/.qtype/config.json)',
9
9
  'config.opt.showOrigin': '显示配置项来源(文件路径 / 哪一层)',
10
+ 'config.opt.all': '列出所有可配置项(含 schema 里未设置的默认值)',
10
11
  'config.example.setLang': '设置 CLI 语言',
11
12
  'config.example.list': '列出所有生效配置',
13
+ 'config.list.example.all': '列出所有可配置项及默认值',
12
14
  'config.get.summary': '读配置',
13
15
  'config.get.description': '读取指定 key 的值(默认合并两层;key 不存在 exit 1)',
14
16
  'config.get.example.basic': '读 CLI 语言偏好',
@@ -29,6 +31,8 @@ export const messages = {
29
31
  'config.list.example.global': '只看 user 层',
30
32
  'config.list.msg.empty': '配置为空',
31
33
  'config.list.msg.emptyScoped': '配置为空 ({scope})',
34
+ 'config.help.keysHeader': '可配置项:',
35
+ 'config.help.keysFooter': '用 `qtype config set <key> <value>` 修改;`qtype config list --all` 查看完整状态。',
32
36
  },
33
37
  'en-US': {
34
38
  'config.summary': 'Manage config',
@@ -38,8 +42,10 @@ export const messages = {
38
42
  'config.opt.global': 'Operate on user scope (~/.config/qtype/config.json)',
39
43
  'config.opt.local': 'Operate on repo scope (<repo>/.qtype/config.json)',
40
44
  'config.opt.showOrigin': 'Show where each entry comes from (file path and scope)',
45
+ 'config.opt.all': 'List every configurable key, including schema defaults for unset keys',
41
46
  'config.example.setLang': 'Set CLI language',
42
47
  'config.example.list': 'List all effective config',
48
+ 'config.list.example.all': 'List every configurable key with defaults',
43
49
  'config.get.summary': 'Get value',
44
50
  'config.get.description': 'Get value for a key (merged by default; exit 1 if key not set)',
45
51
  'config.get.example.basic': 'Read CLI language preference',
@@ -60,5 +66,7 @@ export const messages = {
60
66
  'config.list.example.global': 'Only user scope',
61
67
  'config.list.msg.empty': 'Config is empty',
62
68
  'config.list.msg.emptyScoped': 'Config is empty ({scope})',
69
+ 'config.help.keysHeader': 'Available keys:',
70
+ 'config.help.keysFooter': 'Use `qtype config set <key> <value>` to change one; `qtype config list --all` to see the full state.',
63
71
  },
64
72
  };
@@ -1,5 +1,7 @@
1
1
  import { t } from '../../i18n/index.js';
2
2
  import { messages } from './i18n.js';
3
+ import { userKv, repoKv } from '../../bootstrap.js';
4
+ import { bold, dim } from '../../utils/output.js';
3
5
  import { configGetAction, configSetAction, configUnsetAction, configListAction, } from './action.js';
4
6
  /** Shared --global / --local / --show-origin options. */
5
7
  const scopeOptions = [
@@ -52,13 +54,59 @@ const listCmd = {
52
54
  options: [
53
55
  ...scopeOptions,
54
56
  { flags: '--show-origin', describe: () => t('config.opt.showOrigin') },
57
+ { flags: '--all', describe: () => t('config.opt.all') },
55
58
  ],
56
59
  examples: () => [
57
60
  { command: 'qtype config list', describe: () => t('config.list.example.merged') },
58
61
  { command: 'qtype config list --global', describe: () => t('config.list.example.global') },
62
+ { command: 'qtype config list --all', describe: () => t('config.list.example.all') },
59
63
  ],
60
64
  action: async (opts) => configListAction(opts),
61
65
  };
66
+ /**
67
+ * Render every configurable key (from both user and repo schemas)
68
+ * as a `key = <default> — description` list. Used by `qtype
69
+ * config --help` so users can discover what can be tuned without
70
+ * hunting through docs.
71
+ */
72
+ function renderAvailableKeys() {
73
+ const rows = [];
74
+ const seen = new Set();
75
+ for (const k of userKv.knownKeys()) {
76
+ if (seen.has(k))
77
+ continue;
78
+ seen.add(k);
79
+ const e = userKv.getSchema(k);
80
+ rows.push({ key: k, scope: 'user', default: e.default, description: e.description, options: e.options });
81
+ }
82
+ for (const k of repoKv.knownKeys()) {
83
+ if (seen.has(k))
84
+ continue;
85
+ seen.add(k);
86
+ const e = repoKv.getSchema(k);
87
+ rows.push({ key: k, scope: 'repo', default: e.default, description: e.description, options: e.options });
88
+ }
89
+ rows.sort((a, b) => a.key.localeCompare(b.key));
90
+ const fmtDefault = (v) => {
91
+ if (v === undefined)
92
+ return '(no default)';
93
+ if (typeof v === 'string')
94
+ return v || '""';
95
+ return JSON.stringify(v);
96
+ };
97
+ const lines = [bold(t('config.help.keysHeader'))];
98
+ for (const r of rows) {
99
+ lines.push(` ${r.key} ${dim('=')} ${fmtDefault(r.default)} ${dim(`(${r.scope})`)}`);
100
+ if (r.description)
101
+ lines.push(` ${dim(r.description)}`);
102
+ if (r.options && r.options.length > 0) {
103
+ lines.push(` ${dim(`options: ${r.options.join(' | ')}`)}`);
104
+ }
105
+ }
106
+ lines.push('');
107
+ lines.push(dim(t('config.help.keysFooter')));
108
+ return lines.join('\n');
109
+ }
62
110
  export const configCmd = {
63
111
  name: 'config',
64
112
  messages,
@@ -67,6 +115,8 @@ export const configCmd = {
67
115
  examples: () => [
68
116
  { command: 'qtype config set cli.lang zh', describe: () => t('config.example.setLang') },
69
117
  { command: 'qtype config list', describe: () => t('config.example.list') },
118
+ { command: 'qtype config list --all', describe: () => t('config.list.example.all') },
70
119
  ],
120
+ afterHelp: () => renderAvailableKeys(),
71
121
  subcommands: [getCmd, setCmd, unsetCmd, listCmd],
72
122
  };
@@ -66,6 +66,12 @@ export function applyHelpMetadata(cmd, mod) {
66
66
  if (mod.seeAlso && mod.seeAlso.length > 0) {
67
67
  cmd.addHelpText('after', renderSeeAlso(mod.seeAlso));
68
68
  }
69
+ // Free-form afterHelp — printed last, useful for reference lists.
70
+ if (mod.afterHelp) {
71
+ const extra = mod.afterHelp();
72
+ if (extra && extra.trim())
73
+ cmd.addHelpText('after', '\n' + extra);
74
+ }
69
75
  // Aliases
70
76
  if (mod.aliases && mod.aliases.length > 0) {
71
77
  for (const alias of mod.aliases)
@@ -5,48 +5,50 @@
5
5
  */
6
6
  import { enumValidator, boolValidator, positiveIntValidator, } from '../../store/index.js';
7
7
  export const bothSchema = {
8
- // Repo language. Drives:
9
- // • which template locale (examples/zh vs examples/en) new repos are
10
- // scaffolded from
11
- // • the web UI language — the reader is locked to repo.lang so the UI
12
- // matches the material's own language
13
- //
14
- // Values are BCP 47 codes (`zh-CN` / `en-US`); the short forms `zh` /
15
- // `en` are accepted for backwards compatibility with older `.qtype/
16
- // config.json` files and normalised at read time.
17
8
  'repo.lang': {
18
9
  validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
19
10
  default: 'en-US',
11
+ description: 'Repo (and its published web UI) language.',
12
+ options: ['zh-CN', 'en-US'],
20
13
  },
21
14
  'typing.theme': {
22
15
  validator: enumValidator(['mocha', 'latte']),
23
16
  default: 'mocha',
17
+ description: 'Web reader theme.',
18
+ options: ['mocha', 'latte'],
24
19
  },
25
20
  'typing.targetWpm': {
26
21
  validator: positiveIntValidator(),
22
+ description: 'Ghost-pacer target speed in WPM. 0 = disabled.',
27
23
  },
28
24
  'typing.tabBehavior': {
29
25
  validator: enumValidator(['reset', 'input', 'disabled']),
30
26
  default: 'reset',
27
+ description: 'What pressing Tab in the arena does.',
28
+ options: ['reset', 'input', 'disabled'],
31
29
  },
32
30
  'typing.hint': {
33
31
  validator: enumValidator(['none', 'ghost']),
34
32
  default: 'none',
33
+ description: 'Whether to render a faint ghost hint under the caret.',
34
+ options: ['none', 'ghost'],
35
35
  },
36
36
  'typing.showKeyboard': {
37
37
  validator: boolValidator(),
38
38
  default: true,
39
+ description: 'Show the on-screen keyboard beneath the arena.',
40
+ options: ['true', 'false'],
39
41
  },
40
42
  'typing.showHands': {
41
43
  validator: boolValidator(),
42
44
  default: true,
45
+ description: 'Show the hand overlay on the on-screen keyboard.',
46
+ options: ['true', 'false'],
43
47
  },
44
- // Mermaid pre-render mode. `prerender` (default) uses puppeteer at build
45
- // time to bake SVGs into dist/material/mermaid.json; `client` ships raw
46
- // ```mermaid fences and lets the browser render them; `off` treats mermaid
47
- // as plain code blocks.
48
48
  'publish.mermaid': {
49
49
  validator: enumValidator(['prerender', 'client', 'off']),
50
50
  default: 'prerender',
51
+ description: 'Mermaid diagram handling at `publish build` time.',
52
+ options: ['prerender', 'client', 'off'],
51
53
  },
52
54
  };
@@ -6,43 +6,35 @@ import { enumValidator, nonEmptyStringValidator, boolValidator, } from '../../st
6
6
  export const repoOnlySchema = {
7
7
  'repo.name': {
8
8
  validator: nonEmptyStringValidator(),
9
+ description: 'Repo display name (folder title, package.json name).',
9
10
  },
10
11
  // ── Password protection for `publish build` ─────────────
11
- // On/off toggle. When true, `publish build` reads the other three fields
12
- // and encrypts every material/*.md before writing to dist.
13
12
  'publish.password.enabled': {
14
13
  validator: boolValidator(),
15
14
  default: false,
15
+ description: 'Encrypt materials at `publish build` time.',
16
+ options: ['true', 'false'],
16
17
  },
17
- // Argon2id PHC-style hash of the plaintext password, used to verify the
18
- // password entered at build time. NOT the AES key — that's derived
19
- // separately via `publish.password.aesSalt`. Empty string when protection
20
- // is off; shape enforced by `publish password set` writing a well-formed
21
- // PHC string.
22
18
  'publish.password.hash': {
23
19
  validator: (v) => typeof v === 'string'
24
20
  ? null
25
21
  : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
26
22
  default: '',
23
+ description: 'Argon2id PHC hash of the publish password (managed by `qtype publish password`).',
27
24
  },
28
- // Base64 salt used with argon2id (password → AES-256 key). Rotated only
29
- // when the user calls `publish password set` — stable across rebuilds so
30
- // the same password unlocks previously published sites.
31
25
  'publish.password.aesSalt': {
32
26
  validator: (v) => typeof v === 'string'
33
27
  ? null
34
28
  : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
35
29
  default: '',
30
+ description: 'Base64 salt used to derive the AES key (managed by `qtype publish password`).',
36
31
  },
37
- // What to encrypt. `all` covers material/ + any future encrypted assets;
38
- // `material-only` limits encryption to material/*.md (same behavior in
39
- // v0 since qtype has no other content streams).
40
32
  'publish.password.scope': {
41
33
  validator: enumValidator(['all', 'material-only', 'selected']),
42
34
  default: 'all',
35
+ description: 'What to encrypt when password protection is on.',
36
+ options: ['all', 'material-only', 'selected'],
43
37
  },
44
- // When scope='selected', the list of file names (relative to material/)
45
- // to encrypt. Ignored otherwise.
46
38
  'publish.password.selected': {
47
39
  validator: (v) => Array.isArray(v) && v.every((x) => typeof x === 'string')
48
40
  ? null
@@ -51,5 +43,6 @@ export const repoOnlySchema = {
51
43
  fallback: 'must be an array of strings',
52
44
  },
53
45
  default: [],
46
+ description: 'File names to encrypt when scope is `selected` (JSON array).',
54
47
  },
55
48
  };
@@ -11,9 +11,13 @@ export const userOnlySchema = {
11
11
  'cli.lang': {
12
12
  validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
13
13
  default: 'en-US',
14
+ description: 'CLI display language.',
15
+ options: ['zh-CN', 'en-US'],
14
16
  },
15
17
  'cli.color': {
16
18
  validator: enumValidator(['always', 'never', 'auto']),
17
19
  default: 'auto',
20
+ description: 'Color output policy for CLI messages.',
21
+ options: ['always', 'never', 'auto'],
18
22
  },
19
23
  };
@@ -140,6 +140,24 @@ export class TypingInput {
140
140
  this.#position = 0;
141
141
  this.#notify();
142
142
  }
143
+ /**
144
+ * Mark every character as Hit — used by the route's `completed`
145
+ * state (a card the user finished earlier) so downstream renderers
146
+ * paint the whole answer in green without needing a full snapshot
147
+ * of the original keystroke stream. Purely cosmetic: stats derived
148
+ * from timestamps stay zero.
149
+ */
150
+ markAllHit() {
151
+ const next = this.#chars.map((c) => ({
152
+ target: c.target,
153
+ typed: c.target,
154
+ attrs: (c.attrs & ~(CharAttr.Miss | CharAttr.Cursor)) | CharAttr.Hit,
155
+ timestamp: 0,
156
+ }));
157
+ this.#chars = next;
158
+ this.#position = this.#length;
159
+ this.#notify();
160
+ }
143
161
  get position() {
144
162
  return this.#position;
145
163
  }