pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s

- model the existing hardware schema in Drizzle (devices, components, new sensitivities)
- idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables
- seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S),
  other devices, sensitivity placeholders (no invented cm/360)
- owner-only add/edit via modals; public view hides notes/order numbers
- trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
This commit is contained in:
2026-10-24 17:55:48 +00:00
parent 8442d59b68
commit 085a30bec8
21 changed files with 23538 additions and 6 deletions

View File

@@ -17,6 +17,7 @@ const staticPaths = [
'/trackers/cars',
'/trackers/cars/brands',
'/trackers/music',
'/trackers/pc',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -0,0 +1,53 @@
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// Public page; only the owner sees notes/order numbers and the edit controls.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const { devices, games } = await loadSetup(owner);
return {
owner,
devices,
games,
seo: {
title: 'PC setup',
description:
'Every part in my PC with specs and prices, my other devices and servers, the planned next build and my mouse sensitivities.'
}
};
};
const run = async (result: Promise<{ ok: true } | { ok: false; message: string }>) => {
const r = await result;
return r.ok ? { saved: true } : fail(400, { message: r.message });
};
export const actions: Actions = {
savePart: async (event) => {
requireOwner(event);
return run(savePart(await event.request.formData()));
},
deletePart: async (event) => {
requireOwner(event);
return run(deleteRow('part', await event.request.formData()));
},
saveDevice: async (event) => {
requireOwner(event);
return run(saveDevice(await event.request.formData()));
},
deleteDevice: async (event) => {
requireOwner(event);
return run(deleteRow('device', await event.request.formData()));
},
saveGame: async (event) => {
requireOwner(event);
return run(saveGame(await event.request.formData()));
},
deleteGame: async (event) => {
requireOwner(event);
return run(deleteRow('game', await event.request.formData()));
}
};

View File

