feat: roomier BO2 companion with +/- rank stepping
All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s

- Scale the COD page to the screen: larger gun art, text and progress
  ribbon, sized from one viewport unit so it fills a MacBook Pro 16"
  and big monitors and still fits 1280x720 without scrolling.
- Five columns on wide screens and four below, picked by CSS, so the
  server-rendered page already has the final layout.
- Replace the tabs/filters/overview rows with one toolbar of matching
  controls.
- Player and weapon ranks step with + and -: level 55 + 1 is the next
  prestige at level 1, up to max prestige. Presses show at once, batch
  while a save is running, and stepRank does the arithmetic in SQL.
- Clicking a rank still opens exact entry for big jumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:50:19 +00:00
parent 8eec207f2d
commit 0ee9ed0abb
10 changed files with 1128 additions and 588 deletions

View File

@@ -2,33 +2,107 @@
import { base } from '$app/paths';
import ChevronsUp from 'lucide-svelte/icons/chevrons-up';
import Award from 'lucide-svelte/icons/award';
let { game, level, prestige }: { game: string; level: number; prestige: number } = $props();
let {
game,
level,
prestige,
levelCap = null,
prestigeCap = null,
onedit
}: {
game: string;
level: number;
prestige: number;
levelCap?: number | null;
prestigeCap?: number | null;
/** Makes the badge a button for typing an exact rank. */
onedit?: () => void;
} = $props();
const master = $derived(game === 'black-ops-2' && prestige === 10 && level === 55);
</script>
<div class="flex items-center gap-4" aria-label="Player level and prestige">
<div class="flex items-center gap-2">
<ChevronsUp class="size-7 text-amber-500" aria-label="Level icon" role="img" />
<div>
<span class="block text-[9px] tracking-widest text-muted-foreground">LEVEL</span><strong
class="text-sm tabular-nums">{level}</strong
>
</div>
</div>
<div class="flex items-center gap-2">
<svelte:element
this={onedit ? 'button' : 'div'}
type={onedit ? 'button' : undefined}
class="badge"
class:editable={!!onedit}
aria-label="Player level and prestige"
title={onedit ? 'Type an exact level and prestige' : undefined}
onclick={onedit}
role={onedit ? undefined : 'group'}
>
<span class="stat">
{#if game === 'black-ops-2' && prestige > 0 && prestige <= 10}
<img
src={`${base}/game-art/call-of-duty/ranks/black-ops-2-prestige-${master ? 11 : prestige}.webp`}
alt={master ? 'Prestige Master emblem' : `Prestige ${prestige} emblem`}
width="36"
height="36"
class="size-9 object-contain"
width="48"
height="48"
class="emblem"
/>
{:else}<Award class="size-7 text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
<div>
<span class="block text-[9px] tracking-widest text-muted-foreground">PRESTIGE</span><strong
class="text-sm tabular-nums">{master ? 'Master' : prestige}</strong
>
</div>
</div>
</div>
{:else}<Award class="icon text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
<span
><span class="label">PRESTIGE</span><strong
>{master ? 'Master' : prestige}{#if prestigeCap !== null && !master}<small
>/{prestigeCap}</small
>{/if}</strong
></span
>
</span>
<span class="stat">
<ChevronsUp class="icon text-amber-500" aria-label="Level icon" role="img" />
<span
><span class="label">LEVEL</span><strong
>{level}{#if levelCap !== null}<small>/{levelCap}</small>{/if}</strong
></span
>
</span>
</svelte:element>
<style>
.badge {
display: flex;
align-items: center;
gap: calc(18 * var(--u, 1px));
border-radius: calc(10 * var(--u, 1px));
padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
text-align: left;
}
.editable {
cursor: pointer;
}
.editable:hover {
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
.editable:focus-visible {
outline: 2px solid #f59e0b;
}
.stat {
display: flex;
align-items: center;
gap: calc(8 * var(--u, 1px));
}
.emblem,
.stat :global(.icon) {
width: calc(44 * var(--u, 1px));
height: calc(44 * var(--u, 1px));
object-fit: contain;
}
.label {
display: block;
font-size: calc(11 * var(--u, 1px));
letter-spacing: 0.14em;
color: var(--site-muted);
}
strong {
display: block;
font-size: calc(24 * var(--u, 1px));
line-height: 1.05;
font-variant-numeric: tabular-nums;
}
small {
font-size: 0.6em;
font-weight: 500;
color: var(--site-muted);
}
</style>

View File

@@ -7,7 +7,8 @@
detail,
hint = '',
color = 'emerald',
compact = false
compact = false,
hero = false
}: {
label: string;
progress: ProgressCount;
@@ -15,6 +16,8 @@
hint?: string;
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
compact?: boolean;
/** Large dashboard stat; sizes follow the page's --u scale. */
hero?: boolean;
} = $props();
const percent = $derived(fraction(progress) * 100);
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
@@ -36,23 +39,89 @@
};
</script>
<div class="min-w-0 space-y-1" title={hint}>
<div class="flex items-baseline justify-between gap-2">
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
>{label}</span
>
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
>{display}%{percent === 100 ? ' ✓' : ''}</span
>
</div>
<Progress
value={percent}
max={100}
aria-label={label}
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
/>
{#if !compact}<p class="text-[10px] text-muted-foreground">
{#if hero}
<div class="hero" title={hint}>
<div class="hero-top">
<span class="hero-label">{label}</span>
<span class="hero-value">{display}<small>%</small>{percent === 100 ? ' ✓' : ''}</span>
</div>
<Progress
value={percent}
max={100}
aria-label={label}
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
class={`hero-bar ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
/>
<p class="hero-detail">
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
</p>{/if}
</div>
</p>
</div>
{:else}
<div class="min-w-0 space-y-1" title={hint}>
<div class="flex items-baseline justify-between gap-2">
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
>{label}</span
>
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
>{display}%{percent === 100 ? ' ✓' : ''}</span
>
</div>
<Progress
value={percent}
max={100}
aria-label={label}
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
/>
{#if !compact}<p class="text-[10px] text-muted-foreground">
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
</p>{/if}
</div>
{/if}
<style>
.hero {
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(6 * var(--u, 1px));
}
.hero-top {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.hero-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: calc(14 * var(--u, 1px));
font-weight: 500;
color: var(--site-muted);
}
.hero-value {
flex-shrink: 0;
font-size: calc(24 * var(--u, 1px));
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.hero-value small {
font-size: 0.6em;
font-weight: 600;
color: var(--site-muted);
}
.hero :global(.hero-bar) {
height: calc(7 * var(--u, 1px));
}
.hero-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: calc(12 * var(--u, 1px));
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { deserialize } from '$app/forms';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
import type { RankLimits } from './rank-limits';
import { maxUncappedLevel, rankProgress, stepRank, type Rank } from './rank-progress';
type SavedRank = Rank & { itemId: number | null };
let {
itemId,
name,
level,
prestige,
limits,
disabled,
large = false,
onupdate,
children
}: {
/** null steps the player rank. */
itemId: number | null;
name: string;
level: number;
prestige: number;
limits: RankLimits;
disabled: boolean;
large?: boolean;
onupdate: (rank: SavedRank) => void;
children?: Snippet;
} = $props();
let error = $state('');
let status = $state('');
// Presses show immediately; presses made while a save is running go out as one batch.
let unsent = 0;
let saving = false;
let confirmed: Rank = { level: 1, prestige: 0 };
const progress = $derived(rankProgress(limits, { level, prestige }));
const atTop = $derived(
limits.maxLevel === null ? level >= maxUncappedLevel : progress.value >= progress.total
);
const atBottom = $derived(level <= 1 && (limits.maxLevel === null || prestige <= 0));
function press(delta: 1 | -1) {
const next = stepRank(limits, { level, prestige }, delta);
if (next.level === level && next.prestige === prestige) return;
if (!saving && !unsent) confirmed = { level, prestige };
unsent += delta;
error = '';
onupdate({ itemId, ...next });
void save();
}
async function save() {
if (saving || !unsent) return;
saving = true;
status = 'Saving…';
// The server accepts at most 100 levels per request; any rest goes in the next one.
const delta = Math.max(-100, Math.min(100, unsent));
unsent -= delta;
const body = new FormData();
if (itemId !== null) body.set('itemId', String(itemId));
body.set('delta', String(delta));
try {
const result = deserialize(
await (await fetch('?/stepRank', { method: 'POST', body })).text()
);
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.'
);
const rank = result.data?.rank as SavedRank;
confirmed = { level: rank.level, prestige: rank.prestige };
} catch (e) {
unsent = 0;
error = e instanceof Error ? e.message : 'Could not save. Try again.';
}
saving = false;
if (unsent) return save();
status = error ? '' : 'Saved';
onupdate({ itemId, ...confirmed });
}
</script>
<div class="stepper" class:large>
<button
type="button"
class="step"
aria-label={`Level down ${name}`}
disabled={disabled || atBottom}
onclick={() => press(-1)}><Minus aria-hidden="true" /></button
>
{@render children?.()}
<button
type="button"
class="step up"
aria-label={`Level up ${name}`}
disabled={disabled || atTop}
onclick={() => press(1)}><Plus aria-hidden="true" /></button
>
<span role="status" class={error ? 'error' : 'sr-only'}>{error || status}</span>
</div>
<style>
.stepper {
--size: calc(30 * var(--u, 1px));
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
gap: calc(4 * var(--u, 1px));
}
.large {
--size: 40px;
gap: 10px;
}
.step {
display: grid;
place-items: center;
width: var(--size);
height: var(--size);
border: 1px solid var(--site-border);
border-radius: calc(var(--size) / 4);
background: var(--background);
color: var(--site-text);
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.step :global(svg) {
width: 55%;
height: 55%;
}
.step.up {
border-color: color-mix(in srgb, #f59e0b 55%, var(--site-border));
background: color-mix(in srgb, #f59e0b 14%, var(--background));
}
.step:hover:not(:disabled) {
border-color: #f59e0b;
background: color-mix(in srgb, #f59e0b 26%, var(--background));
}
.step:focus-visible {
outline: 2px solid #f59e0b;
outline-offset: 1px;
}
.step:disabled {
cursor: default;
opacity: 0.35;
}
.error {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 20;
width: 13rem;
border-radius: 6px;
background: var(--destructive);
padding: 6px 8px;
font-size: 12px;
color: white;
}
</style>

View File

@@ -7,7 +7,13 @@
name,
icon,
compact = false
}: { game: string; name: string; icon: string | null; compact?: boolean } = $props();
}: {
game: string;
name: string;
icon: string | null;
/** Fill the parent box instead of using a fixed thumbnail size. */
compact?: boolean;
} = $props();
const source = $derived(
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
);
@@ -17,7 +23,9 @@
{#if src}
<Dialog.Root>
<Dialog.Trigger
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
class={compact
? 'block h-full w-full cursor-zoom-in rounded-md focus-visible:outline-2'
: 'shrink-0 overflow-hidden rounded-md border focus-visible:outline-2'}
aria-label={`View ${name} artwork`}
><img
{src}
@@ -25,7 +33,7 @@
loading="lazy"
width="112"
height="64"
class={compact ? 'h-6 w-12 object-contain' : 'h-16 w-28 object-contain'}
class={compact ? 'h-full w-full object-contain' : 'h-28 w-56 object-contain'}
/></Dialog.Trigger
>
<Dialog.Content class="sm:max-w-3xl"

View File

@@ -1,179 +0,0 @@
<script lang="ts">
import { deserialize } from '$app/forms';
import { Button } from '$lib/components/ui/button/index.js';
import Minus from 'lucide-svelte/icons/minus';
import Plus from 'lucide-svelte/icons/plus';
let {
itemId,
name,
level,
prestige,
levelCap = null,
prestigeCap = 1000,
prestigeRequiresMaxLevel = false,
compact = false,
dense = false,
disabled,
onupdate,
onedit
}: {
itemId: number;
name: string;
level: number;
prestige: number;
levelCap?: number | null;
prestigeCap?: number;
prestigeRequiresMaxLevel?: boolean;
compact?: boolean;
dense?: boolean;
disabled: boolean;
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
onedit: () => void;
} = $props();
let saving = $state(false);
let error = $state('');
let saved = $state(false);
async function adjust(field: 'level' | 'prestige', delta: number) {
if (saving) return;
saving = true;
error = '';
saved = false;
const body = new FormData();
body.set('itemId', String(itemId));
body.set('field', field);
body.set('delta', String(delta));
try {
const result = deserialize(
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
);
if (result.type !== 'success')
throw new Error(
result.type === 'failure'
? String(result.data?.message ?? 'Could not save.')
: 'Could not save.'
);
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
onupdate(rank);
saved = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Could not save. Try again.';
} finally {
saving = false;
}
}
</script>
{#if dense}
<div class="relative flex shrink-0 items-center gap-0.5">
<button
type="button"
class="rounded px-1 py-1 text-[11px] tabular-nums hover:bg-accent"
aria-label={`Edit ${name} level and prestige`}
disabled={disabled || saving}
onclick={onedit}
title="Edit level and prestige"
>
<span class="sr-only">LEVEL</span>{level}{#if levelCap}<span class="text-muted-foreground"
>/{levelCap}</span
>{/if}
</button>
<Button
variant="secondary"
size="icon"
class="size-6"
aria-label={`Level up ${name}`}
disabled={disabled || saving || level >= (levelCap ?? 10000)}
onclick={() => adjust('level', 1)}><Plus class="size-3" /></Button
>
<span
role="status"
class={error
? 'absolute right-0 top-full z-20 w-52 rounded border bg-destructive p-2 text-xs text-white'
: 'sr-only'}>{saving ? 'Saving…' : error || (saved ? 'Saved' : '')}</span
>
</div>
{:else}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-3">
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} level controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level down ${name}`}
disabled={disabled || saving || level <= 1}
onclick={() => adjust('level', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<Button
variant="ghost"
class={compact
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
aria-label={`Edit ${name} level and prestige`}
disabled={disabled || saving}
onclick={onedit}
><span
class={compact
? 'text-[8px] text-muted-foreground'
: 'text-[10px] text-muted-foreground'}>LEVEL</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}
>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
></Button
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Level up ${name}`}
disabled={disabled || saving || level >= (levelCap ?? 10000)}
onclick={() => adjust('level', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
<div
class="flex items-center gap-1 rounded-lg border bg-background p-1"
aria-label={`${name} prestige controls`}
>
<Button
variant="ghost"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige down ${name}`}
disabled={disabled || saving || prestige <= 0}
onclick={() => adjust('prestige', -1)}
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
<span class="min-w-12 text-center tabular-nums"
><span
class={compact
? 'block text-[8px] text-muted-foreground'
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}
>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground"
>/{prestigeCap}</span
>{/if}</span
></span
>
<Button
variant="secondary"
size="icon"
class={compact ? 'size-7' : 'size-10'}
aria-label={`Prestige up ${name}`}
disabled={disabled ||
saving ||
prestige >= prestigeCap ||
(prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
onclick={() => adjust('prestige', 1)}
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
>
</div>
</div>
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
</p>
</div>
{/if}

View File

@@ -6,6 +6,7 @@ import {
gameRanksProgress,
hasReachedMaxLevel,
rankProgress,
stepRank,
weaponFinished,
weaponLevelsProgress
} from './rank-progress';
@@ -72,6 +73,24 @@ describe('BO2 rank progression', () => {
expect(weaponFinished(rules, m27, { level: 16, prestige: 2 }, () => true)).toBe(false);
expect(weaponFinished(rules, m27, { level: 17, prestige: 2 }, () => true)).toBe(true);
});
it('steps level and prestige as one counter', () => {
const player = playerRankLimits(rules);
expect(stepRank(player, null, 1)).toEqual({ level: 2, prestige: 0 });
expect(stepRank(player, null, -1)).toEqual({ level: 1, prestige: 0 });
expect(stepRank(player, { level: 55, prestige: 0 }, 1)).toEqual({ level: 1, prestige: 1 });
expect(stepRank(player, { level: 1, prestige: 1 }, -1)).toEqual({ level: 55, prestige: 0 });
expect(stepRank(player, { level: 54, prestige: 10 }, 1)).toEqual({ level: 55, prestige: 10 });
expect(stepRank(player, { level: 55, prestige: 10 }, 1)).toEqual({ level: 55, prestige: 10 });
const m27Limits = weaponRankLimits(rules, m27);
expect(stepRank(m27Limits, { level: 17, prestige: 1 }, 1)).toEqual({ level: 1, prestige: 2 });
expect(stepRank(m27Limits, { level: 16, prestige: 0 }, 3)).toEqual({ level: 2, prestige: 1 });
// Historic out-of-range prestiges count as max prestige instead of growing further.
expect(stepRank(m27Limits, { level: 1, prestige: 4 }, -1)).toEqual({ level: 17, prestige: 1 });
expect(stepRank(m27Limits, { level: 1, prestige: 4 }, 1)).toEqual({ level: 2, prestige: 2 });
const uncapped = weaponRankLimits({ ...rules, rankCaps: undefined }, m27);
expect(stepRank(uncapped, { level: 40, prestige: 3 }, 1)).toEqual({ level: 41, prestige: 3 });
expect(stepRank(uncapped, { level: 1, prestige: 3 }, -1)).toEqual({ level: 1, prestige: 3 });
});
it('bounds historic out-of-range values without mutating saved data', () => {
const rank = { level: 100, prestige: 999 };
expect(rankProgress(weaponRankLimits(rules, m27), rank)).toEqual({ value: 51, total: 51 });

View File

@@ -15,6 +15,23 @@ export function rankProgress(limits: RankLimits, rank?: Rank | null): ProgressCo
return { value: prestige * limits.maxLevel + level, total };
}
export const maxUncappedLevel = 10000;
// One + / − press. With known caps, level and prestige form one counter, so
// level 55 + 1 is the next prestige at level 1, until max prestige at max level.
// Without caps only the level moves; prestige is set explicitly instead.
export function stepRank(limits: RankLimits, rank: Rank | null | undefined, delta: number): Rank {
const current = rank ?? { level: 1, prestige: 0 };
if (limits.maxLevel === null)
return {
level: Math.max(1, Math.min(maxUncappedLevel, current.level + delta)),
prestige: current.prestige
};
const { value, total } = rankProgress(limits, current);
const next = Math.max(1, Math.min(total, value + delta)) - 1;
return { level: (next % limits.maxLevel) + 1, prestige: Math.floor(next / limits.maxLevel) };
}
export function weaponLevelsProgress(
rules: MasteryRules,
weapons: readonly Weapon[],