@3sln/trove 0.0.2

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 (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,101 @@
1
+ // SettingsService — schema-driven, persisted, reactive settings (VS Code's
2
+ // settings model). Core and plugins register typed setting schemas; values are
3
+ // stored in localStorage, namespaced, and merged over defaults. The Settings UI
4
+ // is generated from the schema, so adding a setting never means touching UI code.
5
+ // Plugin keys are namespaced under the plugin id.
6
+
7
+ import { cell } from '../runtime.js';
8
+
9
+ const STORAGE_KEY = 'trove.settings';
10
+
11
+ export class SettingsService {
12
+ constructor() {
13
+ this.schema = new Map(); // key -> { type, default, title, description, enum?, minimum?, maximum?, category, order }
14
+ this.values = read();
15
+ this.cell = cell(this.effective());
16
+ }
17
+
18
+ /**
19
+ * Register one or more settings. Each: { key, type, default, title,
20
+ * description?, enum?, enumLabels?, minimum?, maximum?, category?, order? }
21
+ */
22
+ register(schemas) {
23
+ for (const s of [].concat(schemas)) this.schema.set(s.key, s);
24
+ this.cell.setValue(this.effective());
25
+ return () => {
26
+ for (const s of [].concat(schemas)) this.schema.delete(s.key);
27
+ this.cell.setValue(this.effective());
28
+ };
29
+ }
30
+
31
+ get(key) {
32
+ if (key in this.values) return this.values[key];
33
+ return this.schema.get(key)?.default;
34
+ }
35
+
36
+ set(key, value) {
37
+ const schema = this.schema.get(key);
38
+ if (schema && value === schema.default) delete this.values[key];
39
+ else this.values[key] = value;
40
+ write(this.values);
41
+ this.cell.setValue(this.effective());
42
+ }
43
+
44
+ reset(key) {
45
+ delete this.values[key];
46
+ write(this.values);
47
+ this.cell.setValue(this.effective());
48
+ }
49
+
50
+ /** Full resolved values (defaults ⊕ overrides). */
51
+ effective() {
52
+ const out = {};
53
+ for (const [key, s] of this.schema) out[key] = key in this.values ? this.values[key] : s.default;
54
+ // Include stored keys with no (current) schema, so unknown/plugin values survive.
55
+ for (const [key, v] of Object.entries(this.values)) if (!(key in out)) out[key] = v;
56
+ return out;
57
+ }
58
+
59
+ observe() {
60
+ return this.cell;
61
+ }
62
+
63
+ /** Schema entries grouped by category, for the Settings UI. */
64
+ grouped() {
65
+ const groups = new Map();
66
+ for (const s of [...this.schema.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
67
+ // `hidden` settings are state the app keeps under the settings key, not choices a
68
+ // person makes — the opener associations, for one, which are edited through the
69
+ // opener chooser and have their own section below. Rendering them anyway put a row
70
+ // reading "[object Object]" at the top of Settings.
71
+ if (s.hidden) continue;
72
+ const cat = s.category || 'General';
73
+ if (!groups.has(cat)) groups.set(cat, []);
74
+ groups.get(cat).push({ ...s, value: this.get(s.key) });
75
+ }
76
+ return [...groups.entries()].map(([category, items]) => ({ category, items }));
77
+ }
78
+
79
+ scopedFor(pluginId) {
80
+ const prefix = `${pluginId}.`;
81
+ return {
82
+ register: (schemas) =>
83
+ this.register([].concat(schemas).map((s) => ({ ...s, key: prefix + s.key, category: s.category || pluginId }))),
84
+ get: (key) => this.get(prefix + key),
85
+ set: (key, value) => this.set(prefix + key, value),
86
+ };
87
+ }
88
+ }
89
+
90
+ function read() {
91
+ try {
92
+ return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+ function write(values) {
98
+ try {
99
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
100
+ } catch { /* quota / private mode */ }
101
+ }
@@ -0,0 +1,286 @@
1
+ // Driving the drive with a remote control.
2
+ //
3
+ // A TV has four arrows, an OK button, and a Back button. It has no pointer and no Tab
4
+ // key, so a layout that is perfectly usable with a mouse can be completely unreachable
5
+ // from a sofa: focus lands somewhere, the arrows do nothing, and there is no way out.
6
+ //
7
+ // This maps the arrows onto GEOMETRY rather than DOM order. Pressing right should move
8
+ // to the thing that is visually to the right, which is what someone holding a remote
9
+ // expects and what tab order — written for reading order — does not give. Two rules keep
10
+ // it from fighting the rest of the app:
11
+ //
12
+ // 1. If something already handled the key (the launcher's own list navigation, a text
13
+ // field's caret), it is left alone. `defaultPrevented` is the contract.
14
+ // 2. Anything the app made clickable is reachable, whether or not the browser thinks
15
+ // it is focusable. A div with a click handler is a destination on a TV; the
16
+ // alternative is a list of files that cannot be opened.
17
+ //
18
+ // Only installed when the layout is 'tv'. On a desktop the pointer and Tab already work,
19
+ // and remapping the arrow keys there would break scrolling.
20
+
21
+ // Natively focusable, plus the app's own clickable rows. Keeping this list explicit
22
+ // (rather than "anything with a click handler", which the DOM cannot tell us) means a
23
+ // new clickable component is opted in deliberately.
24
+ import { effect } from '../runtime.js';
25
+
26
+ const NATIVE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
27
+ // `.setting-row` used to be listed here and has never existed — the rendered class is
28
+ // `.setting`, and it isn't clickable anyway (its control is). Every entry below is
29
+ // checked by probe15-tv-reach, so a renamed class fails a test instead of silently
30
+ // dropping a destination.
31
+ const APP_CLICKABLE = '.launch-item, .grid-tile, .sheet-row, .inbox-item, .plugin-card, .act-task, .act-issue, .chapter';
32
+ const SELECTOR = `${NATIVE}, ${APP_CLICKABLE}`;
33
+
34
+ // Keys a television sends for "back". The named ones are the web standard; the numbers
35
+ // are Tizen (Samsung) and webOS (LG), which predate it and still ship.
36
+ const BACK_KEYS = new Set(['BrowserBack', 'GoBack', 'Backspace']);
37
+ const BACK_CODES = new Set([10009, 461]);
38
+
39
+ const DIRS = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' };
40
+ // Elements the browser already fires a click on when Enter is pressed.
41
+ const ACTIVATES_ITSELF = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
42
+
43
+ export class SpatialNavigationService {
44
+ /**
45
+ * @param {object} deps
46
+ * @param {import('./workbench.js').WorkbenchService} deps.workbench
47
+ * @param {import('./viewport.js').ViewportService} deps.viewport
48
+ * @param {Window} [deps.window]
49
+ */
50
+ constructor({ workbench, viewport, window: win = globalThis }) {
51
+ this.workbench = workbench;
52
+ this.viewport = viewport;
53
+ this.window = win;
54
+ this.active = false;
55
+ this._onKey = (e) => this.handleKey(e);
56
+ this._observer = null;
57
+ this._pending = 0;
58
+ this._bootstrapped = false;
59
+ }
60
+
61
+ /** Follow the viewport: arrows are remapped only while the TV layout is showing. */
62
+ install() {
63
+ if (this.viewport) effect(this.viewport.observe(), (vp) => this.setActive(vp.mode === 'tv'));
64
+ this.setActive(this.viewport?.state?.mode === 'tv');
65
+ return this;
66
+ }
67
+
68
+ setActive(on) {
69
+ if (on === this.active) return;
70
+ this.active = on;
71
+ // Bubble phase, so a component that wants the key gets it first and this only sees
72
+ // what nothing else claimed.
73
+ if (on) {
74
+ this.window.addEventListener?.('keydown', this._onKey);
75
+ this.#watchDom();
76
+ // The first frame may not have rendered yet; a TV with nothing focused is a TV
77
+ // where the first arrow press has no origin and appears to do nothing at all.
78
+ this.prime();
79
+ this.window.setTimeout?.(() => this.prime(), 60);
80
+ } else {
81
+ this.window.removeEventListener?.('keydown', this._onKey);
82
+ this._observer?.disconnect();
83
+ this._observer = null;
84
+ this._bootstrapped = false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Make everything clickable focusable, and make sure something IS focused.
90
+ *
91
+ * Both halves matter. A div with a click handler is a destination on a TV but the
92
+ * browser will not focus it without a tabindex, and the app re-renders constantly, so
93
+ * this has to run again after every render rather than once at startup.
94
+ */
95
+ prime() {
96
+ this.candidates(); // stamps tabindex as a side effect
97
+ this.focusFirst();
98
+ }
99
+
100
+ #watchDom() {
101
+ const Obs = this.window.MutationObserver;
102
+ if (!Obs || this._observer) return;
103
+ // Coalesced to one pass per frame: the workbench replaces whole subtrees on a
104
+ // re-render, and stamping per mutation would be thousands of calls per second.
105
+ this._observer = new Obs(() => {
106
+ if (this._pending) return;
107
+ this._pending = this.window.requestAnimationFrame?.(() => {
108
+ this._pending = 0;
109
+ if (this.active) this.prime();
110
+ }) || 0;
111
+ });
112
+ this._observer.observe(this.window.document.body, { childList: true, subtree: true });
113
+ }
114
+
115
+ handleKey(e) {
116
+ if (!this.active || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
117
+ const doc = this.window.document;
118
+ const from = doc.activeElement;
119
+
120
+ if (BACK_KEYS.has(e.key) || BACK_CODES.has(e.keyCode)) {
121
+ // Backspace with something to delete is a backspace. Getting this wrong throws
122
+ // away what someone typed on an on-screen keyboard. But an EMPTY field has nothing
123
+ // to delete, and a remote whose only Back button silently does nothing while a
124
+ // search box happens to hold focus is a remote that can't leave the screen.
125
+ if (e.key === 'Backspace' && isTextEntry(from) && (from.value ?? '').length > 0) return;
126
+ e.preventDefault();
127
+ this.goBack();
128
+ return;
129
+ }
130
+
131
+ const dir = DIRS[e.key];
132
+ if (!dir) {
133
+ // Enter on a real button already activates it. Enter on one of the app's clickable
134
+ // divs does nothing at all unless we say so — and note the test is the TAG, not
135
+ // "is it focusable": we stamped a tabindex on those divs ourselves, so asking
136
+ // whether they look focusable would answer yes for every one of them and OK would
137
+ // silently do nothing on every file in the list.
138
+ if (e.key === 'Enter' && from && !ACTIVATES_ITSELF.has(from.tagName) && from.matches(APP_CLICKABLE)) {
139
+ e.preventDefault();
140
+ from.click();
141
+ }
142
+ return;
143
+ }
144
+ // Inside a text field, left and right move the caret — until the caret runs out of
145
+ // text. Then the next press leaves the field. Without the second half the search box
146
+ // is a trap: focus goes in and the only way out is a key a remote doesn't have.
147
+ if ((dir === 'left' || dir === 'right') && isTextEntry(from) && !atTextEdge(from, dir)) return;
148
+
149
+ // A re-render can drop the focused node, leaving focus on <body> with no origin to
150
+ // navigate from. Any arrow press should recover rather than appear to do nothing.
151
+ const origin = from && from !== doc.body ? from : null;
152
+ const next = this.find(dir, origin);
153
+ if (!next) return;
154
+ e.preventDefault();
155
+ focusIt(next);
156
+ }
157
+
158
+ goBack() {
159
+ // An open overlay is what "back" means while one is open. Only past that does back
160
+ // mean the viewer stack, and only past THAT does it mean the browser.
161
+ if (this.workbench?.closeOverlays()) return;
162
+ this.window.history?.back?.();
163
+ }
164
+
165
+ /** Every candidate currently on screen, in whatever order the DOM gives them. */
166
+ candidates() {
167
+ const doc = this.window.document;
168
+ const out = [];
169
+ for (const el of doc.querySelectorAll(SELECTOR)) {
170
+ if (el.getAttribute?.('aria-hidden') === 'true' || el.hasAttribute?.('disabled')) continue;
171
+ const r = el.getBoundingClientRect();
172
+ if (r.width < 2 || r.height < 2) continue;
173
+ // Off-screen is not a destination — a remote can't scroll to something it can't
174
+ // move to, so anything outside the viewport is skipped until it scrolls in.
175
+ if (r.bottom < 0 || r.top > this.window.innerHeight || r.right < 0 || r.left > this.window.innerWidth) continue;
176
+ // The app's clickable rows aren't focusable until we make them so. Doing it here,
177
+ // lazily, keeps tabindex out of every component for a mode most users never see.
178
+ if (!el.matches(NATIVE) && !el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
179
+ out.push({ el, rect: r });
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** The best thing to move to from `from` in `dir`, or null if there isn't one. */
185
+ find(dir, from) {
186
+ const all = this.candidates();
187
+ if (!all.length) return null;
188
+ const origin = from && from.getBoundingClientRect && from.getBoundingClientRect().width
189
+ ? from.getBoundingClientRect()
190
+ : null;
191
+ if (!origin) return all[0].el;
192
+
193
+ let best = null;
194
+ let bestScore = Infinity;
195
+ for (const { el, rect } of all) {
196
+ if (el === from) continue;
197
+ const score = scoreCandidate(origin, rect, dir);
198
+ if (score == null || score >= bestScore) continue;
199
+ bestScore = score;
200
+ best = el;
201
+ }
202
+ return best;
203
+ }
204
+
205
+ /**
206
+ * Put focus somewhere sensible, once.
207
+ *
208
+ * Deliberately only ONCE. Every re-render replaces DOM nodes and drops focus to
209
+ * <body>, and re-running this each time would repeatedly yank the selection back to
210
+ * the top of the screen while someone is halfway down a list. Recovering from lost
211
+ * focus is the arrow handler's job instead — it navigates from the first candidate
212
+ * when there is no origin, so a press always does something.
213
+ */
214
+ focusFirst() {
215
+ if (this._bootstrapped) return;
216
+ const doc = this.window.document;
217
+ if (doc?.activeElement && doc.activeElement !== doc.body) return;
218
+ const all = this.candidates();
219
+ if (!all.length) return;
220
+ // Search is what the drive is for, so that is where a remote should start.
221
+ const target = all.find((c) => c.el.classList?.contains('launch-input')) || all[0];
222
+ focusIt(target.el);
223
+ this._bootstrapped = true;
224
+ }
225
+ }
226
+
227
+ /** Is the caret already at the end it would be moving toward? */
228
+ function atTextEdge(el, dir) {
229
+ const start = el.selectionStart;
230
+ const end = el.selectionEnd;
231
+ // contenteditable and some input types report nothing; treat "can't tell" as at the
232
+ // edge, since being unable to leave is the worse failure.
233
+ if (start == null || end == null) return true;
234
+ if (start !== end) return false; // a selection: the arrow collapses it
235
+ return dir === 'left' ? start === 0 : end >= (el.value ?? '').length;
236
+ }
237
+
238
+ function isTextEntry(el) {
239
+ if (!el) return false;
240
+ const tag = el.tagName;
241
+ if (tag === 'TEXTAREA') return true;
242
+ if (el.isContentEditable) return true;
243
+ if (tag !== 'INPUT') return false;
244
+ return !['checkbox', 'radio', 'button', 'submit', 'range', 'file'].includes(el.type);
245
+ }
246
+
247
+ function focusIt(el) {
248
+ el.focus?.({ preventScroll: true });
249
+ // A TV screen is mostly not where your eye is; scrolling the target to the middle is
250
+ // what makes a long list navigable without the selection hugging an edge.
251
+ el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
252
+ }
253
+
254
+ /**
255
+ * How good a move is, lower being better — or null if the candidate isn't in that
256
+ * direction at all.
257
+ *
258
+ * Two distances matter and they are not equal. How far it is ALONG the direction of
259
+ * travel is the honest cost. How far it is OFF to the side is worse than it looks: a
260
+ * candidate that is 10px further right but 400px down is not "to the right" in any sense
261
+ * that matters to someone pressing right, so the sideways distance is weighted heavily.
262
+ */
263
+ function scoreCandidate(from, to, dir) {
264
+ const vertical = dir === 'up' || dir === 'down';
265
+ // Near edges along the axis of travel.
266
+ const [fromNear, toNear] = vertical
267
+ ? (dir === 'down' ? [from.bottom, to.top] : [from.top, to.bottom])
268
+ : (dir === 'right' ? [from.right, to.left] : [from.left, to.right]);
269
+ const forward = dir === 'down' || dir === 'right';
270
+ const along = forward ? toNear - fromNear : fromNear - toNear;
271
+ // A tolerance, so items in the same row that overlap by a pixel or two still count.
272
+ if (along < -2) return null;
273
+
274
+ // Overlap on the cross axis is what makes something feel "in line". Where they overlap
275
+ // the sideways cost is zero; where they don't, it is the gap between them.
276
+ const [fs, fe, ts, te] = vertical
277
+ ? [from.left, from.right, to.left, to.right]
278
+ : [from.top, from.bottom, to.top, to.bottom];
279
+ const aside = Math.max(0, Math.max(fs, ts) - Math.min(fe, te));
280
+ // Also break ties by centre alignment, so a wide row doesn't beat the one directly
281
+ // under the cursor just because they both overlap.
282
+ const centerGap = Math.abs((fs + fe) / 2 - (ts + te) / 2);
283
+ return Math.max(0, along) + aside * 3 + centerGap * 0.2;
284
+ }
285
+
286
+ export { scoreCandidate };
@@ -0,0 +1,123 @@
1
+ // What kind of screen is this, and how is it being driven?
2
+ //
3
+ // The drive runs on three shapes of device that want genuinely different chrome: a
4
+ // desktop with a mouse and a left rail, a phone held in one hand, and a TV across the
5
+ // room driven by a d-pad. This is the one place that decides which, so components ask
6
+ // `state.vp.mode` instead of each inventing its own breakpoint — and so a single
7
+ // override flips the whole shell at once.
8
+ //
9
+ // Detection is a guess, and guesses about TVs are especially bad (a browser on a set-top
10
+ // box reports a large screen and, often, a mouse it does not have). So the guess is only
11
+ // the default: `?ui=phone` in the URL, or the `workbench.layout` setting, wins outright.
12
+ // Someone whose TV we mis-detect can fix it in Settings rather than living with it.
13
+
14
+ import { cell, effect } from '../runtime.js';
15
+
16
+ export const LAYOUTS = ['auto', 'desktop', 'phone', 'tv'];
17
+
18
+ // Below this, the left rail plus a panel leaves nothing for the panel.
19
+ const PHONE_MAX = 720;
20
+ // A TV is a big screen you sit far away from. Only consulted alongside a UA hint —
21
+ // width alone would call every large monitor a television.
22
+ const TV_MIN = 1100;
23
+ const TV_UA = /\b(smart-?tv|smarttv|appletv|googletv|android\s*tv|hbbtv|netcast|web0s|webos|tizen|viera|bravia|aquos|crkey|nettv|dtv|philipstv|roku|aft[a-z]{1,3})\b/i;
24
+
25
+ /** Does the user agent say, unprompted, that it is a television? */
26
+ export function looksLikeTv(ua = '', width = 0) {
27
+ if (!ua) return false;
28
+ return TV_UA.test(ua) && width >= TV_MIN;
29
+ }
30
+
31
+ export class ViewportService {
32
+ /**
33
+ * @param {object} [deps]
34
+ * @param {Window} [deps.window] the window to measure and listen to
35
+ * @param {import('./settings.js').SettingsService} [deps.settings]
36
+ * @param {import('./context.js').ContextKeyService} [deps.context]
37
+ */
38
+ constructor({ window: win = globalThis, settings = null, context = null } = {}) {
39
+ this.window = win;
40
+ this.settings = settings;
41
+ this.context = context;
42
+ // A URL override outranks the setting: it is how someone checks the phone layout on
43
+ // a laptop, and how the e2e suite drives each shell without a device farm.
44
+ this.urlOverride = readUrlOverride(win);
45
+ this.state = this.#measure();
46
+ this.cell = cell(this.state);
47
+ this._onResize = () => this.refresh();
48
+ }
49
+
50
+ observe() {
51
+ return this.cell;
52
+ }
53
+
54
+ /** Start listening. Separate from the constructor so tests can measure without hooks. */
55
+ install() {
56
+ this.window.addEventListener?.('resize', this._onResize);
57
+ this.window.addEventListener?.('orientationchange', this._onResize);
58
+ // `effect` runs now and on every change. The old form was
59
+ // `settings.observe().subscribe?.(…)` — optional-chained, so when `observe()`
60
+ // stopped returning something with `.subscribe` it would have become a silent
61
+ // no-op and the viewport would have stopped following its own setting.
62
+ if (this.settings) effect(this.settings.observe(), () => this.refresh());
63
+ this.#publish(this.state);
64
+ return this;
65
+ }
66
+
67
+ dispose() {
68
+ this.window.removeEventListener?.('resize', this._onResize);
69
+ this.window.removeEventListener?.('orientationchange', this._onResize);
70
+ }
71
+
72
+ refresh() {
73
+ const next = this.#measure();
74
+ const same = next.mode === this.state.mode && next.width === this.state.width
75
+ && next.height === this.state.height && next.coarse === this.state.coarse;
76
+ if (same) return;
77
+ this.state = next;
78
+ this.#publish(next);
79
+ this.cell.setValue(next);
80
+ }
81
+
82
+ #publish(vp) {
83
+ // Context keys so plugin `when` clauses and keybindings can target a form factor
84
+ // the same way they target a view.
85
+ this.context?.set('viewport.mode', vp.mode);
86
+ this.context?.set('viewport.phone', vp.mode === 'phone');
87
+ this.context?.set('viewport.tv', vp.mode === 'tv');
88
+ // The root element carries it too, so CSS can respond without every rule needing a
89
+ // media query that would disagree with the JS branch above it.
90
+ const el = this.window.document?.documentElement;
91
+ if (el) el.dataset.layout = vp.mode;
92
+ }
93
+
94
+ #measure() {
95
+ const win = this.window;
96
+ const width = win.innerWidth || 1280;
97
+ const height = win.innerHeight || 800;
98
+ const coarse = matches(win, '(pointer: coarse)');
99
+ const ua = win.navigator?.userAgent || '';
100
+ const forced = this.urlOverride || this.settings?.get?.('workbench.layout') || 'auto';
101
+ const mode = forced !== 'auto' && LAYOUTS.includes(forced) ? forced : detect({ width, coarse, ua });
102
+ return { mode, width, height, coarse, forced: forced !== 'auto' };
103
+ }
104
+ }
105
+
106
+ function detect({ width, coarse, ua }) {
107
+ if (looksLikeTv(ua, width)) return 'tv';
108
+ // Narrow is a phone whether or not the pointer is coarse — a desktop window dragged
109
+ // small has the same problem a phone does, and the layout that fits one fits the other.
110
+ if (width <= PHONE_MAX) return 'phone';
111
+ return 'desktop';
112
+ }
113
+
114
+ function matches(win, query) {
115
+ try { return !!win.matchMedia?.(query)?.matches; } catch { return false; }
116
+ }
117
+
118
+ function readUrlOverride(win) {
119
+ try {
120
+ const value = new URL(win.location.href).searchParams.get('ui');
121
+ return value && LAYOUTS.includes(value) ? value : null;
122
+ } catch { return null; }
123
+ }
@@ -0,0 +1,133 @@
1
+ // Dictating into the search box.
2
+ //
3
+ // THE REMOTE'S MIC BUTTON IS NOT OURS. On every TV platform it belongs to the system:
4
+ // webOS's SDK exposes no way for an app to listen to the Magic Remote, Tizen's voice
5
+ // control is a packaged-app API that does not exist for a page in the TV browser, and
6
+ // on Android TV the button raises Assistant. No key event reaches the document, so
7
+ // there is nothing to intercept and no shortcut to bind.
8
+ //
9
+ // What the button DOES do is dictate into a focused text field, through the platform's
10
+ // own keyboard. That is the whole opportunity: the mic is useful exactly when there is
11
+ // somewhere for its text to land. `search.voice` therefore does not try to capture a
12
+ // button — it makes sure the search field is open and focused, so the mic on the remote
13
+ // has a target. That half works on every TV, with no API at all.
14
+ //
15
+ // This module is the OTHER half, for browsers that can transcribe themselves (Chrome,
16
+ // so Android TV and the desktop). It is deliberately narrow:
17
+ //
18
+ // ON-DEVICE ONLY. By default `SpeechRecognition` streams audio to the browser
19
+ // vendor's servers. Trove is a drive you host so that your files stay yours, and
20
+ // shipping a microphone that quietly forwards your voice to a third party would
21
+ // contradict the entire point of it. `processLocally` asks for recognition that never
22
+ // leaves the machine; a browser that cannot promise that is simply reported as
23
+ // unsupported, and the button is never offered.
24
+
25
+ /**
26
+ * Resolved per call, not captured at import.
27
+ *
28
+ * A module-level constant would freeze the answer at load time, which is wrong twice
29
+ * over: the page may gain the API later (a polyfill, a flag flipped behind a restart),
30
+ * and a test could never stand in for it. Reading it when asked costs a property lookup.
31
+ */
32
+ function impl() {
33
+ if (typeof window === 'undefined') return null;
34
+ return window.SpeechRecognition || window.webkitSpeechRecognition || null;
35
+ }
36
+
37
+ /**
38
+ * Whether this browser can transcribe WITHOUT sending audio anywhere.
39
+ *
40
+ * `available()` is the on-device half of the API; a browser that lacks it has no notion
41
+ * of local recognition, which for us is the same as having no recognition. Detecting
42
+ * the constructor alone would be the bug: every Chrome has that, and most of them would
43
+ * happily record you into a datacentre.
44
+ */
45
+ export function canTranscribeLocally() {
46
+ const Impl = impl();
47
+ return !!Impl && typeof Impl.available === 'function';
48
+ }
49
+
50
+ const optionsFor = (lang) => ({ langs: [lang], processLocally: true });
51
+
52
+ /**
53
+ * `'available' | 'downloadable' | 'downloading' | 'unavailable'`
54
+ *
55
+ * A language pack that is merely `downloadable` is not usable yet, and downloading one
56
+ * unasked would spend someone's bandwidth on a button they have not pressed.
57
+ */
58
+ export async function localAvailability(lang = navigator.language || 'en-US') {
59
+ if (!canTranscribeLocally()) return 'unavailable';
60
+ try {
61
+ return await impl().available(optionsFor(lang));
62
+ } catch {
63
+ return 'unavailable';
64
+ }
65
+ }
66
+
67
+ /** Fetch the on-device language pack. Only ever called from an explicit user action. */
68
+ export async function installLocal(lang = navigator.language || 'en-US') {
69
+ if (typeof impl()?.install !== 'function') return false;
70
+ try {
71
+ return await impl().install(optionsFor(lang));
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Listen, and report what was heard.
79
+ *
80
+ * `onText(text, { final })` fires for interim results too, so the query updates as
81
+ * someone speaks rather than landing in one lump at the end — on a TV that feedback is
82
+ * the difference between "it is listening" and "did it hear me".
83
+ *
84
+ * @returns {{stop: () => void}}
85
+ */
86
+ export function listen({ lang = navigator.language || 'en-US', onText, onEnd, onError } = {}) {
87
+ if (!canTranscribeLocally()) throw new Error('On-device speech recognition is not available here');
88
+ const rec = new (impl())();
89
+ rec.lang = lang;
90
+ rec.interimResults = true;
91
+ rec.continuous = false;
92
+ // Both spellings: `options` is the shape the explainer settled on, and shipping
93
+ // builds also read the flag straight off the instance. Setting one the browser
94
+ // ignores is harmless; setting neither would mean asking for local processing and
95
+ // silently getting the remote kind.
96
+ rec.options = optionsFor(lang);
97
+ rec.processLocally = true;
98
+
99
+ let stopped = false;
100
+ rec.onresult = (event) => {
101
+ let text = '';
102
+ let final = false;
103
+ for (let i = event.resultIndex; i < event.results.length; i++) {
104
+ text += event.results[i][0].transcript;
105
+ if (event.results[i].isFinal) final = true;
106
+ }
107
+ onText?.(text.trim(), { final });
108
+ };
109
+ // `no-speech` and `aborted` are what happens when someone changes their mind. They
110
+ // are not failures worth a toast.
111
+ rec.onerror = (event) => {
112
+ if (event.error === 'no-speech' || event.error === 'aborted') return;
113
+ onError?.(new Error(event.error || 'Speech recognition failed'));
114
+ };
115
+ rec.onend = () => { if (!stopped) { stopped = true; onEnd?.(); } };
116
+
117
+ try {
118
+ rec.start();
119
+ } catch (err) {
120
+ stopped = true;
121
+ onError?.(err);
122
+ onEnd?.();
123
+ }
124
+
125
+ return {
126
+ stop() {
127
+ if (stopped) return;
128
+ stopped = true;
129
+ try { rec.stop(); } catch { /* already finished */ }
130
+ onEnd?.();
131
+ },
132
+ };
133
+ }