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
+8 -3
View File
@@ -272,9 +272,14 @@ on its own: publishing 1.2.0 got *"invalid username, password or token"* on the
second package while the first had just gone up with the same token, and the same second package while the first had just gone up with the same token, and the same
command succeeded immediately afterwards. command succeeded immediately afterwards.
Package names contain a space`WarpEngine Client-1.5.0-arm64.dmg`so the list of Package names have no spaces in them`WarpEngineClient-2.3.0-arm64.dmg`because a
files is passed one path per line rather than as one string; splitting it on space in a release asset is a space in every `curl`, script and shell command that ever
whitespace is what broke the first attempt at publishing 1.1.0. touches it. The app itself is still called **WarpEngine Client**: that name is what
appears in the Dock and in `/Applications`, and only the file names were the problem.
The list of files is still passed one path per line rather than as one string, since a
path given on the command line can contain a space even when a built one cannot;
splitting it on whitespace is what broke the first attempt at publishing 1.1.0.
It needs `tea` installed and logged in — the devarea repo has `make tea` for that. It needs `tea` installed and logged in — the devarea repo has `make tea` for that.
Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`. Overridable: `TAG`, `REPO`, `TEA_LOGIN`, `NOTES`, `DIST`.
+41 -40
View File
@@ -1,41 +1,39 @@
# WarpEngine Client 2.2.0 # WarpEngine Client 2.3.0
**A registry record is a name and a catalog.** Nothing else. The `config` field added in **A card says which version you have, and offers the newer one.** Where the catalog has
2.1.0 is gone, and so is `storeRepositoryUrl` — along with the two store repositories they moved on, an installed title's version line reads `0.1 → 0.3` instead of just the number
pointed at, which have been deleted. on your disk, so the card answers both questions a person came to it with: what is
installed, and is there anything better.
2.1.0 had the registry say how each store behaves. That was the wrong shape. How a store Which version is installed was already recorded — that is what `state.json` has always
behaves is fixed per installed client: this application carries its own store engine and been for. What was missing was somewhere to act on it.
is the only thing that can see the machine it is running on. A copy of that on a server
was a second authority over decisions this side had already made correctly — including
which directories the store is allowed to delete from — and a second authority is a way
for the two to disagree.
Keeping two stores on one machine out of each other's files needs none of it. It is a **The actions moved into a three-dot menu.** Installed cards now lead with **Play** (or
subfolder, and the client derives it: **Open** for a hosted title) and put the rest behind the ⋮ button beside it:
| | | | | |
|---|---| |---|---|
| **store id** | a slug of the catalog host — `teletypegames.org` becomes `teletypegames` | | **Upgrade** | fetches whatever the catalog now has, replacing the old payload and menu entry. Greyed out when there is nothing newer |
| **store home** | `<id>-desktop`, under the usual store root | | **Uninstall** | as before — the payload, the icon and the menu entry, and nothing else |
| **games folder** | `<id>`, inside the OS's usual place for programs |
| **delete boundary** | that same folder, and nothing outside it |
Derived from the *catalog* on purpose: the catalog is what a store is, so two records Upgrade stays visible while disabled rather than appearing and disappearing: a menu whose
naming the same one are the same store and land in the same place. Installing the same items come and go makes a person hunt for the one they used last time, and greyed out
store twice is idempotent rather than a way to orphan what is already there. already says "not now".
**Existing installations are untouched.** A store already on the machine is recognised by Playing stays the headline action even when an upgrade is waiting. The version on the disk
the `config.json` in its own home and keeps the identity written there — a store installed still runs, and wanting to play it is not the same as wanting to wait for a download.
as `ttg` stays `ttg`, in `ttg-desktop`, with its games where they are. Only a *new* install
derives its id from the catalog. **Package names have no spaces.** `WarpEngineClient-2.3.0-arm64.dmg` rather than
`WarpEngine Client-2.3.0-arm64.dmg`, and the same for the AppImage and both Windows
builds — a space in a release asset is a space in every `curl`, script and shell command
that ever touches it. The app is still called **WarpEngine Client**, which is what appears
in the Dock and in `/Applications`; only the file names changed.
### Also ### Also
The public `/stores` page loses its desktop card, which advertised a `curl … | sh` for a The catalog refresh control's tooltip now says *Refresh the catalog*, because in Hungarian
repository that no longer exists; an ordinary computer is served by the app. The `/desktop` it and the new Upgrade both wanted the word "Frissítés" and only one of them reloads a
URL now lands on the app rather than on a device tab, so what someone typing it wants is list.
what they get.
### Opening it on macOS ### Opening it on macOS
@@ -54,18 +52,21 @@ Windows (installer, portable) packages the pipeline builds when the tag is pushe
`make check` is clean: typecheck, lint, the headless smoke test and the window self-test. `make check` is clean: typecheck, lint, the headless smoke test and the window self-test.
The install path was measured end to end against a local registry serving exactly the new The upgrade path was tested on a sandbox store rather than reasoned about. Two titles were
record shape. The slug came out `teletypegames`, the home `teletypegames-desktop`, the installed, then one of their state records was rewritten to claim an older build — which is
games subfolder `teletypegames`, and installing the same record twice landed in the same exactly what the engine compares — and the window was asked what it would offer:
home. A record carrying `config` and `storeRepositoryUrl` — what a stale registry or a
tampering renderer might still send — changed nothing on disk, because neither field exists
in the model any more.
On this machine the check also shows both halves of the compatibility claim at once: the ```
registry's new slug reads `teletypegames`, while the store that was installed before today BombExpert [newer] Upgrade:on Uninstall:on
is still found as `ttg` in `ttg-desktop`, listing its ten installed titles from Rabbit Roller [current] Upgrade:off Uninstall:on
`Application Support/teletypegames`. ```
The site side was migrated and its specs re-run, and the frontend was built — which first Pressing Upgrade runs the same call the window makes, and the record went from `0.1` to
required removing a dead `engines` list that had been failing `vue-tsc` on master, so that `0.2` with the old payload removed first. The self-test now asserts that pairing on every
page could not be built to check at all. installed card, because a closed menu photographs identically whether or not its items are
right.
The new package names were read off a real build (`WarpEngineClient-2.3.0-arm64.dmg`,
`…-arm64-mac.zip`) and checked against the release script's own file filter, which finds
both. The Windows names are `-Setup-` and `-Portable-` so the two `.exe` targets cannot
resolve to one name and overwrite each other.
+15 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "warp-engine-client", "name": "warp-engine-client",
"productName": "WarpEngine Client", "productName": "WarpEngine Client",
"version": "2.2.0", "version": "2.3.0",
"description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.", "description": "Graphical client for WarpEngine stores: install a catalog into your own application menu.",
"license": "MIT", "license": "MIT",
"author": "Teletype Games <games@teletype.hu>", "author": "Teletype Games <games@teletype.hu>",
@@ -44,7 +44,11 @@
"target": [ "target": [
"dmg", "dmg",
"zip" "zip"
] ],
"artifactName": "WarpEngineClient-${version}-${arch}-mac.${ext}"
},
"dmg": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
}, },
"win": { "win": {
"target": [ "target": [
@@ -52,6 +56,12 @@
"portable" "portable"
] ]
}, },
"nsis": {
"artifactName": "WarpEngineClient-Setup-${version}-${arch}.${ext}"
},
"portable": {
"artifactName": "WarpEngineClient-Portable-${version}-${arch}.${ext}"
},
"linux": { "linux": {
"category": "Game", "category": "Game",
"target": [ "target": [
@@ -59,6 +69,9 @@
"deb" "deb"
] ]
}, },
"appImage": {
"artifactName": "WarpEngineClient-${version}-${arch}.${ext}"
},
"afterPack": "scripts/after-pack.js" "afterPack": "scripts/after-pack.js"
}, },
"allowScripts": { "allowScripts": {
+4 -3
View File
@@ -53,9 +53,10 @@ api() {
curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@" curl -fsS -X "$method" -H "$AUTH" "$FORGE$path" "$@"
} }
# Package names contain spaces — "WarpEngine Client Setup 1.5.0.exe" does — so the list # The list lives one path per line in a file and is read with `while IFS= read -r`. The
# lives one path per line in a file and is read with `while IFS= read -r`. A single # built package names have no spaces in them any more, but a path given on the command
# variable looped over with $list splits on the space and uploads nothing. # line still can — and a single variable looped over with $list splits on the space and
# uploads nothing, which is a silent way to publish a release with no assets.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
+4 -3
View File
@@ -43,9 +43,10 @@ REPO="${REPO:-$(git remote get-url origin 2>/dev/null |
# #
# - the version filter, because dist/ keeps whatever earlier builds left there # - the version filter, because dist/ keeps whatever earlier builds left there
# and a release would quietly get the previous version's files attached; # and a release would quietly get the previous version's files attached;
# - the spaces. "WarpEngine Client-1.5.0-arm64.dmg" has one, so the list lives one # - the spaces. The built names have none since 2.3.0 — `WarpEngineClient-2.3.0-arm64.dmg`
# path per line in a file and is read with `while IFS= read -r`. Holding it in # — but a path given as an argument still can, so the list stays one path per line in
# a single variable and looping over $list splits it on the space. # a file, read with `while IFS= read -r`. Holding it in a single variable and looping
# over $list splits it on the space, and publishes nothing.
LIST="$(mktemp)" LIST="$(mktemp)"
trap 'rm -f "$LIST"' EXIT trap 'rm -f "$LIST"' EXIT
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
+19 -2
View File
@@ -28,6 +28,8 @@ interface SelfTestReport {
readonly locales: readonly string[] readonly locales: readonly string[]
/** `<accessible name>:<glyph count>` per icon-only control in the footer. */ /** `<accessible name>:<glyph count>` per icon-only control in the footer. */
readonly iconControls: readonly string[] 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. */ /** 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. // picker are the only way to reach those two actions now that neither has a label.
const iconsNamed = report.iconControls.length === 2 && const iconsNamed = report.iconControls.length === 2 &&
report.iconControls.every((control: string): boolean => /^.+:1$/.test(control)) 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.cards > 0 && !report.gateVisible && report.stores.length > 0 &&
report.categories.length > 0 && report.activeCategory !== null) || report.categories.length > 0 && report.activeCategory !== null) ||
(report.gateVisible && report.gateAction.length > 0)) (report.gateVisible && report.gateAction.length > 0))
@@ -107,6 +113,16 @@ export class SelfTestRunner {
locales: [...document.getElementById('locale').options].map((option) => option.value), locales: [...document.getElementById('locale').options].map((option) => option.value),
// The two icon-only controls: a glyph with no accessible name is a button nobody // 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. // 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')] iconControls: [...document.querySelectorAll('.side-tools .icon-btn')]
.map((control) => { .map((control) => {
const named = control.getAttribute('aria-label') || control.getAttribute('title') || const named = control.getAttribute('aria-label') || control.getAttribute('title') ||
@@ -132,7 +148,8 @@ export class SelfTestRunner {
paths: readString(record, 'paths'), paths: readString(record, 'paths'),
logLines: readNumber(record, 'logLines'), logLines: readNumber(record, 'logLines'),
locales: readStringArray(record, 'locales'), 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({ this.grid = new CatalogGridView(new GameCardView({
onInstall: (name: string): void => { void this.catalog.syncGames([name]) }, 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) }, onLaunch: (name: string): void => { void this.catalog.launchGame(name) },
onRemove: (name: string): void => { void this.catalog.removeGame(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; } .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 ---------------------------------------------------------------- /* --- log ----------------------------------------------------------------
No permanent footer: the panel is in the flow only while it is open, and the 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. */ 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 grid = requireElement('grid', HTMLElement)
private readonly empty = requireElement('empty', 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 { public render (state: AppState): void {
const shown = state.games.filter((game: GameDto): boolean => matchesFilter(game, state.filter)) 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 { export interface GameCardViewCallbacks {
readonly onInstall: (name: string) => void readonly onInstall: (name: string) => void
readonly onUpgrade: (name: string) => void
readonly onLaunch: (name: string) => void readonly onLaunch: (name: string) => void
readonly onRemove: (name: string) => void readonly onRemove: (name: string) => void
} }
@@ -11,8 +12,11 @@ export interface GameCardViewCallbacks {
/** /**
* One card. * One card.
* *
* A card is a function of a title and the strings: it holds no state of its own, so * A card is a function of a title and the strings: it holds no state of its own, so the
* the grid can throw the lot away and rebuild after every listing. * 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 { export class GameCardView {
public constructor (private readonly callbacks: GameCardViewCallbacks) {} public constructor (private readonly callbacks: GameCardViewCallbacks) {}
@@ -71,13 +75,30 @@ export class GameCardView {
meta.appendChild(badge) meta.appendChild(badge)
} }
meta.appendChild(createElement('span', 'badge badge-plain', game.platform)) meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
meta.appendChild(createElement('span', 'version', meta.appendChild(this.createVersion(game, messages))
game.installed && game.installedVersion !== null
? `${game.installedVersion} · ${messages.installed}`
: game.version))
return meta 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 { private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
const actions = createElement('div', 'actions') const actions = createElement('div', 'actions')
@@ -89,25 +110,88 @@ export class GameCardView {
return actions return actions
} }
const primary = createElement('button', 'btn btn-primary') actions.appendChild(this.createPrimary(game, messages, busy))
primary.disabled = 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.textContent = game.mode === 'web' ? messages.open : messages.play
primary.disabled = busy || !game.launchable primary.disabled = busy || !game.launchable
primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) }) primary.addEventListener('click', (): void => { this.callbacks.onLaunch(game.name) })
} else { return primary
primary.textContent = game.updateAvailable ? messages.update : messages.install
primary.addEventListener('click', (): void => { this.callbacks.onInstall(game.name) })
} }
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) * The three-dot menu: upgrade, and uninstall.
remove.disabled = busy *
remove.addEventListener('click', (): void => { this.callbacks.onRemove(game.name) }) * A `<details>` rather than a scripted popover, so the open state is the DOM's and the
actions.appendChild(remove) * 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 = { export const ENGLISH_MESSAGES = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Refresh', refresh: 'Refresh the catalog',
install: 'Install', install: 'Install',
update: 'Update', upgrade: 'Upgrade',
play: 'Play', play: 'Play',
open: 'Open', open: 'Open',
remove: 'Remove', uninstall: 'Uninstall',
moreActions: 'More actions',
installed: 'installed', installed: 'installed',
native: 'native', native: 'native',
hosted: 'hosted', hosted: 'hosted',
+4 -3
View File
@@ -6,12 +6,13 @@ import type { MessageBundle } from './MessageBundle'
*/ */
export const HUNGARIAN_MESSAGES: MessageBundle = { export const HUNGARIAN_MESSAGES: MessageBundle = {
appName: 'WarpEngine Client', appName: 'WarpEngine Client',
refresh: 'Frissítés', refresh: 'Katalógus frissítése',
install: 'Telepítés', install: 'Telepítés',
update: 'Frissítés', upgrade: 'Frissítés',
play: 'Indítás', play: 'Indítás',
open: 'Megnyitás', open: 'Megnyitás',
remove: 'Eltávolítás', uninstall: 'Eltávolítás',
moreActions: 'További műveletek',
installed: 'telepítve', installed: 'telepítve',
native: 'natív', native: 'natív',
hosted: 'hosztolt', hosted: 'hosztolt',