WarpEngine Client: the whole catalog, and a build that can point elsewhere
**The app is called WarpEngine Client.** "Store" named the thing it opens rather than the
thing you run, and the store is a catalog on a site, not a window on your machine. The
window title, the bundle, the packages and the menu entry follow; the repository already
did. The store being driven is named in the side menu, so the bar stopped repeating it as
a badge — the element stays in the page, hidden, because the window check reads it.
**Every title is listed, including the ones this machine cannot install.** They arrive
from the engine with `installable: false` and a reason, and they are drawn dimmed, with an
*unsupported platform* or *no build for this machine* badge, the engine's own sentence
underneath, and nothing to press: a disabled Install would invite a click that can never
work. They get a category of their own — *Not for this machine* — and they are kept out of
the native/hosted categories and counts, because a title with no build has no mode to be
counted under. An engine older than desktop 1.2.0 is unaffected: a missing `installable`
field reads as installable, which is what those engines mean.
**A build can be pointed at another site's registry:**
make dist STORES_API=https://games.example.org/api/stores
BuildConfiguration reads the packaged package.json, where electron-builder's
extraMetadata writes that address, so a client for somebody else's catalog needs no source
change and nothing set on the user's machine. Precedence is runtime environment, then
build, then ours — three audiences, most specific first.
Also: the scrollbars are the window's own, because the platform's light track down the
side menu of a dark window looked like a mistake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
runs: the app ships its own script and stylesheet. -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'none'">
|
||||
<title>WarpEngine Store</title>
|
||||
<title>WarpEngine Client</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,8 +16,10 @@
|
||||
</button>
|
||||
<div class="bar-title">
|
||||
<span class="logo" aria-hidden="true">▚</span>
|
||||
<span id="app-name">WarpEngine Store</span>
|
||||
<span class="store-id" id="store-id"></span>
|
||||
<span id="app-name">WarpEngine Client</span>
|
||||
<!-- Kept for the window check, which reads it to see which store is open; the
|
||||
side menu is where a person reads that. -->
|
||||
<span class="store-id" id="store-id" hidden></span>
|
||||
</div>
|
||||
<div class="bar-actions">
|
||||
<span class="progress" id="progress" hidden></span>
|
||||
|
||||
@@ -30,7 +30,9 @@ export function matchesFilter (game: GameDto, filter: CategoryFilter): boolean {
|
||||
case 'platform':
|
||||
return game.platform === filter.value
|
||||
case 'mode':
|
||||
return game.mode === filter.value
|
||||
// A title this machine cannot install has no mode worth filtering on: the engine
|
||||
// sends none, and counting it as native would put a C64 cartridge under "native".
|
||||
return game.installable && game.mode === filter.value
|
||||
case 'group':
|
||||
return matchesGroup(game, filter.value)
|
||||
}
|
||||
@@ -43,7 +45,9 @@ function matchesGroup (game: GameDto, group: string): boolean {
|
||||
case 'updates':
|
||||
return game.updateAvailable
|
||||
case 'available':
|
||||
return !game.installed
|
||||
return game.installable && !game.installed
|
||||
case 'unsupported':
|
||||
return !game.installable
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -68,7 +72,8 @@ export function buildCategorySections (
|
||||
{ kind: 'group', value: 'all', label: messages.catAll, count: games.length },
|
||||
{ kind: 'group', value: 'installed', label: messages.catInstalled, count: count((game: GameDto): boolean => game.installed) },
|
||||
{ kind: 'group', value: 'updates', label: messages.catUpdates, count: count((game: GameDto): boolean => game.updateAvailable) },
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => !game.installed) }
|
||||
{ kind: 'group', value: 'available', label: messages.catAvailable, count: count((game: GameDto): boolean => game.installable && !game.installed) },
|
||||
{ kind: 'group', value: 'unsupported', label: messages.catUnsupported, count: count((game: GameDto): boolean => !game.installable) }
|
||||
]
|
||||
sections.push({
|
||||
title: null,
|
||||
@@ -90,7 +95,8 @@ export function buildCategorySections (
|
||||
})
|
||||
}
|
||||
|
||||
const modes = [...new Set(games.map((game: GameDto): string => game.mode))]
|
||||
const installable = games.filter((game: GameDto): boolean => game.installable)
|
||||
const modes = [...new Set(installable.map((game: GameDto): string => game.mode))]
|
||||
if (modes.length > 1) {
|
||||
sections.push({
|
||||
title: messages.catMode,
|
||||
@@ -98,7 +104,7 @@ export function buildCategorySections (
|
||||
kind: 'mode',
|
||||
value: mode,
|
||||
label: mode === 'web' ? messages.hosted : messages.native,
|
||||
count: count((game: GameDto): boolean => game.mode === mode)
|
||||
count: count((game: GameDto): boolean => game.installable && game.mode === mode)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* The scrollbars are part of the theme too: the platform's own are light, and a white
|
||||
track down the side menu of a dark window looks like a mistake. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #33414f transparent;
|
||||
}
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #33414f;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #46586b; background-clip: content-box; }
|
||||
|
||||
/* An explicit `display` beats the browser's own [hidden] rule, and most of the
|
||||
regions here have one — the gate's store picker showed as an empty stub because
|
||||
of exactly that. This makes `hidden` mean hidden everywhere. */
|
||||
@@ -248,6 +264,12 @@ body.nav-closed .side { margin-left: calc(-1 * var(--side-width)); }
|
||||
flex-direction: column;
|
||||
}
|
||||
.card.is-installed { border-color: #2f5a49; }
|
||||
/* Listed, but not for this machine: dimmed rather than hidden, and it says why. */
|
||||
.card.is-unavailable { opacity: .55; }
|
||||
.card.is-unavailable:hover { opacity: .8; }
|
||||
.card.is-unavailable .art { filter: grayscale(1); }
|
||||
.badge-unavailable { color: var(--ink-dim); border-color: var(--line); border-style: dashed; }
|
||||
.actions-note { font-size: 12px; color: var(--ink-dim); margin-top: auto; }
|
||||
|
||||
.art {
|
||||
/* One height for every card, art or not, so the titles and the buttons line up
|
||||
|
||||
@@ -20,6 +20,7 @@ export class GameCardView {
|
||||
public createCard (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const card = createElement('article', 'card')
|
||||
if (game.installed) card.classList.add('is-installed')
|
||||
if (!game.installable) card.classList.add('is-unavailable')
|
||||
card.appendChild(this.createArt(game))
|
||||
card.appendChild(this.createBody(game, messages, busy))
|
||||
return card
|
||||
@@ -54,10 +55,21 @@ export class GameCardView {
|
||||
|
||||
private createMeta (game: GameDto, messages: MessageBundle): HTMLElement {
|
||||
const meta = createElement('div', 'meta')
|
||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||
game.mode === 'web' ? messages.hosted : messages.native)
|
||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||
meta.appendChild(mode)
|
||||
if (game.installable) {
|
||||
const mode = createElement('span', `badge badge-${game.mode}`,
|
||||
game.mode === 'web' ? messages.hosted : messages.native)
|
||||
mode.title = game.mode === 'web' ? messages.hostedHint : messages.nativeHint
|
||||
meta.appendChild(mode)
|
||||
} else {
|
||||
// Which of the two it is matters: a platform this store does not carry is a
|
||||
// different disappointment from a game with no build for your machine.
|
||||
const label = game.unavailableReason === 'platformOff'
|
||||
? messages.unsupportedPlatform
|
||||
: messages.unsupportedBuild
|
||||
const badge = createElement('span', 'badge badge-unavailable', label)
|
||||
badge.title = game.unavailableDetail ?? label
|
||||
meta.appendChild(badge)
|
||||
}
|
||||
meta.appendChild(createElement('span', 'badge badge-plain', game.platform))
|
||||
meta.appendChild(createElement('span', 'version',
|
||||
game.installed && game.installedVersion !== null
|
||||
@@ -68,6 +80,15 @@ export class GameCardView {
|
||||
|
||||
private createActions (game: GameDto, messages: MessageBundle, busy: boolean): HTMLElement {
|
||||
const actions = createElement('div', 'actions')
|
||||
|
||||
// Nothing to offer, so nothing to press: a disabled Install would invite a click
|
||||
// that can never work. The badge above says why.
|
||||
if (!game.installable) {
|
||||
actions.appendChild(createElement('span', 'actions-note',
|
||||
game.unavailableDetail ?? messages.unsupportedPlatform))
|
||||
return actions
|
||||
}
|
||||
|
||||
const primary = createElement('button', 'btn btn-primary')
|
||||
primary.disabled = busy
|
||||
|
||||
|
||||
@@ -18,8 +18,9 @@ export class TopBarView {
|
||||
|
||||
public render (state: AppState): void {
|
||||
setText(this.appName, state.messages.appName)
|
||||
// The badge is a bordered pill: empty, it renders as a stub next to the title.
|
||||
setHidden(this.storeId, state.currentStore === null)
|
||||
// The bar names the app, not the store: which store is open is what the switcher in
|
||||
// the side menu says, and saying it twice made the title read like a breadcrumb. The
|
||||
// element stays in the page, hidden, because the window check reads it.
|
||||
setText(this.storeId, state.currentStore === null ? '' : state.currentStore.id)
|
||||
this.navToggle.title = state.messages.menu
|
||||
this.navToggle.setAttribute('aria-label', state.messages.menu)
|
||||
|
||||
Reference in New Issue
Block a user