@jkwd/inbase 0.1.21 → 0.1.23

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 (58) hide show
  1. package/README.md +13 -7
  2. package/apps/explorer/package.json +1 -0
  3. package/apps/explorer/scripts/explain-store.d.ts +135 -0
  4. package/apps/explorer/scripts/explain-store.mjs +666 -0
  5. package/apps/explorer/scripts/patch-lib.mjs +4 -0
  6. package/apps/explorer/scripts/scan-target.mjs +22 -5
  7. package/apps/explorer/scripts/session-store.d.ts +86 -11
  8. package/apps/explorer/scripts/session-store.mjs +371 -58
  9. package/apps/explorer/scripts/target-config.d.ts +38 -3
  10. package/apps/explorer/scripts/target-config.mjs +147 -3
  11. package/apps/explorer/src/App.tsx +1073 -158
  12. package/apps/explorer/src/agentIntent.ts +61 -7
  13. package/apps/explorer/src/codebase.ts +1 -1
  14. package/apps/explorer/src/devTargets.ts +66 -0
  15. package/apps/explorer/src/explain.ts +312 -0
  16. package/apps/explorer/src/index.css +874 -222
  17. package/apps/explorer/src/layout.ts +55 -0
  18. package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
  19. package/apps/explorer/src/scene/FileBlock.tsx +95 -72
  20. package/apps/explorer/src/scene/FolderArea.tsx +55 -32
  21. package/apps/explorer/src/scene/MapView.tsx +506 -33
  22. package/apps/explorer/src/scene/RelationLines.tsx +7 -0
  23. package/apps/explorer/src/scene/World.tsx +146 -32
  24. package/apps/explorer/src/speech.ts +228 -0
  25. package/apps/explorer/src/theme.ts +29 -0
  26. package/apps/explorer/src/types.ts +98 -8
  27. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
  28. package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
  29. package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
  30. package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
  31. package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
  32. package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
  33. package/apps/explorer/src/ui/HUD.tsx +1067 -795
  34. package/apps/explorer/src/ui/NameInput.tsx +114 -5
  35. package/apps/explorer/src/userContext.ts +0 -11
  36. package/apps/explorer/src/userCreated.ts +54 -1
  37. package/apps/explorer/vite.config.ts +173 -26
  38. package/bin/inbase.mjs +11 -2
  39. package/bin/project.mjs +6 -4
  40. package/bin/session.mjs +287 -38
  41. package/package.json +4 -1
  42. package/skill/commands/amber.md +23 -0
  43. package/skill/commands/blue.md +13 -0
  44. package/skill/commands/coral.md +23 -0
  45. package/skill/commands/explain.md +77 -0
  46. package/skill/commands/green.md +23 -0
  47. package/skill/commands/inbase.md +7 -5
  48. package/skill/commands/lime.md +23 -0
  49. package/skill/commands/orange.md +23 -0
  50. package/skill/commands/purple.md +23 -0
  51. package/skill/commands/red.md +23 -0
  52. package/skill/commands/skipinbase.md +1 -1
  53. package/skill/commands/violet.md +23 -0
  54. package/skill/commands/yellow.md +23 -0
  55. package/skill/inbase/SKILL.md +124 -76
  56. package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
  57. package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
  58. package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
@@ -17,6 +17,7 @@ type RelationLinesProps = {
17
17
  layout: WorldLayout
18
18
  extras?: Record<string, PlacedFile>
19
19
  plannedEdges?: PatchImport[]
20
+ extraEdges?: PatchImport[]
20
21
  fromAbove?: boolean
21
22
  importedBy?: boolean
22
23
  }
