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.
- package/CHANGELOG.md +27 -0
- package/README.md +53 -5
- package/dist/glasses/action-bar.d.ts +3 -3
- package/dist/glasses/action-bar.js +7 -7
- package/dist/glasses/action-bar.js.map +1 -1
- package/dist/glasses/action-map.js +3 -3
- package/dist/glasses/action-map.js.map +1 -1
- package/dist/glasses/gestures.d.ts +4 -0
- package/dist/glasses/gestures.d.ts.map +1 -1
- package/dist/glasses/gestures.js +44 -0
- package/dist/glasses/gestures.js.map +1 -1
- package/dist/glasses/glass-chat-display.d.ts +43 -0
- package/dist/glasses/glass-chat-display.d.ts.map +1 -0
- package/dist/glasses/glass-chat-display.js +102 -0
- package/dist/glasses/glass-chat-display.js.map +1 -0
- package/dist/glasses/glass-format.d.ts +50 -0
- package/dist/glasses/glass-format.d.ts.map +1 -0
- package/dist/glasses/glass-format.js +65 -0
- package/dist/glasses/glass-format.js.map +1 -0
- package/dist/glasses/index.d.ts +2 -0
- package/dist/glasses/index.d.ts.map +1 -1
- package/dist/glasses/index.js +2 -0
- package/dist/glasses/index.js.map +1 -1
- package/dist/glasses/useGlasses.js +1 -1
- package/dist/glasses/useGlasses.js.map +1 -1
- package/dist/stt/providers/deepgram.d.ts +1 -0
- package/dist/stt/providers/deepgram.d.ts.map +1 -1
- package/dist/stt/providers/deepgram.js +25 -8
- package/dist/stt/providers/deepgram.js.map +1 -1
- package/dist/web/components/dialog.d.ts.map +1 -1
- package/dist/web/components/dialog.js +16 -1
- package/dist/web/components/dialog.js.map +1 -1
- package/dist/web/components/drawer-shell.d.ts +19 -0
- package/dist/web/components/drawer-shell.d.ts.map +1 -0
- package/dist/web/components/drawer-shell.js +59 -0
- package/dist/web/components/drawer-shell.js.map +1 -0
- package/dist/web/components/list-item.d.ts +1 -1
- package/dist/web/components/list-item.d.ts.map +1 -1
- package/dist/web/components/list-item.js +20 -5
- package/dist/web/components/list-item.js.map +1 -1
- package/dist/web/components/multi-select.d.ts +22 -0
- package/dist/web/components/multi-select.d.ts.map +1 -0
- package/dist/web/components/multi-select.js +52 -0
- package/dist/web/components/multi-select.js.map +1 -0
- package/dist/web/components/select.d.ts +13 -3
- package/dist/web/components/select.d.ts.map +1 -1
- package/dist/web/components/select.js +36 -3
- package/dist/web/components/select.js.map +1 -1
- package/dist/web/components/side-drawer.d.ts +43 -0
- package/dist/web/components/side-drawer.d.ts.map +1 -0
- package/dist/web/components/side-drawer.js +88 -0
- package/dist/web/components/side-drawer.js.map +1 -0
- package/dist/web/icons/svg-icons.js +1 -1
- package/dist/web/icons/svg-icons.js.map +1 -1
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -0
- package/dist/web/index.js.map +1 -1
- package/glasses/action-bar.ts +7 -7
- package/glasses/action-map.ts +3 -3
- package/glasses/gestures.ts +50 -0
- package/glasses/glass-chat-display.ts +152 -0
- package/glasses/glass-format.ts +75 -0
- package/glasses/index.ts +2 -0
- package/glasses/useGlasses.ts +1 -1
- package/package.json +10 -1
- package/stt/providers/deepgram.ts +23 -7
- package/web/components/dialog.tsx +20 -1
- package/web/components/drawer-shell.tsx +145 -0
- package/web/components/list-item.tsx +25 -10
- package/web/components/multi-select.tsx +118 -0
- package/web/components/select.tsx +90 -20
- package/web/components/side-drawer.tsx +246 -0
- package/web/icons/svg-icons.tsx +1 -2
- package/web/index.ts +9 -0
- package/web/theme/utilities.css +11 -0
package/glasses/action-bar.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Renders a row of named buttons with triangle indicators:
|
|
5
5
|
* ▶Timer◀ ▷Scroll◁ Steps
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
|
30
|
+
// Active/confirmed button: filled triangles
|
|
31
31
|
return `\u25B6${name}\u25C0`;
|
|
32
32
|
}
|
|
33
|
-
if (
|
|
34
|
-
//
|
|
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 (
|
|
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 `\
|
|
51
|
+
return `\u25B6${name}\u25C0`;
|
|
52
52
|
}
|
|
53
53
|
return ` ${name} `;
|
|
54
54
|
}).join(' ');
|
package/glasses/action-map.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
27
|
+
if (shouldIgnoreScroll('prev')) return null;
|
|
28
28
|
return { type: 'HIGHLIGHT_MOVE', direction: 'up' };
|
|
29
29
|
case OsEventTypeList.SCROLL_BOTTOM_EVENT:
|
|
30
|
-
if (
|
|
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).
|
package/glasses/gestures.ts
CHANGED
|
@@ -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
package/glasses/useGlasses.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
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
|
-
|
|
176
|
-
|
|
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 };
|