From 7c18b3f7ddb6f5956ba132f39422cf14a1a06402 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 09:26:01 +0800 Subject: [PATCH] Desktop: compact app hero on every tab but Releases; Ctrl+K lists the current app's menu actions; Alt+Up/Down switches apps; shortcuts list; no focus ring on code-focused headings --- apps/desktop/src/App.tsx | 30 ++++++++++++++++++++++++--- apps/desktop/src/design.css | 12 +++++++++++ apps/desktop/src/i18n/en.ts | 5 +++++ apps/desktop/src/i18n/tr.ts | 5 +++++ apps/desktop/src/screens/AppView.tsx | 1 + apps/desktop/src/screens/Settings.tsx | 2 ++ apps/desktop/src/ui/ds.tsx | 18 +++++++++------- 7 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d0adbed..242211c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -26,6 +26,7 @@ import { ErrorBoundary } from './ui/ErrorBoundary'; import { Toasts } from './ui/Toasts'; import { useContextMenu } from './ui/ContextMenu'; import { appMenu } from './ui/app-menu'; +import { isEditable, isItem, type MenuInput } from './ui/menu-logic'; import { worstTone } from './ui/ds'; import { attention } from './lib/model'; @@ -53,6 +54,15 @@ export function App({ onTheme }: { onTheme: () => void }) { const [showIgnored, setShowIgnored] = useShowIgnored(); const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]); const listed = showIgnored ? [...visible, ...ignoredApps] : visible; + const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; + // Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the tab when the other app has it) + const sideOrder = groups.flatMap(x => x.rows.map(r => r.app)); + const stepApp = useCallback((dir: 1 | -1) => { + if (!sideOrder.length) return; + const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1; + const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!; + go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsFor(!!next.project).includes(route.tab) ? route.tab : 'releases' }); + }, [sideOrder, route, go]); const toggleGroup = (id: string) => setCollapsed(c => { const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id]; try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ } @@ -89,6 +99,7 @@ export function App({ onTheme }: { onTheme: () => void }) { const mod = e.metaKey || e.ctrlKey; if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; } if (palette || welcome) return; + if (e.altKey && !mod && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isEditable(e.target as HTMLInputElement)) { e.preventDefault(); stepApp(e.key === 'ArrowDown' ? 1 : -1); return; } if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) { e.preventDefault(); const n = Number(e.key); @@ -103,7 +114,7 @@ export function App({ onTheme }: { onTheme: () => void }) { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [palette, welcome, route, go, tabs]); + }, [palette, welcome, route, go, tabs, stepApp]); const closeWelcome = useCallback(() => { setWelcome(false); @@ -118,10 +129,24 @@ export function App({ onTheme }: { onTheme: () => void }) { const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name; for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) }, run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsFor(!!a.project).includes(route.tab) ? route.tab : 'releases' }) }); + c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) }); + c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) }); c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) }); WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) })); for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) }); if (app) for (const tab of tabs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) }); + // the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is + // also one Ctrl+K away; opening a tab is already above + if (app) { + const skip = new Set([t('cm.open'), t('cm.openTab')]); + const add = (items: MenuInput, prefix: string) => items.forEach((e, i) => { + if (!e || !isItem(e) || skip.has(e.label) || e.disabled) return; + const title = prefix ? `${prefix} · ${e.label}` : e.label; + if (e.submenu) { add(e.submenu, title); return; } + if (e.onSelect) c.push({ id: `appcmd-${prefix}-${i}-${e.label}`, group: t('palette.gApp', { name: app.name }), title, icon: e.icon, hint: e.shortcut, keywords: app.name, run: e.onSelect }); + }); + add(appMenu(app, d, { route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }, { showIgnored }), ''); + } for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } }); if (app?.project) { const p = app.project.id; @@ -137,7 +162,7 @@ export function App({ onTheme }: { onTheme: () => void }) { c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme }); c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) }); return c; - }, [d, visible, app, tabs, route, go, onTheme, exports, runExport]); + }, [d, visible, app, tabs, route, go, onTheme, exports, runExport, showIgnored, stepApp]); const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }] : route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(TAB_KEY[route.tab]) }] @@ -148,7 +173,6 @@ export function App({ onTheme }: { onTheme: () => void }) { useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]); const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]); const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined); - const groups = d.studios.length ? groupByStudio(listed.map(a => ({ app: a })), d.studios) : [{ studio: null as string | null, rows: listed.map(a => ({ app: a })) }]; const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length; const menu = useContextMenu(); diff --git a/apps/desktop/src/design.css b/apps/desktop/src/design.css index ad3e644..e73887f 100644 --- a/apps/desktop/src/design.css +++ b/apps/desktop/src/design.css @@ -99,6 +99,16 @@ .ds-hero-shots img { height: 280px; width: auto; aspect-ratio: 9 / 19.5; object-fit: cover; object-position: top; border-radius: 16px; box-shadow: 0 18px 36px -14px rgba(0, 0, 0, .55); flex: none; transition: transform var(--speed) var(--ease); } .ds-hero-shots img:nth-child(even) { margin-top: 26px; } .ds-hero:hover .ds-hero-shots img { transform: translateY(-3px); } +/* compact hero: every app tab but Releases (one slim row, the tab content starts above the fold) */ +.ds-hero.compact { grid-template-columns: minmax(0, 1fr); } +.ds-hero.compact .ds-hero-main { padding: 14px 20px; gap: 0; } +.ds-hero.compact .ds-hero-icon { width: 52px; height: 52px; border-radius: 14px; font-size: 18px; } +.ds-hero.compact .ds-hero-id { gap: 14px; } +.ds-hero.compact .ds-hero-t { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } +.ds-hero.compact .ds-hero-row { flex-basis: 100%; } +.ds-hero.compact .ds-hero-name { font-size: 20px; } +.ds-hero.compact .ds-hero-ids .store-badge { height: 26px; } +.ds-hero-cta { margin-left: auto; flex: none; } @media (max-width: 1180px) { .ds-hero { grid-template-columns: minmax(0, 1fr); } .ds-hero-shots { display: none; } } @media (max-width: 900px) { .ds-hero-pipe { grid-template-columns: minmax(0, 1fr); } .ds-hero-foot { justify-items: start; } } @@ -220,3 +230,5 @@ .ds-menu-sep { height: 1px; margin: 5px 6px; background: var(--ds-stroke); } @media (prefers-reduced-motion: reduce) { .ds-menu { transition: none; transform: none; } } :root[data-motion="reduce"] .ds-menu { transition: none; transform: none; } +/* headings focused by code (dialog / wizard step titles) for screen readers: no ring */ +[tabindex="-1"]:focus:not(:focus-visible), h1[tabindex="-1"]:focus, h2[tabindex="-1"]:focus { outline: none; } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 2d8329e..0e2d6c0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -59,6 +59,11 @@ export const en = { 'palette.gApps': 'Apps', 'palette.gGo': 'Go to', 'palette.gTabs': 'App tabs', + 'palette.gApp': 'This app: {name}', + 'palette.nextApp': 'Next app', + 'palette.prevApp': 'Previous app', + 'settings.scApps': 'Previous / next app', + 'settings.scMenu': 'Context menu of the focused item', 'palette.gActions': 'Actions', 'palette.openApp': 'Open', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index cce0065..ce7af40 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -61,6 +61,11 @@ export const tr: Record = { 'palette.gApps': 'Uygulamalar', 'palette.gGo': 'Git', 'palette.gTabs': 'Uygulama sekmeleri', + 'palette.gApp': 'Bu uygulama: {name}', + 'palette.nextApp': 'Sonraki uygulama', + 'palette.prevApp': 'Önceki uygulama', + 'settings.scApps': 'Önceki / sonraki uygulama', + 'settings.scMenu': 'Odaktaki öğenin bağlam menüsü', 'palette.gActions': 'İşlemler', 'palette.openApp': 'Aç', diff --git a/apps/desktop/src/screens/AppView.tsx b/apps/desktop/src/screens/AppView.tsx index 19a047c..1736d8d 100644 --- a/apps/desktop/src/screens/AppView.tsx +++ b/apps/desktop/src/screens/AppView.tsx @@ -137,6 +137,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { {android && } } all={all} ios={!!ios} android={!!android} next={l?.release.next?.version ?? null} + compact={tab !== 'releases'} cta={nextStep({ project: !!app.project, att, all, next: l?.release.next?.version ?? null })} onCta={setTab} /> {!app.project && } diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 59a67f8..91c51d3 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -225,6 +225,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{t('nav.portfolio')}
{t('settings.scWorkspace')}
–
{t('settings.scTabs')}
+
{t('settings.scApps')}
+
{t('settings.scMenu')}
{t('settings.scTabArrows')}
{t('settings.scClose')}
{t('set.scQuit')}
diff --git a/apps/desktop/src/ui/ds.tsx b/apps/desktop/src/ui/ds.tsx index 97ea2e0..bc36d96 100644 --- a/apps/desktop/src/ui/ds.tsx +++ b/apps/desktop/src/ui/ds.tsx @@ -179,16 +179,19 @@ export function PageHeader({ title, subtitle, eyebrow, actions, icon }: { title: } /** App hero: screenshots strip, big icon, name, ids, store links, the two-store pipeline and the next step. */ -export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children, onContextMenu }: { +export function Hero({ name, icon, projectId, ids, badges, links, all, ios, android, cta, onCta, next, children, onContextMenu, compact }: { name: string; icon: string | null; projectId: string | null; ids?: ReactNode; badges?: ReactNode; links?: ReactNode; /** The app's context menu (useContextMenu props). */ onContextMenu?: (e: ReactMouseEvent) => void; + /** One slim row (icon, name, ids, next step): every tab but the overview, so the tab's content starts high. */ + compact?: boolean; all: StoreRelease[]; ios: boolean; android: boolean; cta?: Cta | null; onCta?: (tab: AppTab) => void; next?: string | null; children?: ReactNode; }) { const hue = useTint(icon, name); - const shots = useShots(projectId, 4); + const shots = useShots(compact ? null : projectId, 4); + const ctaBtn = cta && ; return ( -
+
@@ -197,18 +200,19 @@ export function Hero({ name, icon, projectId, ids, badges, links, all, ios, andr {ids &&
{ids}
} {links &&
{links}
}
+ {compact && ctaBtn &&
{ctaBtn}
}
-
+ {!compact &&
{t('pf.next')}{next ?? '–'}
- {cta && } + {ctaBtn}
-
+
} {children} {/* no screenshots: no second (cropped) copy of the icon, which already leads the hero */} - {shots.length > 0 &&