@@ -0,0 +1,868 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Crosshair from 'lucide-svelte/icons/crosshair';
import Modal from '#lib/pc/Modal.svelte';
import PartCard from '#lib/pc/PartCard.svelte';
import PcIcon from '#lib/pc/PcIcon.svelte';
import {
CATEGORIES,
DEVICE_KINDS,
DEVICE_KIND_META,
DEVICE_STATUSES,
PART_STATUSES,
categoryLabel,
money,
sensFor,
shortDate,
specKey,
specsToText,
statusLabel,
type Device,
type Game,
type Part
} from '#lib/pc/shared.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const today = new Date().toISOString().slice(0, 10);
const main = $derived(
data.devices.find((d) => d.status === 'active' && d.kind === 'desktop') ?? null
);
const planned = $derived(data.devices.filter((d) => d.status === 'planned'));
const others = $derived(
data.devices.filter((d) => d !== main && (d.status === 'active' || d.status === 'spare'))
);
const retired = $derived(
data.devices.filter((d) => d.status === 'retired' || d.status === 'sold')
);
const navDevices = $derived([main, ...planned, ...others].filter((d): d is Device => !!d));
const sum = (parts: Part[]) => parts.reduce((s, p) => s + (p.totalPriceCents ?? 0), 0);
const inUse = (d: Device) => d.parts.filter((p) => p.status === 'in_use');
const mainParts = $derived(main ? inUse(main) : []);
const build = $derived(
mainParts.filter((p) =>
[
'case',
'cpu',
'motherboard',
'ram',
'cpu_cooler',
'gpu',
'psu',
'storage',
'fan',
'lighting'
].includes(p.category)
)
);
const desk = $derived(mainParts.filter((p) => !build.includes(p)));
const mice = $derived(
data.devices
.filter((d) => d.status !== 'retired' && d.status !== 'sold')
.flatMap((d) => d.parts)
.filter((p) => p.category === 'mouse' && p.status !== 'sold' && p.status !== 'retired')
);
const monthly = $derived(
data.devices.filter((d) => d.status === 'active').reduce((s, d) => s + (d.monthlyCents ?? 0), 0)
);
const deviceSpecs = (d: Device) =>
Object.entries(d.specs).filter(([, v]) => v !== null && v !== '');
const fmtSens = (n: number | null) =>
n === null ? null : n.toLocaleString('en-GB', { maximumFractionDigits: 3 });
// ---- owner editing ----
let editPart = $state<Partial<Part> | null>(null);
let editDevice = $state<Partial<Device> | null>(null);
let editGame = $state<Partial<Game> | null>(null);
let problem = $state('');
const submit =
(close: () => void): SubmitFunction =>
() => {
problem = '';
return async ({ result, update }) => {
if (result.type === 'failure')
problem = String((result.data as { message?: string })?.message ?? 'Could not save');
else if (result.type === 'success') {
close();
await update({ reset: false });
} else await update();
};
};
const confirmDelete = (what: string) => (event: SubmitEvent) => {
if (!confirm(`Delete ${what}?`)) event.preventDefault();
};
const euros = (cents: number | null | undefined) =>
cents === null || cents === undefined ? '' : (cents / 100).toFixed(2);
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">pc setup</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
Every part in my PC with specs and what I paid, the rest of my devices and servers, the next
build, and the mouse sensitivities I keep the same everywhere.
</p>
</div>
{#if data.owner}
<div class="flex gap-2">
<button
type="button"
class="action"
onclick={() =>
(editPart = {
deviceId: main?.id,
category: 'other',
status: 'in_use',
verified: true,
quantity: 1,
specs: {}
})}><Plus size={15} /> part</button
>
<button
type="button"
class="action"
onclick={() =>
(editDevice = { kind: 'desktop', status: 'active', specs: {}, hostnames: [] })}
><Plus size={15} /> device</button
>
</div>
{/if}
</header>
<dl class="stats">
<div>
<dt>parts in my PC</dt>
<dd>{build.length}</dd>
</div>
<div>
<dt>PC cost (known prices)</dt>
<dd>{money(sum(build)) ?? '–'}</dd>
</div>
<div>
<dt>desk + peripherals</dt>
<dd>{money(sum(desk)) ?? '–'}</dd>
</div>
<div>
<dt>servers / month</dt>
<dd>{money(monthly) ?? '–'}</dd>
</div>
</dl>
<nav class="devices" aria-label="Devices">
{#each navDevices as d (d.id)}
<a href="#{d.name}" class="device-tile">
<PcIcon kind={d.kind} class="size-11" />
<span class="min-w-0">
<span class="block truncate font-medium">{d.label ?? d.name}</span>
<span class="block truncate text-xs text-[var(--site-muted)]"
>{[d.brand, d.model].filter(Boolean).join(' ') || DEVICE_KIND_META[d.kind]?.label}</span
>
</span>
{#if d.status !== 'active'}<span class="pill">{d.status}</span>{/if}
</a>
{/each}
<a href="#sensitivity" class="device-tile">
<span
class="flex size-11 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
aria-hidden="true"><Crosshair size={22} /></span
>
<span class="min-w-0">
<span class="block font-medium">Sensitivity</span>
<span class="block text-xs text-[var(--site-muted)]">cm/360 per game</span>
</span>
</a>
</nav>
{#snippet deviceHeader(d: Device, title: string)}
<div class="mb-4 flex flex-wrap items-center gap-3">
<PcIcon kind={d.kind} class="size-12" />
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
<p class="text-sm text-[var(--site-muted)]">
<code>{d.name}</code>{#each d.hostnames as h (h)}&nbsp;· <code>{h}</code>{/each}
{#if d.brand || d.model}&nbsp;· {[d.brand, d.model].filter(Boolean).join(' ')}{/if}
{#if d.monthlyCents}&nbsp;· {money(d.monthlyCents)}/month{/if}
{#if d.priceCents}&nbsp;· {money(d.priceCents)}{/if}
{#if d.purchaseDate}&nbsp;· since {shortDate(d.purchaseDate)}{/if}
</p>
</div>
{#if data.owner}
<button
type="button"
class="action"
onclick={() =>
(editPart = {
deviceId: d.id,
category: 'other',
status: d.status === 'planned' ? 'planned' : 'in_use',
verified: true,
quantity: 1,
specs: {}
})}><Plus size={15} /> part</button
>
<button
type="button"
class="action"
onclick={() => (editDevice = d)}
aria-label="Edit {d.name}"><Pencil size={15} /></button
>
{/if}
</div>
{#if deviceSpecs(d).length}
<ul class="mb-4 flex flex-wrap gap-2">
{#each deviceSpecs(d) as [k, v] (k)}
<li class="spec"><span>{specKey(k)}</span>{v}</li>
{/each}
</ul>
{/if}
{#if d.notes}<p class="mb-4 max-w-3xl text-sm text-[var(--site-muted)]">{d.notes}</p>{/if}
{/snippet}
{#snippet grid(parts: Part[])}
<ul class="parts">
{#each parts as p (p.id)}
<li><PartCard part={p} owner={data.owner} {today} onedit={(x) => (editPart = x)} /></li>
{/each}
</ul>
{/snippet}
{#if main}
<section id={main.name} class="section">
{@render deviceHeader(main, main.label ?? main.name)}
{@render grid(build)}
{#if desk.length}
<h3 class="mt-8 mb-3 text-lg font-semibold">Desk and peripherals</h3>
{@render grid(desk)}
{/if}
</section>
{/if}
{#each planned as d (d.id)}
{@const spent = sum(d.parts)}
<section id={d.name} class="section">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.budgetCents}
<div class="budget" aria-label="Budget">
<div class="flex justify-between text-sm">
<span>picked so far <strong class="tabular-nums">{money(spent)}</strong></span>
<span class="text-[var(--site-muted)]">budget {money(d.budgetCents)}</span>
</div>
<div class="bar">
<span style:width="{Math.min(100, (spent / d.budgetCents) * 100)}%"></span>
</div>
</div>
{/if}
{@render grid(d.parts)}
</section>
{/each}
<section id="sensitivity" class="section">
<div class="mb-4 flex flex-wrap items-center gap-3">
<span
class="flex size-12 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
aria-hidden="true"><Crosshair size={24} /></span
>
<div class="flex-1">
<h2 class="text-2xl font-semibold tracking-tight">Mice and sensitivity</h2>
<p class="text-sm text-[var(--site-muted)]">
One physical sensitivity per game (cm per 360° turn), converted per mouse from its
measured DPI, so every mouse feels the same on every PC.
</p>
</div>
{#if data.owner}
<button type="button" class="action" onclick={() => (editGame = {})}
><Plus size={15} /> game</button
>
{/if}
</div>
<div class="mice">
{#each mice as m (m.id)}
<div class="mouse">
<PcIcon category="mouse" class="size-9" />
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{m.brand} {m.model}</p>
<p class="text-sm text-[var(--site-muted)]">
set {m.dpi ?? '?'} DPI · measured
<strong class="text-[var(--site-text)]">{m.measuredDpi ?? '?'}</strong> DPI
</p>
</div>
{#if data.owner}
<button
type="button"
class="action"
onclick={() => (editPart = m)}
aria-label="Edit {m.model}"><Pencil size={14} /></button
>
{/if}
</div>
{:else}
<p class="text-sm text-[var(--site-muted)]">No mice yet.</p>
{/each}
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>game</th>
<th>cm/360</th>
<th>yaw</th>
<th>set now</th>
{#each mice as m (m.id)}<th>{m.model}</th>{/each}
{#if data.owner}<th><span class="sr-only">edit</span></th>{/if}
</tr>
</thead>
<tbody>
{#each data.games as g (g.id)}
{@const setWith = mice.find((m) => m.id === g.mouseId)}
<tr>
<td class="font-medium">{g.game}</td>
<td class="tabular-nums">{g.cmPer360 ?? '–'}</td>
<td class="tabular-nums text-[var(--site-muted)]">{g.yaw ?? '–'}</td>
<td class="tabular-nums"
>{g.inGameSens ?? '–'}{#if setWith}<span class="text-xs text-[var(--site-muted)]">
· {setWith.model}</span
>{/if}</td
>
{#each mice as m (m.id)}
<td class="tabular-nums font-semibold"
>{fmtSens(sensFor(g.cmPer360, g.yaw, m.measuredDpi ?? m.dpi)) ?? '–'}</td
>
{/each}
{#if data.owner}
<td
><button
type="button"
class="action"
onclick={() => (editGame = g)}
aria-label="Edit {g.game}"><Pencil size={14} /></button
></td
>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="mt-3 text-xs text-[var(--site-muted)]">
Per-mouse values use in-game sens = 360 × 2.54 / (yaw × DPI × cm/360), with the measured DPI
when known. Measure DPI by moving the mouse exactly 10 cm and counting the cursor's pixels:
DPI = pixels / 3.937.
</p>
</section>
{#each others as d (d.id)}
<section id={d.name} class="section">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.parts.length}{@render grid(d.parts)}{/if}
</section>
{/each}
{#if retired.length}
<details class="section retired">
<summary class="cursor-pointer text-lg font-semibold">Retired ({retired.length})</summary>
{#each retired as d (d.id)}
<section id={d.name} class="mt-6">
{@render deviceHeader(d, d.label ?? d.name)}
{#if d.parts.length}{@render grid(d.parts)}{/if}
</section>
{/each}
</details>
{/if}
</div>
{#if editPart}
{@const p = editPart}
<Modal
title={p.id ? `Edit ${p.model}` : 'Add a part'}
onclose={() => {
editPart = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/savePart" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={p.id ?? ''} />
<label
>device<select name="deviceId" value={p.deviceId}>
{#each data.devices as d (d.id)}<option value={d.id}>{d.label ?? d.name}</option>{/each}
</select></label
>
<label
>category<select name="category" value={p.category}>
{#each CATEGORIES as c (c)}<option value={c}>{categoryLabel(c)}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={p.brand ?? ''} maxlength="120" /></label>
<label class="wide"
>model<input name="model" value={p.model ?? ''} required maxlength="300" /></label
>
<label
>quantity<input name="quantity" type="number" min="1" value={p.quantity ?? 1} /></label
>
<label
>price per unit (€)<input
name="unitPrice"
inputmode="decimal"
value={euros(p.unitPriceCents)}
/></label
>
<label>shop<input name="shop" value={p.shop ?? ''} /></label>
<label>order date<input name="orderDate" type="date" value={p.orderDate ?? ''} /></label>
<label
>warranty until<input
name="warrantyUntil"
type="date"
value={p.warrantyUntil ?? ''}
/></label
>
<label
>status<select name="status" value={p.status}>
{#each PART_STATUSES as s (s)}<option value={s}>{statusLabel(s)}</option>{/each}
</select></label
>
<label>order number<input name="orderNumber" value={p.orderNumber ?? ''} /></label>
<label>invoice number<input name="invoiceNumber" value={p.invoiceNumber ?? ''} /></label>
<label>DPI (set)<input name="dpi" type="number" min="1" value={p.dpi ?? ''} /></label>
<label
>DPI (measured)<input
name="measuredDpi"
type="number"
min="1"
value={p.measuredDpi ?? ''}
/></label
>
<label class="wide">link<input name="url" type="url" value={p.url ?? ''} /></label>
<label class="wide"
>image link<input name="imageUrl" type="url" value={p.imageUrl ?? ''} /></label
>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(p.specs ?? {})}</textarea
></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{p.notes ?? ''}</textarea></label
>
<label class="check"
><input type="checkbox" name="verified" checked={p.verified ?? true} /> confirmed still in this
setup</label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if p.id}
<form
method="POST"
action="?/deletePart"
use:enhance={submit(close)}
onsubmit={confirmDelete(p.model ?? 'this part')}
class="mt-2"
>
<input type="hidden" name="id" value={p.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete part</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editDevice}
{@const d = editDevice}
<Modal
title={d.id ? `Edit ${d.name}` : 'Add a device'}
onclose={() => {
editDevice = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveDevice" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={d.id ?? ''} />
<label
>name (hostname)<input
name="name"
value={d.name ?? ''}
required
pattern="[a-z0-9][a-z0-9\-]*"
/></label
>
<label>label<input name="label" value={d.label ?? ''} /></label>
<label
>kind<select name="kind" value={d.kind}>
{#each DEVICE_KINDS as k (k)}<option value={k}>{DEVICE_KIND_META[k].label}</option
>{/each}
</select></label
>
<label
>status<select name="status" value={d.status}>
{#each DEVICE_STATUSES as s (s)}<option value={s}>{s}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={d.brand ?? ''} /></label>
<label>model<input name="model" value={d.model ?? ''} /></label>
<label class="wide"
>other hostnames (comma separated)<input
name="hostnames"
value={(d.hostnames ?? []).join(', ')}
/></label
>
<label
>price (€)<input name="price" inputmode="decimal" value={euros(d.priceCents)} /></label
>
<label
>per month (€)<input
name="monthly"
inputmode="decimal"
value={euros(d.monthlyCents)}
/></label
>
<label
>budget (€)<input name="budget" inputmode="decimal" value={euros(d.budgetCents)} /></label
>
<label>shop<input name="shop" value={d.shop ?? ''} /></label>
<label>since<input name="purchaseDate" type="date" value={d.purchaseDate ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={d.sortOrder ?? 100}
/></label
>
<label class="wide">link<input name="url" type="url" value={d.url ?? ''} /></label>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(d.specs ?? {})}</textarea
></label
>
<label class="wide"
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if d.id}
<form
method="POST"
action="?/deleteDevice"
use:enhance={submit(close)}
onsubmit={confirmDelete(`${d.name} and all its parts`)}
class="mt-2"
>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="text-sm text-red-500 hover:underline"
>Delete device and its parts</button
>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editGame}
{@const g = editGame}
<Modal
title={g.id ? `Edit ${g.game}` : 'Add a game'}
onclose={() => {
editGame = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveGame" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={g.id ?? ''} />
<label class="wide">game<input name="game" value={g.game ?? ''} required /></label>
<label>cm/360<input name="cmPer360" inputmode="decimal" value={g.cmPer360 ?? ''} /></label>
<label
>yaw (°/count at sens 1)<input
name="yaw"
inputmode="decimal"
value={g.yaw ?? ''}
/></label
>
<label
>in-game sens now<input
name="inGameSens"
inputmode="decimal"
value={g.inGameSens ?? ''}
/></label
>
<label
>set with mouse<select name="mouseId" value={g.mouseId ?? ''}>
<option value="">–</option>
{#each mice as m (m.id)}<option value={m.id}>{m.brand} {m.model}</option>{/each}
</select></label
>
<label>FOV<input name="fov" value={g.fov ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={g.sortOrder ?? 100}
/></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{g.notes ?? ''}</textarea></label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if g.id}
<form
method="POST"
action="?/deleteGame"
use:enhance={submit(close)}
onsubmit={confirmDelete(g.game ?? 'this game')}
class="mt-2"
>
<input type="hidden" name="id" value={g.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete game</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
<style>
.stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
@media (min-width: 640px) {
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.stats > div {
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.9rem 1rem;
background: var(--site-surface);
}
.stats dt {
font-size: 0.75rem;
color: var(--site-muted);
}
.stats dd {
margin-top: 0.2rem;
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.devices {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.6rem;
margin-bottom: 3rem;
}
.device-tile {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.6rem 0.75rem;
background: var(--site-surface);
transition:
transform 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.device-tile:hover {
transform: translateY(-2px);
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.pill {
margin-left: auto;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.45rem;
font-size: 0.7rem;
color: var(--site-muted);
}
.section {
margin-bottom: 3.5rem;
scroll-margin-top: 5rem;
}
.section :global(code) {
font-size: 0.8em;
}
.parts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.75rem;
}
.spec {
display: inline-flex;
gap: 0.4rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.spec span {
color: var(--site-muted);
}
.action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.35rem 0.7rem;
font-size: 0.85rem;
transition:
background 0.15s,
border-color 0.15s;
}
.action:hover {
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
}
.action.primary {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.action.primary:hover {
opacity: 0.85;
}
.budget {
margin-bottom: 1rem;
max-width: 36rem;
}
.bar {
margin-top: 0.4rem;
height: 0.55rem;
border-radius: 999px;
background: var(--site-hover);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
min-width: 0.55rem;
border-radius: 999px;
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
}
.mice {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
margin-bottom: 1rem;
}
.mouse {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
padding: 0.6rem 0.75rem;
background: var(--site-surface);
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--site-border);
border-radius: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
text-align: left;
font-weight: 500;
font-size: 0.75rem;
color: var(--site-muted);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
td {
padding: 0.55rem 0.9rem;
border-bottom: 1px solid var(--site-border);
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr:hover {
background: var(--site-hover);
}
.retired summary {
list-style: none;
color: var(--site-muted);
}
.retired summary:hover {
color: var(--site-text);
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.form .wide,
.form .check,
.form .buttons {
grid-column: 1 / -1;
}
.form .check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--site-text);
}
.form input:not([type='checkbox']),
.form select,
.form textarea {
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.form textarea {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.form .buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>