partforge 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/bin/cli.js +73 -0
  3. package/docs/AUTHORING-PARTS.md +200 -3
  4. package/package.json +15 -1
  5. package/src/app-filleted-box.js +7 -0
  6. package/src/filleted-box-worker.js +3 -0
  7. package/src/framework/app.css +26 -1
  8. package/src/framework/controls.js +153 -36
  9. package/src/framework/debug-overlay.js +40 -0
  10. package/src/framework/geometry/edge-selector.js +17 -0
  11. package/src/framework/geometry/errors.js +10 -0
  12. package/src/framework/geometry/face-selector.js +19 -0
  13. package/src/framework/geometry/kernel.js +9 -1
  14. package/src/framework/geometry/manifold-backend.js +76 -18
  15. package/src/framework/geometry/occt-backend.js +118 -4
  16. package/src/framework/geometry/polygon.js +117 -0
  17. package/src/framework/geometry/probe.js +52 -0
  18. package/src/framework/geometry/solid-cache.js +39 -0
  19. package/src/framework/geometry/solid-hash.js +21 -0
  20. package/src/framework/geometry-service.js +8 -10
  21. package/src/framework/jobs.js +24 -8
  22. package/src/framework/markdown.js +41 -0
  23. package/src/framework/mount.js +144 -18
  24. package/src/framework/param-deps.js +81 -0
  25. package/src/framework/selection/format.js +31 -0
  26. package/src/framework/selection/index.js +5 -0
  27. package/src/framework/selection/pick.js +48 -0
  28. package/src/framework/selection/resolve.js +54 -0
  29. package/src/framework/view-state.js +55 -0
  30. package/src/framework/viewer.js +42 -3
  31. package/src/parts/demo.js +29 -11
  32. package/src/parts/filleted-box.js +46 -0
  33. package/src/testing/build.js +17 -0
  34. package/src/testing/measure.js +53 -0
  35. package/src/testing/mesh.js +27 -0
  36. package/src/testing/render.js +159 -0
  37. package/src/testing.js +3 -0
package/README.md CHANGED
@@ -45,7 +45,9 @@ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright i
45
45
 
46
46
  **[docs/AUTHORING-PARTS.md](docs/AUTHORING-PARTS.md)** is the full guide — the part
47
47
  contract, the geometry kernel API, the parameter schema, app wiring, testing, and
48
- gotchas. `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
48
+ gotchas. See **Designing the control panel** in that guide for how to write descriptions,
49
+ hide internal params, and keep the interface simple while staying deeply adjustable.
50
+ `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
49
51
  `/demo.html` to see it live.
50
52
 
51
53
  ## License
