Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
601 changes: 601 additions & 0 deletions .claude/mockups/favorites-empty-state.html

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions .claude/solution-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/main/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&&'),
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
26 changes: 15 additions & 11 deletions src/renderer/components/cards/DocsCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 (
<section
Expand All @@ -38,13 +39,16 @@ export default function DocsCard({ icon, title, body, links, className }: DocsCa
<div className="min-w-0">
<h3 id={headingId} className="font-headline font-bold text-accent mb-1">{title}</h3>
<p className="text-sm text-on-surface-variant leading-relaxed mb-4">{body}</p>
<ul className="space-y-1">
{links.map((link) => (
<li key={docsUrl(link.target)}>
<DocsLink target={link.target} label={link.label} />
</li>
))}
</ul>
{links.length > 0 && (
<ul className="space-y-1">
{links.map((link) => (
<li key={docsUrl(link.target)}>
<DocsLink target={link.target} label={link.label} />
</li>
))}
</ul>
)}
{children}
{showEnglishNote && (
<p className="text-xs text-on-surface-variant mt-4">{t('docs.englishNote')}</p>
)}
Expand Down
71 changes: 42 additions & 29 deletions src/renderer/components/cards/SpaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
>
<div className={`w-16 h-16 rounded-xl ${gradient} flex items-center justify-center shrink-0`}>
<Icon name={(space.icon as IconName) || 'hub'} size={32} className="text-on-primary" />
Expand All @@ -48,33 +52,42 @@ export default function SpaceCard({ space, now, onClick }: SpaceCardProps) {
{t('spaces.memberCount', { count: memberCount })} • {t('spaces.activeLabel', { when: activeWhen })}
</p>
</div>
{space.status === 'pending' && (
<span className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-surface-container-high text-on-surface-variant text-xs font-bold border border-outline">
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-on-surface-variant" />
{t('spaces.waitingForApproval')}
</span>
)}
{(space.pendingCount ?? 0) > 0 && (
<span className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold">
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-on-secondary-container" />
{t('spaces.waitingCount', { count: space.pendingCount })}
</span>
)}
{memberCount > 0 && (
<AvatarStack
className="shrink-0"
size="lg"
surface="surface-container-lowest"
announce="each"
max={3}
total={memberCount}
avatars={facepile.map((m, i) => ({
key: String(i),
src: m.avatar ?? null,
displayName: m.displayName,
}))}
/>
)}
<div className="relative shrink-0 flex items-center gap-6">
{shortcut && (
<Keycap
spec={shortcut}
ariaHidden
className="absolute right-full mr-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
/>
)}
{space.status === 'pending' && (
<span className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-surface-container-high text-on-surface-variant text-xs font-bold border border-outline">
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-on-surface-variant" />
{t('spaces.waitingForApproval')}
</span>
)}
{(space.pendingCount ?? 0) > 0 && (
<span className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold">
<span aria-hidden="true" className="w-1.5 h-1.5 rounded-full bg-on-secondary-container" />
{t('spaces.waitingCount', { count: space.pendingCount })}
</span>
)}
{memberCount > 0 && (
<AvatarStack
className="shrink-0"
size="lg"
surface="surface-container-lowest"
announce="each"
max={3}
total={memberCount}
avatars={facepile.map((m, i) => ({
key: String(i),
src: m.avatar ?? null,
displayName: m.displayName,
}))}
/>
)}
</div>
</div>
)
}
60 changes: 60 additions & 0 deletions src/renderer/components/space/FavoritesEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center justify-center py-12 text-center">
<h2 className="text-2xl font-headline font-bold text-accent mb-3">{t('spaces.emptyNoFavorites')}</h2>
<DocsCard
icon="star"
title={t('spaces.favoritesDocsTitle')}
body={t('spaces.favoritesDocsBody')}
className="w-full max-w-md mt-8"
>
<ul className="space-y-1">
<li className={TIP_CLASS}>
<Icon name="keyboard" size={16} className="shrink-0 mt-0.5 text-secondary" />
<span>
<Trans
i18nKey="spaces.favoritesTipJump"
components={{ first: <Keycap spec={spaceDigitAccelerator(0)} />, last: <Keycap spec={spaceDigitAccelerator(8)} /> }}
/>
</span>
</li>
<li className={TIP_CLASS}>
<Icon name="star" size={16} className="shrink-0 mt-0.5 text-secondary" />
<span>
<Trans
i18nKey="spaces.favoritesTipAdd"
values={{ more: t('space.more'), add: t('space.addFavorite') }}
components={{ menu: <strong className="font-semibold text-on-surface" />, palette: <Keycap spec={acceleratorFor('palette.open')} /> }}
/>
</span>
</li>
<li>
<button
type="button"
onClick={openCheatsheet}
aria-keyshortcuts={showShortcuts ? ariaKeyShortcuts(showShortcuts) : undefined}
className="flex items-center gap-2 rounded-lg -mx-1 px-1 py-1.5 text-sm font-headline font-bold text-secondary hover:underline focus-ring"
>
<Icon name="arrow_forward" size={16} className="shrink-0" />
<span>{t('spaces.favoritesAllShortcuts')}</span>
<Keycap spec={showShortcuts} ariaHidden />
</button>
</li>
</ul>
</DocsCard>
</div>
)
}
15 changes: 9 additions & 6 deletions src/renderer/hooks/useAppCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/keyboard/Keycap.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<kbd
aria-hidden={ariaHidden || undefined}
className={`font-mono text-xs font-normal px-1.5 py-0.5 rounded bg-surface-control text-on-surface-variant whitespace-nowrap${className ? ` ${className}` : ''}`}
>
<AcceleratorLabel spec={spec} />
</kbd>
)
}
10 changes: 10 additions & 0 deletions src/renderer/keyboard/accelerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): boolean {
if (e.key === 'Escape') return false
const target = e.target
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <first/> bis <last/>, 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 <menu>{{more}} › {{add}}</menu>. Oder drücke dort <palette/> und tippe „Favoriten“.",
"favoritesAllShortcuts": "Alle Tastenkürzel anzeigen",
"memberCount_one": "{{count}} Mitglied",
"memberCount_other": "{{count}} Mitglieder",
"activeLabel": "Aktiv {{when}}",
Expand Down Expand Up @@ -621,7 +625,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",
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <first/> to <last/> 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 <menu>{{more}} › {{add}}</menu>. Or press <palette/> there and type “favorite”.",
"favoritesAllShortcuts": "See all keyboard shortcuts",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"activeLabel": "Active {{when}}",
Expand Down Expand Up @@ -650,7 +654,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",
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <first/> a <last/> 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 <menu>{{more}} › {{add}}</menu>. O pulsa <palette/> allí y escribe «favoritos».",
"favoritesAllShortcuts": "Ver todos los atajos de teclado",
"memberCount_one": "{{count}} miembro",
"memberCount_other": "{{count}} miembros",
"activeLabel": "Activo {{when}}",
Expand Down Expand Up @@ -621,7 +625,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",
Expand Down
Loading
Loading