@brftech/filex-core 0.39.1 → 0.40.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.
@@ -2029,9 +2029,39 @@ useKeyboardShortcuts(rootEl, {
2029
2029
  onTabNext: () => nextTab(),
2030
2030
  onTabPrev: () => prevTab(),
2031
2031
  /* /wiring:d1 */
2032
+ /* tus:t1 — the menu verbs, routed through the SAME dispatcher the right-click
2033
+ * menu and the toolbar use (dispatchItemAction), so a key and a click cannot
2034
+ * drift apart. The ones that act on a selection do nothing without one, which
2035
+ * is what the menu does too. */
2036
+ onNewFolder: () => {
2037
+ showNewFolder.value = true;
2038
+ },
2039
+ onUpload: () => triggerUpload(),
2040
+ onRefresh: () => void load(),
2041
+ onDownload: () => void dispatchItemAction('download', shortcutTargets()),
2042
+ onPreview: () => void dispatchItemAction('preview', shortcutTargets()),
2043
+ onShare: () => void dispatchItemAction('access', shortcutTargets()),
2044
+ onTags: () => void dispatchItemAction('tags', shortcutTargets()),
2045
+ onConvert: () => void dispatchItemAction('convert', shortcutTargets()),
2046
+ onOpenTab: () => void dispatchItemAction('open-tab', shortcutTargets()),
2047
+ onCopyPath: () => {
2048
+ const n = shortcutTargets()[0];
2049
+ if (n) void onCopyPath(n.path);
2050
+ },
2051
+ onCopyId: () => void dispatchItemAction('copy-id', shortcutTargets()),
2052
+ onRestore: () => void dispatchItemAction('restore', shortcutTargets()),
2053
+ /* /tus:t1 */
2032
2054
  hasSelection: () => !selection.isEmpty.value,
2033
2055
  });
2034
2056
 
2057
+ /* tus:t1 — which rows a keyboard verb acts on: the active pane's selection when
2058
+ * the split pane has focus, otherwise the main listing's. Same rule the delete
2059
+ * and rename shortcuts have followed since wiring:d1. */
2060
+ function shortcutTargets(): FileNode[] {
2061
+ if (paneIsActive.value) return splitPaneRef.value?.selectedNodes() ?? [];
2062
+ return selection.nodes.value;
2063
+ }
2064
+
2035
2065
  // --------------------------------------------------------------------
2036
2066
  // Actions
2037
2067
  // --------------------------------------------------------------------
@@ -10,6 +10,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
10
10
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
11
11
  import type { FileNode } from '../types/FileNode';
12
12
  import { useLocale } from '../composables/useLocale';
13
+ import { menuShortcutHint } from '../composables/useKeyboardShortcuts';
13
14
 