package/bin/cli.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { resolve } from "node:path";
4
+ import { writeFileSync } from "node:fs";
5
+ import Module from "manifold-3d";
6
+ import { createManifoldKernel } from "../src/framework/geometry/manifold-backend.js";
7
+ import { detectBackend } from "../src/framework/geometry/probe.js";
8
+ import { bootOcctKernel } from "../src/testing/occt.js";
9
+ import { measure } from "../src/testing/measure.js";
10
+ import { renderViews } from "../src/testing/render.js";
11
+
12
+ const die = (msg) => { console.error(msg); process.exit(1); };
13
+ const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
14
+
15
+ const [, , cmd, partPath, ...rest] = process.argv;
16
+ const flags = {};
17
+ const positional = [];
18
+ for (let i = 0; i < rest.length; i++) {
19
+ if (rest[i].startsWith("--")) {
20
+ const key = rest[i].slice(2);
21
+ flags[key] = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true;
22
+ } else positional.push(rest[i]);
23
+ }
24
+ const view = positional[0];
25
+
26
+ if (!["measure", "render"].includes(cmd)) die("usage: partforge <measure|render> <part-module> [view] [flags]");
27
+ if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
28
+
29
+ const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
30
+ .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
31
+ const part = mod.default;
32
+ if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
33
+
34
+ let kernel;
35
+ if (detectBackend(part) === "occt") {
36
+ kernel = await bootOcctKernel();
37
+ } else {
38
+ const wasm = await Module(); wasm.setup();
39
+ kernel = createManifoldKernel(wasm, { quality: "preview" });
40
+ }
41
+
42
+ try {
43
+ if (cmd === "measure") {
44
+ const report = measure(kernel, part, view);
45
+ printMeasure(report);
46
+ const file = `measure-${slug(report.part)}-${report.view}.json`;
47
+ writeFileSync(file, JSON.stringify(report, null, 2));
48
+ console.log(`\nwrote ${file}`);
49
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
50
+ process.exit(report.ok ? 0 : 1);
51
+ } else {
52
+ const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
53
+ const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
54
+ for (const f of files) console.log(`wrote ${f}`);
55
+ process.exit(0);
56
+ }
57
+ } catch (e) {
58
+ die(`${cmd} failed: ${e.message || e}`);
59
+ }
60
+
61
+ function printMeasure(r) {
62
+ console.log(`${r.part} / ${r.view}`);
63
+ for (const s of r.subparts) {
64
+ const wt = s.watertight === null ? "watertight n/a" : (s.watertight ? "watertight ✓" : "NOT watertight ✗");
65
+ const holes = s.holes === null ? "holes n/a" : `holes ${s.holes}`;
66
+ console.log(` ${s.name} bbox ${s.bbox.map((n) => n.toFixed(1)).join("×")} ` +
67
+ `vol ${(s.volume / 1000).toFixed(2)}cm³ area ${(s.surfaceArea / 100).toFixed(1)}cm² ` +
68
+ `tris ${s.triangleCount} ${wt} ${holes}`);
69
+ }
70
+ const a = r.aggregate;
71
+ console.log(` ── view bbox ${a.bbox.map((n) => n.toFixed(1)).join("×")} vol ${(a.volume / 1000).toFixed(2)}cm³ tris ${a.triangleCount}`);
72
+ console.log(` overlaps: ${r.overlaps.length ? r.overlaps.map((o) => `${o.a}×${o.b} (${o.volume.toFixed(1)}mm³)`).join(", ") : "none"}`);
73
+ }
@@ -50,6 +50,7 @@ export default {
50
50
  place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
51
51
  views, // string[] — which views show this sub-part
52
52
  enabled?: (p) => boolean, // optional — gate a conditional sub-part
53
+ display?: { color?, opacity? }, // optional viewer-only override (0xRRGGBB / 0..1) — e.g. a reference/ghost part
53
54
  export?: { name }, // filename/object name on export; defaults to the key
54
55
  },
55
56
  },
@@ -87,7 +88,9 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
87
88
  |---|---|
88
89
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
89
90
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
90
- | `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
91
+ | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
92
+ | `k.sphere(r)` | sphere centred at the origin |
93
+ | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
91
94
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
92
95
  | `k.union(solids[])` | boolean union |
93
96
 
@@ -105,6 +108,9 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
105
108
  | `s.translate([x,y,z])` | move |
106
109
  | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
107
110
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
+ | `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
112
+ | `s.clone()` | independent copy (replicad consumes solids on transform) |
113
+ | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
108
114
  | `s.volume()` | volume in mm³ (Manifold) |
109
115
  | `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
110
116
  | `k.toSTEP(named[])` | STEP bytes (OCCT only) — the framework calls this |
@@ -112,6 +118,18 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
112
118
  You normally only call the *make/combine/transform* ops; the framework handles
113
119
  `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
114
120
 
121
+ ### Caching & determinism
122
+
123
+ The preview kernel memoizes geometry by content hash, so editing a parameter only
124
+ re-runs the operations that parameter actually affects. For this to be sound, a
125
+ `build` must be a **pure function of `(k, p, d)`** — no `Math.random`, no clock, no
126
+ module-level mutable state. An impure build will silently return stale geometry.
127
+
128
+ Cache granularity follows the operations you call. Booleans and heavy primitives are
129
+ cached; cheap transforms are recomputed. To make a multi-step shape into a single
130
+ cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
131
+ it hashes from its own arguments and never exposes its internals to the cache.
132
+
115
133
  ---
116
134
 
117
135
  ## Parameters: the control-panel schema
@@ -151,8 +169,112 @@ slider or type an exact value (finer than `step` is allowed; typed values clamp
151
169
  }
