even-toolkit 1.4.1 → 1.5.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +53 -5
  3. package/dist/glasses/action-bar.d.ts +3 -3
  4. package/dist/glasses/action-bar.js +7 -7
  5. package/dist/glasses/action-bar.js.map +1 -1
  6. package/dist/glasses/action-map.js +3 -3
  7. package/dist/glasses/action-map.js.map +1 -1
  8. package/dist/glasses/gestures.d.ts +4 -0
  9. package/dist/glasses/gestures.d.ts.map +1 -1
  10. package/dist/glasses/gestures.js +44 -0
  11. package/dist/glasses/gestures.js.map +1 -1
  12. package/dist/glasses/glass-chat-display.d.ts +43 -0
  13. package/dist/glasses/glass-chat-display.d.ts.map +1 -0
  14. package/dist/glasses/glass-chat-display.js +102 -0
  15. package/dist/glasses/glass-chat-display.js.map +1 -0
  16. package/dist/glasses/glass-format.d.ts +50 -0
  17. package/dist/glasses/glass-format.d.ts.map +1 -0
  18. package/dist/glasses/glass-format.js +65 -0
  19. package/dist/glasses/glass-format.js.map +1 -0
  20. package/dist/glasses/index.d.ts +2 -0
  21. package/dist/glasses/index.d.ts.map +1 -1
  22. package/dist/glasses/index.js +2 -0
  23. package/dist/glasses/index.js.map +1 -1
  24. package/dist/glasses/useGlasses.js +1 -1
  25. package/dist/glasses/useGlasses.js.map +1 -1
  26. package/dist/stt/providers/deepgram.d.ts +1 -0
  27. package/dist/stt/providers/deepgram.d.ts.map +1 -1
  28. package/dist/stt/providers/deepgram.js +25 -8
  29. package/dist/stt/providers/deepgram.js.map +1 -1
  30. package/dist/web/components/dialog.d.ts.map +1 -1
  31. package/dist/web/components/dialog.js +16 -1
  32. package/dist/web/components/dialog.js.map +1 -1
  33. package/dist/web/components/drawer-shell.d.ts +19 -0
  34. package/dist/web/components/drawer-shell.d.ts.map +1 -0
  35. package/dist/web/components/drawer-shell.js +59 -0
  36. package/dist/web/components/drawer-shell.js.map +1 -0
  37. package/dist/web/components/list-item.d.ts +1 -1
  38. package/dist/web/components/list-item.d.ts.map +1 -1
  39. package/dist/web/components/list-item.js +20 -5
  40. package/dist/web/components/list-item.js.map +1 -1
  41. package/dist/web/components/multi-select.d.ts +22 -0
  42. package/dist/web/components/multi-select.d.ts.map +1 -0
  43. package/dist/web/components/multi-select.js +52 -0
  44. package/dist/web/components/multi-select.js.map +1 -0
  45. package/dist/web/components/select.d.ts +13 -3
  46. package/dist/web/components/select.d.ts.map +1 -1
  47. package/dist/web/components/select.js +36 -3
  48. package/dist/web/components/select.js.map +1 -1
  49. package/dist/web/components/side-drawer.d.ts +43 -0
  50. package/dist/web/components/side-drawer.d.ts.map +1 -0
  51. package/dist/web/components/side-drawer.js +88 -0
  52. package/dist/web/components/side-drawer.js.map +1 -0
  53. package/dist/web/icons/svg-icons.js +1 -1
  54. package/dist/web/icons/svg-icons.js.map +1 -1
  55. package/dist/web/index.d.ts +6 -0
  56. package/dist/web/index.d.ts.map +1 -1
  57. package/dist/web/index.js +3 -0
  58. package/dist/web/index.js.map +1 -1
  59. package/glasses/action-bar.ts +7 -7
  60. package/glasses/action-map.ts +3 -3
  61. package/glasses/gestures.ts +50 -0
  62. package/glasses/glass-chat-display.ts +152 -0
  63. package/glasses/glass-format.ts +75 -0
  64. package/glasses/index.ts +2 -0
  65. package/glasses/useGlasses.ts +1 -1
  66. package/package.json +10 -1
  67. package/stt/providers/deepgram.ts +23 -7
  68. package/web/components/dialog.tsx +20 -1
  69. package/web/components/drawer-shell.tsx +145 -0
  70. package/web/components/list-item.tsx +25 -10
  71. package/web/components/multi-select.tsx +118 -0
  72. package/web/components/select.tsx +90 -20
  73. package/web/components/side-drawer.tsx +246 -0
  74. package/web/icons/svg-icons.tsx +1 -2
  75. package/web/index.ts +9 -0
  76. package/web/theme/utilities.css +11 -0
