From 5b1b03fa69f2c1488cedeab68f41698d8ad3f3a4 Mon Sep 17 00:00:00 2001 From: oliver <54379826+ok@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:29:25 +0200 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20Give=20=E2=8C=981=E2=80=939=20to?= =?UTF-8?q?=20favorites=20only,=20shown=20on=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every space got a digit, favorites first, so a space never pinned could hold one and the numbers matched nothing on screen. Now only favorites are numbered, and a favorite's card shows its chord on hover or focus. The native menu becomes Go to Favorite. --- .claude/solution-architecture.md | 7 +- src/main/menu.js | 2 +- src/renderer/app.tsx | 2 +- src/renderer/components/cards/SpaceCard.tsx | 71 +++++++++++-------- src/renderer/hooks/useAppCommands.ts | 15 ++-- src/renderer/keyboard/Keycap.tsx | 19 +++++ src/renderer/keyboard/accelerator.ts | 10 +++ src/renderer/locales/de/common.json | 2 +- src/renderer/locales/en/common.json | 2 +- src/renderer/locales/es/common.json | 2 +- src/renderer/locales/fr/common.json | 2 +- src/renderer/locales/it/common.json | 2 +- src/renderer/model/space-shortcut-order.js | 12 ++-- src/renderer/screens/SpacesScreen.tsx | 4 ++ test/frontend/README.md | 2 +- .../scenarios/s116-keyboard-coverage.mjs | 4 +- test/invariants/keyboard-catalog.test.js | 11 ++- test/unit/app-menu.test.js | 12 ++-- test/unit/space-shortcut-order.test.js | 22 +++--- 19 files changed, 133 insertions(+), 70 deletions(-) create mode 100644 src/renderer/keyboard/Keycap.tsx diff --git a/.claude/solution-architecture.md b/.claude/solution-architecture.md index c223a9d29..cd1656456 100644 --- a/.claude/solution-architecture.md +++ b/.claude/solution-architecture.md @@ -618,9 +618,10 @@ this device, and its author's time cannot be trusted. A member's folder share is something reads its catalog (a listing, a download, a mirror), so edits in a share nobody here has opened do not move the space until it is. It is bookkeeping for backups (no hint, no snapshot of its own) and appears in no report that -leaves the device. The ⌘1-9 shortcuts deliberately do not follow it: they number favorites first, -then the oldest spaces (`src/renderer/model/space-shortcut-order.js`), so activity never moves a -digit; only a favorite toggled, or a space before it left, does. The renderer holds the grid's order +leaves the device. The ⌘1-9 shortcuts deliberately do not follow it: they number favorites only, +oldest first (`src/renderer/model/space-shortcut-order.js`), so activity never moves a digit; only +a favorite toggled, or a favorite before it left, does. A space that is not a favorite has no +digit, so a number never appears on a space the user did not pin. The renderer holds the grid's order while the pointer is over it or focus is in it (`src/renderer/model/held-order.js`), so a card never moves under a click or a Tab, and a push re-sorts the list only when the order or a card's "now" would change (`src/worker/space-order-push.js`). diff --git a/src/main/menu.js b/src/main/menu.js index 466af9191..4a04a7053 100644 --- a/src/main/menu.js +++ b/src/main/menu.js @@ -61,7 +61,7 @@ function buildAppMenuTemplate({ platform, isDev, inSpace, spaces = [], appName, // only place this chord can live. It doubles as the discoverable form of it. if (spaces.length > 0) { viewSubmenu.push({ - label: 'Go to Space', + label: 'Go to Favorite', submenu: spaces.slice(0, 9).map((space, i) => ({ // Windows menus read a lone '&' as a mnemonic marker; '&&' renders one. label: String(space.name ?? '').replace(/&/g, '&&'), diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 046c2147b..a1004560a 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -132,7 +132,7 @@ function AppShell({ nav, profile, onSaveProfile, deepLinks, identity }: AppShell }) }, [nav.resetToRoot]) - // The space list rides along because the native menu owns the ⌘1-9 chords: macOS resolves a menu + // The favorites ride along because the native menu owns the ⌘1-9 chords: macOS resolves a menu // key equivalent before Chromium's own digit accelerators, which otherwise swallow them before the // webContents ever sees the key. useEffect(() => { diff --git a/src/renderer/components/cards/SpaceCard.tsx b/src/renderer/components/cards/SpaceCard.tsx index d3912f862..f2754b50d 100644 --- a/src/renderer/components/cards/SpaceCard.tsx +++ b/src/renderer/components/cards/SpaceCard.tsx @@ -6,15 +6,18 @@ import { useSpaceMembers } from '../../hooks/useSpaceMembers.js' import Icon from '../primitives/Icon.js' import type { IconName } from '../../types/ui.js' import AvatarStack from '../primitives/AvatarStack.js' +import Keycap from '../../keyboard/Keycap.js' +import { ariaKeyShortcuts } from '../../keyboard/accelerator.js' interface SpaceCardProps { space: Space /** The clock the activity time is read against, which the list advances. */ now: number + shortcut?: string onClick: () => void } -export default function SpaceCard({ space, now, onClick }: SpaceCardProps) { +export default function SpaceCard({ space, now, shortcut, onClick }: SpaceCardProps) { const { t, i18n } = useTranslation() const gradient = gradientForSpaceId(space.spaceId) // spaces:list rosters are slim (no avatars); the facepile reads the cached full roster and @@ -37,7 +40,8 @@ export default function SpaceCard({ space, now, onClick }: SpaceCardProps) { aria-label={space.status === 'pending' ? t('spaceCard.openSpacePending', { name: space.name }) : t('spaceCard.openSpace', { name: space.name })} - className="bg-surface-container-lowest hover:bg-surface-container-highest hover:[--avatar-ring:var(--color-surface-container-highest)] p-5 rounded-2xl flex items-center gap-6 transition-colors cursor-pointer focus-ring" + aria-keyshortcuts={shortcut ? ariaKeyShortcuts(shortcut) : undefined} + className="group bg-surface-container-lowest hover:bg-surface-container-highest hover:[--avatar-ring:var(--color-surface-container-highest)] p-5 rounded-2xl flex items-center gap-6 transition-colors cursor-pointer focus-ring" >
@@ -48,33 +52,42 @@ export default function SpaceCard({ space, now, onClick }: SpaceCardProps) { {t('spaces.memberCount', { count: memberCount })} • {t('spaces.activeLabel', { when: activeWhen })}

- {space.status === 'pending' && ( - - - )} - {(space.pendingCount ?? 0) > 0 && ( - - - )} - {memberCount > 0 && ( - ({ - key: String(i), - src: m.avatar ?? null, - displayName: m.displayName, - }))} - /> - )} +
+ {shortcut && ( + + )} + {space.status === 'pending' && ( + + + )} + {(space.pendingCount ?? 0) > 0 && ( + + + )} + {memberCount > 0 && ( + ({ + key: String(i), + src: m.avatar ?? null, + displayName: m.displayName, + }))} + /> + )} +
) } diff --git a/src/renderer/hooks/useAppCommands.ts b/src/renderer/hooks/useAppCommands.ts index 377fd5b7e..700ecd5f4 100644 --- a/src/renderer/hooks/useAppCommands.ts +++ b/src/renderer/hooks/useAppCommands.ts @@ -64,16 +64,16 @@ function toCommand(row: AppCommandRow, targets: AppCommandTargets): Command { } } -// Every space is reachable by name in the palette. The first nine also carry their ⌘1-9 chord, but -// only as a label: the binding itself lives in the native Go-to-Space menu, because Chromium claims -// the digit chords before the renderer can see them. -function spaceOpenCommand(space: Space, index: number, nav: AppNavigation): Command { +// Every space is reachable by name in the palette. The first nine favorites also carry their ⌘1-9 +// chord, but only as a label: the binding itself lives in the native Go-to-Favorite menu, because +// Chromium claims the digit chords before the renderer can see them. +function spaceOpenCommand(space: Space, accelerator: string | undefined, nav: AppNavigation): Command { return { id: `space.open.${space.spaceId}`, labelKey: 'shortcuts.openSpace', labelParams: { name: space.name }, group: 'navigation', - accelerator: spaceDigitAccelerator(index), + accelerator, run: () => nav.navigateToSpace(space.spaceId), } } @@ -104,7 +104,10 @@ export function useAppCommands({ nav, spaces, canGoBack, openDialog }: AppComman const openWhatsNew = useOpenWhatsNew() const targets: AppCommandTargets = { nav, canGoBack, openDialog, openPalette, openCheatsheet, openWhatsNew } useRegisterCommands(APP_COMMANDS.map((row) => toCommand(row, targets)), []) - useRegisterCommands(shortcutOrder(spaces).map((space, i) => spaceOpenCommand(space, i, nav)), [spaces]) + useRegisterCommands([ + ...shortcutOrder(spaces).map((space, i) => spaceOpenCommand(space, spaceDigitAccelerator(i), nav)), + ...spaces.filter((space) => !space.favorite).map((space) => spaceOpenCommand(space, undefined, nav)), + ], [spaces]) useOrganizationCommand(nav) useMouseBackButton(runCommand) } diff --git a/src/renderer/keyboard/Keycap.tsx b/src/renderer/keyboard/Keycap.tsx new file mode 100644 index 000000000..8409fccda --- /dev/null +++ b/src/renderer/keyboard/Keycap.tsx @@ -0,0 +1,19 @@ +import AcceleratorLabel from './AcceleratorLabel.js' + +interface KeycapProps { + spec: string | undefined + ariaHidden?: boolean + className?: string +} + +export default function Keycap({ spec, ariaHidden, className }: KeycapProps) { + if (!spec) return null + return ( + + + + ) +} diff --git a/src/renderer/keyboard/accelerator.ts b/src/renderer/keyboard/accelerator.ts index 7ea78f033..517296a39 100644 --- a/src/renderer/keyboard/accelerator.ts +++ b/src/renderer/keyboard/accelerator.ts @@ -62,6 +62,16 @@ export function acceleratorParts(spec: string): string[] { return parts } +export function ariaKeyShortcuts(spec: string): string { + const a = parseAccelerator(spec) + const keys: string[] = [] + if (a.mod) keys.push(isMacRuntime ? 'Meta' : 'Control') + if (a.shift) keys.push('Shift') + if (a.alt) keys.push('Alt') + keys.push(a.key.startsWith('digit') ? a.key.replace('digit', '') : a.key) + return keys.join('+') +} + export function shouldIgnore(e: KeyboardEvent, allowList: ReadonlyArray): boolean { if (e.key === 'Escape') return false const target = e.target diff --git a/src/renderer/locales/de/common.json b/src/renderer/locales/de/common.json index 585b7e4e6..c41dc3385 100644 --- a/src/renderer/locales/de/common.json +++ b/src/renderer/locales/de/common.json @@ -621,7 +621,7 @@ "dismissDialog": "Dialog schließen", "back": "Zurück", "home": "Startseite", - "openNthSpace": "Space über Nummer öffnen", + "openNthSpace": "Favoriten über Nummer öffnen", "openSpace": "{{name}} öffnen", "openSettings": "Einstellungen öffnen", "openProfile": "Profil öffnen", diff --git a/src/renderer/locales/en/common.json b/src/renderer/locales/en/common.json index 55d927caa..f16fe2abd 100644 --- a/src/renderer/locales/en/common.json +++ b/src/renderer/locales/en/common.json @@ -650,7 +650,7 @@ "dismissDialog": "Dismiss a dialog", "back": "Go back", "home": "Home", - "openNthSpace": "Jump to space by number", + "openNthSpace": "Jump to a favorite by number", "openSpace": "Open {{name}}", "openSettings": "Open Settings", "openProfile": "Open Profile", diff --git a/src/renderer/locales/es/common.json b/src/renderer/locales/es/common.json index 9cd03659e..501100b9c 100644 --- a/src/renderer/locales/es/common.json +++ b/src/renderer/locales/es/common.json @@ -621,7 +621,7 @@ "dismissDialog": "Cerrar un diálogo", "back": "Volver", "home": "Inicio", - "openNthSpace": "Abrir un espacio por su número", + "openNthSpace": "Abrir un favorito por su número", "openSpace": "Abrir {{name}}", "openSettings": "Abrir Ajustes", "openProfile": "Abrir Perfil", diff --git a/src/renderer/locales/fr/common.json b/src/renderer/locales/fr/common.json index 5c67d084b..d3e1943fa 100644 --- a/src/renderer/locales/fr/common.json +++ b/src/renderer/locales/fr/common.json @@ -621,7 +621,7 @@ "dismissDialog": "Fermer une boîte de dialogue", "back": "Retour", "home": "Accueil", - "openNthSpace": "Ouvrir un espace par son numéro", + "openNthSpace": "Ouvrir un favori par son numéro", "openSpace": "Ouvrir {{name}}", "openSettings": "Ouvrir les paramètres", "openProfile": "Ouvrir le profil", diff --git a/src/renderer/locales/it/common.json b/src/renderer/locales/it/common.json index 5438d821e..79a3bb87e 100644 --- a/src/renderer/locales/it/common.json +++ b/src/renderer/locales/it/common.json @@ -621,7 +621,7 @@ "dismissDialog": "Chiudi una finestra di dialogo", "back": "Indietro", "home": "Home", - "openNthSpace": "Apri uno spazio dal numero", + "openNthSpace": "Apri un preferito dal numero", "openSpace": "Apri {{name}}", "openSettings": "Apri Impostazioni", "openProfile": "Apri Profilo", diff --git a/src/renderer/model/space-shortcut-order.js b/src/renderer/model/space-shortcut-order.js index 60899590d..d8793d88d 100644 --- a/src/renderer/model/space-shortcut-order.js +++ b/src/renderer/model/space-shortcut-order.js @@ -1,6 +1,7 @@ -// The order the ⌘1-9 shortcuts number spaces in. Favorites first, then the oldest created or joined -// on this device. Activity reorders the home screen, never the shortcuts: a digit moves only when a -// space is favorited or unfavorited, or one before it is left. A new space goes last. +// The order the ⌘1-9 shortcuts number favorites in: the oldest created or joined on this device first. +// A space that is not a favorite gets no digit. Activity reorders the home screen, never the +// shortcuts: a digit moves only when a space is favorited or unfavorited, or a favorite before it is +// left. A new favorite goes last. /** @import { Space } from '../../shared/contract/responses.js' */ /** @@ -9,8 +10,7 @@ * @returns {S[]} */ export function shortcutOrder(spaces) { - return [...spaces].sort((a, b) => - Number(!!b.favorite) - Number(!!a.favorite) - || (Date.parse(a.created) || 0) - (Date.parse(b.created) || 0) + return spaces.filter((s) => s.favorite).sort((a, b) => + (Date.parse(a.created) || 0) - (Date.parse(b.created) || 0) || a.spaceId.localeCompare(b.spaceId)) } diff --git a/src/renderer/screens/SpacesScreen.tsx b/src/renderer/screens/SpacesScreen.tsx index c1acc06c0..24eaea908 100644 --- a/src/renderer/screens/SpacesScreen.tsx +++ b/src/renderer/screens/SpacesScreen.tsx @@ -11,6 +11,8 @@ import BackupPromptCard from '../components/backup/BackupPromptCard.js' import { useRestoreHold } from '../hooks/useRestoreHold.js' import { useHeldControl } from '../hooks/useHeldControl.js' import { inHeldOrder } from '../model/held-order.js' +import { shortcutOrder } from '../model/space-shortcut-order.js' +import { spaceDigitAccelerator } from '../keyboard/known-commands.js' const LABEL_REFRESH_MS = 60000 @@ -46,6 +48,7 @@ export default function YourSpaces({ onSelectSpace, onShowCreate, onShowJoin }: const filteredSpaces = filter === 'favorites' ? ordered.filter(s => s.favorite) : ordered + const shortcuts = new Map(shortcutOrder(spaces).map((s, i) => [s.spaceId, spaceDigitAccelerator(i)])) return (
@@ -126,6 +129,7 @@ export default function YourSpaces({ onSelectSpace, onShowCreate, onShowJoin }: onSelectSpace(space.spaceId)} />
diff --git a/test/frontend/README.md b/test/frontend/README.md index 845c0d940..5a569f1fb 100644 --- a/test/frontend/README.md +++ b/test/frontend/README.md @@ -29,7 +29,7 @@ One row per file in `scenarios/`; the id is the `run.mjs` argument (`node test/f | s20 | `s20-command-palette.mjs` | ⌘K → search → Enter opens Create Space; ⌘N opens it directly; the palette reaches the Activity Log, a settings sub-page and the system commands. | | s21 | `s21-empty-states.mjs` | Empty-state copy: no spaces, empty Favorites tab, empty-share copy in a new space, each with its docs card (real anchors, not buttons). | | s110 | `s110-header-logo.mjs` | The header wordmark is an inline SVG: the top-bar logo stays one named ("Home") node that navigates home; the onboarding header exposes the wordmark as a named image. | -| s116 | `s116-keyboard-coverage.mjs` | ⌘1-9 Go-to-Space via the native menu, ⌘⇧P Profile, ⌘⇧L Activity Log, ⌘F its search field, ⌘⇧H back to the list. | +| s116 | `s116-keyboard-coverage.mjs` | ⌘1-9 Go-to-Favorite via the native menu, ⌘⇧P Profile, ⌘⇧L Activity Log, ⌘F its search field, ⌘⇧H back to the list. | | s117 | `s117-space-switch-no-empty-flash.mjs` | Moving between spaces never paints an empty hero over content: not on entering a space, not on returning to the list, not on the warm path; a genuinely empty space still shows its hero. | | s133 | `s133-modal-keyboard.mjs` | The dialog keyboard contract: Escape dismisses, ⌘Enter submits from a text field once, Enter reaches the primary action of a fieldless dialog and never fires a destructive confirm. | | s136 | `s136-screen-header-parity.mjs` | Space and folder screens share one `EntityHeader`: back control, name and actions are present and going back restores the space header. | diff --git a/test/frontend/scenarios/s116-keyboard-coverage.mjs b/test/frontend/scenarios/s116-keyboard-coverage.mjs index b57e4ea58..ef16ce330 100644 --- a/test/frontend/scenarios/s116-keyboard-coverage.mjs +++ b/test/frontend/scenarios/s116-keyboard-coverage.mjs @@ -3,7 +3,7 @@ import { Instance } from '../instance.mjs' import { makeReport } from '../assert.mjs' // The accelerators added alongside the palette's screen coverage. The digit chord is -// served by the native Go-to-Space menu rather than the renderer's keydown listener — +// served by the native Go-to-Favorite menu rather than the renderer's keydown listener — // Chromium claims ⌘1-9 before the page sees them — so this exercises that wiring end // to end, along with Profile, the Activity Log, and the Activity Log's Find. export default async function s116({ runDir, bootstrap }) { @@ -36,7 +36,7 @@ export default async function s116({ runDir, bootstrap }) { // the app at all — neither the renderer's keydown listener nor before-input-event // sees it, and the app loses focus and drops off-screen the moment it is pressed, so // something outside Mirall claims it system-wide. The binding itself lives in the - // native Go-to-Space menu (test/unit/app-menu.test.js covers its structure and + // native Go-to-Favorite menu (test/unit/app-menu.test.js covers its structure and // chords), which is the only place macOS resolves a digit chord for an app. await r.ok('Cmd+Shift+P opens Profile from inside a space', async () => { diff --git a/test/invariants/keyboard-catalog.test.js b/test/invariants/keyboard-catalog.test.js index c733795a7..2c26e8cb1 100644 --- a/test/invariants/keyboard-catalog.test.js +++ b/test/invariants/keyboard-catalog.test.js @@ -19,7 +19,7 @@ function loadModule(name) { } const { KEYBOARD_SHORTCUTS, acceleratorFor, spaceDigitAccelerator } = loadModule('known-commands.ts') -const { parseAccelerator } = loadModule('accelerator.ts') +const { parseAccelerator, ariaKeyShortcuts } = loadModule('accelerator.ts') function rendererSources(exclude = []) { return readdirSync(RENDERER_DIR, { recursive: true }) @@ -70,7 +70,7 @@ test('every command id the main process sends is registered in the renderer', (t const sent = [...main.matchAll(/(? ({ id: literal ?? prefix, template })) t.ok(sent.some(({ id }) => id === 'about.open'), 'the About menu item is among the sent ids') - t.ok(sent.some(({ template }) => template), 'the Go to Space template is among the sent ids') + t.ok(sent.some(({ template }) => template), 'the Go to Favorite template is among the sent ids') const sources = rendererSources() for (const { id, template } of sent) { const registered = template ? `id: \`${id}\${` : `id: '${id}'` @@ -122,3 +122,10 @@ test('space digit accelerators cover exactly the documented range', (t) => { t.is(spaceDigitAccelerator(last - 1), range.acceleratorRangeEnd, 'last space matches the range end') t.is(spaceDigitAccelerator(last), undefined, 'nothing past the range gets a chord') }) + +test('a chord reads the same as an aria-keyshortcuts value', (t) => { + // Loaded under Node, so mod resolves to the Windows/Linux Control. + t.is(ariaKeyShortcuts(spaceDigitAccelerator(0)), 'Control+1') + t.is(ariaKeyShortcuts(acceleratorFor('shortcuts.show')), 'Control+/') + t.is(ariaKeyShortcuts(acceleratorFor('space.addFolder')), 'Control+Shift+u') +}) diff --git a/test/unit/app-menu.test.js b/test/unit/app-menu.test.js index a34cd378d..92d770779 100644 --- a/test/unit/app-menu.test.js +++ b/test/unit/app-menu.test.js @@ -193,23 +193,23 @@ test('the Activity Log Find chord stays out of the native menu', (t) => { t.absent(json.includes('CmdOrCtrl+F'), 'no global Find accelerator') }) -test('Go to Space lists the spaces it is given and binds them to mod+1..9', (t) => { +test('Go to Favorite lists the favorites it is given and binds them to mod+1..9', (t) => { const spaces = Array.from({ length: 12 }, (_, i) => ({ id: `s${i}`, name: `Space ${i}` })) const view = find(buildAppMenuTemplate({ ...baseOpts, platform: 'darwin', spaces }), 'View').submenu - const go = findInSubmenu(view, 'Go to Space') - t.ok(go, 'Go to Space submenu present') + const go = findInSubmenu(view, 'Go to Favorite') + t.ok(go, 'Go to Favorite submenu present') t.is(go.submenu.length, 9, 'only the first nine spaces get a chord') t.is(go.submenu[0].label, 'Space 0', 'first item is the first space') t.is(go.submenu[0].accelerator, 'CmdOrCtrl+1', 'first space bound to mod+1') t.is(go.submenu[8].accelerator, 'CmdOrCtrl+9', 'ninth space bound to mod+9') }) -test('Go to Space is omitted with no spaces and escapes Windows mnemonics', (t) => { +test('Go to Favorite is omitted with no favorites and escapes Windows mnemonics', (t) => { const empty = find(buildAppMenuTemplate({ ...baseOpts, platform: 'darwin' }), 'View').submenu - t.absent(findInSubmenu(empty, 'Go to Space'), 'no empty submenu when there are no spaces') + t.absent(findInSubmenu(empty, 'Go to Favorite'), 'no empty submenu when there are no favorites') const view = find(buildAppMenuTemplate({ ...baseOpts, platform: 'win32', spaces: [{ id: 'a', name: 'R&D' }] }), 'View').submenu - t.is(findInSubmenu(view, 'Go to Space').submenu[0].label, 'R&&D', 'a lone & is escaped so it renders') + t.is(findInSubmenu(view, 'Go to Favorite').submenu[0].label, 'R&&D', 'a lone & is escaped so it renders') }) test('inSpace: false → Add Files / Add Folder / Invite disabled', (t) => { diff --git a/test/unit/space-shortcut-order.test.js b/test/unit/space-shortcut-order.test.js index 5ecebac40..87a6b34c3 100644 --- a/test/unit/space-shortcut-order.test.js +++ b/test/unit/space-shortcut-order.test.js @@ -4,30 +4,36 @@ import { shortcutOrder } from '../../src/renderer/model/space-shortcut-order.js' const space = (spaceId, created, over = {}) => ({ spaceId, created, favorite: false, lastActivity: 0, ...over }) const ids = (spaces) => spaces.map((s) => s.spaceId) -test('favorites come first, then the oldest created or joined', (t) => { +test('only favorites are numbered, the oldest created or joined first', (t) => { const spaces = [ space('new', '2026-10-01T00:00:00Z'), space('old', '2026-01-01T00:00:00Z'), space('fav-new', '2026-09-01T00:00:00Z', { favorite: true }), space('fav-old', '2026-02-01T00:00:00Z', { favorite: true }), ] - t.alike(ids(shortcutOrder(spaces)), ['fav-old', 'fav-new', 'old', 'new']) + t.alike(ids(shortcutOrder(spaces)), ['fav-old', 'fav-new']) +}) + +test('no favorites, no digits', (t) => { + t.alike(shortcutOrder([space('a', '2026-01-01T00:00:00Z'), space('b', '2026-02-01T00:00:00Z')]), []) }) test('activity never moves a digit', (t) => { - const quiet = [space('a', '2026-01-01T00:00:00Z', { lastActivity: 1 }), space('b', '2026-02-01T00:00:00Z', { lastActivity: 2 })] - const busy = [space('b', '2026-02-01T00:00:00Z', { lastActivity: 99 }), space('a', '2026-01-01T00:00:00Z', { lastActivity: 1 })] + const fav = { favorite: true } + const quiet = [space('a', '2026-01-01T00:00:00Z', { ...fav, lastActivity: 1 }), space('b', '2026-02-01T00:00:00Z', { ...fav, lastActivity: 2 })] + const busy = [space('b', '2026-02-01T00:00:00Z', { ...fav, lastActivity: 99 }), space('a', '2026-01-01T00:00:00Z', { ...fav, lastActivity: 1 })] t.alike(ids(shortcutOrder(quiet)), ids(shortcutOrder(busy))) }) -test('a new space goes last and leaves the existing digits in place', (t) => { - const before = [space('a', '2026-01-01T00:00:00Z'), space('b', '2026-02-01T00:00:00Z')] - const after = [space('c', '2026-10-21T00:00:00Z'), ...before] +test('a new favorite goes last and leaves the existing digits in place', (t) => { + const fav = { favorite: true } + const before = [space('a', '2026-01-01T00:00:00Z', fav), space('b', '2026-02-01T00:00:00Z', fav)] + const after = [space('c', '2026-10-21T00:00:00Z', fav), ...before] t.alike(ids(shortcutOrder(after)), ['a', 'b', 'c']) }) test('the input is not reordered in place', (t) => { - const spaces = [space('b', '2026-02-01T00:00:00Z'), space('a', '2026-01-01T00:00:00Z')] + const spaces = [space('b', '2026-02-01T00:00:00Z', { favorite: true }), space('a', '2026-01-01T00:00:00Z', { favorite: true })] shortcutOrder(spaces) t.alike(ids(spaces), ['b', 'a']) }) From 07ade097aee05df9aa2815e74958c693ee8cee7b Mon Sep 17 00:00:00 2001 From: oliver <54379826+ok@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:29:38 +0200 Subject: [PATCH 2/2] [feat] Explain favorites on the empty Favorites tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty tab only said how to add a favorite, never why. It now shows the docs card style with what favorites give you (⌘1–9 from anywhere), how to add one from the More menu or ⌘K, and a button to the full shortcut list. --- .claude/mockups/favorites-empty-state.html | 601 ++++++++++++++++++ CHANGELOG.md | 4 +- src/renderer/components/cards/DocsCard.tsx | 26 +- .../components/space/FavoritesEmptyState.tsx | 60 ++ src/renderer/locales/de/common.json | 6 +- src/renderer/locales/en/common.json | 6 +- src/renderer/locales/es/common.json | 6 +- src/renderer/locales/fr/common.json | 6 +- src/renderer/locales/it/common.json | 6 +- src/renderer/screens/SpacesScreen.tsx | 8 +- test/frontend/README.md | 2 +- test/frontend/scenarios/s21-empty-states.mjs | 23 +- 12 files changed, 724 insertions(+), 30 deletions(-) create mode 100644 .claude/mockups/favorites-empty-state.html create mode 100644 src/renderer/components/space/FavoritesEmptyState.tsx diff --git a/.claude/mockups/favorites-empty-state.html b/.claude/mockups/favorites-empty-state.html new file mode 100644 index 000000000..b855a554b --- /dev/null +++ b/.claude/mockups/favorites-empty-state.html @@ -0,0 +1,601 @@ + + + + + + +Favorites empty state — mockup + + + + + + + + + + + + + + + + +
+
+

Favorites empty state — mockup

+

When the Favorites tab is empty, explain what a favorite gives you (the first ⌘1–9 shortcuts), how to add one, and where the rest of the shortcuts are — in the same card the All Spaces empty state uses. Extends SpacesScreen.tsx and DocsCard.tsx. Baseline: origin/main @ 310c438e.

+
+ +
+ + +
+
+ Recreated from code reproduced from the shipped .tsx + Proposed new built from existing primitives and design.md tokens + a11y name / role / state intent +
+
+ + +
+
+

What the copy claims, and where the code says so

+
    +
  • Today: favorites take the first digits, then every other space (src/renderer/model/space-shortcut-order.js:1-3); activity never moves a digit. Proposed (state 4): favorites only.
  • +
  • ⌘1–9 / Ctrl+1–9 work from any screen on every platform: native View › Go to Space menu, src/main/menu.js:61-71.
  • +
  • Adding: the space header's More menu, SpaceHeaderBar.tsx:51-58; and the palette command “Add this space to Favorites”, only while in a joined space, useSpaceCommands.ts:31-37. It has no chord of its own (known-commands.ts).
  • +
  • All shortcuts: shortcuts.show = ⌘/ (known-commands.ts:32).
  • +
  • There is no favorites section on mirall.app to link to: DOCS_ANCHORS in shell/docs-links.js has none.
  • +
+
+
+ + +
+

1 · Favorites tab, nothing pinned yet (macOS)

+
+ Recreated from code + src/renderer/screens/SpacesScreen.tsx + header, chips and heading. + Proposed new + the card replaces the hint. +
+ +
+ + + +
+
+
+
+

+ Shared Spaces +

+

Create spaces to share files with your colleagues and peers.

+
+ + +
+ + +
+
+ + +
+

No favorites yet

+ + +
+
+
+ +
+
+

What favorites do

+

Pin the spaces you use most often for quick access. Each favorite gets its own keyboard shortcut.

+
    +
  • + + Press ⌘1 to ⌘9 to jump to a favorite from anywhere in Mirall. Hover a card to see its number. +
  • +
  • + + To add one, open a space and choose More › Add to Favorites. Or press ⌘K there and type “favorite”. +
  • +
  • + +
  • +
+
+
+
+ +
+
+
+
+
+ +
+

a11y The card stays DocsCard's <section aria-labelledby> with an <h3>. The three items are a <ul>; the first two are text (nothing to activate), so they sit outside the Tab order. Chords are <kbd> rendered through AcceleratorLabel, so VoiceOver reads them as text and Windows gets “Ctrl+1”.

+

a11y The third item is a real <button> named “See all keyboard shortcuts”, with aria-keyshortcuts="Meta+Slash" (Control+Slash off macOS). It runs the existing shortcuts.show command — no new dialog. It keeps DocsLink's row look but drops the open_in_new glyph, since it does not leave the app.

+

Proposed new The card’s description carries the main point, so the hint under “No favorites yet” goes: the current one (“Open a space and choose Add to Favorites from the menu…”) would repeat item 2, and a new one would repeat the card’s first sentence. The heading stays.

+
+
+ + +
+

2 · Same card on Windows and Linux

+
+ Recreated from code + keyboard/AcceleratorLabel.tsx + — off macOS the chord is the plus-joined “Ctrl+1” form. Everything else is identical. +
+
+ +
+
+
+ +
+
+

What favorites do

+

Pin the spaces you use most often for quick access. Each favorite gets its own keyboard shortcut.

+
    +
  • + + Press Ctrl+1 to Ctrl+9 to jump to a favorite from anywhere in Mirall. Hover a card to see its number. +
  • +
  • + + To add one, open a space and choose More › Add to Favorites. Or press Ctrl+K there and type “favorite”. +
  • +
  • + +
  • +
+
+
+
+ +
+
+ + +
+

3 · Favorites tab: the shortcut appears on hover

+
+ Recreated from codecomponents/cards/SpaceCard.tsx + Proposed newa ⌘n keycap left of the avatars, shown only while the card is hovered or keyboard-focused. +
+

Folder Testing is shown hovered. Hover the other cards to try it. The keycap is absolutely positioned against the avatar stack (absolute right-full), so it takes no width: names truncate exactly where they do today. Card data (members, times, avatars) is made up.

+
+ + + +
+
+

Shared Spaces

+
+ + +
+
+
+
+

Test AVK

2 members • Active just now

+
+
+
+

Folder Testing

4 members • Active 5 minutes ago

+
+
+
+

Test

3 members • Active yesterday

+
+
+
+
+

a11y Also shown on :focus-visible, not just hover, so keyboard users see it too. The card gains aria-keyshortcuts="Meta+1" (Control+1 off macOS); the keycap is aria-hidden, so the name stays “Open Folder Testing”.

+

Proposed new Keycap style is the palette's chord (CommandPalette.tsx:115, font-mono text-xs text-on-surface-variant) on a surface-control chip, rendered through AcceleratorLabel. It fades with the card's existing transition-colors timing.

+
+
+ +
+

4 · All Spaces: only favorites have a shortcut

+
+ Proposed behavior changeregTest is shown hovered: it isn't a favorite, so no keycap and no ⌘4. +
+
+ + + +
+
+

Shared Spaces

+
+ + +
+
+
+
+

Test AVK

2 members • Active just now

+
+
+
+

Folder Testing

4 members • Active 5 minutes ago

+
+
+
+

regTest

2 members • Active 2 hours ago

+
+
+
+

Test

3 members • Active yesterday

+
+
+
+
+

Proposed behavior change ⌘1–9 go to the first nine favorites only, oldest favorite first, unchanged by activity. Today every space gets a number, favorites first (src/renderer/model/space-shortcut-order.js:1-3, src/main/menu.js:61-71). After the change, the View › Go to Space menu and the palette's ⌘ labels list favorites only. Every space stays reachable by name in ⌘K.

+

Proposed behavior change Someone with no favorites loses the digit shortcuts they have today. The empty-state card is what tells them how to get them back.

+
+
+ + +
+

Rejected alternatives

+
+
+

Rejected DocsCard with three “learn more” links. Matches the screenshot exactly, but mirall.app has no favorites or shortcuts section, so every link would land on a generic page. The point of this card is the shortcuts, which fit in a sentence.

+
+
+
+
+ +
+
+

What favorites do

+

Favorites come first in your shortcuts.

+
    +
  • → Keyboard shortcuts ↗
  • +
  • → Add a favorite ↗
  • +
+
+
+
+
+
+
+

Rejected An “Add to Favorites” button in the card. The tab has no space to act on, so the button would need a space picker. That's a new flow for a one-time step; the real control lives in the space's More menu, where the user already is when they want it.

+

Rejected Keycap always visible at the end of the row. Too prominent on a list people scan by name, and it widened every row. Numbering every space (favorites first, then the rest) was dropped with it: a number on a space you never pinned read as a mismatch.

+

Rejected A dedicated chord for favoriting (e.g. ⌘D). Out of scope for an empty state. The card would advertise a new shortcut, and every chord has to go through known-commands.ts and the cheatsheet invariant. Worth its own decision if wanted.

+
+
+
+ + +
+

How it would be built

+
+

Proposed new DocsCard already owns the shell: icon tile, title, body, list, English note. The proposal gives it an items slot (ReactNode rows) alongside links, so this card and the share card stay one component. The English note shows only when the card has doc links, so it is absent here.

+

Proposed new New strings under spaces.*: emptyNoFavoritesHint removed, favoritesDocsTitle, favoritesDocsBody, favoritesTipJump, favoritesTipAdd; favoritesTipAllShortcuts reuses shortcuts.showShortcuts' meaning with its own wording. All five locales.

+

a11y Tests: the frontend scenario for the Favorites tab checks the card's heading, the three list items and that the button opens the cheatsheet; jsx-a11y covers the button name.

+
+
+ + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index cc275d143..58afe6f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,9 @@ organization manages, the organization's page. - **Spaces are listed by recent activity.** The space where something last happened, by you or a member, comes first, and each card says when. -⌘1–9 now go to your favorites first, then your oldest spaces. +- **⌘1–9 open your favorites.** Each favorite keeps its number however busy +your spaces get; hover its card to see it. An empty Favorites tab explains +what favorites do and how to add one. - **Settings → Storage sets the download folder for new spaces.** Spaces you already have keep their own folder when you change it. - **Folder paths can be pasted, typed and copied.** Every folder field takes diff --git a/src/renderer/components/cards/DocsCard.tsx b/src/renderer/components/cards/DocsCard.tsx index 0be2ff157..c6d48856a 100644 --- a/src/renderer/components/cards/DocsCard.tsx +++ b/src/renderer/components/cards/DocsCard.tsx @@ -1,7 +1,7 @@ // "Learn more" card for the empty states. The documentation is English-only while the app // ships five languages, so a non-English UI gets one note at the foot of the card rather // than a language suffix on every link. -import { useId } from 'react' +import { useId, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { docsUrl, type DocsTarget } from '../../shell/docs-links.js' import Icon from '../primitives/Icon.js' @@ -17,14 +17,15 @@ interface DocsCardProps { icon: IconName title: string body: string - links: DocsCardEntry[] + links?: DocsCardEntry[] + children?: ReactNode className?: string } -export default function DocsCard({ icon, title, body, links, className }: DocsCardProps) { +export default function DocsCard({ icon, title, body, links = [], children, className }: DocsCardProps) { const { t, i18n } = useTranslation() const headingId = useId() - const showEnglishNote = !(i18n.language ?? 'en').startsWith('en') + const showEnglishNote = links.length > 0 && !(i18n.language ?? 'en').startsWith('en') return (

{title}

{body}

-
    - {links.map((link) => ( -
  • - -
  • - ))} -
+ {links.length > 0 && ( +
    + {links.map((link) => ( +
  • + +
  • + ))} +
+ )} + {children} {showEnglishNote && (

{t('docs.englishNote')}

)} diff --git a/src/renderer/components/space/FavoritesEmptyState.tsx b/src/renderer/components/space/FavoritesEmptyState.tsx new file mode 100644 index 000000000..41d7d970c --- /dev/null +++ b/src/renderer/components/space/FavoritesEmptyState.tsx @@ -0,0 +1,60 @@ +import { Trans, useTranslation } from 'react-i18next' +import DocsCard from '../cards/DocsCard.js' +import Icon from '../primitives/Icon.js' +import Keycap from '../../keyboard/Keycap.js' +import { acceleratorFor, spaceDigitAccelerator } from '../../keyboard/known-commands.js' +import { ariaKeyShortcuts } from '../../keyboard/accelerator.js' +import { useKeyboard } from '../../keyboard/KeyboardProvider.js' + +const TIP_CLASS = 'flex items-start gap-2 py-1.5 text-sm text-on-surface-variant leading-relaxed' + +export default function FavoritesEmptyState() { + const { t } = useTranslation() + const { openCheatsheet } = useKeyboard() + const showShortcuts = acceleratorFor('shortcuts.show') + return ( +
+

{t('spaces.emptyNoFavorites')}

+ +
    +
  • + + + , last: }} + /> + +
  • +
  • + + + , palette: }} + /> + +
  • +
  • + +
  • +
+
+
+ ) +} diff --git a/src/renderer/locales/de/common.json b/src/renderer/locales/de/common.json index c41dc3385..cb5ed6282 100644 --- a/src/renderer/locales/de/common.json +++ b/src/renderer/locales/de/common.json @@ -54,7 +54,11 @@ "emptyDocsTitle": "Neu bei Mirall?", "emptyDocsBody": "Ein Raum ist eine private Gruppe. Deine Dateien bleiben auf deinem Gerät und gehen direkt an die Leute, die du einlädst — ohne Server dazwischen.", "emptyNoFavorites": "Noch keine Favoriten", - "emptyNoFavoritesHint": "Öffne einen Raum und wähle „Zu Favoriten hinzufügen“ im Menü, um ihn hier festzuhalten.", + "favoritesDocsTitle": "Was Favoriten bringen", + "favoritesDocsBody": "Pinne die Räume, die du am häufigsten nutzt, für den schnellen Zugriff an. Jeder Favorit bekommt ein eigenes Tastenkürzel.", + "favoritesTipJump": "Drücke bis , um von überall in Mirall zu einem Favoriten zu springen. Fahre über eine Karte, um ihre Nummer zu sehen.", + "favoritesTipAdd": "Öffne dazu einen Raum und wähle {{more}} › {{add}}. Oder drücke dort und tippe „Favoriten“.", + "favoritesAllShortcuts": "Alle Tastenkürzel anzeigen", "memberCount_one": "{{count}} Mitglied", "memberCount_other": "{{count}} Mitglieder", "activeLabel": "Aktiv {{when}}", diff --git a/src/renderer/locales/en/common.json b/src/renderer/locales/en/common.json index f16fe2abd..b492327ae 100644 --- a/src/renderer/locales/en/common.json +++ b/src/renderer/locales/en/common.json @@ -83,7 +83,11 @@ "emptyDocsTitle": "New to Mirall?", "emptyDocsBody": "A space is a private group. Files stay on your device and move straight to the people you invite — there's no server in between.", "emptyNoFavorites": "No favorites yet", - "emptyNoFavoritesHint": "Open a space and choose Add to Favorites from the menu to pin it here.", + "favoritesDocsTitle": "What favorites do", + "favoritesDocsBody": "Pin the spaces you use most often for quick access. Each favorite gets its own keyboard shortcut.", + "favoritesTipJump": "Press to to jump to a favorite from anywhere in Mirall. Hover a card to see its number.", + "favoritesTipAdd": "To add one, open a space and choose {{more}} › {{add}}. Or press there and type “favorite”.", + "favoritesAllShortcuts": "See all keyboard shortcuts", "memberCount_one": "{{count}} member", "memberCount_other": "{{count}} members", "activeLabel": "Active {{when}}", diff --git a/src/renderer/locales/es/common.json b/src/renderer/locales/es/common.json index 501100b9c..af70743e2 100644 --- a/src/renderer/locales/es/common.json +++ b/src/renderer/locales/es/common.json @@ -54,7 +54,11 @@ "emptyDocsTitle": "¿Nuevo en Mirall?", "emptyDocsBody": "Un espacio es un grupo privado. Los archivos permanecen en tu dispositivo y van directamente a las personas que invitas: no hay ningún servidor de por medio.", "emptyNoFavorites": "Aún no hay favoritos", - "emptyNoFavoritesHint": "Abre un espacio y elige «Añadir a favoritos» en el menú para fijarlo aquí.", + "favoritesDocsTitle": "Para qué sirven los favoritos", + "favoritesDocsBody": "Fija los espacios que más usas para acceder a ellos rápidamente. Cada favorito tiene su propio atajo de teclado.", + "favoritesTipJump": "Pulsa a para ir a un favorito desde cualquier lugar de Mirall. Pasa el cursor por una tarjeta para ver su número.", + "favoritesTipAdd": "Para añadir uno, abre un espacio y elige {{more}} › {{add}}. O pulsa allí y escribe «favoritos».", + "favoritesAllShortcuts": "Ver todos los atajos de teclado", "memberCount_one": "{{count}} miembro", "memberCount_other": "{{count}} miembros", "activeLabel": "Activo {{when}}", diff --git a/src/renderer/locales/fr/common.json b/src/renderer/locales/fr/common.json index d3e1943fa..a7eda3213 100644 --- a/src/renderer/locales/fr/common.json +++ b/src/renderer/locales/fr/common.json @@ -54,7 +54,11 @@ "emptyDocsTitle": "Nouveau sur Mirall ?", "emptyDocsBody": "Un espace est un groupe privé. Vos fichiers restent sur votre appareil et vont directement aux personnes que vous invitez — sans serveur intermédiaire.", "emptyNoFavorites": "Aucun favori pour le moment", - "emptyNoFavoritesHint": "Ouvrez un espace et choisissez « Ajouter aux favoris » dans le menu pour l'épingler ici.", + "favoritesDocsTitle": "À quoi servent les favoris", + "favoritesDocsBody": "Épinglez les espaces que vous utilisez le plus pour y accéder rapidement. Chaque favori a son propre raccourci clavier.", + "favoritesTipJump": "Appuyez sur à pour ouvrir un favori depuis n'importe où dans Mirall. Survolez une carte pour voir son numéro.", + "favoritesTipAdd": "Pour en ajouter un, ouvrez un espace et choisissez {{more}} › {{add}}. Ou appuyez sur et tapez « favoris ».", + "favoritesAllShortcuts": "Voir tous les raccourcis clavier", "memberCount_one": "{{count}} membre", "memberCount_other": "{{count}} membres", "activeLabel": "Actif {{when}}", diff --git a/src/renderer/locales/it/common.json b/src/renderer/locales/it/common.json index 79a3bb87e..b391d1b4b 100644 --- a/src/renderer/locales/it/common.json +++ b/src/renderer/locales/it/common.json @@ -54,7 +54,11 @@ "emptyDocsTitle": "Nuovo su Mirall?", "emptyDocsBody": "Uno spazio è un gruppo privato. I file restano sul tuo dispositivo e vanno direttamente alle persone che inviti, senza alcun server di mezzo.", "emptyNoFavorites": "Nessun preferito per ora", - "emptyNoFavoritesHint": "Apri uno spazio e scegli «Aggiungi ai preferiti» dal menu per fissarlo qui.", + "favoritesDocsTitle": "A cosa servono i preferiti", + "favoritesDocsBody": "Fissa gli spazi che usi più spesso per raggiungerli subito. Ogni preferito ha la sua scorciatoia da tastiera.", + "favoritesTipJump": "Premi da a per aprire un preferito da qualsiasi punto di Mirall. Passa il puntatore su una scheda per vederne il numero.", + "favoritesTipAdd": "Per aggiungerne uno, apri uno spazio e scegli {{more}} › {{add}}. Oppure premi lì e digita «preferiti».", + "favoritesAllShortcuts": "Mostra tutte le scorciatoie da tastiera", "memberCount_one": "{{count}} membro", "memberCount_other": "{{count}} membri", "activeLabel": "Attivo {{when}}", diff --git a/src/renderer/screens/SpacesScreen.tsx b/src/renderer/screens/SpacesScreen.tsx index 24eaea908..35ea5ba18 100644 --- a/src/renderer/screens/SpacesScreen.tsx +++ b/src/renderer/screens/SpacesScreen.tsx @@ -7,6 +7,7 @@ import { useHasVerticalOverflow } from '../hooks/useHasVerticalOverflow.js' import SpaceCard from '../components/cards/SpaceCard.js' import Button from '../components/primitives/Button.js' import DocsCard from '../components/cards/DocsCard.js' +import FavoritesEmptyState from '../components/space/FavoritesEmptyState.js' import BackupPromptCard from '../components/backup/BackupPromptCard.js' import { useRestoreHold } from '../hooks/useRestoreHold.js' import { useHeldControl } from '../hooks/useHeldControl.js' @@ -118,12 +119,7 @@ export default function YourSpaces({ onSelectSpace, onShowCreate, onShowJoin }: /> )} - {!loading && filteredSpaces.length === 0 && filter === 'favorites' && ( -
-

{t('spaces.emptyNoFavorites')}

-

{t('spaces.emptyNoFavoritesHint')}

-
- )} + {!loading && filteredSpaces.length === 0 && filter === 'favorites' && } {filteredSpaces.map((space) => (
{ + await r.ok('the empty Favorites tab explains favorites and their shortcuts', async () => { await A.click({ name: 'Favorites' }) await A.waitText('No favorites yet', 8000) - assert(!(await A.hasText('New to Mirall?')), 'the favorites empty state stays bare') + await A.waitText('What favorites do', 8000) + assert(!(await A.hasText('New to Mirall?')), 'the no-spaces docs card is not shown') + assert(!(await A.hasText('Opens mirall.app in your browser')), 'the card links nowhere outside the app') + await A.shot('s21-no-favorites', runDir) + }) + await r.ok('the favorites card opens the keyboard shortcuts', async () => { + await A.click({ role: 'button', name: 'See all keyboard shortcuts' }) + await A.waitText('Jump to a favorite by number', 8000) + await A.click({ role: 'button', name: 'Close' }) + await new Promise((res) => setTimeout(res, 400)) + assert(!(await A.hasText('Jump to a favorite by number')), 'the shortcuts dialog closed') await A.click({ name: 'All Spaces' }) }) await r.ok('a new space shows the empty-share copy', async () => {