14
15
  export interface ContextAction {
15
16
  key: string;
@@ -19,6 +20,12 @@ export interface ContextAction {
19
20
  disabled?: boolean;
20
21
  hidden?: boolean;
21
22
  divider?: boolean;
23
+ /**
24
+ * tus:t1 — registry action id whose key this row should print. Optional:
25
+ * rows whose `key` already matches the registry (rename, delete, copy…)
26
+ * are resolved from the shared map and need nothing here.
27
+ */
28
+ shortcutId?: string;
22
29
  }
23
30
 
24
31
  const props = defineProps<{
@@ -48,6 +55,12 @@ const emit = defineEmits<{
48
55
 
49
56
  const { t } = useLocale(() => props.locale); // bag:b4 — sheet aria labels need t()
50
57
 
58
+ /* tus:t1 — a row's key label. `shortcutId` wins when an embedder sets one;
59
+ * otherwise the row's own key is looked up in the shared menu map. */
60
+ function hintFor(a: ContextAction): string {
61
+ return menuShortcutHint(a.shortcutId ?? a.key);
62
+ }
63
+
51
64
  const open = ref(false);
52
65
  const x = ref(0);
53
66
  const y = ref(0);
@@ -320,6 +333,7 @@ defineExpose({ show, hide });
320
333
  >
321
334
  <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
322
335
  <span class="fe-ctx__label">{{ a.label }}</span>
336
+ <span v-if="hintFor(a)" class="fe-ctx__key" aria-hidden="true">{{ hintFor(a) }}</span>
323
337
  </button>
324
338
  </template>
325
339
  </div>
@@ -347,6 +361,10 @@ defineExpose({ show, hide });
347
361
  >
348
362
  <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
349
363
  <span class="fe-ctx__label">{{ a.label }}</span>
364
+ <!-- tus:t1 — the key that does this from the keyboard, read from
365
+ the registry so a remap reaches it. Empty for a verb with no
366
+ binding, and the span then does not render at all. -->
367
+ <span v-if="hintFor(a)" class="fe-ctx__key" aria-hidden="true">{{ hintFor(a) }}</span>
350
368
  </button>
351
369
  </template>
352
370
  </div>
@@ -6,11 +6,15 @@
6
6
  * mode), so every viewer the modal knows (image/video/pdf/office/code/
7
7
  * 3D/archive/…) works in quick-look for free. What this layer adds:
8
8
  *
9
- * - Space closes again (macOS Quick Look convention)
9
+ * - the quick-look key closes the peek again (Space by default — macOS
10
+ * Quick Look convention), and the open key promotes it into the full
11
+ * open flow (Enter by default, emit 'open-full'). Both come from the
12
+ * shortcut registry rather than from a comparison against the default
13
+ * key, so a remap reaches the overlay too
10
14
  * - ← ↑ / → ↓ ask the host to move the selection; the host keeps the
11
- * `file` prop in sync so the preview follows the selection
12
- * - Enter promotes the peek into the full open flow (emit 'open-full')
13
- * - a small floating hint bar with the key legend
15
+ * `file` prop in sync so the preview follows the selection. These are
16
+ * the peek's own arrows, not registry actions
17
+ * - a small floating hint bar naming those keys, read from the registry
14
18
  *
15
19
  * Keys are handled in window CAPTURE phase with stopPropagation so the
16
20
  * global shortcut registry never double-handles them; events that
@@ -24,6 +24,7 @@ import {
24
24
  comboFromEvent,
25
25
  effectiveCombo,
26
26
  findShortcutConflict,
27
+ isReservedCombo,
27
28
  resetAllShortcuts,
28
29
  resetShortcut,
29
30
  setShortcutOverride,
@@ -66,6 +67,8 @@ const anyOverridden = computed(() => list.value.some((s) => s.overridden));
66
67
 
67
68
  const capturingId = ref<string | null>(null);
68
69
  const conflict = ref<{ id: string; combo: string; conflictId: string; fixed: boolean } | null>(null);
70
+ /* tus:t1 — the user pressed something the browser keeps for itself. */
71
+ const reserved = ref<{ id: string; combo: string } | null>(null);
69
72
 
70
73
  function labelOf(id: string): string {
71
74
  const def = SHORTCUT_ACTIONS.find((a) => a.id === id);
@@ -74,6 +77,7 @@ function labelOf(id: string): string {
74
77
 
75
78
  function beginCapture(id: string) {
76
79
  conflict.value = null;
80
+ reserved.value = null;
77
81
  capturingId.value = id;
78
82
  }
79
83
 
@@ -84,6 +88,13 @@ function cancelCapture() {
84
88
  function attemptAssign(id: string, combo: string) {
85
89
  capturingId.value = null;
86
90
  if (combo === effectiveCombo(id)) return; // unchanged
91
+ /* tus:t1 — a combo the browser takes before the page sees it would be a key
92
+ * that does nothing where the user is standing. Refuse it and say why,
93
+ * rather than storing a binding that never fires. */
94
+ if (isReservedCombo(combo)) {
95
+ reserved.value = { id, combo };
96
+ return;
97
+ }
87
98
  const clash = findShortcutConflict(combo, id);
88
99
  if (clash) {
89
100
  conflict.value = { id, combo, conflictId: clash.id, fixed: clash.fixed };
@@ -104,13 +115,19 @@ function dismissConflict() {
104
115
  conflict.value = null;
105
116
  }
106
117
 
118
+ function dismissReserved() {
119
+ reserved.value = null;
120
+ }
121
+
107
122
  function onReset(id: string) {
108
123
  conflict.value = null;
124
+ reserved.value = null;
109
125
  resetShortcut(id);
110
126
  }
111
127
 
112
128
  function onResetAll() {
113
129
  conflict.value = null;
130
+ reserved.value = null;
114
131
  capturingId.value = null;
115
132
  resetAllShortcuts();
116
133
  }
@@ -145,6 +162,7 @@ watch(
145
162
  window.removeEventListener('keydown', onWindowKeydown, true);
146
163
  capturingId.value = null;
147
164
  conflict.value = null;
165
+ reserved.value = null;
148
166
  }
149
167
  },
150
168
  );
@@ -201,6 +219,14 @@ function onClose() {
201
219
  </span>
202
220
  <template v-for="(combo, i) in s.keys" v-else :key="combo">
203
221
  <kbd class="fe-kbd">{{ combo }}</kbd>
222
+ <!-- tus:t1 — this combo never reaches a browser tab. Say so
223
+ on the row rather than letting the user discover it by
224
+ pressing the key and watching nothing happen. -->
225
+ <span
226
+ v-if="isReservedCombo(combo)"
227
+ class="fe-shortset__reserved"
228
+ :title="t('shortcuts.settings.reserved_title')"
229
+ >{{ t('shortcuts.settings.reserved_badge') }}</span>
204
230
  <span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
205
231
  </template>
206
232
  </template>
@@ -260,6 +286,17 @@ function onClose() {
260
286
  </button>
261
287
  </span>
262
288
  </div>
289
+ <div v-if="reserved && reserved.id === s.id" class="fe-shortset__conflict" role="alert">
290
+ <span class="fe-shortset__conflict-msg">
291
+ <kbd class="fe-kbd">{{ reserved.combo }}</kbd>
292
+ {{ t('shortcuts.settings.reserved') }}
293
+ </span>
294
+ <span class="fe-shortset__conflict-actions">
295
+ <button type="button" class="fe-btn fe-shortset__btn" @click="dismissReserved">
296
+ {{ t('shortcuts.settings.conflict_cancel') }}
297
+ </button>
298
+ </span>
299
+ </div>
263
300
  </div>
264
301
  </section>
265
302
  </div>
@@ -17,7 +17,7 @@ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
17
17
  import ContextMenu, { type ContextAction } from './ContextMenu.vue';
18
18
  import ViewSwitcher from './ViewSwitcher.vue';
19
19
  import { useLocale } from '../composables/useLocale';
20
- import { eventMatchesShortcut, shortcutHint } from '../composables/useKeyboardShortcuts';
20
+ import { eventMatchesShortcut, menuShortcutHint, shortcutHint } from '../composables/useKeyboardShortcuts';
21
21
 
22
22
  export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
23
23
 
@@ -389,6 +389,14 @@ const moreActions = computed<ContextAction[]>(() => {
389
389
  * that key. */
390
390
  const paletteCombo = computed(() => shortcutHint('palette'));
391
391
 
392
+ /* tus:t1 — a button's tooltip names its key. Same source as the right-click
393
+ * menu's key column, so the two can only ever say the same thing, and a verb
394
+ * with no binding gets its plain label back rather than an empty bracket. */
395
+ function withKey(label: string, key: string): string {
396
+ const combo = menuShortcutHint(key);
397
+ return combo ? `${label} (${combo})` : label;
398
+ }
399
+
392
400
  const drivePlaceholder = computed(() =>
393
401
  props.scopeLabel
394
402
  ? t('drive.search.placeholder', { scope: props.scopeLabel })
@@ -509,7 +517,7 @@ function onMoreSelect(a: ContextAction) {
509
517
  v-if="canGoUp"
510
518
  type="button"
511
519
  class="fe-btn fe-btn--icon-only"
512
- :title="t('toolbar.go_up')"
520
+ :title="withKey(t('toolbar.go_up'), 'go-up')"
513
521
  :aria-label="t('toolbar.go_up')"
514
522
  @click="emit('go-up')"
515
523
  >
@@ -523,7 +531,7 @@ function onMoreSelect(a: ContextAction) {
523
531
  v-if="shell !== 'drive' && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
524
532
  type="button"
525
533
  class="fe-btn fe-btn--primary"
526
- :title="t('toolbar.new_folder')"
534
+ :title="withKey(t('toolbar.new_folder'), 'new-folder')"
527
535
  @click="emit('new-folder')"
528
536
  >
529
537
  <span class="fe-icon">📁</span>
@@ -537,7 +545,7 @@ function onMoreSelect(a: ContextAction) {
537
545
  class="fe-btn fe-btn--fold"
538
546
  :class="{ 'fe-btn--danger': a.danger, 'is-disabled': a.disabled }"
539
547
  :disabled="a.disabled"
540
- :title="a.label"
548
+ :title="withKey(a.label, a.key)"
541
549
  @click="fire(a.key)"
542
550
  >
543
551
  <span class="fe-icon">{{ a.icon }}</span>
@@ -578,7 +586,7 @@ function onMoreSelect(a: ContextAction) {
578
586
  v-if="pasteEnabled && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
579
587
  type="button"
580
588
  class="fe-btn"
581
- :title="t('ctx.paste')"
589
+ :title="withKey(t('ctx.paste'), 'paste')"
582
590
  @click="fire('paste')"
583
591
  >
584
592
  <span class="fe-icon">📋</span>
@@ -641,7 +649,7 @@ function onMoreSelect(a: ContextAction) {
641
649
  v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
642
650
  type="button"
643
651
  class="fe-btn fe-btn--icon-only"
644
- :title="t('toolbar.upload')"
652
+ :title="withKey(t('toolbar.upload'), 'upload')"
645
653
  :aria-label="t('toolbar.upload') /* wiring:c4 */"
646
654
  @click="emit('upload')"
647
655
  >
@@ -650,7 +658,7 @@ function onMoreSelect(a: ContextAction) {
650
658
  <button
651
659
  type="button"
652
660
  class="fe-btn fe-btn--icon-only"
653
- :title="t('toolbar.refresh')"
661
+ :title="withKey(t('toolbar.refresh'), 'refresh')"
654
662
  :aria-label="t('toolbar.refresh') /* wiring:c4 */"
655
663
  @click="emit('refresh')"
656
664
  >
@@ -722,7 +730,7 @@ function onMoreSelect(a: ContextAction) {
722
730
  class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
723
731
  :class="{ 'is-active': inspectorOpen }"
724
732
  :aria-pressed="!!inspectorOpen"
725
- :title="t('toolbar.inspector')"
733
+ :title="withKey(t('toolbar.inspector'), 'inspector')"
726
734
  :aria-label="t('toolbar.inspector')"
727
735
  @click="emit('toggle-inspector')"
728
736
  >
@@ -922,7 +930,7 @@ function onMoreSelect(a: ContextAction) {
922
930
  v-if="canGoUp"
923
931
  type="button"
924
932
  class="fe-btn fe-btn--icon-only"
925
- :title="t('toolbar.go_up')"
933
+ :title="withKey(t('toolbar.go_up'), 'go-up')"
926
934
  :aria-label="t('toolbar.go_up')"
927
935
  @click="emit('go-up')"
928
936
  >
@@ -935,7 +943,7 @@ function onMoreSelect(a: ContextAction) {
935
943
  type="button"
936
944
  class="fe-btn fe-btn--icon-only fe-toolbar__search-toggle"
937
945
  :class="{ 'is-active': !!localSearch }"
938
- :title="t('toolbar.search')"
946
+ :title="withKey(t('toolbar.search'), 'search')"
939
947
  :aria-label="t('toolbar.search')"
940
948
  @click="openSearch"
941
949
  >
@@ -945,7 +953,7 @@ function onMoreSelect(a: ContextAction) {
945
953
  v-if="!atVirtualRoot && canWrite !== false"
946
954
  type="button"
947
955
  class="fe-btn fe-btn--icon-only"
948
- :title="t('toolbar.upload')"
956
+ :title="withKey(t('toolbar.upload'), 'upload')"
949
957
  :aria-label="t('toolbar.upload')"
950
958
  @click="emit('upload')"
951
959
  >
@@ -47,6 +47,22 @@ export interface ShortcutHandlers {
47
47
  onTabNext?: () => void; // Ctrl+Tab
48
48
  onTabPrev?: () => void; // Ctrl+Shift+Tab
49
49
  /* /wiring:d1 */
50
+ /* tus:t1 — the verbs that were only ever reachable from the menus. Every
51
+ * one of them is remappable like the rest; the ones that ship unbound are
52
+ * there so the user can give them a key, not because they do less. */
53
+ onNewFolder?: () => void; // Shift+N
54
+ onUpload?: () => void; // U
55
+ onRefresh?: () => void; // R
56
+ onDownload?: () => void; // D
57
+ onPreview?: () => void; // P
58
+ onShare?: () => void; // Shift+S
59
+ onTags?: () => void; // T
60
+ onConvert?: () => void; // (unbound)
61
+ onOpenTab?: () => void; // (unbound)
62
+ onCopyPath?: () => void; // (unbound)
63
+ onCopyId?: () => void; // (unbound)
64
+ onRestore?: () => void; // (unbound)
65
+ /* /tus:t1 */
50
66
  hasSelection?: () => boolean; // disambiguates Backspace
51
67
  }
52
68
 
@@ -77,6 +93,33 @@ export interface ShortcutActionDef {
77
93
  inForms?: boolean;
78
94
  }
79
95
 
96
+ /**
97
+ * Combos the BROWSER takes before the page sees them. `preventDefault()`
98
+ * cannot stop these in an ordinary tab — Chrome and Firefox handle them at
99
+ * the window level — so an action bound to one of them only ever fires in
100
+ * the desktop app, an installed PWA or a kiosk window.
101
+ *
102
+ * They are not forbidden as DEFAULTS (the tab actions have shipped on
103
+ * Ctrl+T/W/Tab since wiring:d1 and work in the desktop app), but the
104
+ * settings modal refuses to let a user assign one: choosing a key that
105
+ * silently does nothing where you are standing is not a choice, it is a
106
+ * trap. `isReservedCombo` is the one place that list lives.
107
+ */
108
+ const RESERVED_COMBOS = new Set([
109
+ 'Ctrl+N', 'Ctrl+Shift+N',
110
+ 'Ctrl+T', 'Ctrl+Shift+T',
111
+ 'Ctrl+W', 'Ctrl+Shift+W',
112
+ 'Ctrl+Tab', 'Ctrl+Shift+Tab',
113
+ 'Ctrl+Q',
114
+ 'Ctrl+Shift+I', 'Ctrl+Shift+J', 'Ctrl+Shift+C',
115
+ 'F11', 'F12',
116
+ ]);
117
+
118
+ /** Does the browser eat this combo before the page can see it? */
119
+ export function isReservedCombo(combo: string): boolean {
120
+ return RESERVED_COMBOS.has(combo);
121
+ }
122
+
80
123
  /** Registry order = help/settings display order. */
81
124
  export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
82
125
  // Navigation
@@ -109,6 +152,25 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
109
152
  { id: 'tab-next', defaultCombo: 'Ctrl+Tab', labelKey: 'shortcuts.tab_next', groupKey: 'shortcuts.group.tabs' },
110
153
  { id: 'tab-prev', defaultCombo: 'Ctrl+Shift+Tab', labelKey: 'shortcuts.tab_prev', groupKey: 'shortcuts.group.tabs' },
111
154
  /* /wiring:d1 */
155
+ /* tus:t1 — the rest of the menu verbs. Until now these were reachable only
156
+ * by right-clicking, and the right-click menu named no keys at all, so 16 of
157
+ * the registry's actions were invisible unless somebody opened the `?` sheet
158
+ * on their own. The ones with no default are deliberate: they are rare
159
+ * enough that taking a key from the user by default is the wrong trade, and
160
+ * they are in the registry so the user can take one. */
161
+ { id: 'new-folder', defaultCombo: 'Shift+N', labelKey: 'shortcuts.new_folder', groupKey: 'shortcuts.group.file' },
162
+ { id: 'upload', defaultCombo: 'U', labelKey: 'shortcuts.upload', groupKey: 'shortcuts.group.file' },
163
+ { id: 'refresh', defaultCombo: 'R', labelKey: 'shortcuts.refresh', groupKey: 'shortcuts.group.nav' },
164
+ { id: 'download', defaultCombo: 'D', labelKey: 'shortcuts.download', groupKey: 'shortcuts.group.file' },
165
+ { id: 'preview', defaultCombo: 'P', labelKey: 'shortcuts.preview', groupKey: 'shortcuts.group.nav' },
166
+ { id: 'share', defaultCombo: 'Shift+S', labelKey: 'shortcuts.share', groupKey: 'shortcuts.group.file' },
167
+ { id: 'tags', defaultCombo: 'T', labelKey: 'shortcuts.tags', groupKey: 'shortcuts.group.file' },
168
+ { id: 'convert', defaultCombo: '', labelKey: 'shortcuts.convert', groupKey: 'shortcuts.group.file' },
169
+ { id: 'open-tab', defaultCombo: '', labelKey: 'shortcuts.open_tab', groupKey: 'shortcuts.group.tabs' },
170
+ { id: 'copy-path', defaultCombo: '', labelKey: 'shortcuts.copy_path', groupKey: 'shortcuts.group.file' },
171
+ { id: 'copy-id', defaultCombo: '', labelKey: 'shortcuts.copy_id', groupKey: 'shortcuts.group.file' },
172
+ { id: 'restore', defaultCombo: '', labelKey: 'shortcuts.restore', groupKey: 'shortcuts.group.file' },
173
+ /* /tus:t1 */
112
174
  ];
113
175
 
114
176
  /** action id → ShortcutHandlers callback name. */
@@ -134,6 +196,19 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
134
196
  'tab-close': 'onTabClose',
135
197
  'tab-next': 'onTabNext',
136
198
  'tab-prev': 'onTabPrev',
199
+ /* tus:t1 */
200
+ 'new-folder': 'onNewFolder',
201
+ upload: 'onUpload',
202
+ refresh: 'onRefresh',
203
+ download: 'onDownload',
204
+ preview: 'onPreview',
205
+ share: 'onShare',
206
+ tags: 'onTags',
207
+ convert: 'onConvert',
208
+ 'open-tab': 'onOpenTab',
209
+ 'copy-path': 'onCopyPath',
210
+ 'copy-id': 'onCopyId',
211
+ restore: 'onRestore',
137
212
  };
138
213
 
139
214
  // --------------------------------------------------------------------
@@ -336,6 +411,53 @@ export function comboLabel(combo: string): string {
336
411
  return isMacLike() ? combo.replace(/\bCtrl\b/g, '⌘') : combo;
337
412
  }
338
413
 
414
+ /**
415
+ * Menu action key → registry action id.
416
+ *
417
+ * The right-click menu, the toolbar and the keyboard all name the same verbs,
418
+ * but the menu's `key` and the registry's `id` are separate vocabularies and
419
+ * two of them genuinely differ (`access` is the share dialog, `details` is the
420
+ * inspector). One map, in one place: a second copy is a second chance to
421
+ * forget, and the symptom would be a menu row that silently stops naming its
422
+ * key while every other row keeps working.
423
+ */
424
+ export const MENU_ACTION_SHORTCUTS: Record<string, string> = {
425
+ open: 'open',
426
+ 'open-tab': 'open-tab',
427
+ preview: 'preview',
428
+ download: 'download',
429
+ convert: 'convert',
430
+ access: 'share',
431
+ details: 'inspector',
432
+ 'copy-id': 'copy-id',
433
+ 'copy-path': 'copy-path',
434
+ rename: 'rename',
435
+ cut: 'cut',
436
+ copy: 'copy',
437
+ paste: 'paste',
438
+ star: 'star',
439
+ tags: 'tags',
440
+ delete: 'delete',
441
+ restore: 'restore',
442
+ 'new-folder': 'new-folder',
443
+ 'toggle-hidden': 'toggle-hidden',
444
+ upload: 'upload',
445
+ refresh: 'refresh',
446
+ };
447
+
448
+ /**
449
+ * What a menu row or a toolbar button should print beside its label: the
450
+ * current combo for the verb it triggers, or '' when it has none (unbound, or
451
+ * not a registry action at all — `keep-local`, the E2E entries and so on).
452
+ */
453
+ export function menuShortcutHint(menuKey: string): string {
454
+ /* Fall back to the key itself: the toolbar's own buttons (go-up, upload,
455
+ * refresh…) pass a registry id directly, and an id nothing knows returns ''
456
+ * rather than throwing. */
457
+ const id = MENU_ACTION_SHORTCUTS[menuKey] ?? menuKey;
458
+ return SHORTCUT_ACTIONS.some((a) => a.id === id) ? shortcutHint(id) : '';
459
+ }
460
+
339
461
  /**
340
462
  * The one way a hint may name a key.
341
463
  *
package/src/index.ts CHANGED
@@ -146,6 +146,11 @@ export {
146
146
  comboLabel,
147
147
  eventMatchesShortcut,
148
148
  isMacLike,
149
+ /* tus:t1 — menu/toolbar rows name their key from the same registry, and the
150
+ * combos a browser tab never receives are declared in one place. */
151
+ menuShortcutHint,
152
+ MENU_ACTION_SHORTCUTS,
153
+ isReservedCombo,
149
154
  } from './composables/useKeyboardShortcuts';
150
155
  export type {
151
156
  ShortcutActionDef,
package/src/lib/themes.ts CHANGED
@@ -83,7 +83,11 @@ export const THEMES: ThemeDef[] = [
83
83
  '--fe-text': '#1a1e27',
84
84
  '--fe-text-muted': '#5a6475',
85
85
  '--fe-text-on-primary': '#ffffff',
86
- '--fe-primary': '#3b82f6',
86
+ /* ⚠ Mirrors styles/variables.css. It drifted once: the stock primary was
87
+ * darkened to #2f6fe0 for WCAG AA against white text and this preview
88
+ * map kept the old #3b82f6, so the gallery card advertised a blue the
89
+ * product no longer used. */
90
+ '--fe-primary': '#2f6fe0',
87
91
  '--fe-primary-hover': '#2563eb',
88
92
  '--fe-danger': '#dc2626',
89
93
  '--fe-danger-hover': '#b91c1c',
@@ -97,7 +101,9 @@ export const THEMES: ThemeDef[] = [
97
101
  '--fe-border-strong': '#3a4453',
98
102
  '--fe-text': '#e5e9f0',
99
103
  '--fe-text-muted': '#8b95a7',
100
- '--fe-text-on-primary': '#ffffff',
104
+ /* Dark ink, not white: white on #60a5fa measures 2.54 against the 4.5
105
+ * this file claims. Mirrors styles/variables.css. */
106
+ '--fe-text-on-primary': '#0f1419',
101
107
  '--fe-primary': '#60a5fa',
102
108
  '--fe-primary-hover': '#3b82f6',
103
109
  '--fe-danger': '#f87171',
@@ -355,6 +361,94 @@ export const THEMES: ThemeDef[] = [
355
361
  '--fe-font': 'ui-monospace, "SF Mono", Consolas, Menlo, monospace',
356
362
  },
357
363
  },
364
+ {
365
+ /**
366
+ * Drive — the palette of the end-user shell alfatm built on top of filex
367
+ * and put on a demo stand for review (GitHub #14). The drive profile's
368
+ * layout came out of their mockups; this is the colour and shape half of
369
+ * the same look, mapped onto our own tokens so every surface the explorer
370
+ * has — not just the drive shell — can wear it.
371
+ *
372
+ * Measured from their demo rather than eyeballed: 43 custom properties,
373
+ * of which the ones with a home here are the palette, the radii (6/8/10)
374
+ * and the per-file-type accents. Inter leads the font stack because that
375
+ * is what the demo uses; nothing is downloaded for it, so a machine
376
+ * without Inter installed falls back to the same system face as every
377
+ * other theme.
378
+ */
379
+ id: 'drive',
380
+ nameKey: 'theme.name.drive',
381
+ light: {
382
+ '--fe-bg': '#ffffff',
383
+ '--fe-bg-elev': '#f7f8fb',
384
+ '--fe-bg-hover': '#f3f4f6',
385
+ '--fe-bg-selected': '#eef3ff',
386
+ '--fe-border': '#e5e7eb',
387
+ '--fe-border-strong': '#d1d5db',
388
+ '--fe-text': '#1f2937',
389
+ '--fe-text-muted': '#6b7280',
390
+ '--fe-text-on-primary': '#ffffff',
391
+ '--fe-primary': '#2f6ceb',
392
+ '--fe-primary-hover': '#2559c9',
393
+ '--fe-danger': '#dc2626',
394
+ '--fe-danger-hover': '#b91c1c',
395
+ '--fe-font': 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
396
+ '--fe-radius-sm': '6px',
397
+ '--fe-radius': '8px',
398
+ '--fe-radius-md': '10px',
399
+ '--fe-radius-lg': '12px',
400
+ '--fe-shadow': '0 20px 60px rgba(17, 24, 39, 0.18)',
401
+ '--fe-shadow-sm': '0 8px 24px rgba(17, 24, 39, 0.12)',
402
+ '--fe-icon-folder': '#f4b400',
403
+ '--fe-icon-image': '#2f6ceb',
404
+ '--fe-icon-video': '#7c3aed',
405
+ '--fe-icon-audio': '#0d9488',
406
+ '--fe-icon-pdf': '#dc2626',
407
+ '--fe-icon-doc': '#2f6ceb',
408
+ '--fe-icon-sheet': '#16a34a',
409
+ '--fe-icon-slides': '#ea580c',
410
+ '--fe-icon-archive': '#6b7280',
411
+ '--fe-icon-code': '#2f6ceb',
412
+ '--fe-icon-text': '#374151',
413
+ '--fe-icon-unknown': '#6b7280',
414
+ },
415
+ dark: {
416
+ '--fe-bg': '#15171c',
417
+ '--fe-bg-elev': '#1a1d23',
418
+ '--fe-bg-hover': '#1f232a',
419
+ '--fe-bg-selected': '#1c2740',
420
+ '--fe-border': '#2e333c',
421
+ '--fe-border-strong': '#3d444f',
422
+ '--fe-text': '#e6e8ec',
423
+ '--fe-text-muted': '#888f9b',
424
+ /* Dark ink on a light-blue button: white on #5b8cff measures 3.16, and
425
+ * the bar this file documents is 4.5. */
426
+ '--fe-text-on-primary': '#15171c',
427
+ '--fe-primary': '#5b8cff',
428
+ '--fe-primary-hover': '#7ba3ff',
429
+ '--fe-danger': '#f87171',
430
+ '--fe-danger-hover': '#ef4444',
431
+ '--fe-font': 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
432
+ '--fe-radius-sm': '6px',
433
+ '--fe-radius': '8px',
434
+ '--fe-radius-md': '10px',
435
+ '--fe-radius-lg': '12px',
436
+ '--fe-shadow': '0 20px 60px rgba(0, 0, 0, 0.6)',
437
+ '--fe-shadow-sm': '0 8px 24px rgba(0, 0, 0, 0.5)',
438
+ '--fe-icon-folder': '#f4b400',
439
+ '--fe-icon-image': '#4d7ff0',
440
+ '--fe-icon-video': '#9061f0',
441
+ '--fe-icon-audio': '#2dd4bf',
442
+ '--fe-icon-pdf': '#e05252',
443
+ '--fe-icon-doc': '#4d7ff0',
444
+ '--fe-icon-sheet': '#2eab63',
445
+ '--fe-icon-slides': '#fb923c',
446
+ '--fe-icon-archive': '#79808d',
447
+ '--fe-icon-code': '#4d7ff0',
448
+ '--fe-icon-text': '#5b6472',
449
+ '--fe-icon-unknown': '#79808d',
450
+ },
451
+ },
358
452
  ];
359
453
 
360
454
  export function themeById(id: string | null | undefined): ThemeDef | undefined {
package/src/locales/en.ts CHANGED
@@ -306,6 +306,7 @@ export const en: Record<string, string> = {
306
306
  'theme.name.contrast': 'High Contrast',
307
307
  'theme.name.gray': 'Soft Gray',
308
308
  'theme.name.terminal': 'Terminal Green',
309
+ 'theme.name.drive': 'Drive',
309
310
  /* === wiring:c2 — customizable shortcuts + quick look === */
310
311
  'shortcuts.quicklook': 'Quick look',
311
312
  'shortcuts.customize': 'Customize',
@@ -395,6 +396,24 @@ export const en: Record<string, string> = {
395
396
  'shortcuts.tab_close': 'Close tab',
396
397
  'shortcuts.tab_next': 'Next tab',
397
398
  'shortcuts.tab_prev': 'Previous tab',
399
+ /* tus:t1 — the menu verbs */
400
+ 'shortcuts.new_folder': 'New folder',
401
+ 'shortcuts.upload': 'Upload files',
402
+ 'shortcuts.refresh': 'Refresh the listing',
403
+ 'shortcuts.download': 'Download',
404
+ 'shortcuts.preview': 'Preview',
405
+ 'shortcuts.share': 'Share / permissions',
406
+ 'shortcuts.tags': 'Tags',
407
+ 'shortcuts.convert': 'Convert',
408
+ 'shortcuts.open_tab': 'Open in a new tab',
409
+ 'shortcuts.copy_path': 'Copy path',
410
+ 'shortcuts.copy_id': 'Copy id',
411
+ 'shortcuts.restore': 'Restore from trash',
412
+ 'shortcuts.settings.reserved':
413
+ 'is taken by the browser before the page sees it, so it would do nothing here. Pick another key.',
414
+ 'shortcuts.settings.reserved_badge': 'Desktop app only',
415
+ 'shortcuts.settings.reserved_title':
416
+ 'The browser handles this combination itself. It fires in the desktop app and in an installed PWA, not in a browser tab.',
398
417
  'cmd.tab_new': 'Open a new tab',
399
418
  'cmd.split_toggle': 'Toggle split view',
400
419
  'split.pane': 'Secondary pane',