Upgrade from the card, behind a three-dot menu
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

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:
2026-08-19 08:17:43 +02:00
co-authored by Claude Opus 5
parent 82590d3ec4
commit e35a72336a
12 changed files with 275 additions and 79 deletions
+19 -2
View File
@@ -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')
}
}
+3
View File
@@ -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) }
}))
+50
View File
@@ -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. */
+20 -1
View File
@@ -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))
+103 -19
View File
@@ -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
}
}
+4 -3
View File
@@ -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',
+4 -3
View File
@@ -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',