152
170
  ```
153
171
 
154
- Every `key` used must exist in `defaults`. (The drum's schema is exported as `SECTIONS`
155
- in `src/parts/drum/params.js` if you want a large reference.)
172
+ Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
173
+ everything below.
174
+
175
+ **Control metadata (optional — on any control def, feature, or section):**
176
+
177
+ - `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
178
+ label. Supports **bold/italic**, lists, `code`, links, and images (for diagrams);
179
+ links open in a new tab and the rendered HTML is sanitized. Write one for every
180
+ control — see "A description for every control" below.
181
+ - `hidden: true` — omits the control/feature/section from the panel. Its `key` must still
182
+ exist in `defaults` and still drives the geometry: use it for internal constants the
183
+ end user shouldn't edit (it is *no UI*, not *no parameter*). A section left with no
184
+ presets and no visible controls doesn't render at all.
185
+
186
+ ```js
187
+ advanced: [
188
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
189
+ description: "Barrel OD. Keep it larger than the bore so a wall remains. See the [guide](https://example.com)." },
190
+ { key: "wall_seg", min: 8, max: 256, step: 1, hidden: true, // internal constant; no UI, still in defaults
191
+ description: "Facet count — fixed by the design." },
192
+ ],
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Designing the control panel
198
+
199
+ A good part exposes a **simple** interface — a handful of controls most users will
200
+ touch — while still giving deep, correct adjustability underneath. `src/parts/demo.js`
201
+ is the worked example for the patterns below.
202
+
203
+ ### Procedural & parametric parts
204
+
205
+ Drive many features from a few controls, so tweaking one control reshapes the part
206
+ coherently:
207
+
208
+ - **`derive(p) => d`** computes shared/dependent values once per build; sub-part `build`
209
+ functions read `d`. Put the "design intent" math here — clearances, ratios, wall
210
+ thicknesses — so a single input feeds everything downstream. In the demo, `derive`
211
+ turns the nominal `bore` into `boreR` (with a fixed print clearance) and `h` into the
212
+ cut-tool height `cutH`; `build(k, p, d)` reads those.
213
+ - **Reuse a param `key`** across sub-parts/features so one slider moves all of them.
214
+ - **`enabled(p)`** gates a whole sub-part on a toggle param (the part appears/disappears
215
+ with the control).
216
+
217
+ ### Progressive disclosure (simple, but deep)
218
+
219
+ Tier the controls so the default view is uncluttered:
220
+
221
+ 1. **Presets** for the common cases — the first thing most users pick.
222
+ 2. A **few primary sliders** for the dimensions users change most.
223
+ 3. **`Advanced`** (the collapsible block) for the rest.
224
+ 4. **`hidden`** for internal constants the end user shouldn't edit.
225
+
226
+ Aim for a panel with a few visible controls that still exposes the full design when
227
+ someone opens Advanced.
228
+
229
+ ### A description for every control
230
+
231
+ Give every section and control a `description`. Keep each one short and make it cover:
232
+
233
+ - **what** the control does,
234
+ - its **units**,
235
+ - a **sensible range** (and what's typical),
236
+ - **when it matters** (what it interacts with).
237
+
238
+ Use Markdown links or images for diagrams and deeper reference. These are the popovers
239
+ end users rely on — treat writing them as part of authoring the control, not an
240
+ afterthought.
241
+
242
+ ### The relevance-aware panel
243
+
244
+ The panel updates itself to match what's on screen: a **section is hidden** when none of
245
+ its controls affect the active view's visible parts, and a **control is dimmed** (but
246
+ still usable) when it doesn't currently affect them — recomputed as the view and the
247
+ parameters change. You don't wire this up; it's automatic. To get the most from it:
248
+
249
+ - Group controls into **sections by the sub-parts they affect**, so whole sections drop
250
+ away in views that don't use them.
251
+ - Scope a parameter to the **views/sub-parts that read it** — a control read by no
252
+ on-screen part shows dimmed, which is a useful signal that it's vestigial or
253
+ misplaced.
254
+
255
+ ---
256
+
257
+ ## Profiles & patterns
258
+
259
+ Pure helpers from `partforge/geometry` (no backend dependency):
260
+
261
+ **2-D profiles** (CCW point arrays for `k.prism` / `k.revolve`):
262
+ `roundedRectPolygon(w,h,r)`, `regularPolygon(n,r,{flat})`, `ellipsePolygon(rx,ry)`,
263
+ `slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
264
+ `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
265
+ cut an inner cylinder from an outer one instead).
266
+ `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
267
+ Compose it for round solids: `k.prism(circleProfile(r), h)` is a cylinder, and
268
+ **a torus is `k.revolve(circleProfile(minorR, [majorR, 0]))`** (with `majorR > minorR`) —
269
+ partforge has no `torus` primitive because it's just a revolved circle.
270
+
271
+ **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
272
+ `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
273
+
274
+ ```js
275
+ const hole = k.cylinder(2, 2, 20).translate([20, 0, 0]);
276
+ body = body.cutAll(circularPattern(hole, 8, { axis: "Z" })); // 8 bolt holes on a 40mm circle
277
+ ```
156
278
 
157
279
  ---
158
280
 
@@ -254,6 +376,81 @@ own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/
254
376
 
255
377
  ---
256
378
 
379
+ ## Verifying a part headlessly (render + measure)
380
+
381
+ Once the package is installed you get two CLI commands that build your part in
382
+ pure Node (no dev server, no browser) so you — or an LLM authoring the part — can
383
+ check it without opening the app:
384
+
385
+ npx partforge measure src/parts/<part>.js [view] # geometric facts
386
+ npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
387
+
388
+ `measure` prints a report and writes `measure-<part>-<view>.json`: per sub-part
389
+ and per view it reports bounding box, volume, surface area, triangle count,
390
+ whether the solid is watertight, and the number of through-holes (genus), plus an
391
+ assembly overlap check. It exits non-zero if any sub-part isn't watertight or any
392
+ parts interpenetrate — so it doubles as a CI/agent gate. (Manifold output is
393
+ manifold by construction, so `watertight` is mainly a build-sanity check for
394
+ empty/degenerate results; `holes` is the informative topology number.)
395
+
396
+ `render` writes one PNG per angle (`iso`, `front`, `top` by default; choose with
397
+ `--views iso,front`, output dir with `--out`) to `render/`. The view defaults to
398
+ the part's first declared view.
399
+
400
+ The `measure` function is also exported for vitest (boot a Manifold kernel as in
401
+ "Testing a part", then `measure(kernel, part, "<view>")`):
402
+
403
+ import { measure } from "partforge/testing";
404
+ test("part is sound", () => {
405
+ const r = measure(kernel, part, "<view>");
406
+ expect(r.ok).toBe(true);
407
+ expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
408
+ });
409
+
410
+ ---
411
+
412
+ ## Fillet & chamfer (automatic OCCT backend)
413
+
414
+ Two backends build your part: **Manifold** (fast meshes — preview, STL, 3MF) and
415
+ **OCCT/replicad** (exact B-rep — STEP). Most parts run on Manifold. But Manifold has no
416
+ fillet, so if your `build` calls a **CAD-only op** the framework automatically routes the
417
+ whole part to OCCT — no declaration needed:
418
+
419
+ | Op | Meaning |
420
+ |---|---|
421
+ | `s.fillet(radius, selector?)` | round edges (curve-following, exact) |
422
+ | `s.chamfer(distance, selector?)` | bevel edges |
423
+ | `s.shell(thickness, openFaces)` | hollow inward, wall = `thickness`; `openFaces` selector (`{inPlane,at}`/`{dir}`/`{near}`) chooses which face(s) to open. Closed (no-open-face) hollows are not supported. |
424
+
425
+ `selector` chooses which edges (omit it for **all** edges):
426
+
427
+ - `{ dir: "X"|"Y"|"Z" }` — edges running along an axis (e.g. `{dir:"Z"}` = the vertical edges)
428
+ - `{ inPlane: "XY"|"XZ"|"YZ", at }` — edges lying in a plane (e.g. base edges: `{inPlane:"XY", at:0}`)
429
+ - `{ near: [x,y,z] }` — edges passing through a point
430
+ - a raw `(edgeFinder) => edgeFinder` replicad finder, for anything fancier
431
+
432
+ ```js
433
+ let s = k.box([0,0,0],[40,30,16]);
434
+ s = s.fillet(3, { dir: "Z" }); // round the 4 vertical edges
435
+ s = s.chamfer(1, { inPlane: "XY", at: 0 }); // bevel the base
436
+ ```
437
+
438
+ See `src/parts/filleted-box.js` for the worked example.
439
+
440
+ **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
441
+ of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
442
+ everything else (so sweep-heavy parts like the drum stay fast). Force it with
443
+ `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
444
+ entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
445
+
446
+ > Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
447
+ > `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
448
+
449
+ > `partforge measure` reports `watertight`/`holes` as `n/a` for OCCT parts (Manifold-only
450
+ > topology); `render` works on both.
451
+
452
+ ---
453
+
257
454
  ## Conventions & gotchas
258
455
 
259
456
  - **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scottsykora/partforge.git"
10
+ },
11
+ "homepage": "https://github.com/scottsykora/partforge#readme",
12
+ "bugs": "https://github.com/scottsykora/partforge/issues",
7
13
  "engines": {
8
14
  "node": ">=24"
9
15
  },
10
16
  "files": [
11
17
  "src",
18
+ "bin",
12
19
  "docs/AUTHORING-PARTS.md",
13
20
  "README.md"
14
21
  ],
@@ -18,6 +25,9 @@
18
25
  "./geometry": "./src/framework/geometry/polygon.js",
19
26
  "./testing": "./src/testing.js"
20
27
  },
28
+ "bin": {
29
+ "partforge": "./bin/cli.js"
30
+ },
21
31
  "scripts": {
22
32
  "dev": "vite",
23
33
  "build": "vite build",
@@ -27,13 +37,17 @@
27
37
  "check": "node scripts/check-app.mjs"
28
38
  },
29
39
  "dependencies": {
40
+ "dompurify": "^3.4.11",
30
41
  "fflate": "^0.8.3",
31
42
  "manifold-3d": "^3.5.1",
43
+ "marked": "^18.0.5",
44
+ "pngjs": "^7.0.0",
32
45
  "replicad": "^0.23.1",
33
46
  "replicad-opencascadejs": "^0.23.0",
34
47
  "three": "^0.184.0"
35
48
  },
36
49
  "devDependencies": {
50
+ "happy-dom": "^20.10.6",
37
51
  "playwright": "^1.49.0",
38
52
  "vite": "^8.0.16",
39
53
  "vitest": "^4.1.9"
@@ -0,0 +1,7 @@
1
+ import part from "./parts/filleted-box.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ mount(part, {
5
+ createWorker: (name) =>
6
+ new Worker(new URL("./filleted-box-worker.js", import.meta.url), { type: "module", name }),
7
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/filleted-box.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -138,4 +138,29 @@ button.action:disabled { opacity: .5; cursor: default; }
138
138
  animation: spin 0.9s linear infinite;
139
139
  }
140
140
  #busy .phase { color: var(--text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
141
- @keyframes spin { to { transform: rotate(360deg); } }
141
+ @keyframes spin { to { transform: rotate(360deg); } }
142
+
143
+ /* info glyph + description popover */
144
+ .info {
145
+ appearance: none; border: none; background: none; cursor: pointer;
146
+ color: var(--muted); font-size: 12px; line-height: 1; padding: 0 0 0 5px;
147
+ vertical-align: middle;
148
+ }
149
+ .info:hover { color: var(--text-2); }
150
+ .info:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
151
+ .popover {
152
+ position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
153
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
154
+ border-radius: 8px; padding: 10px 12px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
155
+ font-size: 12px; line-height: 1.5;
156
+ }
157
+ .popover[hidden] { display: none; }
158
+ .popover img { max-width: 100%; height: auto; border-radius: 4px; }
159
+ .popover a { color: var(--accent); }
160
+ .popover p:first-child { margin-top: 0; }
161
+ .popover p:last-child { margin-bottom: 0; }
162
+
163
+ /* relevance: controls/sections that don't affect the on-screen parts */
164
+ .irrelevant { opacity: 0.45; }
165
+ .irrelevant:hover { opacity: 0.7; }
166
+ .section-hidden { display: none; }