Upgrade from the card, behind a three-dot menu
An installed title's version line now reads `0.1 → 0.3` where the catalog has moved on, so a card answers both questions somebody brings to it: what is installed, and is there anything better. Which version is installed was already recorded — `state.json` has always carried it — what was missing was anywhere to act on it. The card leads with Play (or Open, for a hosted title) and puts the rest behind a ⋮ button: Upgrade, which fetches whatever the catalog now has, and Uninstall. Upgrade stays visible while disabled rather than appearing and disappearing — a menu whose items come and go makes a person hunt for the one they used last time, and greyed out already says "not now". Playing stays the headline even with an upgrade waiting: the build on the disk still runs, and wanting to play it is not the same as wanting to wait for a download. The menu is a `<details>`, so its open state is the DOM's and the keyboard needs no teaching. Closing it on an outside click is the grid's job, not a card's: cards are rebuilt on every render, so a listener per card would be a listener per render. Package names lose their spaces — `WarpEngineClient-2.3.0-arm64.dmg` — because a space in a release asset is a space in every curl, script and shell command that touches it. Set per target rather than globally: nsis and portable would otherwise resolve to the same .exe name and overwrite each other. `productName` is untouched, so the app is still called WarpEngine Client where a person sees it — in the Dock and in /Applications. Tested on a sandbox store by rewriting one state record to claim an older build, which is what the engine actually compares: the window then offered `Upgrade:on` for that title and `Upgrade:off` for the current one, and pressing it took the record from 0.1 to 0.2 with the old payload removed first. The self-test asserts that pairing on every installed card, because a closed menu photographs identically whether or not its items are right. In Hungarian the catalog refresh and the new Upgrade both wanted "Frissítés"; the refresh is an icon with a tooltip, and a tooltip can afford to say *Katalógus frissítése*. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ interface SelfTestReport {
|
||||
readonly locales: readonly string[]
|
||||
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */
|
||||
readonly iconControls: readonly string[]
|
||||
/** `<title> [current|newer] Upgrade:on|off Uninstall:on|off` per installed card. */
|
||||
readonly cardMenus: readonly string[]
|
||||
}
|
||||
|
||||
/** What changed after clicking a store that was not open. */
|
||||
@@ -74,7 +76,11 @@ export class SelfTestRunner {
|
||||
// picker are the only way to reach those two actions now that neither has a label.
|
||||
const iconsNamed = report.iconControls.length === 2 &&
|
||||
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control))
|
||||
const rendered = report.locales.length > 1 && iconsNamed && (
|
||||
// Every installed card offers both actions, and Upgrade is enabled exactly when the
|
||||
// version line says there is something newer. Uninstall is always available.
|
||||
const menusAgree = report.cardMenus.every((entry: string): boolean =>
|
||||
/\[newer\] \S+:on \S+:on$/.test(entry) || /\[current\] \S+:off \S+:on$/.test(entry))
|
||||
const rendered = report.locales.length > 1 && iconsNamed && menusAgree && (
|
||||
(report.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
|
||||
report.categories.length > 0 && report.activeCategory !== null) ||
|
||||
(report.gateVisible && report.gateAction.length > 0))
|
||||
@@ -107,6 +113,16 @@ export class SelfTestRunner {
|
||||
locales: [...document.getElementById('locale').options].map((option) => option.value),
|
||||
// The two icon-only controls: a glyph with no accessible name is a button nobody
|
||||
// can identify, and the failure is silent because the icon still draws.
|
||||
// One entry per installed card: its title, whether the version line shows an
|
||||
// upgrade, and the menu's two items with their disabled state. This is the only
|
||||
// way to see that Upgrade is offered exactly when there is something newer —
|
||||
// a screenshot shows a closed menu.
|
||||
cardMenus: [...document.querySelectorAll('.card.is-installed')].map((card) => {
|
||||
const items = [...card.querySelectorAll('.menu-item')]
|
||||
.map((item) => item.textContent + (item.disabled ? ':off' : ':on'))
|
||||
const arrow = card.querySelector('.version.has-update') === null ? 'current' : 'newer'
|
||||
return (card.querySelector('h2') || {}).textContent + ' [' + arrow + '] ' + items.join(' ')
|
||||
}),
|
||||
iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
|
||||
.map((control) => {
|
||||
const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
|
||||
@@ -132,7 +148,8 @@ export class SelfTestRunner {
|
||||
paths: readString(record, 'paths'),
|
||||
logLines: readNumber(record, 'logLines'),
|
||||
locales: readStringArray(record, 'locales'),
|
||||
iconControls: readStringArray(record, 'iconControls')
|
||||
iconControls: readStringArray(record, 'iconControls'),
|
||||
cardMenus: readStringArray(record, 'cardMenus')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export class RendererApplication {
|
||||
|
||||
this.grid = new CatalogGridView(new GameCardView({
|
||||
onInstall: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||
// The same call as an install: a sync of one name fetches whatever the catalog
|
||||
// now has for it, and the engine replaces the old payload and menu entry.
|
||||
onUpgrade: (name: string): void => { void this.catalog.syncGames([name]) },
|
||||
onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
|
||||
onRemove: (name: string): void => { void this.catalog.removeGame(name) }
|
||||
}))
|
||||
|
||||
@@ -357,6 +357,56 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
/* --- the card's actions menu -------------------------------------------- */
|
||||
/*
|
||||
* A <details> holding the two actions that are not the card's headline. The card is the
|
||||
* positioning context, and the panel is pinned to the button's right edge so it opens
|
||||
* inward rather than off the side of the grid.
|
||||
*/
|
||||
.menu { position: relative; margin-left: auto; }
|
||||
.menu-toggle { list-style: none; }
|
||||
/* Safari and Chrome each draw their own marker on a summary; both have to go. */
|
||||
.menu-toggle::-webkit-details-marker { display: none; }
|
||||
.menu-toggle::marker { content: ''; }
|
||||
.menu[open] .menu-toggle { color: var(--ink); background: var(--panel-2); border-color: var(--line); }
|
||||
|
||||
.menu-items {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
min-width: 148px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / .45);
|
||||
}
|
||||
.menu-item {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-item:hover:not(:disabled) { background: #2b3746; }
|
||||
.menu-item:disabled { color: var(--ink-dim); opacity: .5; cursor: default; }
|
||||
|
||||
.icon-dots { fill: currentColor; stroke: none; }
|
||||
|
||||
/* An installed version with a newer one behind it: the arrow carries the news, so the
|
||||
colour only has to make it findable in a grid. */
|
||||
.version.has-update { color: var(--accent); font-weight: 600; }
|
||||
|
||||
|
||||
/* --- log ----------------------------------------------------------------
|
||||
No permanent footer: the panel is in the flow only while it is open, and the
|
||||
switch for it sits in the side menu with everything else that is not a title. */
|
||||
|
||||
@@ -9,7 +9,26 @@ export class CatalogGridView {
|
||||
private readonly grid = requireElement('grid', HTMLElement)
|
||||
private readonly empty = requireElement('empty', HTMLElement)
|
||||
|
||||
public constructor (private readonly cards: GameCardView) {}
|
||||
public constructor (private readonly cards: GameCardView) {
|
||||
this.closeMenusOnOutsideClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* One listener for every card's actions menu.
|
||||
*
|
||||
* A `<details>` does not close when the pointer goes elsewhere, and a card cannot own
|
||||
* this: cards are rebuilt on every render, so a listener per card would be a listener
|
||||
* per render. The grid is created once, which makes it the right place for it.
|
||||
*/
|
||||
private closeMenusOnOutsideClick (): void {
|
||||
document.addEventListener('click', (event: MouseEvent): void => {
|
||||
const target = event.target
|
||||
const clicked = target instanceof Node ? target : null
|
||||
for (const menu of this.grid.querySelectorAll('details.menu[open]')) {
|
||||
if (clicked === null || !menu.contains(clicked)) menu.removeAttribute('open')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public render (state: AppState): void {
|
||||
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from '../dom/Dom'
|
||||
|
||||
export interface GameCardViewCallbacks {
|
||||
readonly onInstall: (name: string) => void
|
||||
readonly onUpgrade: (name: string) => void
|
||||
readonly onLaunch: (name: string) => void
|
||||
readonly onRemove: (name: string) => void
|
||||
}
|
||||
@@ -11,8 +12,11 @@ export interface GameCardViewCallbacks {
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so
|
||||
* the grid can throw the lot away and rebuild after every listing.
|
||||
* A card is a function of a title and the strings: it holds no state of its own, so the
|
||||
* grid can throw the lot away and rebuild after every listing. The one exception is the
|
||||
* actions menu, whose open/closed state lives in a `<details>` element — and being
|
||||
* thrown away is exactly what should happen to an open menu when the catalog changes
|
||||
* under it.
|
||||
*/
|
||||
export class GameCardView {
|
||||
public constructor (private readonly callbacks: GameCardViewCallbacks) {}
|
||||
@@ -71,13 +75,30 @@ export class GameCardView {
|
||||
meta.appendChild(badge)
|
||||
}
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
? `${game.installedVersion} · ${messages.installed}`
|
||||
: game.version))
|
||||
meta.appendChild(this.createVersion(game, messages))
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* The version, and what the catalog has that this machine does not.
|
||||
*
|
||||
* Both numbers, when they differ: which version is installed is the thing a person
|
||||
* came to the card to find out, and "there is a newer one" is only meaningful next to
|
||||
* it. Where they agree, the second number would be noise.
|
||||
*/
|
||||
private createVersion (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||
if (!game.installed || game.installedVersion === null) {
|
||||
return createElement('span', 'version', game.version)
|
||||
}
|
||||
if (!game.updateAvailable) {
|
||||
return createElement('span', 'version', `${game.installedVersion} · ${messages.installed}`)
|
||||
}
|
||||
const version = createElement('span', 'version has-update',
|
||||
`${game.installedVersion} → ${game.version}`)
|
||||
version.title = messages.updateAvailable
|
||||
return version
|
||||
}
|
||||
|
||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const actions = createElement('div', 'actions')
|
||||
|
||||
@@ -89,25 +110,88 @@ export class GameCardView {
|
||||
return actions
|
||||
}
|
||||
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
actions.appendChild(this.createPrimary(game, messages, busy))
|
||||
// Only an installed title has anything in the menu: nothing to upgrade and nothing
|
||||
// to uninstall until there is something on the disk.
|
||||
if (game.installed) actions.appendChild(this.createMenu(game, messages, busy))
|
||||
return actions
|
||||
}
|
||||
|
||||
if (game.installed && !game.updateAvailable) {
|
||||
/**
|
||||
* The one action a card leads with.
|
||||
*
|
||||
* For an installed title that is playing it — including when an upgrade is waiting,
|
||||
* because the version on the disk still runs and wanting to play it is not the same
|
||||
* as wanting to wait for a download.
|
||||
*/
|
||||
private createPrimary (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
if (game.installed) {
|
||||
primary.textContent = game.mode === 'web' ? messages.open : messages.play
|
||||
primary.disabled = busy || !game.launchable
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
|
||||
} else {
|
||||
primary.textContent = game.updateAvailable ? messages.update : messages.install
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||
return primary
|
||||
}
|
||||
actions.appendChild(primary)
|
||||
primary.textContent = messages.install
|
||||
primary.disabled = busy
|
||||
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
|
||||
return primary
|
||||
}
|
||||
|
||||
if (game.installed) {
|
||||
const remove = createElement('button', 'btn btn-ghost', messages.remove)
|
||||
remove.disabled = busy
|
||||
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) })
|
||||
actions.appendChild(remove)
|
||||
/**
|
||||
* The three-dot menu: upgrade, and uninstall.
|
||||
*
|
||||
* A `<details>` rather than a scripted popover, so the open state is the DOM's and the
|
||||
* keyboard works without being taught to. Upgrade is present but disabled when there
|
||||
* is nothing newer — a menu whose items appear and disappear makes a person hunt for
|
||||
* the one they used last time, and "greyed out" already says "not now".
|
||||
*/
|
||||
private createMenu (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const menu = createElement('details', 'menu')
|
||||
const toggle = createElement('summary', 'icon-btn menu-toggle')
|
||||
toggle.title = messages.moreActions
|
||||
toggle.setAttribute('aria-label', messages.moreActions)
|
||||
toggle.appendChild(this.createDotsIcon())
|
||||
menu.appendChild(toggle)
|
||||
|
||||
const items = createElement('div', 'menu-items')
|
||||
items.appendChild(this.createMenuItem(messages.upgrade, busy || !game.updateAvailable,
|
||||
(): void => { this.callbacks.onUpgrade(game.name) }))
|
||||
items.appendChild(this.createMenuItem(messages.uninstall, busy,
|
||||
(): void => { this.callbacks.onRemove(game.name) }))
|
||||
menu.appendChild(items)
|
||||
return menu
|
||||
}
|
||||
|
||||
private createMenuItem (
|
||||
label: string,
|
||||
disabled: boolean,
|
||||
perform: () => void
|
||||
): HTMLButtonElement {
|
||||
const item = createElement('button', 'menu-item', label)
|
||||
item.disabled = disabled
|
||||
item.addEventListener('click', (): void => {
|
||||
// Close before acting: the click starts work that re-renders the grid, and a menu
|
||||
// left open would vanish mid-gesture rather than answer the press.
|
||||
item.closest('details')?.removeAttribute('open')
|
||||
perform()
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
private createDotsIcon (): SVGSVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('class', 'icon icon-dots')
|
||||
svg.setAttribute('viewBox', '0 0 16 16')
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
svg.setAttribute('focusable', 'false')
|
||||
for (const y of [3.5, 8, 12.5]) {
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
||||
dot.setAttribute('cx', '8')
|
||||
dot.setAttribute('cy', String(y))
|
||||
dot.setAttribute('r', '1.35')
|
||||
svg.appendChild(dot)
|
||||
}
|
||||
return actions
|
||||
return svg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
*/
|
||||
export const ENGLISH_MESSAGES = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Refresh',
|
||||
refresh: 'Refresh the catalog',
|
||||
install: 'Install',
|
||||
update: 'Update',
|
||||
upgrade: 'Upgrade',
|
||||
play: 'Play',
|
||||
open: 'Open',
|
||||
remove: 'Remove',
|
||||
uninstall: 'Uninstall',
|
||||
moreActions: 'More actions',
|
||||
installed: 'installed',
|
||||
native: 'native',
|
||||
hosted: 'hosted',
|
||||
|
||||
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
|
||||
*/
|
||||
export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
appName: 'WarpEngine Client',
|
||||
refresh: 'Frissítés',
|
||||
refresh: 'Katalógus frissítése',
|
||||
install: 'Telepítés',
|
||||
update: 'Frissítés',
|
||||
upgrade: 'Frissítés',
|
||||
play: 'Indítás',
|
||||
open: 'Megnyitás',
|
||||
remove: 'Eltávolítás',
|
||||
uninstall: 'Eltávolítás',
|
||||
moreActions: 'További műveletek',
|
||||
installed: 'telepítve',
|
||||
native: 'natív',
|
||||
hosted: 'hosztolt',
|
||||
|
||||
Reference in New Issue
Block a user