feat(ui): label streamed installs as not shareable

NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be
clear to users. Keep streamed installs in the installed visual state so
sorting, filters, and the primary Play action stay unchanged, but make the
sharing limitation visible in the UI.

Cards now label that state as `Not shareable`, while the detail modal
status says `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` wording.

Test Plan:
- just frontend-test
- just build
- git diff --check
- git diff --cached --check

Refs: NEXT_STEPS.md item 7
This commit is contained in:
2026-06-07 22:29:26 +02:00
parent 9288fda037
commit f62515451b
5 changed files with 79 additions and 26 deletions
+5 -4
View File
@@ -54,11 +54,12 @@ product-ready.
The peer-cli harness now exposes `cancel-download` so cancellation scenarios The peer-cli harness now exposes `cancel-download` so cancellation scenarios
exercise the same runtime path as the GUI. exercise the same runtime path as the GUI.
7. **Clean product semantics** 7. **Done — Clean product semantics**
Decide how the UI labels this state. It is installed but not downloaded, so The UI now keeps streamed installs in the installed visual state while making
“Local only” is technically correct, but users may need a clear affordance the sharing limitation explicit: cards show `Not shareable`, and the detail
like “Installed, not shareable”. modal status shows `Installed, not shareable`. Downloaded-and-installed games
keep the normal `Installed` label.
My recommended next slice: make the provider abstraction final-ish, then My recommended next slice: make the provider abstraction final-ish, then
implement a real one-pass provider. Everything else builds cleanly on that. implement a real one-pass provider. Everything else builds cleanly on that.
@@ -1,13 +1,5 @@
import { Game } from '../lib/types'; import { Game } from '../lib/types';
import { deriveState } from '../lib/gameState'; import { deriveState, stateChipLabel } from '../lib/gameState';
const LABELS: Record<string, string> = {
installed: 'Installed',
local: 'Local',
downloading: 'Downloading',
busy: 'Working',
none: '',
};
interface Props { interface Props {
game: Game; game: Game;
@@ -17,7 +9,7 @@ interface Props {
export const StateChip = ({ game, showNone = false }: Props) => { export const StateChip = ({ game, showNone = false }: Props) => {
const state = deriveState(game); const state = deriveState(game);
const label = LABELS[state] ?? ''; const label = stateChipLabel(game);
if (!label && !showNone) return null; if (!label && !showNone) return null;
return ( return (
<div className="state-chip" data-state={state}> <div className="state-chip" data-state={state}>
@@ -5,7 +5,7 @@ import { StateChip } from '../StateChip';
import { ActionButton } from '../ActionButton'; import { ActionButton } from '../ActionButton';
import { Game, InstallStatus } from '../../lib/types'; import { Game, InstallStatus } from '../../lib/types';
import { canStreamInstall, deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState'; import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState';
import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format';
interface Props { interface Props {
@@ -29,16 +29,6 @@ const tagsFromGame = (game: Game): string[] => {
return tags; return tags;
}; };
const statusLabelFor = (game: Game): string => {
switch (deriveState(game)) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const GameDetailModal = ({ export const GameDetailModal = ({
game, game,
thumbnailUrl, thumbnailUrl,
@@ -115,7 +105,7 @@ export const GameDetailModal = ({
</div> </div>
<div className="meta-cell"> <div className="meta-cell">
<div className="meta-label">Status</div> <div className="meta-label">Status</div>
<div className="meta-value">{statusLabelFor(game)}</div> <div className="meta-value">{gameStatusLabel(game)}</div>
</div> </div>
</div> </div>
@@ -82,6 +82,35 @@ export const deriveState = (game: Game): DerivedState => {
return 'none'; return 'none';
}; };
export const isInstalledNotShareable = (game: Game): boolean =>
game.installed && !game.downloaded;
export const stateChipLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable';
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Local';
case 'downloading': return 'Downloading';
case 'busy': return 'Working';
case 'none': return '';
}
};
export const gameStatusLabel = (game: Game): string => {
const state = deriveState(game);
if (state === 'installed' && isInstalledNotShareable(game)) {
return 'Installed, not shareable';
}
switch (state) {
case 'installed': return 'Installed';
case 'local': return 'Downloaded';
case 'downloading': return 'Downloading';
case 'busy': return 'Working…';
case 'none': return 'Not downloaded';
}
};
export const isUnavailable = (game: Game): boolean => export const isUnavailable = (game: Game): boolean =>
!game.installed !game.installed
&& !game.downloaded && !game.downloaded
@@ -11,7 +11,9 @@ import {
formatDownloadEta, formatDownloadEta,
formatDownloadSpeed, formatDownloadSpeed,
formatDownloadSpeedShort, formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate, mergeGameUpdate,
stateChipLabel,
} from '../src/lib/gameState.ts'; } from '../src/lib/gameState.ts';
import { import {
ActiveOperationKind, ActiveOperationKind,
@@ -243,3 +245,42 @@ Deno.test('stream install is available only for idle remote games', () => {
'busy games should not expose streamed install', 'busy games should not expose streamed install',
); );
}); });
Deno.test('streamed local installs are labeled installed but not shareable', () => {
const streamed = game({
downloaded: false,
installed: true,
install_status: InstallStatus.Installed,
});
const downloadedInstall = game({
downloaded: true,
installed: true,
install_status: InstallStatus.Installed,
});
assertEquals(
deriveState(streamed),
'installed',
'streamed local installs should keep installed visual state',
);
assertEquals(
stateChipLabel(streamed),
'Not shareable',
'card chip should make the non-shareable state visible',
);
assertEquals(
gameStatusLabel(streamed),
'Installed, not shareable',
'detail status should spell out installed plus non-shareable',
);
assertEquals(
stateChipLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed chip label',
);
assertEquals(
gameStatusLabel(downloadedInstall),
'Installed',
'normal downloaded installs should keep the installed detail label',
);
});