@crossworks/share-ui 0.230.43

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.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +68 -0
  3. package/src/app-bridge-protocol.ts +115 -0
  4. package/src/app-presenter.tsx +25 -0
  5. package/src/app-sandbox.tsx +552 -0
  6. package/src/appearance.ts +192 -0
  7. package/src/avatar.test.ts +229 -0
  8. package/src/avatar.ts +731 -0
  9. package/src/backgrounds.test.ts +119 -0
  10. package/src/backgrounds.ts +118 -0
  11. package/src/draw-presenter.tsx +39 -0
  12. package/src/event-presenter.tsx +62 -0
  13. package/src/file-presenter.tsx +76 -0
  14. package/src/formula-calculator.tsx +209 -0
  15. package/src/formula-presenter.test.ts +128 -0
  16. package/src/formula-presenter.tsx +301 -0
  17. package/src/help-topics.ts +104 -0
  18. package/src/lib/ink-audit.test.ts +314 -0
  19. package/src/lib/theme-css-blocks.ts +26 -0
  20. package/src/lib/theme-generator.test.ts +179 -0
  21. package/src/lib/theme-registry.gen.ts +352 -0
  22. package/src/lib/themes.test.ts +308 -0
  23. package/src/lib/themes.ts +75 -0
  24. package/src/lib/utils.ts +6 -0
  25. package/src/nav-items.ts +225 -0
  26. package/src/note-presenter.tsx +14 -0
  27. package/src/page-outline.tsx +127 -0
  28. package/src/table-presenter.tsx +226 -0
  29. package/src/task-presenter.tsx +60 -0
  30. package/src/ui/button.tsx +50 -0
  31. package/src/ui/input.tsx +18 -0
  32. package/src/ui/label.tsx +20 -0
  33. package/src/view-payload.ts +82 -0
  34. package/styles/app.css +1098 -0
  35. package/styles/themes.css +6198 -0
  36. package/themes/generate.d.mts +11 -0
  37. package/themes/generate.mjs +618 -0
  38. package/themes/model.d.mts +24 -0
  39. package/themes/model.mjs +213 -0
  40. package/themes/preview.html +145 -0
  41. package/themes/seeds.d.mts +16 -0
  42. package/themes/seeds.mjs +3694 -0
  43. package/tsconfig.json +15 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createElement } from 'react';