@@ -117,6 +118,7 @@ export function RelationLines({
117
118
  layout,
118
119
  extras = {},
119
120
  plannedEdges = [],
121
+ extraEdges = [],
120
122
  fromAbove = false,
121
123
  importedBy = false,
122
124
  }: RelationLinesProps) {
@@ -146,6 +148,10 @@ export function RelationLines({
146
148
  addLine(edge.from, edge.to, true, plannedRadius)
147
149
  }
148
150
 
151
+ for (const edge of extraEdges) {
152
+ addLine(edge.from, edge.to, false, selectedRadius)
153
+ }
154
+
149
155
  if (selectedId) {
150
156
  if (importedBy) {
151
157
  for (const file of files) {
@@ -165,6 +171,7 @@ export function RelationLines({
165
171
  return lines
166
172
  }, [
167
173
  extras,
174
+ extraEdges,
168
175
  files,
169
176
  fromAbove,
170
177
  importedBy,
@@ -5,11 +5,9 @@ import { DistantFileBlocks } from './DistantFileBlocks'
5
5
  import { Bridge } from './Bridge'
6
6
  import { RelationLines } from './RelationLines'
7
7
  import { Player } from './Player'
8
- import { MapView, type MapBlueprintMenu } from './MapView'
8
+ import { MapView, type MapBlueprintMenu, type MapFileLabel, type MapFocusBounds } from './MapView'
9
9
  import { SelectionController } from './SelectionController'
10
10
  import { UserContextTracker } from './UserContextTracker'
11
- import { BlockPlacer } from './BlockPlacer'
12
- import { IslandPlacer } from './IslandPlacer'
13
11
  import { WalkLodTracker } from './WalkLodTracker'
14
12
  import { computeWalkLod, type WalkLod } from './walkLod'
15
13
  import {
@@ -19,6 +17,13 @@ import {
19
17
  folderOfFile,
20
18
  mapPointOntoFolder,
21
19
  } from '../layout'
20
+ import {
21
+ explainFileFocused,
22
+ explainFileHighlighted,
23
+ explainFolderFocused,
24
+ explainHasFocus,
25
+ type ExplainFocus,
26
+ } from '../explain'
22
27
  import { WORLD_VOID, CONFIG, fileHeight } from '../theme'
23
28
  import type {
24
29
  CodebaseGraph,
@@ -45,6 +50,7 @@ type WorldProps = {
45
50
  locked: boolean
46
51
  onSelect: (fileId: string | null) => void
47
52
  onSelectFolder: (folderPath: string | null) => void
53
+ pickingImport?: boolean
48
54
  onLockedChange: (locked: boolean) => void
49
55
  onLand: (x: number, z: number) => void
50
56
  onWalkPosition: (x: number, z: number) => void
@@ -63,8 +69,6 @@ type WorldProps = {
63
69
  onTravelTo: (fromId: string, toId: string) => void
64
70
  importedBy?: boolean
65
71
  namingId?: string | null
66
- onPlaceBlock?: (spot: { x: number; z: number; folder: string }) => void
67
- onPlaceIsland?: (parent: string) => void
68
72
  onBlueprintMenu?: (menu: MapBlueprintMenu) => void
69
73
  onCommitName?: (id: string, name: string) => boolean
70
74
  onCancelName?: (id: string) => void
@@ -74,10 +78,17 @@ type WorldProps = {
74
78
  userCreatedIslands?: UserCreatedIsland[]
75
79
  overlayBlocks?: UserCreatedBlock[]
76
80
  pointedFileIds?: string[]
81
+ pointedFileColors?: Record<string, string[]>
77
82
  pointedFolderPaths?: string[]
83
+ pointedFolderColors?: Record<string, string[]>
78
84
  namingIslandId?: string | null
79
85
  mapGraph?: CodebaseGraph | null
80
86
  mapLayout?: WorldLayout | null
87
+ explainActive?: boolean
88
+ explainFocus?: ExplainFocus | null
89
+ focusBounds?: MapFocusBounds | null
90
+ focusFlightKey?: string | number
91
+ landEnabled?: boolean
81
92
  }
82
93
 
83
94
  export function World({
@@ -90,6 +101,7 @@ export function World({
90
101
  locked,
91
102
  onSelect,
92
103
  onSelectFolder,
104
+ pickingImport = false,
93
105
  onLockedChange,
94
106
  onLand,
95
107
  onWalkPosition,
@@ -109,8 +121,6 @@ export function World({
109
121
  importedBy = false,
110
122
  namingId = null,
111
123
  namingIslandId = null,
112
- onPlaceBlock,
113
- onPlaceIsland,
114
124
  onBlueprintMenu,
115
125
  onCommitName,
116
126
  onCancelName,
@@ -120,9 +130,16 @@ export function World({
120
130
  userCreatedIslands = [],
121
131
  overlayBlocks = [],
122
132
  pointedFileIds = [],
133
+ pointedFileColors = {},
123
134
  pointedFolderPaths = [],
135
+ pointedFolderColors = {},
124
136
  mapGraph = null,
125
137
  mapLayout = null,
138
+ explainActive = false,
139
+ explainFocus = null,
140
+ focusBounds = null,
141
+ focusFlightKey = 0,
142
+ landEnabled = true,
126
143
  }: WorldProps) {
127
144
  const created = new Set(createdIds)
128
145
  const deleted = new Set(deletedIds)
@@ -224,6 +241,68 @@ export function World({
224
241
  placed: PlacedFile
225
242
  dimmed: boolean
226
243
  }[] = []
244
+ const mapFileLabels = useMemo(() => {
245
+ if (!mapping) return []
246
+ const pointed = new Set(pointedFileIds)
247
+ const seen = new Set<string>()
248
+ const items: MapFileLabel[] = []
249
+ const push = (id: string, name: string, placed: PlacedFile) => {
250
+ if (!placed || seen.has(id)) return
251
+ seen.add(id)
252
+ const colors = pointedFileColors[id]
253
+ items.push({
254
+ id,
255
+ name,
256
+ x: placed.position[0],
257
+ z: placed.position[2],
258
+ width: placed.size[0],
259
+ depth: placed.size[2],
260
+ outer: -placed.aisleFace as 1 | -1,
261
+ selected: id === selectedId,
262
+ pointed: pointed.has(id),
263
+ pointedColor: colors?.[colors.length - 1],
264
+ dimmed:
265
+ explainActive &&
266
+ !explainFileFocused(explainFocus, id, folderOfFile(id)),
267
+ focused: explainFileHighlighted(explainFocus, id, folderOfFile(id)),
268
+ })
269
+ }
270
+ for (const file of viewGraph.files) {
271
+ const placed = viewLayout.files[file.id]
272
+ if (placed) push(file.id, file.name, placed)
273
+ }
274
+ for (const placed of Object.values(ghosts)) {
275
+ push(placed.id, placed.id.split('/').pop() ?? placed.id, placed)
276
+ }
277
+ for (const block of overlayBlocks) {
278
+ const height = fileHeight(12)
279
+ push(block.id, block.name, {
280
+ id: block.id,
281
+ position: [block.x, height / 2 + 0.42, block.z],
282
+ size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
283
+ aisleFace: 1,
284
+ })
285
+ }
286
+ return items
287
+ }, [
288
+ ghosts,
289
+ mapping,
290
+ overlayBlocks,
291
+ pointedFileColors,
292
+ pointedFileIds,
293
+ selectedId,
294
+ viewGraph.files,
295
+ viewLayout.files,
296
+ explainActive,
297
+ explainFocus,
298
+ ])
299
+
300
+ const dimmedFolderPaths = useMemo(() => {
301
+ if (!explainActive || !explainHasFocus(explainFocus)) return []
302
+ return Object.keys(viewLayout.folders).filter(
303
+ (path) => !explainFolderFocused(explainFocus, path),
304
+ )
305
+ }, [explainActive, explainFocus, viewLayout.folders])
227
306
 
228
307
  return (
229
308
  <>
@@ -238,15 +317,28 @@ export function World({
238
317
  <MapView
239
318
  layout={viewLayout}
240
319
  enabled={mapping}
241
- marker={mapMarker}
320
+ marker={explainActive ? null : mapMarker}
242
321
  highlightedFolders={highlightedFolders}
243
322
  selectedFolder={selectedFolder}
244
323
  namingFolderPath={namingIslandId}
324
+ namingFileId={namingId}
325
+ pointedFolderPaths={pointedFolderPaths}
326
+ pointedFolderColors={pointedFolderColors}
327
+ fileLabels={mapFileLabels}
328
+ focusBounds={focusBounds}
329
+ focusFlightKey={focusFlightKey}
330
+ hudReserve={explainActive ? 24 : 88}
331
+ topReserve={explainActive ? 24 : 28}
332
+ landEnabled={landEnabled}
333
+ dimmedFolderPaths={dimmedFolderPaths}
245
334
  onLand={onLand}
246
335
  onSelect={onSelect}
247
336
  onSelectFolder={onSelectFolder}
337
+ pickingImport={pickingImport}
248
338
  onBlueprintMenu={
249
- mapping && !placing && onBlueprintMenu ? onBlueprintMenu : undefined
339
+ mapping && !placing && !explainActive && onBlueprintMenu
340
+ ? onBlueprintMenu
341
+ : undefined
250
342
  }
251
343
  />
252
344
 
@@ -263,6 +355,12 @@ export function World({
263
355
  mapping ? highlightedFolders[folder.path] ?? null : null
264
356
  }
265
357
  pointed={pointedFolders.has(folder.path)}
358
+ pointedColors={pointedFolderColors[folder.path]}
359
+ opacity={
360
+ explainActive && !explainFolderFocused(explainFocus, folder.path)
361
+ ? 0.5
362
+ : 1
363
+ }
266
364
  labelVisible={!lod || lod.folderLabels.has(folder.path)}
267
365
  onCommitName={
268
366
  folder.path === namingIslandId && onCommitIslandName
@@ -295,6 +393,7 @@ export function World({
295
393
  const naming = file.id === namingId
296
394
  const aimed = file.id === aimedRelation?.flyTo
297
395
  const pointed = pointedFiles.has(file.id)
396
+ const focused = explainFileHighlighted(explainFocus, file.id, file.folder)
298
397
  const detailed =
299
398
  selected ||
300
399
  isRelated ||
@@ -302,16 +401,20 @@ export function World({
302
401
  naming ||
303
402
  aimed ||
304
403
  pointed ||
404
+ focused ||
305
405
  Boolean(changeKind)
306
406
  if (lod && !lod.files.has(file.id)) return null
307
407
  const dimmed =
308
- hasFocus &&
309
- !selected &&
310
- !isRelated &&
311
- !changeKind &&
312
- !pointed &&
313
- !patchLinked.has(file.id) &&
314
- !folderFileIds.has(file.id)
408
+ explainActive
409
+ ? !explainFileFocused(explainFocus, file.id, file.folder)
410
+ : hasFocus &&
411
+ !selected &&
412
+ !isRelated &&
413
+ !changeKind &&
414
+ !pointed &&
415
+ !patchLinked.has(file.id) &&
416
+ !folderFileIds.has(file.id)
417
+ const opacity = explainActive && dimmed ? 0.5 : 1
315
418
  if (lod && !detailed && !lod.labels.has(file.id)) {
316
419
  distantFiles.push({ file, placed, dimmed })
317
420
  return null
@@ -328,7 +431,10 @@ export function World({
328
431
  added={created.has(file.id) || file.userCreated}
329
432
  aimed={aimed}
330
433
  pointed={pointed}
434
+ pointedColors={pointedFileColors[file.id]}
331
435
  dimmed={dimmed}
436
+ focused={focused}
437
+ opacity={opacity}
332
438
  naming={naming}
333
439
  mapMode={mapping}
334
440
  labelVisible={!lod || lod.labels.has(file.id) || naming}
@@ -366,7 +472,18 @@ export function World({
366
472
  changeKind="add"
367
473
  added
368
474
  pointed={pointedFiles.has(file.id)}
369
- dimmed={false}
475
+ pointedColors={pointedFileColors[file.id]}
476
+ dimmed={
477
+ explainActive &&
478
+ !explainFileFocused(explainFocus, file.id, file.folder)
479
+ }
480
+ focused={explainFileHighlighted(explainFocus, file.id, file.folder)}
481
+ opacity={
482
+ explainActive &&
483
+ !explainFileFocused(explainFocus, file.id, file.folder)
484
+ ? 0.5
485
+ : 1
486
+ }
370
487
  mapMode={mapping}
371
488
  labelVisible
372
489
  />
@@ -395,7 +512,17 @@ export function World({
395
512
  planned
396
513
  changeKind="add"
397
514
  added
398
- dimmed={false}
515
+ dimmed={
516
+ explainActive &&
517
+ !explainFileFocused(explainFocus, file.id, file.folder)
518
+ }
519
+ focused={explainFileHighlighted(explainFocus, file.id, file.folder)}
520
+ opacity={
521
+ explainActive &&
522
+ !explainFileFocused(explainFocus, file.id, file.folder)
523
+ ? 0.5
524
+ : 1
525
+ }
399
526
  mapMode={mapping}
400
527
  />
401
528
  )
@@ -407,6 +534,7 @@ export function World({
407
534
  layout={viewLayout}
408
535
  extras={ghosts}
409
536
  plannedEdges={plannedImports}
537
+ extraEdges={explainFocus?.relations ?? []}
410
538
  fromAbove={mapping}
411
539
  importedBy={importedBy}
412
540
  />
@@ -431,20 +559,6 @@ export function World({
431
559
  onChange={setWalkLod}
432
560
  />
433
561
  )}
434
- {onPlaceBlock && (
435
- <BlockPlacer
436
- enabled={!mapping && !placing}
437
- layout={layout}
438
- onPlace={onPlaceBlock}
439
- />
440
- )}
441
- {onPlaceIsland && (
442
- <IslandPlacer
443
- enabled={!mapping && !placing}
444
- layout={layout}
445
- onPlace={onPlaceIsland}
446
- />
447
- )}
448
562
  <SelectionController
449
563
  locked={locked && !mapping}
450
564
  onSelect={onSelect}
@@ -0,0 +1,228 @@
1
+ import type { KokoroTTS } from 'kokoro-js'
2
+
3
+ let speakGeneration = 0
4
+ let neuralSpeaking = false
5
+ let currentSource: AudioBufferSourceNode | null = null
6
+ let audioContext: AudioContext | null = null
7
+ let kokoroFailed = false
8
+ let kokoroLoading: Promise<KokoroTTS | null> | null = null
9
+
10
+ export const AUTO_STEP_DELAY_MS = 4000
11
+ export type SpeakStatus = 'idle' | 'loading' | 'speaking'
12
+
13
+ const KOKORO_MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX'
14
+ const KOKORO_VOICE = 'af_heart' as const
15
+
16
+ let speakStatus: SpeakStatus = 'idle'
17
+ const statusListeners = new Set<(status: SpeakStatus) => void>()
18
+
19
+ function setSpeakStatus(next: SpeakStatus) {
20
+ speakStatus = next
21
+ for (const listener of statusListeners) listener(next)
22
+ }
23
+
24
+ export function subscribeSpeakStatus(listener: (status: SpeakStatus) => void) {
25
+ statusListeners.add(listener)
26
+ listener(speakStatus)
27
+ return () => {
28
+ statusListeners.delete(listener)
29
+ }
30
+ }
31
+
32
+ function canUseWebSpeech() {
33
+ return typeof window !== 'undefined' && 'speechSynthesis' in window
34
+ }
35
+
36
+ export function canSpeak() {
37
+ return (
38
+ typeof window !== 'undefined' &&
39
+ ('AudioContext' in window ||
40
+ 'webkitAudioContext' in window ||
41
+ canUseWebSpeech())
42
+ )
43
+ }
44
+
45
+ export function isSpeaking() {
46
+ return (
47
+ neuralSpeaking ||
48
+ (canUseWebSpeech() && window.speechSynthesis.speaking)
49
+ )
50
+ }
51
+
52
+ function getAudioContext() {
53
+ const Context =
54
+ window.AudioContext ||
55
+ (window as typeof window & { webkitAudioContext?: typeof AudioContext })
56
+ .webkitAudioContext
57
+ if (!Context) return null
58
+ if (!audioContext || audioContext.state === 'closed') {
59
+ audioContext = new Context()
60
+ }
61
+ if (audioContext.state === 'suspended') void audioContext.resume()
62
+ return audioContext
63
+ }
64
+
65
+ function stopPlayback() {
66
+ neuralSpeaking = false
67
+ if (currentSource) {
68
+ try {
69
+ currentSource.stop()
70
+ } catch {
71
+ // Already stopped.
72
+ }
73
+ currentSource = null
74
+ }
75
+ if (canUseWebSpeech()) window.speechSynthesis.cancel()
76
+ }
77
+
78
+ export function stopSpeaking() {
79
+ speakGeneration += 1
80
+ stopPlayback()
81
+ setSpeakStatus('idle')
82
+ }
83
+
84
+ function voiceScore(voice: SpeechSynthesisVoice) {
85
+ const label = `${voice.name} ${voice.voiceURI} ${voice.lang}`.toLowerCase()
86
+ let score = 0
87
+ if (voice.lang.toLowerCase().startsWith('en')) score += 10
88
+ if (/neural|premium|enhanced|natural|online|wavenet|studio/.test(label)) {
89
+ score += 50
90
+ }
91
+ if (/google/.test(label)) score += 30
92
+ if (/microsoft/.test(label)) score += 20
93
+ if (/\b(zoe|evan|nolan|samantha|allison|ava|daniel|karen|moira)\b/.test(label)) {
94
+ score += 15
95
+ }
96
+ if (voice.localService) score += 2
97
+ if (voice.default) score += 1
98
+ return score
99
+ }
100
+
101
+ function preferredWebVoice() {
102
+ const voices = window.speechSynthesis.getVoices()
103
+ if (voices.length === 0) return undefined
104
+ return [...voices].sort((a, b) => voiceScore(b) - voiceScore(a))[0]
105
+ }
106
+
107
+ async function detectWebGpu() {
108
+ const gpu = (
109
+ navigator as Navigator & { gpu?: { requestAdapter: () => Promise<unknown> } }
110
+ ).gpu
111
+ if (!gpu) return false
112
+ try {
113
+ return Boolean(await gpu.requestAdapter())
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ async function loadKokoro() {
120
+ if (kokoroFailed) return null
121
+ kokoroLoading ??= (async () => {
122
+ try {
123
+ const { KokoroTTS } = await import('kokoro-js')
124
+ const webgpu = await detectWebGpu()
125
+ return await KokoroTTS.from_pretrained(KOKORO_MODEL, webgpu
126
+ ? { dtype: 'fp32', device: 'webgpu' }
127
+ : { dtype: 'q8', device: 'wasm' },
128
+ )
129
+ } catch (error) {
130
+ kokoroFailed = true
131
+ console.warn('Natural voice unavailable, using the browser voice', error)
132
+ return null
133
+ }
134
+ })()
135
+ return kokoroLoading
136
+ }
137
+
138
+ function playRawAudio(
139
+ input: ArrayLike<number>,
140
+ sampleRate: number,
141
+ generation: number,
142
+ onEnded: () => void,
143
+ ) {
144
+ const context = getAudioContext()
145
+ if (!context) {
146
+ onEnded()
147
+ return
148
+ }
149
+ const samples = new Float32Array(input.length)
150
+ samples.set(input as ArrayLike<number>)
151
+ const buffer = context.createBuffer(1, samples.length, sampleRate)
152
+ buffer.copyToChannel(samples, 0)
153
+ const source = context.createBufferSource()
154
+ source.buffer = buffer
155
+ source.connect(context.destination)
156
+ currentSource = source
157
+ neuralSpeaking = true
158
+ setSpeakStatus('speaking')
159
+ source.onended = () => {
160
+ if (currentSource === source) currentSource = null
161
+ neuralSpeaking = false
162
+ if (generation !== speakGeneration) return
163
+ setSpeakStatus('idle')
164
+ onEnded()
165
+ }
166
+ source.start()
167
+ }
168
+
169
+ function speakWithWebSpeech(text: string, generation: number, onEnded: () => void) {
170
+ if (!canUseWebSpeech()) {
171
+ onEnded()
172
+ return
173
+ }
174
+ const utterance = new SpeechSynthesisUtterance(text)
175
+ utterance.rate = 0.98
176
+ const voice = preferredWebVoice()
177
+ if (voice) utterance.voice = voice
178
+ utterance.onend = () => {
179
+ if (generation !== speakGeneration) return
180
+ setSpeakStatus('idle')
181
+ onEnded()
182
+ }
183
+ utterance.onerror = (event) => {
184
+ if (event.error === 'interrupted' || event.error === 'canceled') return
185
+ if (generation !== speakGeneration) return
186
+ setSpeakStatus('idle')
187
+ onEnded()
188
+ }
189
+ setSpeakStatus('speaking')
190
+ window.speechSynthesis.speak(utterance)
191
+ }
192
+
193
+ export function speakText(text: string, onEnd?: () => void) {
194
+ const generation = ++speakGeneration
195
+ stopPlayback()
196
+ const spoken = text.trim()
197
+ const finish = () => {
198
+ if (generation !== speakGeneration) return
199
+ neuralSpeaking = false
200
+ setSpeakStatus('idle')
201
+ onEnd?.()
202
+ }
203
+ if (!spoken) {
204
+ queueMicrotask(finish)
205
+ return
206
+ }
207
+
208
+ // Keep AudioContext in the user-gesture stack so playback can start later.
209
+ getAudioContext()
210
+ setSpeakStatus('loading')
211
+
212
+ void (async () => {
213
+ try {
214
+ const tts = await loadKokoro()
215
+ if (generation !== speakGeneration) return
216
+ if (tts) {
217
+ const audio = await tts.generate(spoken, { voice: KOKORO_VOICE, speed: 1 })
218
+ if (generation !== speakGeneration) return
219
+ playRawAudio(audio.audio, audio.sampling_rate, generation, finish)
220
+ return
221
+ }
222
+ } catch (error) {
223
+ console.warn('Natural voice failed, using the browser voice', error)
224
+ }
225
+ if (generation !== speakGeneration) return
226
+ speakWithWebSpeech(spoken, generation, finish)
227
+ })()
228
+ }
@@ -64,6 +64,10 @@ export const MAP_SELECTION = {
64
64
  island: FILE_SELECTION.color,
65
65
  islandPad: 0.38,
66
66
  blockPad: 0.1,
67
+ pointed: '#9ad8ff',
68
+ pointedPad: 0.22,
69
+ explain: '#9ad8ff',
70
+ explainPad: 0.16,
67
71
  }
68
72
 
69
73
  export function fileHeight(lines: number) {
@@ -110,6 +114,31 @@ export function dimColor(hex: string, amount = 0.32) {
110
114
  return `#${channel(0)}${channel(2)}${channel(4)}`
111
115
  }
112
116
 
117
+ export function lightenColor(hex: string, amount = 0.35) {
118
+ const value = hex.replace('#', '')
119
+ if (value.length !== 6) return hex
120
+ const mix = Math.max(0, Math.min(1, amount))
121
+ const channel = (start: number) => {
122
+ const n = parseInt(value.slice(start, start + 2), 16)
123
+ return Math.round(n + (255 - n) * mix)
124
+ .toString(16)
125
+ .padStart(2, '0')
126
+ }
127
+ return `#${channel(0)}${channel(2)}${channel(4)}`
128
+ }
129
+
130
+ export function blueprintPalette(hex?: string | null) {
131
+ const color =
132
+ typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#38bdf8'
133
+ return {
134
+ color,
135
+ label: lightenColor(color, 0.42),
136
+ emissive: dimColor(color, 0.45),
137
+ floor: dimColor(color, 0.78),
138
+ aisle: dimColor(color, 0.58),
139
+ }
140
+ }
141
+
113
142
  function folderHue(path: string) {
114
143
  let hash = 0
115
144
  for (let i = 0; i < path.length; i += 1) {