Put the store where people are, not at the bottom of the catalog
The client was reachable from one grey link under the whole catalog listing and from nowhere on the home page — which is a strange place for the thing that actually gets our games onto somebody's machine. **Catalog**: the Stores link moves into the hero, as a button in the shape the Code and HowTo heroes already use (subtitle, breathing room, one white button). Somebody who has come to look at games is one step from wanting to run one, and that step should not be below the fold. The build matrix stays at the bottom, which is where somebody who has already read the catalog goes looking for it. **Home**: a section for the client under the featured game — the same dark band as the YouTube and Spotify blocks below it, so the page keeps one rhythm rather than growing a fifth nav card and breaking the four-column grid. Screenshot, three things it does, download and a link to the full page. The screenshot leads on a wide screen and follows on a narrow one: it is what makes the section legible at a glance, but it is not what somebody reads first on a phone. `btn-hero-purple` joins teal and indigo, and retro mode covers it — that block lists the button classes by name, so a new one is invisible there until added. Two things found on the way: - the stores page said the client "needs Python 3". It has not since the engine moved into the app; the line now says nothing has to be installed first, which is the actual selling point. - there was no test holding the two locale bundles together. A missing translation is not an error at runtime — vue-i18n renders the key itself, so `home.store.title` appears on screen looking like unfinished work, and only after switching language. The new spec names the missing key; I checked it fails by deleting one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '../locales/en'
|
||||
import hu from '../locales/hu'
|
||||
|
||||
/**
|
||||
* The two bundles have to carry the same keys.
|
||||
*
|
||||
* A missing translation is not an error at runtime — vue-i18n falls back and the page
|
||||
* renders the key itself, so `home.store.title` appears on screen looking like a bug
|
||||
* somebody forgot to finish. That is exactly the kind of thing a person only notices
|
||||
* after switching language, which is to say: after it has shipped.
|
||||
*/
|
||||
function paths (value: unknown, prefix = ''): string[] {
|
||||
if (typeof value !== 'object' || value === null) return [prefix]
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.flatMap(([key, nested]) => paths(nested, prefix === '' ? key : `${prefix}.${key}`))
|
||||
}
|
||||
|
||||
describe('locale bundles', () => {
|
||||
const english = paths(en).sort()
|
||||
const hungarian = paths(hu).sort()
|
||||
|
||||
it('has something to compare', () => {
|
||||
expect(english.length).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('translates every English key into Hungarian', () => {
|
||||
expect(hungarian.filter((key) => !english.includes(key))).toEqual([])
|
||||
expect(english.filter((key) => !hungarian.includes(key))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves no empty string behind', () => {
|
||||
const empty = [...paths(en), ...paths(hu)].filter((key) => {
|
||||
const read = (bundle: unknown) =>
|
||||
key.split('.').reduce<unknown>((acc, part) =>
|
||||
(acc as Record<string, unknown> | undefined)?.[part], bundle)
|
||||
return read(en) === '' || read(hu) === ''
|
||||
})
|
||||
expect(empty).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,18 @@ export default {
|
||||
spotify: {
|
||||
openInSpotify: 'Open in Spotify ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Play on your own machine',
|
||||
allStores: 'All stores',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'Our catalog as a grid of cards on your desktop: one click installs a game into your application menu, one plays it. It sets itself up on first run.',
|
||||
point1: 'Nothing to install first — no interpreter, no admin rights',
|
||||
point2: 'Installed games become normal menu entries, so they start without the app running',
|
||||
point3: 'Browser builds too, for titles with no download for your machine',
|
||||
download: 'Download',
|
||||
more: 'How it works',
|
||||
platforms: 'Linux, macOS and Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
@@ -109,6 +121,7 @@ export default {
|
||||
readMore: 'Read more',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Get the store',
|
||||
title: 'Our Games',
|
||||
subtitle: 'Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!',
|
||||
filter: {
|
||||
@@ -206,7 +219,7 @@ export default {
|
||||
|
||||
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
|
||||
clientDownload: 'Download',
|
||||
clientPlatforms: 'Linux, macOS and Windows · needs Python 3',
|
||||
clientPlatforms: 'Linux, macOS and Windows · nothing to install first',
|
||||
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
|
||||
|
||||
docs: 'Documentation',
|
||||
|
||||
@@ -60,6 +60,18 @@ export default {
|
||||
spotify: {
|
||||
openInSpotify: 'Megnyitás Spotify-ban ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Játssz a saját gépeden',
|
||||
allStores: 'Összes bolt',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'A katalógusunk kártyákban, az asztalodon: egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első futáskor magát is beállítja.',
|
||||
point1: 'Semmit nem kell előtte feltenni — se értelmező, se rendszergazdai jog',
|
||||
point2: 'A feltelepített játék rendes menüelem lesz, tehát az app nélkül is indul',
|
||||
point3: 'Böngészős buildek is, ha egy címhez nincs letöltés a gépedre',
|
||||
download: 'Letöltés',
|
||||
more: 'Hogyan működik',
|
||||
platforms: 'Linux, macOS és Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Kapcsolat',
|
||||
@@ -109,6 +121,7 @@ export default {
|
||||
readMore: 'Tovább olvasom',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Töltsd le a boltot',
|
||||
title: 'Játékaink',
|
||||
subtitle: 'Fedezd fel a TIC-80-ra és más platformokra készített játékainkat, amelyeknek közvetlenül a böngésződben is kipróbálhatsz verzióit!',
|
||||
filter: {
|
||||
@@ -206,7 +219,7 @@ export default {
|
||||
|
||||
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
|
||||
clientDownload: 'Letöltés',
|
||||
clientPlatforms: 'Linux, macOS és Windows · Python 3 kell hozzá',
|
||||
clientPlatforms: 'Linux, macOS és Windows · semmit nem kell előtte feltenni',
|
||||
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
|
||||
|
||||
docs: 'Dokumentáció',
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
|
||||
<div class="hero-container">
|
||||
<h1 class="hero-title">{{ t('catalog.title') }}</h1>
|
||||
<p class="hero-subtitle text-purple-50">{{ t('catalog.subtitle') }}</p>
|
||||
<!-- Playing a game needs the store, so the way to it belongs where somebody
|
||||
arrives rather than under the list they have to scroll past first. Same
|
||||
shape as the Code and HowTo heroes: subtitle, breathing room, one button. -->
|
||||
<p class="hero-subtitle text-purple-50 mb-10">{{ t('catalog.subtitle') }}</p>
|
||||
<RouterLink to="/stores" class="btn-hero-purple">
|
||||
<i class="fa-solid fa-download mr-2"></i>{{ t('catalog.getTheStore') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -62,14 +68,12 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Stores moved up into the hero; the build matrix stays down here, which is
|
||||
where somebody who has already read the catalog goes looking for it. -->
|
||||
<div class="builds-link-row">
|
||||
<RouterLink to="/builds" class="builds-link">
|
||||
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
|
||||
</RouterLink>
|
||||
<span class="builds-link-sep">|</span>
|
||||
<RouterLink to="/stores" class="builds-link">
|
||||
<i class="fa-solid fa-gamepad"></i> {{ t('stores.title') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -171,7 +175,4 @@ onMounted(() => store.fetch())
|
||||
.builds-link {
|
||||
@apply text-sm text-gray-400 hover:text-purple-600 font-medium transition-colors;
|
||||
}
|
||||
.builds-link-sep {
|
||||
@apply text-gray-200 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,6 +66,58 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The store. Placed under the featured game on purpose: somebody who has just
|
||||
read about a title is one step from wanting to run it, and this is that step.
|
||||
Same dark band as the two media sections below, so the page keeps one rhythm. -->
|
||||
<section class="mb-16">
|
||||
<div class="store-wrapper group">
|
||||
<div class="store-glow"></div>
|
||||
|
||||
<div class="store-header">
|
||||
<div class="store-header-inner">
|
||||
<div class="store-status-blob">
|
||||
<div class="store-status-dot"></div>
|
||||
</div>
|
||||
<span class="store-label">{{ t('home.store.label') }}</span>
|
||||
</div>
|
||||
<RouterLink to="/stores" class="store-all-link">{{ t('home.store.allStores') }}</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="store-grid">
|
||||
<figure class="store-shot">
|
||||
<img
|
||||
:src="clientScreenshot" :alt="t('stores.clientShotAlt')"
|
||||
width="2304" height="1664" loading="lazy"
|
||||
>
|
||||
</figure>
|
||||
|
||||
<div class="store-info">
|
||||
<h3 class="store-title">{{ t('home.store.title') }}</h3>
|
||||
<p class="store-desc">{{ t('home.store.desc') }}</p>
|
||||
<ul class="store-points">
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('home.store.point1') }}</li>
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('home.store.point2') }}</li>
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('home.store.point3') }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="store-actions">
|
||||
<a :href="CLIENT_RELEASES_URL" target="_blank" rel="noopener noreferrer" class="store-btn-primary">
|
||||
<i class="fa-solid fa-download"></i> {{ t('home.store.download') }}
|
||||
</a>
|
||||
<RouterLink to="/stores" class="store-btn-secondary">
|
||||
<i class="fa-solid fa-circle-info"></i> {{ t('home.store.more') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<p class="store-platforms">
|
||||
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
|
||||
<i class="fa-brands fa-windows"></i> {{ t('home.store.platforms') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Latest YouTube Video -->
|
||||
<section class="mb-16">
|
||||
<div class="yt-featured-wrapper group">
|
||||
@@ -228,6 +280,11 @@ import { useEventStore } from '../../stores/event.store'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import { useYoutubeStore } from '../../stores/youtube.store'
|
||||
import { getDefaultImageUrl } from '../../lib/softwareUtils'
|
||||
import clientScreenshot from '../../assets/warpengine-client.png'
|
||||
|
||||
// The releases page, not a versioned file: whoever follows it should get whatever is
|
||||
// newest, and this page would otherwise need a release every time the client has one.
|
||||
const CLIENT_RELEASES_URL = 'https://git.teletypegames.org/stores/warp-engine-client/releases'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -457,6 +514,73 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply relative z-10 rounded-xl overflow-hidden;
|
||||
}
|
||||
|
||||
/* Store Promo */
|
||||
.store-wrapper {
|
||||
@apply relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-900 to-violet-950 rounded-3xl shadow-2xl border border-slate-800 p-6 md:p-8;
|
||||
}
|
||||
.store-glow {
|
||||
@apply absolute -bottom-24 -right-24 w-96 h-96 bg-violet-500/10 rounded-full blur-[100px] pointer-events-none;
|
||||
}
|
||||
.store-header {
|
||||
@apply flex items-center justify-between mb-6 relative z-10;
|
||||
}
|
||||
.store-header-inner {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
.store-status-blob {
|
||||
@apply w-3 h-3 bg-violet-500/20 rounded-full flex items-center justify-center;
|
||||
}
|
||||
.store-status-dot {
|
||||
@apply w-1.5 h-1.5 bg-violet-400 rounded-full animate-pulse;
|
||||
}
|
||||
.store-label {
|
||||
@apply text-xs font-bold uppercase tracking-widest text-slate-400;
|
||||
}
|
||||
.store-all-link {
|
||||
@apply text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors;
|
||||
}
|
||||
.store-grid {
|
||||
@apply flex flex-col lg:flex-row gap-8 relative z-10;
|
||||
}
|
||||
/* The screenshot leads on a wide screen and follows on a narrow one: it is what makes
|
||||
the section legible at a glance, but it is not what somebody reads first on a phone. */
|
||||
.store-shot {
|
||||
@apply flex-[1.4] order-last lg:order-first overflow-hidden rounded-2xl border border-white/5 shadow-inner bg-black;
|
||||
}
|
||||
.store-shot img {
|
||||
@apply w-full h-auto block;
|
||||
}
|
||||
.store-info {
|
||||
@apply flex-1 flex flex-col justify-center;
|
||||
}
|
||||
.store-title {
|
||||
@apply text-2xl font-black text-white mb-3 leading-tight group-hover:text-violet-200 transition-colors;
|
||||
}
|
||||
.store-desc {
|
||||
@apply text-slate-300 leading-relaxed mb-5;
|
||||
}
|
||||
.store-points {
|
||||
@apply space-y-2 mb-6 text-sm text-slate-400;
|
||||
}
|
||||
.store-points li {
|
||||
@apply flex items-start gap-2;
|
||||
}
|
||||
.store-points i {
|
||||
@apply text-violet-400 mt-1 text-xs;
|
||||
}
|
||||
.store-actions {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
.store-btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-6 py-3 bg-white text-slate-900 font-black rounded-xl transition-all hover:scale-[1.02] hover:bg-violet-50 active:scale-95 shadow-xl;
|
||||
}
|
||||
.store-btn-secondary {
|
||||
@apply inline-flex items-center gap-2 px-6 py-3 bg-violet-500/10 text-white border border-violet-400/30 font-bold rounded-xl transition-all hover:bg-violet-500/20;
|
||||
}
|
||||
.store-platforms {
|
||||
@apply flex items-center gap-2 mt-5 text-xs text-slate-500;
|
||||
}
|
||||
|
||||
/* Nav Grid */
|
||||
.nav-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6;
|
||||
|
||||
@@ -52,6 +52,10 @@ body {
|
||||
@apply btn-hero text-indigo-900 hover:bg-indigo-50;
|
||||
}
|
||||
|
||||
.btn-hero-purple {
|
||||
@apply btn-hero text-purple-700 hover:bg-purple-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center font-bold py-3 px-8 rounded-xl transition-all shadow-lg;
|
||||
}
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
.retro-mode .btn-hero,
|
||||
.retro-mode .btn-hero-teal,
|
||||
.retro-mode .btn-hero-indigo,
|
||||
.retro-mode .btn-hero-purple,
|
||||
.retro-mode .btn-primary {
|
||||
background-color: #00a800 !important;
|
||||
color: #000 !important;
|
||||
|
||||
Reference in New Issue
Block a user