3
+ import { renderToStaticMarkup } from 'react-dom/server';
4
+ import { checkDimensions } from '@mantle/content-core/formula-dimensions';
5
+ import { checkLookupCoverage, parseFormulaSpec } from '@mantle/content-core/formula-spec';
6
+ import { FormulaPresenter } from './formula-presenter';
7
+
8
+ /**
9
+ * The public page is server-rendered on purpose: a shared engineering
10
+ * calculation that appears without its caveats is worse than one that does not
11
+ * appear at all. These tests hold the static HTML — no island, no JavaScript —
12
+ * to carrying everything a reader needs in order to distrust the number.
13
+ */
14
+
15
+ const spec = (() => {
16
+ const parsed = parseFormulaSpec({
17
+ id: 'shared-model',
18
+ name: 'Shared model',
19
+ source: { standard: 'Example standard', part: '3', edition: '2024', sections: ['5.3'] },
20
+ unitSystem: 'USC',
21
+ variables: [
22
+ { symbol: 'Ps', name: 'Storage pressure', unit: 'lbf/in2 (abs)', role: 'input' },
23
+ { symbol: 'k', role: 'constant', value: 1.5 },
24
+ ],
25
+ expressions: [
26
+ {
27
+ id: 'sound',
28
+ equation: '3.6',
29
+ resultSymbol: 'W',
30
+ unit: 'lb/sec',
31
+ expression: '{Ps} * {k}',
32
+ latex: 'W = P_s k',
33
+ },
34
+ {
35
+ id: 'guessed',
36
+ equation: '3.7',
37
+ expression: '{Ps} / {k}',
38
+ unverified: 'Supplied from memory; the source never defines it.',
39
+ },
40
+ ],
41
+ piecewise: [],
42
+ lookups: [
43
+ {
44
+ id: 'fact_di',
45
+ name: 'Reduction factor',
46
+ keys: ['detection'],
47
+ result: 'fact_di',
48
+ domains: { detection: ['A', 'B', 'C'] },
49
+ rows: [
50
+ { detection: 'A', fact_di: 0.25 },
51
+ { detection: 'B', fact_di: 0.1 },
52
+ ],
53
+ },
54
+ ],
55
+ classifications: [
56
+ {
57
+ id: 'detection-rating',
58
+ domain: ['A', 'B', 'C'],
59
+ criteria: {
60
+ A: 'Instrumentation designed specifically to detect material losses.',
61
+ B: 'Suitably located detectors.',
62
+ C: 'Visual detection only.',
63
+ },
64
+ },
65
+ ],
66
+ notes: { transition: 'The source branches on a threshold it never defines.' },
67
+ });
68
+ if (!parsed.ok) throw new Error(parsed.errors.join('; '));
69
+ return parsed.spec;
70
+ })();
71
+
72
+ const html = renderToStaticMarkup(
73
+ createElement(FormulaPresenter, {
74
+ view: {
75
+ title: 'Shared model',
76
+ spec,
77
+ coverageGaps: checkLookupCoverage(spec),
78
+ dimensionIssues: checkDimensions(spec),
79
+ },
80
+ // The /s surface's shape: pre-rendered island markup behind its own
81
+ // dangerouslySetInnerHTML wrapper (the /team reader mounts the live
82
+ // component instead — the prop is a ReactNode either way).
83
+ calculator: createElement('div', {
84
+ dangerouslySetInnerHTML: { __html: '<div data-island="formula-calculator"></div>' },
85
+ }),
86
+ }),
87
+ );
88
+
89
+ describe('FormulaPresenter', () => {
90
+ it('renders the title and a full citation', () => {
91
+ expect(html).toContain('Shared model');
92
+ expect(html).toContain('Example standard Part 3 (2024)');
93
+ expect(html).toContain('§5.3');
94
+ });
95
+
96
+ it('warns about an unverified equation WITHOUT any JavaScript', () => {
97
+ // The whole reason the static half exists. If this ever moves into the
98
+ // island, a visitor with a blocked bundle reads a fabricated equation
99
+ // number as though it were transcribed.
100
+ expect(html).toContain('not read from the source');
101
+ expect(html).toContain('Supplied from memory');
102
+ expect(html).toContain('guessed');
103
+ });
104
+
105
+ it('names the key combinations the source leaves unspecified', () => {
106
+ // detection=C is declared legal but has no row.
107
+ expect(html).toContain('unspecified');
108
+ });
109
+
110
+ it('renders the equations, the lookup rows and the rating criteria', () => {
111
+ expect(html).toContain('Eq 3.6');
112
+ expect(html).toContain('0.25');
113
+ expect(html).toContain('Instrumentation designed specifically');
114
+ });
115
+
116
+ it('keeps the transcription notes — what the source got wrong travels too', () => {
117
+ expect(html).toContain('branches on a threshold it never defines');
118
+ });
119
+
120
+ it('mounts the calculator island', () => {
121
+ expect(html).toContain('data-island="formula-calculator"');
122
+ });
123
+
124
+ it('renders LaTeX server-side rather than shipping the source string raw', () => {
125
+ // KaTeX output, not the `latex` field verbatim.
126
+ expect(html).toContain('katex');
127
+ });
128
+ });
@@ -0,0 +1,301 @@
1
+ import katex from 'katex';
2
+ import type { ReactNode } from 'react';
3
+ import type {
4
+ CoverageGap,
5
+ FormulaSpec,
6
+ FormulaValue,
7
+ SpecLookup,
8
+ } from '@mantle/content-core/formula-spec';
9
+ import type { DimensionIssue } from '@mantle/content-core/formula-dimensions';
10
+
11
+ /**
12
+ * Public render of a shared formula: the calculation model, its citation, and
13
+ * every caveat that travels with it.
14
+ *
15
+ * The static half lives here (server-rendered to HTML, no hydration); the live
16
+ * calculator mounts separately as a client island. That split is deliberate —
17
+ * the equations, the tables and above all the WARNINGS must be readable with
18
+ * JavaScript off or broken, because a shared engineering calculation that
19
+ * silently renders without its `unverified` notice is worse than one that does
20
+ * not render at all.
21
+ *
22
+ * KaTeX runs server-side with `trust: false` (its default, pinned because the
23
+ * output goes through dangerouslySetInnerHTML): a spec transcribed by an agent
24
+ * from a pasted document must not be able to inject a link or fetch a remote
25
+ * asset onto a public page.
26
+ */
27
+
28
+ function cell(v: FormulaValue | undefined): string {
29
+ if (v === null || v === undefined) return '';
30
+ return String(v);
31
+ }
32
+
33
+ function citation(spec: FormulaSpec): string | null {
34
+ const s = spec.source;
35
+ if (!s) return null;
36
+ const head = [s.standard, s.part ? `Part ${s.part}` : '', s.edition ? `(${s.edition})` : '']
37
+ .filter(Boolean)
38
+ .join(' ');
39
+ const sections = s.sections?.length ? `, §${s.sections.join(', §')}` : '';
40
+ const tables = s.tables?.length ? `, Tables ${s.tables.join(', ')}` : '';
41
+ return `${head}${sections}${tables}`.trim() || null;
42
+ }
43
+
44
+ function Equation({ latex, expression }: { latex?: string; expression: string }) {
45
+ if (latex) {
46
+ let html: string | null;
47
+ try {
48
+ html = katex.renderToString(latex, { displayMode: true, throwOnError: false, trust: false });
49
+ } catch {
50
+ // Unrenderable display string — fall through to the literal expression,
51
+ // which is what is actually computed anyway.
52
+ html = null;
53
+ }
54
+ if (html) {
55
+ return (
56
+ <div
57
+ className="overflow-x-auto py-2"
58
+ // KaTeX output from the spec's own display string, trust:false.
59
+ dangerouslySetInnerHTML={{ __html: html }}
60
+ />
61
+ );
62
+ }
63
+ }
64
+ return (
65
+ <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
66
+ <code>{expression}</code>
67
+ </pre>
68
+ );
69
+ }
70
+
71
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
72
+ return (
73
+ <section className="mt-10 space-y-3">
74
+ <h2 className="text-lg font-semibold text-foreground">{title}</h2>
75
+ {children}
76
+ </section>
77
+ );
78
+ }
79
+
80
+ function LookupTable({ lookup }: { lookup: SpecLookup }) {
81
+ const columns = [...lookup.keys, lookup.result];
82
+ return (
83
+ <div className="overflow-x-auto">
84
+ <table className="w-full text-sm">
85
+ <thead>
86
+ <tr className="border-b border-border">
87
+ {columns.map((c) => (
88
+ <th key={c} className="px-2 py-1.5 text-left font-mono text-xs text-muted-foreground">
89
+ {c}
90
+ </th>
91
+ ))}
92
+ </tr>
93
+ </thead>
94
+ <tbody>
95
+ {lookup.rows.map((row, i) => (
96
+ <tr key={i} className="border-b border-border/50">
97
+ {columns.map((c) => (
98
+ <td key={c} className="px-2 py-1.5 font-mono text-xs">
99
+ {cell(row[c])}
100
+ </td>
101
+ ))}
102
+ </tr>
103
+ ))}
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ export type FormulaShareView = {
111
+ title: string;
112
+ spec: FormulaSpec;
113
+ coverageGaps: CoverageGap[];
114
+ dimensionIssues: DimensionIssue[];
115
+ };
116
+
117
+ export function FormulaPresenter({
118
+ view,
119
+ calculator,
120
+ }: {
121
+ view: FormulaShareView;
122
+ /** The live calculator: the /s surface passes pre-rendered island markup
123
+ * (wrapped in its own dangerouslySetInnerHTML div); the /team inline
124
+ * reader mounts <FormulaCalculator> directly. */
125
+ calculator: ReactNode;
126
+ }) {
127
+ const { spec } = view;
128
+ const cite = citation(spec);
129
+ const unverified = spec.expressions.filter((e) => e.unverified);
130
+
131
+ return (
132
+ <article className="mx-auto max-w-3xl px-6 py-12 md:py-16">
133
+ <h1 className="text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
134
+ {cite ? <p className="mt-2 text-sm text-muted-foreground">{cite}</p> : null}
135
+ {spec.unitSystem ? (
136
+ <p className="mt-1 text-xs text-muted-foreground">Unit system: {spec.unitSystem}</p>
137
+ ) : null}
138
+
139
+ {/* Caveats first. Someone who reads nothing else must still see these. */}
140
+ {unverified.length > 0 ? (
141
+ <div className="mt-6 rounded-md border border-destructive/40 bg-destructive/5 p-4">
142
+ <p className="text-sm font-medium text-foreground">
143
+ {unverified.length} equation{unverified.length === 1 ? '' : 's'} here{' '}
144
+ {unverified.length === 1 ? 'was' : 'were'} not read from the source
145
+ </p>
146
+ <ul className="mt-2 space-y-1 text-xs text-muted-foreground">
147
+ {unverified.map((e) => (
148
+ <li key={e.id}>
149
+ <code>{e.id}</code> — {e.unverified}
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ ) : null}
155
+
156
+ {view.dimensionIssues.length > 0 ? (
157
+ <div className="mt-4 rounded-md border border-destructive/40 bg-destructive/5 p-4">
158
+ <p className="text-sm font-medium text-foreground">
159
+ The arithmetic disagrees with a declared unit
160
+ </p>
161
+ <ul className="mt-2 space-y-1 text-xs text-muted-foreground">
162
+ {view.dimensionIssues.map((d, i) => (
163
+ <li key={i}>
164
+ <code>{d.id}</code> — {d.detail}
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ </div>
169
+ ) : null}
170
+
171
+ {view.coverageGaps.length > 0 ? (
172
+ <div className="mt-4 rounded-md border border-border bg-muted/50 p-4">
173
+ <p className="text-sm font-medium text-foreground">
174
+ The source leaves {view.coverageGaps.length} key combination
175
+ {view.coverageGaps.length === 1 ? '' : 's'} unspecified
176
+ </p>
177
+ <p className="mt-1 text-xs text-muted-foreground">
178
+ Evaluating one of these is an error rather than a zero.
179
+ </p>
180
+ </div>
181
+ ) : null}
182
+
183
+ {/* The calculator: the reason a link exists rather than a screenshot. */}
184
+ <div className="mt-10">{calculator}</div>
185
+
186
+ {spec.expressions.length > 0 ? (
187
+ <Section title="Equations">
188
+ <div className="space-y-5">
189
+ {spec.expressions.map((e) => (
190
+ <div key={e.id} className="space-y-1">
191
+ <div className="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground">
192
+ <code className="font-medium text-foreground">{e.id}</code>
193
+ {e.equation ? <span>Eq {e.equation}</span> : null}
194
+ {e.resultSymbol ? (
195
+ <span>
196
+ → {e.resultSymbol}
197
+ {e.unit ? ` [${e.unit}]` : ''}
198
+ </span>
199
+ ) : null}
200
+ </div>
201
+ <Equation latex={e.latex} expression={e.expression} />
202
+ {e.unverified ? (
203
+ <p className="text-xs text-destructive-ink">Unverified — {e.unverified}</p>
204
+ ) : null}
205
+ {e.note ? <p className="text-xs text-muted-foreground">{e.note}</p> : null}
206
+ </div>
207
+ ))}
208
+ </div>
209
+ </Section>
210
+ ) : null}
211
+
212
+ {spec.piecewise.length > 0 ? (
213
+ <Section title="Conditional selection">
214
+ {spec.piecewise.map((p) => (
215
+ <div key={p.id} className="space-y-1 text-sm">
216
+ <code className="text-xs font-medium text-foreground">{p.id}</code>
217
+ <ul className="space-y-1 text-xs text-muted-foreground">
218
+ {p.cases.map((c, i) => (
219
+ <li key={i}>
220
+ {c.label ? <span className="text-foreground">{c.label}</span> : null} when{' '}
221
+ <code>{c.when}</code> use <code>{c.use}</code>
222
+ </li>
223
+ ))}
224
+ {p.otherwise ? (
225
+ <li>
226
+ otherwise use <code>{p.otherwise}</code>
227
+ </li>
228
+ ) : null}
229
+ </ul>
230
+ </div>
231
+ ))}
232
+ </Section>
233
+ ) : null}
234
+
235
+ {spec.variables.length > 0 ? (
236
+ <Section title="Variables">
237
+ <div className="overflow-x-auto">
238
+ <table className="w-full text-sm">
239
+ <thead>
240
+ <tr className="border-b border-border">
241
+ {['Symbol', 'Description', 'Value', 'Unit', 'Role'].map((h) => (
242
+ <th key={h} className="px-2 py-1.5 text-left text-xs text-muted-foreground">
243
+ {h}
244
+ </th>
245
+ ))}
246
+ </tr>
247
+ </thead>
248
+ <tbody>
249
+ {spec.variables.map((v) => (
250
+ <tr key={v.symbol} className="border-b border-border/50">
251
+ <td className="px-2 py-1.5 font-mono text-xs">{v.symbol}</td>
252
+ <td className="px-2 py-1.5 text-xs">{v.name ?? ''}</td>
253
+ <td className="px-2 py-1.5 font-mono text-xs">
254
+ {v.expression ?? (v.value !== undefined ? String(v.value) : '')}
255
+ </td>
256
+ <td className="px-2 py-1.5 text-xs text-muted-foreground">{v.unit ?? ''}</td>
257
+ <td className="px-2 py-1.5 text-xs text-muted-foreground">{v.role}</td>
258
+ </tr>
259
+ ))}
260
+ </tbody>
261
+ </table>
262
+ </div>
263
+ </Section>
264
+ ) : null}
265
+
266
+ {spec.lookups.map((lookup) => (
267
+ <Section key={lookup.id} title={lookup.name ?? lookup.id}>
268
+ <LookupTable lookup={lookup} />
269
+ </Section>
270
+ ))}
271
+
272
+ {spec.classifications.map((c) => (
273
+ <Section key={c.id} title={c.id}>
274
+ <dl className="space-y-2 text-sm">
275
+ {c.domain.map((value) => (
276
+ <div key={value} className="flex gap-3">
277
+ <dt className="w-8 shrink-0 font-mono text-xs font-medium text-foreground">
278
+ {value}
279
+ </dt>
280
+ <dd className="text-xs text-muted-foreground">{c.criteria[value]}</dd>
281
+ </div>
282
+ ))}
283
+ </dl>
284
+ </Section>
285
+ ))}
286
+
287
+ {spec.notes && Object.keys(spec.notes).length > 0 ? (
288
+ <Section title="Transcription notes">
289
+ <dl className="space-y-2">
290
+ {Object.entries(spec.notes).map(([key, text]) => (
291
+ <div key={key}>
292
+ <dt className="font-mono text-xs font-medium text-foreground">{key}</dt>
293
+ <dd className="text-xs text-muted-foreground">{text}</dd>
294
+ </div>
295
+ ))}
296
+ </dl>
297
+ </Section>
298
+ ) : null}
299
+ </article>
300
+ );
301
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Route → help-topic map. The header's "?" button reads this to decide which
3
+ * topic to open; a route with no topic renders no button at all, so coverage
4
+ * can grow screen by screen without anything breaking in between.
5
+ *
6
+ * Matching is LONGEST-PREFIX, so `/settings/agents` beats `/settings`, and a
7
+ * detail route (`/tables/abc-123`) inherits its list route's topic. Keeping the
8
+ * map here — rather than a <SetPageHelp> tag in 80 page files — means the set is
9
+ * exhaustive by construction and checkable in CI (help-topics.test.ts asserts
10
+ * every topic named here has a content file, and vice versa).
11
+ *
12
+ * Topic slugs are file names under `docs/guide/06-help/<topic>.md`.
13
+ */
14
+
15
+ /** Ordered longest-first at module load, so lookup is a plain scan. */
16
+ const ROUTE_TOPICS: ReadonlyArray<readonly [route: string, topic: string]> = [
17
+ // ── Workspace ──────────────────────────────────────────────────────────
18
+ ['/tables', 'tables'],
19
+ ['/pages', 'pages'],
20
+ ['/draw', 'draw'],
21
+ ['/notes', 'notes'],
22
+ ['/files', 'files'],
23
+ ['/assistant', 'assistant'],
24
+ ['/journal', 'journal'],
25
+ ['/tasks', 'tasks'],
26
+ ['/events', 'events'],
27
+ ['/contacts', 'contacts'],
28
+ ['/secrets', 'secrets'],
29
+ // The mail screen lives at /inbox; the topic keeps the user-facing name.
30
+ ['/inbox', 'inbox'],
31
+ ['/formulas', 'formulas'],
32
+ ['/apps', 'apps'],
33
+ ['/docs', 'docs'],
34
+
35
+ // ── Review ─────────────────────────────────────────────────────────────
36
+ ['/models', 'models'],
37
+ ['/settings/discover', 'discover'],
38
+ ['/team-admin', 'team-admin'],
39
+ ['/pending', 'pending'],
40
+
41
+ // ── External ───────────────────────────────────────────────────────────
42
+ ['/team-portal', 'team-portal'],
43
+
44
+ // ── Settings ───────────────────────────────────────────────────────────
45
+ ['/settings/agents', 'agents'],
46
+ ['/settings/ai-workers', 'ai-workers'],
47
+ ['/settings/tools', 'tools'],
48
+ ['/settings/tool-groups', 'tool-groups'],
49
+ ['/settings/skills', 'skills'],
50
+ ['/settings/keys', 'keys'],
51
+ ['/settings/embedding', 'embedding'],
52
+ ['/settings/heartbeats', 'heartbeats'],
53
+ ['/settings/worker-groups', 'worker-groups'],
54
+ ['/settings/appearance', 'appearance'],
55
+ ['/settings/accounts', 'accounts'],
56
+ ['/settings/microsoft', 'microsoft'],
57
+ ['/settings/calendar', 'calendar'],
58
+ ['/settings/profile', 'profile'],
59
+ ['/settings/mcp', 'mcp'],
60
+ ['/settings/network', 'network'],
61
+ ['/settings/config', 'config'],
62
+ ['/settings/entities', 'entities'],
63
+ ['/settings/peers', 'peers'],
64
+ ['/settings/pdf-passwords', 'pdf-passwords'],
65
+ ['/settings/backups', 'backups'],
66
+ ['/settings/updates', 'updates'],
67
+ ['/settings/users', 'users'],
68
+ ['/settings/audit', 'audit'],
69
+
70
+ // ── System ─────────────────────────────────────────────────────────────
71
+ ['/studio', 'studio'],
72
+ ['/dev-tools', 'dev-tools'],
73
+ ['/runners', 'runners'],
74
+ ['/runs', 'runs'],
75
+ ['/sandboxes', 'sandboxes'],
76
+ ['/traces', 'traces'],
77
+ ['/debug', 'debug'],
78
+
79
+ // Dashboard. Matched exact-only by the '/' special case below.
80
+ ['/', 'dashboard'],
81
+ ];
82
+
83
+ const SORTED = [...ROUTE_TOPICS].sort((a, b) => b[0].length - a[0].length);
84
+
85
+ /**
86
+ * The help topic for a pathname, or null when the screen has none yet.
87
+ * `/` matches only itself — a bare prefix test would hand it every route.
88
+ */
89
+ export function helpTopicForPath(pathname: string): string | null {
90
+ const path = pathname.replace(/\/+$/, '') || '/';
91
+ for (const [route, topic] of SORTED) {
92
+ if (route === '/') {
93
+ if (path === '/') return topic;
94
+ continue;
95
+ }
96
+ if (path === route || path.startsWith(`${route}/`)) return topic;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /** Every topic slug the map references — the CI drift test's left-hand side. */
102
+ export function allHelpTopics(): string[] {
103
+ return [...new Set(ROUTE_TOPICS.map(([, topic]) => topic))].sort();
104
+ }