@@ -4,8 +4,8 @@
4
4
  * Renders a row of named buttons with triangle indicators:
5
5
  * ▶Timer◀ ▷Scroll◁ Steps
6
6
  *
7
- * - Active button (mode entered): filled triangles Name
8
- * - Selected button (hovering in button-select mode): empty triangles Name
7
+ * - Selected button (current highlight): empty triangles Name
8
+ * - Active button (entered mode, not highlighted): filled triangles Name
9
9
  * - Inactive button: plain Name
10
10
  */
11
11
 
@@ -27,11 +27,11 @@ export function buildActionBar(
27
27
 
28
28
  return buttons.map((name, i) => {
29
29
  if (activeIdx === i) {
30
- // Active mode: filled triangles
30
+ // Active/confirmed button: filled triangles
31
31
  return `\u25B6${name}\u25C0`;
32
32
  }
33
- if (activeIdx < 0 && i === selectedIndex) {
34
- // Hovering in button-select mode: empty triangles
33
+ if (i === selectedIndex) {
34
+ // Scroll highlight on a non-active button: empty triangles
35
35
  return `\u25B7${name}\u25C1`;
36
36
  }
37
37
  return ` ${name} `;
@@ -39,7 +39,7 @@ export function buildActionBar(
39
39
  }
40
40
 
41
41
  /**
42
- * Build a static action bar (empty triangles on selected).
42
+ * Build a static action bar (filled triangles on selected).
43
43
  * Useful for screens like recipe detail or completion where there's no mode switching.
44
44
  */
45
45
  export function buildStaticActionBar(
@@ -48,7 +48,7 @@ export function buildStaticActionBar(
48
48
  ): string {
49
49
  return buttons.map((name, i) => {
50
50
  if (i === selectedIndex) {
51
- return `\u25B7${name}\u25C1`;
51
+ return `\u25B6${name}\u25C0`;
52
52
  }
53
53
  return ` ${name} `;
54
54
  }).join(' ');
@@ -1,7 +1,7 @@
1
1
  import type { EvenHubEvent } from '@evenrealities/even_hub_sdk';
2
2
  import { OsEventTypeList } from '@evenrealities/even_hub_sdk';
3
3
  import type { GlassAction } from './types';
4
- import { tryConsumeTap, isScrollSuppressed, isScrollDebounced } from './gestures';
4
+ import { tryConsumeTap, shouldIgnoreScroll } from './gestures';
5
5
 
6
6
  export function mapGlassEvent(event: EvenHubEvent): GlassAction | null {
7
7
  if (!event) return null;
@@ -24,10 +24,10 @@ function mapEvent(event: { eventType?: number; currentSelectItemIndex?: number }
24
24
  if (!tryConsumeTap('double')) return null;
25
25
  return { type: 'GO_BACK' };
26
26
  case OsEventTypeList.SCROLL_TOP_EVENT:
27
- if (isScrollDebounced('prev') || isScrollSuppressed()) return null;
27
+ if (shouldIgnoreScroll('prev')) return null;
28
28
  return { type: 'HIGHLIGHT_MOVE', direction: 'up' };
29
29
  case OsEventTypeList.SCROLL_BOTTOM_EVENT:
30
- if (isScrollDebounced('next') || isScrollSuppressed()) return null;
30
+ if (shouldIgnoreScroll('next')) return null;
31
31
  return { type: 'HIGHLIGHT_MOVE', direction: 'down' };
32
32
  default:
33
33
  // Simulator omits eventType for CLICK_EVENT (value 0).
@@ -10,6 +10,14 @@ const SCROLL_SUPPRESS_AFTER_TEXT_MS = 80;
10
10
 
11
11
  let lastTapTime = 0;
12
12
  let lastTapKind: 'tap' | 'double' | null = null;
13
+ let bypassNextScrollChecks = false;
14
+
15
+ function consumeExternalScrollBypass(): boolean {
16
+ const g = globalThis as typeof globalThis & { __evenAllowImmediateScrollOnce?: boolean };
17
+ if (!g.__evenAllowImmediateScrollOnce) return false;
18
+ g.__evenAllowImmediateScrollOnce = false;
19
+ return true;
20
+ }
13
21
 
14
22
  export function tryConsumeTap(kind: 'tap' | 'double'): boolean {
15
23
  const now = Date.now();
@@ -31,6 +39,10 @@ export function tryConsumeTap(kind: 'tap' | 'double'): boolean {
31
39
  }
32
40
 
33
41
  export function isScrollSuppressed(): boolean {
42
+ if (bypassNextScrollChecks || consumeExternalScrollBypass()) {
43
+ bypassNextScrollChecks = false;
44
+ return false;
45
+ }
34
46
  return Date.now() - lastTapTime < SCROLL_SUPPRESS_AFTER_TAP_MS;
35
47
  }
36
48
 
@@ -46,6 +58,12 @@ export function notifyTextUpdate(): void {
46
58
  export function isScrollDebounced(direction: 'prev' | 'next'): boolean {
47
59
  const now = Date.now();
48
60
 
61
+ if (bypassNextScrollChecks || consumeExternalScrollBypass()) {
62
+ lastScrollTime = now;
63
+ lastScrollDir = direction;
64
+ return false;
65
+ }
66
+
49
67
  // Suppress scrolls briefly after a text update (G2 re-layout fires spurious events)
50
68
  if (now - textUpdateTime < SCROLL_SUPPRESS_AFTER_TEXT_MS) return true;
51
69
 
@@ -58,3 +76,35 @@ export function isScrollDebounced(direction: 'prev' | 'next'): boolean {
58
76
  lastScrollDir = direction;
59
77
  return false;
60
78
  }
79
+
80
+ /** Shared scroll gate so a single bypass applies to the full event. */
81
+ export function shouldIgnoreScroll(direction: 'prev' | 'next'): boolean {
82
+ const now = Date.now();
83
+ const bypassed = bypassNextScrollChecks || consumeExternalScrollBypass();
84
+
85
+ if (bypassed) {
86
+ bypassNextScrollChecks = false;
87
+ lastScrollTime = now;
88
+ lastScrollDir = direction;
89
+ return false;
90
+ }
91
+
92
+ if (now - lastTapTime < SCROLL_SUPPRESS_AFTER_TAP_MS) return true;
93
+ if (now - textUpdateTime < SCROLL_SUPPRESS_AFTER_TEXT_MS) return true;
94
+
95
+ const threshold =
96
+ direction === lastScrollDir ? SAME_DIRECTION_DEBOUNCE_MS : DIRECTION_CHANGE_DEBOUNCE_MS;
97
+
98
+ if (now - lastScrollTime < threshold) return true;
99
+
100
+ lastScrollTime = now;
101
+ lastScrollDir = direction;
102
+ return false;
103
+ }
104
+
105
+ /** Allow the next scroll event through immediately after a tap-driven mode change. */
106
+ export function armImmediateScroll(): void {
107
+ bypassNextScrollChecks = true;
108
+ lastScrollTime = 0;
109
+ lastScrollDir = null;
110
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Chat/terminal output display builder for G2 glasses.
3
+ * Optimized for reading streaming AI output on a 10-line text display.
4
+ *
5
+ * Line type prefixes:
6
+ * > user prompt
7
+ * >> tool call
8
+ * + collapsed thinking
9
+ * - expanded thinking header
10
+ * ! error
11
+ * (no prefix) assistant text
12
+ */
13
+
14
+ import type { DisplayData, DisplayLine } from './types';
15
+ import { line, glassHeader } from './types';
16
+ import { applyScrollIndicators } from './text-utils';
17
+
18
+ export interface ChatLine {
19
+ type: 'prompt' | 'text' | 'tool' | 'thinking-collapsed' | 'thinking-expanded' | 'thinking-body' | 'error' | 'system';
20
+ text: string;
21
+ }
22
+
23
+ /**
24
+ * Format a single ChatLine into one or more display strings,
25
+ * word-wrapping at maxChars.
26
+ */
27
+ export function formatChatLine(chatLine: ChatLine, maxChars = 44): string[] {
28
+ const { type, text } = chatLine;
29
+
30
+ let prefix: string;
31
+ switch (type) {
32
+ case 'prompt':
33
+ prefix = '> ';
34
+ break;
35
+ case 'tool':
36
+ prefix = '>> ';
37
+ break;
38
+ case 'thinking-collapsed':
39
+ prefix = '+ ';
40
+ break;
41
+ case 'thinking-expanded':
42
+ prefix = '- ';
43
+ break;
44
+ case 'thinking-body':
45
+ prefix = ' ';
46
+ break;
47
+ case 'error':
48
+ prefix = '! ';
49
+ break;
50
+ case 'system':
51
+ prefix = '= ';
52
+ break;
53
+ case 'text':
54
+ default:
55
+ prefix = '';
56
+ break;
57
+ }
58
+
59
+ const available = maxChars - prefix.length;
60
+ if (text.length <= available) {
61
+ return [`${prefix}${text}`];
62
+ }
63
+
64
+ // Word-wrap: try to break at word boundaries
65
+ const lines: string[] = [];
66
+ let remaining = text;
67
+ let isFirst = true;
68
+
69
+ while (remaining.length > 0) {
70
+ const pfx = isFirst ? prefix : ' '.repeat(prefix.length);
71
+ const avail = maxChars - pfx.length;
72
+
73
+ if (remaining.length <= avail) {
74
+ lines.push(`${pfx}${remaining}`);
75
+ break;
76
+ }
77
+
78
+ // Find last space within available width
79
+ let breakAt = remaining.lastIndexOf(' ', avail);
80
+ if (breakAt <= 0) breakAt = avail; // no space found, hard break
81
+
82
+ lines.push(`${pfx}${remaining.slice(0, breakAt)}`);
83
+ remaining = remaining.slice(breakAt).trimStart();
84
+ isFirst = false;
85
+ }
86
+
87
+ return lines;
88
+ }
89
+
90
+ export interface ChatDisplayOptions {
91
+ /** Header title, e.g. "CLAUDE · opus · running" */
92
+ title: string;
93
+ /** Action bar string from buildStaticActionBar() */
94
+ actionBar: string;
95
+ /** Ordered chat lines to display */
96
+ chatLines: ChatLine[];
97
+ /** Scroll offset from bottom. 0 = show latest. Positive = scrolled up. */
98
+ scrollOffset: number;
99
+ /** Number of visible content lines. Default: 7 (10 total - 3 header) */
100
+ contentSlots?: number;
101
+ /** Max chars per line. Default: 44 */
102
+ maxChars?: number;
103
+ }
104
+
105
+ /**
106
+ * Build a complete chat display for G2 glasses.
107
+ * Auto-scrolls to bottom (latest content) unless scrollOffset > 0.
108
+ * Returns DisplayData with header + scrollable content.
109
+ */
110
+ export function buildChatDisplay(opts: ChatDisplayOptions): DisplayData {
111
+ const {
112
+ title,
113
+ actionBar,
114
+ chatLines,
115
+ scrollOffset,
116
+ contentSlots = 7,
117
+ maxChars = 44,
118
+ } = opts;
119
+
120
+ const lines = [...glassHeader(title, actionBar)];
121
+
122
+ // Format all chat lines into display strings
123
+ const allLines: string[] = [];
124
+ for (const cl of chatLines) {
125
+ allLines.push(...formatChatLine(cl, maxChars));
126
+ }
127
+
128
+ if (allLines.length === 0) {
129
+ lines.push(line(' Waiting for output...', 'meta'));
130
+ return { lines };
131
+ }
132
+
133
+ // Scroll from bottom: offset 0 = show last contentSlots lines
134
+ const totalLines = allLines.length;
135
+ const maxFromBottom = Math.max(0, totalLines - contentSlots);
136
+ const clampedOffset = Math.min(scrollOffset, maxFromBottom);
137
+ const start = Math.max(0, totalLines - contentSlots - clampedOffset);
138
+
139
+ const visible = allLines.slice(start, start + contentSlots);
140
+ const contentDisplayLines: DisplayLine[] = visible.map((t) => line(t, 'normal'));
141
+
142
+ applyScrollIndicators(
143
+ contentDisplayLines,
144
+ start,
145
+ totalLines,
146
+ contentSlots,
147
+ (t) => line(t, 'meta'),
148
+ );
149
+
150
+ lines.push(...contentDisplayLines);
151
+ return { lines };
152
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Glass display formatting constants and helpers.
3
+ * Safe Unicode characters confirmed working on G2 LVGL display.
4
+ * Inspired by tesla-even-g2 formatting patterns.
5
+ */
6
+
7
+ /** Middle dot · field separator */
8
+ export const SEP = '\u00B7';
9
+
10
+ /** Single right-pointing angle › drill-in indicator */
11
+ export const DRILL = '\u203A';
12
+
13
+ /** Single left-pointing angle ‹ back prefix */
14
+ export const BACK_CHAR = '\u2039';
15
+
16
+ /** En-dash – result separator */
17
+ export const DASH = '\u2013';
18
+
19
+ /** Heavy horizontal ═ filled progress bar segment */
20
+ export const BAR_FILL = '\u2501';
21
+
22
+ /** Light horizontal ─ empty progress bar segment */
23
+ export const BAR_EMPTY = '\u2500';
24
+
25
+ /**
26
+ * Join non-empty values with · separator.
27
+ * Falsy values are filtered out.
28
+ *
29
+ * @example fieldJoin('CLAUDE', 'opus', 'running') → "CLAUDE · opus · running"
30
+ * @example fieldJoin('HOSTS', false, '2 total') → "HOSTS · 2 total"
31
+ */
32
+ export function fieldJoin(...parts: (string | undefined | null | false)[]): string {
33
+ return parts.filter(Boolean).join(` ${SEP} `);
34
+ }
35
+
36
+ /**
37
+ * Render an ASCII progress bar.
38
+ * Uses ═ for filled and ─ for empty.
39
+ *
40
+ * @example progressBar(67) → "═══════───"
41
+ * @example progressBar(30, 20) → "══════──────────────"
42
+ */
43
+ export function progressBar(percent: number, width = 10): string {
44
+ const clamped = Math.max(0, Math.min(100, percent));
45
+ const filled = Math.round((clamped / 100) * width);
46
+ return BAR_FILL.repeat(filled) + BAR_EMPTY.repeat(width - filled);
47
+ }
48
+
49
+ /**
50
+ * Format a key · value pair, truncating value if needed.
51
+ *
52
+ * @example kvLine('Language', 'EN') → "Language · EN"
53
+ */
54
+ export function kvLine(label: string, value: string, maxWidth = 44): string {
55
+ const sep = ` ${SEP} `;
56
+ const available = maxWidth - label.length - sep.length;
57
+ const val = value.length > available ? value.slice(0, available - 1) + '~' : value;
58
+ return `${label}${sep}${val}`;
59
+ }
60
+
61
+ /**
62
+ * Append › drill-in indicator to a label.
63
+ *
64
+ * @example drillLabel('Sessions') → "Sessions ›"
65
+ */
66
+ export function drillLabel(text: string): string {
67
+ return `${text} ${DRILL}`;
68
+ }
69
+
70
+ /**
71
+ * Build a "‹ Back" label for menu items.
72
+ */
73
+ export function backLabel(): string {
74
+ return `${BACK_CHAR} Back`;
75
+ }
package/glasses/index.ts CHANGED
@@ -13,3 +13,5 @@ export * from './glass-display-builders';
13
13
  export * from './glass-mode';
14
14
  export * from './glass-router';
15
15
  export * from './glass-screen-router';
16
+ export * from './glass-format';
17
+ export * from './glass-chat-display';
@@ -71,7 +71,7 @@ export function useGlasses<S>(config: UseGlassesConfig<S>): void {
71
71
  // Build display text from lines
72
72
  const data = configRef.current.toDisplayData(snapshot, nav);
73
73
  const text = data.lines.map(l => {
74
- if (l.style === 'separator') return '\u2500'.repeat(28) + '\n';
74
+ if (l.style === 'separator') return '\u2500'.repeat(28);
75
75
  if (l.inverted) return `\u25B6 ${l.text}`;
76
76
  return ` ${l.text}`;
77
77
  }).join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "even-toolkit",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Design system & component library for Even Realities G2 smart glasses apps — 55+ web components, 191 pixel-art icons, glasses SDK bridge, and design tokens.",
5
5
  "type": "module",
6
6
  "main": "./dist/glasses/index.js",
@@ -102,6 +102,14 @@
102
102
  "types": "./dist/glasses/glass-screen-router.d.ts",
103
103
  "import": "./dist/glasses/glass-screen-router.js"
104
104
  },
105
+ "./glass-format": {
106
+ "types": "./dist/glasses/glass-format.d.ts",
107
+ "import": "./dist/glasses/glass-format.js"
108
+ },
109
+ "./glass-chat-display": {
110
+ "types": "./dist/glasses/glass-chat-display.d.ts",
111
+ "import": "./dist/glasses/glass-chat-display.js"
112
+ },
105
113
  "./web": {
106
114
  "types": "./dist/web/index.d.ts",
107
115
  "import": "./dist/web/index.js"
@@ -353,6 +361,7 @@
353
361
  "glasses",
354
362
  "stt",
355
363
  "README.md",
364
+ "CHANGELOG.md",
356
365
  "LICENSE"
357
366
  ],
358
367
  "scripts": {
@@ -28,6 +28,7 @@ export class DeepgramProvider implements STTProvider {
28
28
  private language = 'en';
29
29
  private modelId = 'nova-2';
30
30
  private ws: WebSocket | null = null;
31
+ private suppressSocketEvents = false;
31
32
 
32
33
  private transcriptCbs: Array<(t: STTTranscript) => void> = [];
33
34
  private stateCbs: Array<(s: STTState) => void> = [];
@@ -51,8 +52,9 @@ export class DeepgramProvider implements STTProvider {
51
52
 
52
53
  start(): void {
53
54
  if (this.ws) {
54
- this.closeSocket();
55
+ this.closeSocket(true);
55
56
  }
57
+ this.suppressSocketEvents = false;
56
58
 
57
59
  const params = new URLSearchParams({
58
60
  model: this.modelId,
@@ -98,6 +100,7 @@ export class DeepgramProvider implements STTProvider {
98
100
  };
99
101
 
100
102
  this.ws.onerror = (event) => {
103
+ if (this.suppressSocketEvents) return;
101
104
  sttLog('deepgram: WebSocket error', event);
102
105
  const err: STTError = {
103
106
  code: 'network',
@@ -110,6 +113,11 @@ export class DeepgramProvider implements STTProvider {
110
113
 
111
114
  this.ws.onclose = () => {
112
115
  this.ws = null;
116
+ if (this.suppressSocketEvents) {
117
+ this.suppressSocketEvents = false;
118
+ this.setState('idle');
119
+ return;
120
+ }
113
121
  if (this._state === 'listening') {
114
122
  this.setState('idle');
115
123
  }
@@ -140,15 +148,15 @@ export class DeepgramProvider implements STTProvider {
140
148
  // Send close message per Deepgram protocol
141
149
  this.ws.send(JSON.stringify({ type: 'CloseStream' }));
142
150
  }
143
- this.closeSocket();
151
+ this.closeSocket(true);
144
152
  }
145
153
 
146
154
  abort(): void {
147
- this.closeSocket();
155
+ this.closeSocket(true);
148
156
  }
149
157
 
150
158
  dispose(): void {
151
- this.closeSocket();
159
+ this.closeSocket(true);
152
160
  this.transcriptCbs = [];
153
161
  this.stateCbs = [];
154
162
  this.errorCbs = [];
@@ -171,10 +179,18 @@ export class DeepgramProvider implements STTProvider {
171
179
 
172
180
  // ── Private ──
173
181
 
174
- private closeSocket(): void {
175
- if (this.ws) {
176
- try { this.ws.close(); } catch { /* ignore */ }
182
+ private closeSocket(silent = false): void {
183
+ const socket = this.ws;
184
+ if (socket) {
177
185
  this.ws = null;
186
+ if (silent) {
187
+ this.suppressSocketEvents = true;
188
+ socket.onopen = null;
189
+ socket.onmessage = null;
190
+ socket.onerror = null;
191
+ socket.onclose = null;
192
+ }
193
+ try { socket.close(); } catch { /* ignore */ }
178
194
  }
179
195
  this.setState('idle');
180
196
  }
@@ -21,6 +21,9 @@ interface DialogProps {
21
21
  function Dialog({ open, onClose, title, icon, children, actions, className }: DialogProps) {
22
22
  const [visible, setVisible] = React.useState(false);
23
23
  const [closing, setClosing] = React.useState(false);
24
+ const stopPropagation = React.useCallback((event: React.SyntheticEvent) => {
25
+ event.stopPropagation();
26
+ }, []);
24
27
 
25
28
  React.useEffect(() => {
26
29
  if (open) {
@@ -42,11 +45,27 @@ function Dialog({ open, onClose, title, icon, children, actions, className }: Di
42
45
  return () => document.removeEventListener('keydown', handler);
43
46
  }, [visible, onClose]);
44
47
 
48
+ React.useEffect(() => {
49
+ if (!visible) return;
50
+ const prevBodyOverflow = document.body.style.overflow;
51
+ const prevHtmlOverflow = document.documentElement.style.overflow;
52
+ document.body.style.overflow = 'hidden';
53
+ document.documentElement.style.overflow = 'hidden';
54
+ return () => {
55
+ document.body.style.overflow = prevBodyOverflow;
56
+ document.documentElement.style.overflow = prevHtmlOverflow;
57
+ };
58
+ }, [visible]);
59
+
45
60
  if (!visible) return null;
46
61
 
47
62
  return (
48
63
  <div
49
- className="fixed inset-0 z-50 flex items-center justify-center px-6"
64
+ className="fixed inset-0 z-50 flex items-center justify-center px-6 overscroll-contain"
65
+ onTouchStartCapture={stopPropagation}
66
+ onTouchMoveCapture={stopPropagation}
67
+ onTouchEndCapture={stopPropagation}
68
+ onWheelCapture={stopPropagation}
50
69
  style={{ animation: closing ? 'fadeOut 200ms ease forwards' : 'fadeIn 200ms ease' }}
51
70
  >
52
71
  <div className="absolute inset-0 bg-overlay" onClick={onClose} />
@@ -0,0 +1,145 @@
1
+ import { useState, useCallback, useMemo, type ReactNode } from 'react';
2
+ import { Outlet, useLocation, useNavigate } from 'react-router';
3
+ import { SideDrawer, DrawerTrigger, DrawerHeaderContext } from './side-drawer';
4
+ import type { SideDrawerItem, DrawerHeaderConfig } from './side-drawer';
5
+ import { NavHeader } from './nav-header';
6
+ import { Button } from './button';
7
+
8
+ interface DrawerShellProps {
9
+ items: SideDrawerItem[];
10
+ bottomItems?: SideDrawerItem[];
11
+ title?: string;
12
+ footer?: ReactNode;
13
+ width?: number;
14
+ getPageTitle: (pathname: string) => string;
15
+ deriveActiveId: (pathname: string) => string;
16
+ isNestedRoute?: (pathname: string) => boolean;
17
+ getBackPath?: (pathname: string) => string;
18
+ backIcon?: ReactNode;
19
+ className?: string;
20
+ }
21
+
22
+ const DEFAULT_BACK_ICON = (
23
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
24
+ <path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
25
+ </svg>
26
+ );
27
+
28
+ function DrawerShell({
29
+ items,
30
+ bottomItems,
31
+ title,
32
+ footer,
33
+ width,
34
+ getPageTitle,
35
+ deriveActiveId,
36
+ isNestedRoute,
37
+ getBackPath,
38
+ backIcon = DEFAULT_BACK_ICON,
39
+ className,
40
+ }: DrawerShellProps) {
41
+ const location = useLocation();
42
+ const navigate = useNavigate();
43
+ const [drawerOpen, setDrawerOpen] = useState(false);
44
+ const [headerOverride, setHeaderOverride] = useState<DrawerHeaderConfig | null>(null);
45
+
46
+ const handleNavigate = useCallback((id: string) => {
47
+ navigate(id);
48
+ setDrawerOpen(false);
49
+ }, [navigate]);
50
+
51
+ const allItemIds = useMemo(() => {
52
+ const ids = new Set(items.map((i) => i.id));
53
+ if (bottomItems) bottomItems.forEach((i) => ids.add(i.id));
54
+ return ids;
55
+ }, [items, bottomItems]);
56
+
57
+ const pathname = location.pathname;
58
+ const activeId = deriveActiveId(pathname);
59
+
60
+ // Determine if nested: either explicit function or check if path matches any item id
61
+ const nested = isNestedRoute
62
+ ? isNestedRoute(pathname)
63
+ : !allItemIds.has(pathname);
64
+
65
+ // Resolve header values (screen overrides > defaults)
66
+ const headerTitle = headerOverride?.title ?? getPageTitle(pathname);
67
+ const headerHidden = headerOverride?.hidden ?? false;
68
+
69
+ const handleBack = useCallback(() => {
70
+ const explicit = headerOverride?.backTo ?? getBackPath?.(pathname);
71
+ if (explicit) {
72
+ navigate(explicit);
73
+ } else {
74
+ // Use browser history to go back to the actual previous page
75
+ navigate(-1);
76
+ }
77
+ }, [navigate, headerOverride?.backTo, getBackPath, pathname]);
78
+
79
+ const defaultLeft = nested
80
+ ? (
81
+ <Button variant="ghost" size="icon" onClick={handleBack}>
82
+ {backIcon}
83
+ </Button>
84
+ )
85
+ : <DrawerTrigger onClick={() => setDrawerOpen(true)} />;
86
+
87
+ const headerLeft = headerOverride?.left ?? (headerOverride?.backTo
88
+ ? (
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ onClick={() => navigate(headerOverride.backTo!)}
93
+ >
94
+ {backIcon}
95
+ </Button>
96
+ )
97
+ : defaultLeft);
98
+
99
+ const headerRight = headerOverride?.right ?? undefined;
100
+ const headerBelow = headerOverride?.below ?? undefined;
101
+ const headerFooter = headerOverride?.footer ?? undefined;
102
+
103
+ // Context value
104
+ const ctxValue = useMemo(() => ({
105
+ setHeader: (config: DrawerHeaderConfig) => setHeaderOverride(config),
106
+ resetHeader: () => setHeaderOverride(null),
107
+ }), []);
108
+
109
+ return (
110
+ <SideDrawer
111
+ open={drawerOpen}
112
+ onClose={() => setDrawerOpen(false)}
113
+ onNavigate={handleNavigate}
114
+ activeId={activeId}
115
+ items={items}
116
+ bottomItems={bottomItems}
117
+ title={title}
118
+ footer={footer}
119
+ width={width}
120
+ className={className}
121
+ >
122
+ <DrawerHeaderContext.Provider value={ctxValue}>
123
+ <div className="flex flex-col h-full">
124
+ {!headerHidden && (
125
+ <div className="shrink-0">
126
+ <NavHeader title={headerTitle} left={headerLeft} right={headerRight} />
127
+ {headerBelow}
128
+ </div>
129
+ )}
130
+ <div className="flex-1 overflow-y-auto min-h-0">
131
+ <Outlet />
132
+ </div>
133
+ {headerFooter && (
134
+ <div className="shrink-0">
135
+ {headerFooter}
136
+ </div>
137
+ )}
138
+ </div>
139
+ </DrawerHeaderContext.Provider>
140
+ </SideDrawer>
141
+ );
142
+ }
143
+
144
+ export { DrawerShell };
145
+ export type { DrawerShellProps };