Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ jobs:

- name: Build
run: npm run build

- name: Bundle size
run: npm run size
55 changes: 45 additions & 10 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ no react-three-fiber. UI primitives under `components/ui/` are generated
shadcn/Base UI files and are excluded from linting. The entry point is
`index.html` → `app/main.tsx` → `app/page.tsx`. The 3D engine is imported
dynamically inside `app/viewer.tsx`, so three.js lands in its own chunk and the
interface paints before it arrives.
interface paints before it arrives. That only holds while nothing the
interface imports eagerly reaches a module that imports three: a single
`import { hasAirflow } from '@/lib/airflow'` once pulled the whole engine into
the entry chunk (1.2 MB, 340 KB gzipped) without any error. Questions the
interface asks before the engine loads live in three-free modules
(`airflow-policy.ts`, `deep-link.ts`, `pointer-intent.ts`,
`inventory-labels.ts`), and `npm run size`, run in CI after the build, fails
if three.js reappears in the entry chunk or it grows past its budget.

## Run it

Expand All @@ -35,6 +42,7 @@ npm run lint # oxlint
npm run format # oxfmt
npm test # node --experimental-strip-types --test tests/*.test.ts
npm run build # tsc --noEmit && vite build
npm run size # after a build: entry chunk under budget, no three.js in it
npm start # preview the production build
```

Expand Down Expand Up @@ -174,7 +182,11 @@ subsystems stop touching.
| `lib/graphics-card.ts`, `card-kit.ts`, `radeon-card.ts`, `arc-card.ts` | The three graphics cards. `card-kit.ts` holds the millimetre-scale parts all three share. |
| `lib/gpu-architecture.ts`, `radeon-architecture.ts`, `arc-architecture.ts` | The chip block diagrams. |
| `lib/diagram-kit.ts` | The shared visual language of those diagrams — `put`, `backdrop`, `block` — with a palette per chip. Navi 48 and BMG-G21 draw from it; the GB202 scales predate it and build their own scenery. |
| `lib/airflow.ts` | Where the air goes: the chevron, the sampling and marching of a path, `cardAirflow` for the three cards, `airflowStrength`, the curve that takes it off screen as the machine opens, and `airflowShown`, which decides whether a scale draws it at all. |
| `lib/airflow.ts` | Where the air goes: the chevron, the sampling and marching of a path, `cardAirflow` for the three cards, and `airflowStrength`, the curve that takes it off screen as the machine opens. Re-exports the policy below so builders keep one import. |
| `lib/airflow-policy.ts` | `airflowLevels`, `hasAirflow`, `AirflowMode` and `airflowShown`, which decides whether a scale draws air at all. No three.js, so the interface can import it. |
| `lib/deep-link.ts` | A place (scale, described part, disassembly) to and from the address bar's query string, and the page title for it. Unknown or contradictory links fall back rather than guess. |
| `lib/pointer-intent.ts` | `tapIntent`: a mouse click opens, a tap describes first. |
| `lib/inventory-labels.ts` | Which laid-out parts get a name on screen: biggest first, one per part, no overlaps, nothing off the stage. |
| `lib/parts.ts` | Shared realistic parts: fans, grilles, fin stacks, capacitors, chokes, slots, ports, screws, `glowMaterial`, `buildLightStrip`. |
| `lib/materials.ts` | The ten surface finishes and `temperedGlass`. |
| `lib/surfaces.ts`, `pcb.ts`, `silicon-texture.ts` | Generated material maps. No downloaded textures. |
Expand All @@ -188,15 +200,19 @@ subsystems stop touching.
| `lib/layout.ts` | `inventoryLayout`, `hardwareInventory`, `spatialInventory`, `smoothstep`, `Vec3`. |
| `app/page.tsx` | Chooses the explorer or comparison workbench and otherwise keeps application-level composition state. |
| `app/comparison-workbench.tsx`, `comparison-viewer.tsx`, `comparison-specs.tsx` | Comparison composition and controls, the React-to-Three bridge, and the responsive specification sheet. |
| `app/use-explorer.ts` | All explorer state and every named move: navigate, dive, arrive, choose, isolate, hide, and the rest. |
| `app/use-explorer.ts` | All explorer state and every named move: navigate, goTo, dive, arrive, choose, isolate, hide, and the rest. The first state is read from the address. |
| `app/use-address.ts` | Keeps the address bar, title and canonical link on the place shown: a scale change pushes history, a selection or slider move replaces it, and the back button moves the explorer. |
| `app/use-first-visit-hint.ts` | The one-time line under the heading that says what clicking or tapping does. |
| `app/use-disassembly-playback.ts` | Shared request-animation-frame playback for explorer and comparison disassembly controls. |
| `app/topbar.tsx`, `scale-nav.tsx`, `systems-list.tsx`, `stage-heading.tsx`, `stage-tools.tsx`, `disassembly.tsx`, `search-dialog.tsx`, `detail-panel.tsx`, `about-dialog.tsx` | One panel each. |
| `app/viewer.tsx` | The React ↔ three.js bridge: lazy scene load, hover label, error state. |
| `app/viewer.tsx` | The React ↔ three.js bridge: lazy scene load, the loading line, hover label, names on the laid-out parts, error state. |
| `app/links.ts` | Destinations used by more than one panel. |
| `app/globals.css`, `app/workbench.css` | The visual direction, desktop through phone. |
| `tests/*.test.ts` | 84 tests: catalogue integrity, layout, picking, geometry presence, airflow, the case, the memory dive, and comparison state/data/pane/bounds behaviour. |
| `tests/*.test.ts` | 94 tests: catalogue integrity, layout, picking, geometry presence, airflow, the case, the memory dive, comparison state/data/pane/bounds behaviour, and links, taps, labels and the engine staying out of the entry chunk. |
| `scripts/generate-icons.mjs` | Rasterises `public/favicon.svg` into PNG and ICO variants. Uses Playwright and Edge. |
| `scripts/generate-reference-index.mjs` | Regenerates `docs/component-references.md` from the catalogue. Run it after changing citations. |
| `scripts/generate-sitemap.mjs`, `sitemap-urls.ts` | Regenerates `public/sitemap.xml`: the site, the guide, every scale and every part, in the link format the explorer reads. Run it after adding scales or parts; a test fails when it falls behind. |
| `scripts/check-bundle-size.mjs` | `npm run size`. Fails if three.js is in the entry chunk or the chunk is over budget. |

## The interfaces you will touch

Expand Down Expand Up @@ -302,14 +318,24 @@ itself. State that only one panel cares about lives in that panel — which syst
is unfolded is inside `systems-list.tsx`, whether the window is fullscreen is
inside `stage-tools.tsx`.

Three interaction decisions are settled, and re-opening them has been tried:
These interaction decisions are settled, and re-opening them has been tried:

- **The dive is a zoom, not a scene change.** Clicking an explorable component
scales the rest of the stage away while the camera closes on it, then swaps in
the deeper scale, which grows back out of the same spot. The ramp is owned by
`lib/scene.ts`, not by React timers — timers drifted against the render loop.
`diveInto` is the whole record of a dive in flight, so cancelling one is just
clearing it.
- **A tap describes; a click opens.** A mouse opens on left-click and
inspects on right-click. A finger has no right button and iOS does not fire
`contextmenu` on a long press, so a tap selects the part and shows the card,
whose "Take apart" button opens it; tapping the selected part again also
opens it where the card leaves it in reach. `tapIntent` is the rule.
- **The address is the place, not the view.** `?scale=`, `&part=` (with
`&n=` for the n-th identical part) and `&open=` for the disassembly. Camera,
hidden parts and the airflow preference stay out: they are how one person is
looking, not where. Scale changes are history steps; selections and slider
moves replace the current entry, the slider only once it has settled.
- **The detail panel is suppressed mid-dive.** `selected` is null while
`diveInto` is set, or the outer component's panel flashes for a few hundred
milliseconds before the deeper scale replaces it.
Expand Down Expand Up @@ -354,7 +380,9 @@ A component joins the written catalogue to selectable geometry through its id.
inventory layout and counting.
3. Add any new reference to `lib/sources.ts` and document it in `SOURCES.md`,
then regenerate the index:
`node --experimental-strip-types scripts/generate-reference-index.mjs`.
`node --experimental-strip-types scripts/generate-reference-index.mjs`,
and the sitemap:
`node --experimental-strip-types scripts/generate-sitemap.mjs`.
4. If the concept lives in a new catalogue file, export its array and compose it
into `lib/manifest.ts`.
5. Run the checks. The tests reject anonymous geometry, duplicate ids, broken
Expand Down Expand Up @@ -406,10 +434,11 @@ the model has. 267 concepts are physical, 154 are logical diagrams.

## Tests

84 tests, all through Node's built-in runner.
94 tests, all through Node's built-in runner.

`.github/workflows/ci.yml` runs `npm ci`, type checking, linting, tests, and a
production build on every push and pull request with Node.js 22.
`.github/workflows/ci.yml` runs `npm ci`, type checking, linting, tests, a
production build and the bundle-size check on every push and pull request
with Node.js 22.

- `tests/manifest.test.ts` (16) — unique ids, reciprocal parents, no cycles,
source resolution, SKU counts against full-chip capacity, search behaviour,
Expand Down Expand Up @@ -456,6 +485,12 @@ production build on every push and pull request with Node.js 22.
its empty centre columns, the package stack with the die face down over the
slot, named geometry everywhere, and search reaching every memory scale.

- `tests/discovery.test.ts` (10) — every scale and every part surviving a
round trip through a link, instances and disassembly kept, nonsense links
falling back to the nearest sensible place, titles, the committed sitemap
matching the catalogue, tap versus click, part names never overlapping or
leaving the stage, and the interface's own modules staying free of three.js.

The suite builds every scale, so a geometry regression usually surfaces as a
failing assertion rather than a silent visual change.

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ npm run check # type check
npm run lint # oxlint
npm test # Node's built-in test runner
npm run build # production build
npm run size # after the build: keeps three.js out of the first download
```

Format with `npm run format`. GitHub Actions runs the same checks on every push
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Descending into a part rebuilds it at its own scale with its own timeline, so th

## The interface

The rail on the left carries the scale tree and per-system visibility. The GPU menu holds three cards, the RTX 5090, Radeon RX 9070 XT and Arc B580, each with its own dropdown of scales. The Memory menu holds the installed DDR5 module and a DDR4 module from the same line, so the two generations can be taken apart side by side or compared in the comparison workbench. The Power supply menu lists two TUF Gaming units directly: an 850W Gold with modular sockets and a 750W Bronze with fixed cables. The bar along the bottom is the disassembly timeline, with the **Auto** key at its left-hand end, the named phases above the slider, and a reset on the right. Left-click an explorable component to open it; right-click to inspect it and use the detail, hide, isolate and focus controls. Hidden components remain available from the stage tracker until they are restored, the scale changes, or the explorer is reset. The **Airflow** switch beside the camera views draws the path the air takes, cool where it enters and warm where it leaves, and it fades as soon as the disassembly slider moves. It starts on wherever there are fans except on the complete machine, where the case is closed and the switch is there to turn it on. The corner expand control toggles browser fullscreen.
The rail on the left carries the scale tree and per-system visibility. The GPU menu holds three cards, the RTX 5090, Radeon RX 9070 XT and Arc B580, each with its own dropdown of scales. The Memory menu holds the installed DDR5 module and a DDR4 module from the same line, so the two generations can be taken apart side by side or compared in the comparison workbench. The Power supply menu lists two TUF Gaming units directly: an 850W Gold with modular sockets and a 750W Bronze with fixed cables. The bar along the bottom is the disassembly timeline, with the **Auto** key at its left-hand end, the named phases above the slider, and a reset on the right. Left-click an explorable component to open it; right-click to inspect it and use the detail, hide, isolate and focus controls. On a touch screen, a tap shows the component's card and its **Take apart** button opens it. At 100% every laid-out part carries its name, and tapping a name describes it. Hidden components remain available from the stage tracker until they are restored, the scale changes, or the explorer is reset. The **Airflow** switch beside the camera views draws the path the air takes, cool where it enters and warm where it leaves, and it fades as soon as the disassembly slider moves. It starts on wherever there are fans except on the complete machine, where the case is closed and the switch is there to turn it on. The corner expand control toggles browser fullscreen. The address bar follows you: a link such as `https://pc-anatomy.com/?scale=sm` opens straight on the RTX 5090's streaming multiprocessor, `&part=` describes a part, `&open=` sets the disassembly, and the back button climbs back out.

| Workbench | On a phone |
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -104,6 +104,8 @@ The project is entirely static. The production output is written to `dist/` and

The public URL is `https://pc-anatomy.com/`. The HTML entry includes a canonical URL, descriptive title, social preview metadata, and WebSite / WebApplication structured data. `public/guide/index.html` is a readable hardware guide that works without JavaScript and links back to the explorer.

Every scale and part has its own address, and `public/sitemap.xml` lists them all; regenerate it with `node --experimental-strip-types scripts/generate-sitemap.mjs` after adding scales or parts. The canonical link follows the place being shown.

Deploy the entire `dist/` directory, including `guide/`, `robots.txt`, `sitemap.xml`, and the icon files. Serve `/guide/` from its own `index.html` before applying any single-page-app fallback. If the hostname changes, update the canonical and social URLs in both HTML pages, the structured data, and the sitemap and robots files together.

After deployment, submit `https://pc-anatomy.com/sitemap.xml` in Google Search Console and request indexing of the homepage and guide. Indexing, favicon display, and ranking are decided by Google and may take time after a recrawl. Do not add fabricated ratings or keyword stuffing.
Expand Down
14 changes: 9 additions & 5 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
import { EyeOff } from 'lucide-react';
import { levelPath } from '@/lib/levels';
import { useExplorer } from './use-explorer';
import { useFirstVisitHint } from './use-first-visit-hint';
import Viewer from './viewer';
import PerformanceTip from './performance-tip';
import Topbar from './topbar';
Expand Down Expand Up @@ -30,6 +31,11 @@ export default function Home() {
// structures visible" on screen for the whole of a cold load.
[count, setCount] = useState<number | null>(null);

const { hint, dismissHint } = useFirstVisitHint(
state,
count !== null && count > 0,
);

/** Jump to a component, closing the palette if that is where it came from. */
const selectResult = useCallback(
(id: string) => {
Expand Down Expand Up @@ -137,19 +143,17 @@ export default function Home() {
logical={logical}
selected={selected}
count={count}
hint={hint}
onDismissHint={dismissHint}
onNavigate={navigate}
/>
<Viewer
state={state}
loading={count === null}
onSelect={choose}
onCount={setCount}
onDived={explorer.arrive}
/>
{count === null && (
<div className="empty-scene loading">
<h3>Loading components for you…</h3>
</div>
)}
{count === 0 && (
<div className="empty-scene">
<EyeOff size={27} />
Expand Down
24 changes: 23 additions & 1 deletion app/stage-heading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { ArrowLeft, ChevronRight } from 'lucide-react';
import { ArrowLeft, ChevronRight, MousePointerClick, X } from 'lucide-react';
import type { Concept } from '@/lib/manifest';
import { levelPath, levels, type LevelId } from '@/lib/levels';

Expand All @@ -10,15 +10,28 @@ type Props = {
selected: Concept | null;
/** `null` until the viewer has reported for the first time. */
count: number | null;
/** Show the first-visit line about what clicking does. */
hint?: boolean;
onDismissHint?: () => void;
onNavigate: (level: LevelId) => void;
};

/** A tap cannot right-click, so a phone is told the two-tap version. */
function hintCopy() {
return typeof matchMedia !== 'undefined' &&
matchMedia('(pointer: coarse)').matches
? 'Tap a part to see what it is and take it apart, or press Auto.'
: 'Click a part to open it, right-click to inspect it, or press Auto.';
}

export default function StageHeading({
level,
explode,
logical,
selected,
count,
hint = false,
onDismissHint,
onNavigate,
}: Props) {
const scale = levels[level],
Expand Down Expand Up @@ -65,6 +78,15 @@ export default function StageHeading({
)}
</nav>
</div>
{hint && (
<p className="first-visit-hint">
<MousePointerClick size={15} aria-hidden="true" />
<span>{hintCopy()}</span>
<button aria-label="Dismiss hint" onClick={onDismissHint}>
<X size={14} />
</button>
</p>
)}
</div>
{count !== null && (
<output
Expand Down
6 changes: 5 additions & 1 deletion app/stage-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
Wind,
} from 'lucide-react';
import { byId, colors } from '@/lib/manifest';
import { airflowShown, hasAirflow, type AirflowMode } from '@/lib/airflow';
import {
airflowShown,
hasAirflow,
type AirflowMode,
} from '@/lib/airflow-policy';
import type { ExplorerState } from '@/lib/explorer-state';
import type { LevelId } from '@/lib/levels';

Expand Down
5 changes: 3 additions & 2 deletions app/topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export default function Topbar({
title="PC Anatomy on GitHub"
>
<Code2 size={13} />
<span>
Created by <strong>Yoseph</strong>
<span className="byline-text">
<span className="byline-prefix">Created by </span>
<strong>Yoseph</strong>
</span>
</a>
<div className="header-actions">
Expand Down
Loading
Loading