partforge 0.27.0 → 0.28.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.
- package/README.md +16 -4
- package/docs/AUTHORING-PARTS.md +92 -7
- package/package.json +5 -2
- package/src/app-bracket.js +5 -0
- package/src/app-demo.js +5 -0
- package/src/app-faceted-vase.js +5 -0
- package/src/app-filleted-box.js +5 -0
- package/src/app-hull-sweep.js +5 -0
- package/src/app-nameplate.js +5 -0
- package/src/app-planter.js +5 -0
- package/src/app-text-smoke.js +5 -0
- package/src/framework/app.css +85 -39
- package/src/framework/chrome.css +180 -0
- package/src/framework/debug-overlay.js +16 -1
- package/src/framework/mount.js +14 -0
- package/src/framework/rail-state.js +73 -0
- package/src/framework/rail.js +321 -0
- package/src/framework/tokens.css +14 -1
package/README.md
CHANGED
|
@@ -99,10 +99,11 @@ const runtime = mount(part, {
|
|
|
99
99
|
createWorker,
|
|
100
100
|
elements: {
|
|
101
101
|
viewer, controls, // canvas host + param-panel host
|
|
102
|
+
rail, // full-height resizable/collapsible controls rail
|
|
102
103
|
status: { status, busy, phase }, // status chrome
|
|
103
104
|
tabs, // view-tab segmented control
|
|
104
105
|
exports: { stl, step, threeMf }, // export buttons
|
|
105
|
-
chrome: { pause, reframe, theme },
|
|
106
|
+
chrome: { pause, reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
|
|
106
107
|
},
|
|
107
108
|
onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
|
|
108
109
|
onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
|
|
@@ -112,9 +113,20 @@ runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU
|
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
115
|
-
`#status`/`#busy`/`#phase`, `#part`,
|
|
116
|
-
`#
|
|
117
|
-
|
|
116
|
+
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
117
|
+
`#download`/`#download-step`/`#download-3mf`,
|
|
118
|
+
`#pause`/`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no
|
|
119
|
+
changes. The viewer sizes from its container via ResizeObserver — no window
|
|
120
|
+
coupling.
|
|
121
|
+
|
|
122
|
+
`rail` is the full-height controls rail introduced by the resizable-panel
|
|
123
|
+
layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
|
|
124
|
+
`chrome.railToggle` is its optional collapse/restore button. Both are optional
|
|
125
|
+
— a host that lays out the framework itself (no rail markup) gets a no-op.
|
|
126
|
+
The rail's resize seam is positioned against `rail.parentElement` by default,
|
|
127
|
+
so **the rail must be a direct child of the positioned `.pf-shell`** unless
|
|
128
|
+
the host also supplies `elements.shell` to point at the real containing block
|
|
129
|
+
(e.g. when a wrapper div sits between them, as is common in a React layout).
|
|
118
130
|
|
|
119
131
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
120
132
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -30,8 +30,9 @@ OCCT-only fillet/chamfer/shell ops.
|
|
|
30
30
|
`http://localhost:5173/<your-part>.html`.
|
|
31
31
|
|
|
32
32
|
That's the whole loop. The chrome (panel, tabs, viewer, export buttons) is shared —
|
|
33
|
-
your HTML is
|
|
34
|
-
|
|
33
|
+
your HTML is structural markup only and carries no CSS (the framework supplies it via
|
|
34
|
+
`framework/app.css`, imported by `mount`). See "Wiring a part into a runnable app"
|
|
35
|
+
below for what that markup must contain.
|
|
35
36
|
|
|
36
37
|
---
|
|
37
38
|
|
|
@@ -688,37 +689,121 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
688
689
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
689
690
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
690
691
|
| `#viewbar` with `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
692
|
+
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
693
|
+
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#pause`/`#theme` |
|
|
694
|
+
|
|
695
|
+
Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
|
|
696
|
+
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
697
|
+
`"occt"` for STEP — handled for you).
|
|
698
|
+
|
|
699
|
+
**The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
|
|
700
|
+
`class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
|
|
701
|
+
(`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
|
|
702
|
+
(`#topbar`, `#viewbar`, `#busy`) as absolutely-positioned siblings of the canvas, not
|
|
703
|
+
page-level overlays. `#panel` (`class="pf-rail"`) is a full-height rail docked to the
|
|
704
|
+
right edge, split into three children — `.pf-rail-head` / `.pf-rail-body` /
|
|
705
|
+
`.pf-rail-foot` — of which head and foot are flex-fixed and only the body scrolls: put
|
|
706
|
+
your heading in the head and the download row in the foot so the export buttons never
|
|
707
|
+
scroll out of reach. The rail's drag/collapse seam is created by `rail.js` itself; don't
|
|
708
|
+
add markup for it. This isn't decorative — get the head/body/foot split wrong and either
|
|
709
|
+
the export buttons scroll away or a tall parameter list pushes them off-screen. See
|
|
710
|
+
`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md` for why the rail is
|
|
711
|
+
shaped this way (resize/collapse behavior, breakpoints, the design rationale).
|
|
712
|
+
|
|
713
|
+
**Keyboard (the seam is `role="separator"`, focusable, `tabIndex=0`):**
|
|
714
|
+
|
|
715
|
+
| Key | Action |
|
|
716
|
+
|---|---|
|
|
717
|
+
| ← | widen the rail 16px (64px with Shift). No-op while collapsed. |
|
|
718
|
+
| → | narrow the rail 16px (64px with Shift), clamped at the 240px minimum — never collapses. No-op while collapsed. |
|
|
719
|
+
| Home | jump to the 240px minimum, animated. Reopens even while collapsed. |
|
|
720
|
+
| End | jump to the clamped maximum (half the shell, capped at 560px), animated. Reopens even while collapsed. |
|
|
721
|
+
| Enter / Space | toggle collapse — collapses if open; reopens at the remembered width if collapsed. |
|
|
722
|
+
| double-click (on the seam) | reset to the 288px default, animated, and opens if collapsed. |
|
|
723
|
+
|
|
724
|
+
Arrow keys move the **separator**, not the pane — standard `role="separator"`
|
|
725
|
+
semantics, and why ← *widens* a right-hand rail. `Cmd`/`Ctrl`/`Alt` held with an
|
|
726
|
+
arrow key passes through untouched (those are OS/browser-reserved combos, e.g.
|
|
727
|
+
back navigation or window-switching); `Shift` alone still applies the larger
|
|
728
|
+
step. Collapsed, the two arrow keys are deliberate no-ops rather than a reopen
|
|
729
|
+
gesture — reopening would otherwise silently discard the remembered width and
|
|
730
|
+
clamp to the minimum, and "press an arrow, get narrower" reads backwards for a
|
|
731
|
+
rail that's already shut. Home/End and Enter/Space/double-click are exempt from
|
|
732
|
+
that rule and always reopen, since jumping to an explicit width or toggling is
|
|
733
|
+
an unambiguous, deliberate gesture either way. A held arrow-key repeat
|
|
734
|
+
suppresses the 150ms width transition for the whole repeat window (not just one
|
|
735
|
+
keydown), matching what happens during a drag.
|
|
736
|
+
|
|
737
|
+
Legacy id-only markup (predating this class scheme) still renders: `app.css` keeps
|
|
738
|
+
`:not(.pf-*)` fallbacks (`#app:not(.pf-stage)`, `#panel:not(.pf-rail)`, and
|
|
739
|
+
placement-only ones for `#topbar`/`#viewbar`) that reproduce the old floating-card
|
|
740
|
+
look — `:not()` rather than a plain id rule because an id selector outranks a class. New
|
|
741
|
+
apps should still use the classed markup above; the fallback exists for pages that
|
|
742
|
+
predate it, not as a second supported style.
|
|
743
|
+
|
|
744
|
+
A host that builds its own DOM instead of using `mount`'s markup (e.g. an editor
|
|
745
|
+
embedding the viewer/rail inside a larger UI) can adopt the same layout by importing
|
|
746
|
+
**`partforge/chrome.css`** directly — it's deliberately class-based and id-free for that
|
|
747
|
+
reason. It expects `partforge/tokens.css` to already be loaded for its `--pf-*` custom
|
|
748
|
+
properties, and expects the host to size `.pf-shell` itself (`mount`'s own `app.css`,
|
|
749
|
+
which `@import`s both, does both of these for you already).
|
|
694
750
|
|
|
695
751
|
`#cutaway` is optional viewer chrome. When present, it toggles an interactive
|
|
696
752
|
section plane whose exposed faces are hatched; changing views resets it. Cutaway
|
|
697
753
|
is viewer-only and never changes STL, STEP, or 3MF exports. Hosts that omit the
|
|
698
754
|
button get no cutaway UI.
|
|
699
755
|
|
|
700
|
-
Programmatic hosts can provide the same optional
|
|
701
|
-
ID by passing
|
|
756
|
+
Programmatic hosts can provide the same optional controls, including the rail toggle,
|
|
757
|
+
without relying on an ID by passing them beside the other chrome references — and can
|
|
758
|
+
pass the rail itself as `elements.rail` instead of relying on `#panel`:
|
|
702
759
|
|
|
703
760
|
```js
|
|
704
761
|
mount(part, {
|
|
705
762
|
createWorker,
|
|
706
763
|
elements: {
|
|
764
|
+
rail,
|
|
707
765
|
chrome: {
|
|
708
766
|
pause,
|
|
709
767
|
reframe,
|
|
710
768
|
cutaway,
|
|
711
769
|
theme,
|
|
770
|
+
railToggle,
|
|
712
771
|
},
|
|
713
772
|
},
|
|
714
773
|
});
|
|
715
774
|
```
|
|
716
775
|
|
|
776
|
+
`rail`/`chrome.railToggle` are both optional; a host with no rail markup gets a
|
|
777
|
+
no-op (the resize/collapse behavior below simply doesn't attach). **Constraint:**
|
|
778
|
+
the rail element must be a direct child of the positioned `.pf-shell` — the
|
|
779
|
+
resize seam is created and positioned against `rail.parentElement` by default,
|
|
780
|
+
so an extra wrapper div between them (common in a React layout) puts the seam
|
|
781
|
+
against the wrong ancestor and silently breaks `[data-pf-dragging] .pf-stage`.
|
|
782
|
+
A host that can't make the rail a direct child of `.pf-shell` must also pass
|
|
783
|
+
`elements.shell` pointing at the real positioned ancestor:
|
|
784
|
+
|
|
785
|
+
```js
|
|
786
|
+
mount(part, {
|
|
787
|
+
createWorker,
|
|
788
|
+
elements: { rail, shell, chrome: { railToggle } },
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
717
792
|
> Production deploy compiles only the pages listed in `build.rollupOptions.input`
|
|
718
793
|
> (currently the landing gallery + the demo part pages). Other root `*.html` files are
|
|
719
794
|
> **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
|
|
720
795
|
> ship one, add it to `build.rollupOptions.input` in `vite.config.js`.
|
|
721
796
|
|
|
797
|
+
**Styling hooks:** the rail/stage layout and palette are both plain `--pf-*` custom
|
|
798
|
+
properties from `partforge/tokens.css`, overridable on `:root` (or
|
|
799
|
+
`:root[data-theme="light"]`) without touching `chrome.css`. Layout/shape tokens added
|
|
800
|
+
alongside the rail: `--pf-sans`, `--pf-rail-w`, `--pf-rail-pad`, `--pf-radius-control`,
|
|
801
|
+
`--pf-radius-pill`, `--pf-shadow-float`, `--pf-shadow-rail`. The dev demos self-host
|
|
802
|
+
Geist and Geist Mono (`@fontsource-variable/geist(-mono)`, a `devDependency`, imported
|
|
803
|
+
from each `app-<part>.js` — see `src/app-demo.js`) so a standalone forge looks like the
|
|
804
|
+
finished product; the published library ships no font files, and a consumer that loads
|
|
805
|
+
none falls through `--pf-sans`/`--pf-mono` to system stacks by design.
|
|
806
|
+
|
|
722
807
|
### Developing against a local (linked) partforge
|
|
723
808
|
|
|
724
809
|
A normal `npm install partforge` needs no extra config. But if you `npm link` a local
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"./lint": "./src/lint.js",
|
|
29
29
|
"./derive": "./src/framework/derive.js",
|
|
30
30
|
"./testing": "./src/testing.js",
|
|
31
|
-
"./tokens.css": "./src/framework/tokens.css"
|
|
31
|
+
"./tokens.css": "./src/framework/tokens.css",
|
|
32
|
+
"./chrome.css": "./src/framework/chrome.css"
|
|
32
33
|
},
|
|
33
34
|
"bin": {
|
|
34
35
|
"partforge": "./bin/cli.js"
|
|
@@ -54,6 +55,8 @@
|
|
|
54
55
|
"three": "^0.184.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
58
|
+
"@fontsource-variable/geist": "^5.3.0",
|
|
59
|
+
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
57
60
|
"happy-dom": "^20.10.6",
|
|
58
61
|
"playwright": "^1.49.0",
|
|
59
62
|
"vite": "^8.0.16",
|
package/src/app-bracket.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/bracket.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-demo.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import demoPart from "./parts/demo.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-faceted-vase.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import vasePart from "./parts/faceted-vase.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-filleted-box.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/filleted-box.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-hull-sweep.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/hull-sweep.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-nameplate.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/nameplate.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-planter.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import planterPart from "./parts/planter.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-text-smoke.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/text-smoke.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/framework/app.css
CHANGED
|
@@ -6,42 +6,63 @@
|
|
|
6
6
|
accent, shadcn-shaped radii/shadows/focus rings — plain CSS on --pf-*.
|
|
7
7
|
See docs/AUTHORING-PARTS.md. */
|
|
8
8
|
@import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
|
|
9
|
+
@import "./chrome.css"; /* reusable layout; also exported as partforge/chrome.css */
|
|
9
10
|
|
|
10
11
|
* { box-sizing: border-box; }
|
|
11
12
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
12
|
-
font: 13px/1.4 -
|
|
13
|
-
|
|
13
|
+
font: 13px/1.4 var(--pf-sans); }
|
|
14
|
+
/* Legacy fallback: a page whose markup predates .pf-stage keeps the old
|
|
15
|
+
full-window viewer. :not() rather than a plain #app rule because an id would
|
|
16
|
+
outrank .pf-stage's positioning and win the cascade. */
|
|
17
|
+
#app:not(.pf-stage) { position: fixed; inset: 0; background: var(--pf-bg); }
|
|
14
18
|
canvas { display: block; }
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
/* Legacy fallback: the pre-rail floating card, for markup without .pf-rail. */
|
|
21
|
+
#panel:not(.pf-rail) {
|
|
17
22
|
position: fixed; top: 12px; left: 12px; width: 256px;
|
|
18
23
|
max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
|
|
19
24
|
background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 16px;
|
|
20
25
|
padding: 14px; color: var(--pf-text);
|
|
21
|
-
|
|
22
|
-
panes read as siblings (was the smaller shadow-md). */
|
|
23
|
-
box-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);
|
|
26
|
+
box-shadow: var(--pf-shadow-float);
|
|
24
27
|
}
|
|
28
|
+
/* Legacy fallback only: pre-rail markup has a bare <h1>/.sub directly inside
|
|
29
|
+
#panel, with no .pf-rail-head wrapper, so chrome.css's classed
|
|
30
|
+
.pf-rail-head h1/.sub rule (the single source of truth for that typography)
|
|
31
|
+
can't reach it — chrome.css is deliberately id-free (see test/tokens.test.js)
|
|
32
|
+
so this id-scoped restatement has to live here instead. Classed markup never
|
|
33
|
+
hits this rule (its h1 is inside .pf-rail-head, already styled by
|
|
34
|
+
chrome.css); keep the two in sync if the typography ever changes. */
|
|
25
35
|
#panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em; }
|
|
26
36
|
#panel .sub {
|
|
27
37
|
font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
|
|
28
38
|
letter-spacing: 0.04em; text-transform: uppercase;
|
|
29
|
-
margin: 0 0
|
|
39
|
+
margin: 2px 0 0;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
.seg { display: flex; gap: 2px; margin-bottom: 12px;
|
|
33
|
-
background: var(--pf-surface-2); border-radius:
|
|
43
|
+
background: var(--pf-surface-2); border-radius: var(--pf-radius-control); padding: 3px; }
|
|
34
44
|
.seg button {
|
|
35
|
-
flex: 1; padding: 6px 0; border: 0; border-radius:
|
|
45
|
+
flex: 1; padding: 6px 0; border: 0; border-radius: var(--pf-radius-control);
|
|
36
46
|
background: transparent; color: var(--pf-muted); cursor: pointer;
|
|
37
47
|
font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
|
|
38
48
|
}
|
|
39
49
|
.seg button:hover { color: var(--pf-text-2); }
|
|
40
50
|
.seg button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
41
51
|
|
|
52
|
+
/* Full-bleed rows: the divider spans the rail's whole width while the content
|
|
53
|
+
sits at the rail's own padding, so each slider gains the ~22px the old box
|
|
54
|
+
border + padding used to take from both sides. */
|
|
42
55
|
.section {
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
padding: 11px var(--pf-rail-pad);
|
|
57
|
+
}
|
|
58
|
+
/* Divider BETWEEN visible sections. `~` walks all preceding siblings, and a
|
|
59
|
+
relevance-hidden section (.section-hidden, display:none) fails the :not(),
|
|
60
|
+
so the topmost VISIBLE section never draws a hairline under the rail header.
|
|
61
|
+
Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
62
|
+
matches DOM position rather than visibility, and leaves a stray divider
|
|
63
|
+
floating at the top whenever applyRelevance hides the first section. */
|
|
64
|
+
.section:not(.section-hidden) ~ .section:not(.section-hidden) {
|
|
65
|
+
border-top: 1px solid var(--pf-border);
|
|
45
66
|
}
|
|
46
67
|
.sec-title {
|
|
47
68
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
@@ -49,13 +70,13 @@ canvas { display: block; }
|
|
|
49
70
|
}
|
|
50
71
|
select.preset {
|
|
51
72
|
width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
|
|
52
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
73
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px;
|
|
53
74
|
font-family: var(--pf-mono); font-size: 11px;
|
|
54
75
|
}
|
|
55
76
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
56
77
|
color: var(--pf-text-2); cursor: pointer; }
|
|
57
78
|
.feat input { cursor: pointer; accent-color: var(--pf-accent); }
|
|
58
|
-
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left:
|
|
79
|
+
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 1px solid var(--pf-border); }
|
|
59
80
|
.feat-group.hidden { display: none; }
|
|
60
81
|
.adv-toggle {
|
|
61
82
|
margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 6px;
|
|
@@ -74,7 +95,7 @@ select.preset {
|
|
|
74
95
|
.row .num {
|
|
75
96
|
width: 54px; text-align: right; font-family: var(--pf-mono); font-size: 12px; font-variant-numeric: tabular-nums;
|
|
76
97
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
77
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
98
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 3px 6px;
|
|
78
99
|
}
|
|
79
100
|
.row .num:focus { outline: none; border-color: var(--pf-accent);
|
|
80
101
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
@@ -84,7 +105,7 @@ select.preset {
|
|
|
84
105
|
.text-input {
|
|
85
106
|
display: block; width: 100%; font: 12px/1.4 var(--pf-mono);
|
|
86
107
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
87
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
108
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
|
|
88
109
|
}
|
|
89
110
|
textarea.text-input { min-height: 64px; resize: vertical; }
|
|
90
111
|
.text-input:focus { outline: none; border-color: var(--pf-accent);
|
|
@@ -97,12 +118,12 @@ input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; backgro
|
|
|
97
118
|
input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--pf-accent); }
|
|
98
119
|
input[type="range"]::-webkit-slider-thumb {
|
|
99
120
|
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
|
|
100
|
-
border-radius: 50%; background: var(--pf-accent); border: 2px solid var(--pf-surface
|
|
121
|
+
border-radius: 50%; background: var(--pf-accent); border: 2px solid var(--pf-surface); box-shadow: 0 0 0 1px var(--pf-accent);
|
|
101
122
|
transition: box-shadow .12s ease;
|
|
102
123
|
}
|
|
103
124
|
input[type="range"]::-moz-range-thumb {
|
|
104
125
|
width: 14px; height: 14px; border-radius: 50%; background: var(--pf-accent);
|
|
105
|
-
border: 2px solid var(--pf-surface
|
|
126
|
+
border: 2px solid var(--pf-surface); box-shadow: 0 0 0 1px var(--pf-accent);
|
|
106
127
|
}
|
|
107
128
|
input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
108
129
|
input[type="range"]:focus-visible { outline: none; }
|
|
@@ -110,7 +131,7 @@ input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px
|
|
|
110
131
|
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
111
132
|
|
|
112
133
|
button.action {
|
|
113
|
-
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius:
|
|
134
|
+
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: var(--pf-radius-control);
|
|
114
135
|
background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 500; cursor: pointer;
|
|
115
136
|
}
|
|
116
137
|
button.action:hover:not(:disabled) { background: color-mix(in oklab, var(--pf-accent) 90%, black); }
|
|
@@ -125,7 +146,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
125
146
|
}
|
|
126
147
|
.dl-row { display: flex; gap: 6px; }
|
|
127
148
|
.dl-row button {
|
|
128
|
-
flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius:
|
|
149
|
+
flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
129
150
|
background: transparent; color: var(--pf-text-2);
|
|
130
151
|
font-family: var(--pf-mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
|
|
131
152
|
}
|
|
@@ -143,28 +164,28 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
143
164
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
144
165
|
}
|
|
145
166
|
|
|
146
|
-
/* part tabs
|
|
147
|
-
#topbar {
|
|
148
|
-
position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
|
|
149
|
-
z-index: 15;
|
|
150
|
-
}
|
|
167
|
+
/* part tabs (placement: .pf-float-tabs; legacy markup keeps the old float) */
|
|
168
|
+
#topbar:not(.pf-float-tabs) { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 15; }
|
|
151
169
|
#topbar .seg {
|
|
152
170
|
margin: 0; padding: 4px; background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
153
|
-
border-radius:
|
|
154
|
-
box-shadow:
|
|
171
|
+
border-radius: var(--pf-radius-pill);
|
|
172
|
+
box-shadow: var(--pf-shadow-float);
|
|
155
173
|
}
|
|
156
174
|
#topbar .seg button { min-width: 70px; padding: 7px 10px; }
|
|
157
175
|
|
|
158
|
-
/* viewer controls
|
|
176
|
+
/* viewer controls. APPEARANCE is ungated: partforge-cloud re-anchors #viewbar's
|
|
177
|
+
position in sandbox.css but inherits this pill chrome, so gating it on a class
|
|
178
|
+
the cloud never sets would strip the editor's viewbar. PLACEMENT comes from
|
|
179
|
+
.pf-float-viewbar; the :not() keeps legacy markup's old top-right float. */
|
|
159
180
|
#viewbar {
|
|
160
|
-
position: fixed; top: 12px; right: 12px; z-index: 15;
|
|
161
181
|
display: flex; gap: 4px; padding: 4px;
|
|
162
182
|
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
163
|
-
border-radius:
|
|
164
|
-
box-shadow:
|
|
183
|
+
border-radius: var(--pf-radius-pill);
|
|
184
|
+
box-shadow: var(--pf-shadow-float);
|
|
165
185
|
}
|
|
186
|
+
#viewbar:not(.pf-float-viewbar) { position: fixed; top: 12px; right: 12px; z-index: 15; }
|
|
166
187
|
#viewbar button {
|
|
167
|
-
width: 34px; height: 34px; border: 0; border-radius:
|
|
188
|
+
width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
|
|
168
189
|
background: transparent; color: var(--pf-muted-2); cursor: pointer;
|
|
169
190
|
font-size: 15px; line-height: 1;
|
|
170
191
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -177,16 +198,36 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
177
198
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
178
199
|
#viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
179
200
|
|
|
201
|
+
/* At very narrow widths the full-size pill (5 icon buttons + cutaway's
|
|
202
|
+
Flip/Reset, ~314px) is wider than a small phone's viewport minus the
|
|
203
|
+
stage's 12px margins, and .pf-shell { overflow: hidden } clips the excess
|
|
204
|
+
off its left edge (caught by scripts/check-app.mjs's containment check).
|
|
205
|
+
Shrink the pill instead of letting it wrap — a two-row pill would need its
|
|
206
|
+
own anchoring rework, while shrinking is a pure appearance change. */
|
|
207
|
+
@media (max-width: 360px) {
|
|
208
|
+
#viewbar { gap: 3px; }
|
|
209
|
+
#viewbar button { width: 30px; height: 30px; font-size: 13px; }
|
|
210
|
+
#viewbar .pf-cutaway-actions { gap: 3px; }
|
|
211
|
+
#viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
|
|
215
|
+
(bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
|
|
216
|
+
top-left floating #panel card. Legacy markup still floats #viewbar top-right
|
|
217
|
+
and #panel top-left (both `position: fixed`), which collide once the window
|
|
218
|
+
gets narrow enough — restore the old narrow-width rule that moves #viewbar
|
|
219
|
+
to bottom-centre and gives #panel room, scoped with :not(.pf-*) so it can't
|
|
220
|
+
affect the new layout. */
|
|
180
221
|
@media (max-width: 680px) {
|
|
181
|
-
#panel { max-height: calc(100vh - 80px); }
|
|
182
|
-
#viewbar {
|
|
222
|
+
#panel:not(.pf-rail) { max-height: calc(100vh - 80px); }
|
|
223
|
+
#viewbar:not(.pf-float-viewbar) {
|
|
183
224
|
top: auto; right: auto; bottom: 12px; left: 50%;
|
|
184
225
|
transform: translateX(-50%);
|
|
185
226
|
}
|
|
186
227
|
}
|
|
187
228
|
|
|
188
229
|
#busy {
|
|
189
|
-
position:
|
|
230
|
+
position: absolute; inset: 0; z-index: 20; pointer-events: none;
|
|
190
231
|
display: none; flex-direction: column; align-items: center;
|
|
191
232
|
justify-content: center; gap: 14px;
|
|
192
233
|
}
|
|
@@ -211,8 +252,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
211
252
|
.popover {
|
|
212
253
|
position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
|
|
213
254
|
background: var(--pf-surface); color: var(--pf-text); border: 1px solid var(--pf-border);
|
|
214
|
-
border-radius:
|
|
215
|
-
box-shadow:
|
|
255
|
+
border-radius: var(--pf-radius-pill); padding: 10px 12px;
|
|
256
|
+
box-shadow: var(--pf-shadow-float);
|
|
216
257
|
font-size: 12px; line-height: 1.5;
|
|
217
258
|
}
|
|
218
259
|
.popover[hidden] { display: none; }
|
|
@@ -246,12 +287,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
246
287
|
/* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
|
|
247
288
|
laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
|
|
248
289
|
#pf-pick-banner {
|
|
249
|
-
position: fixed; top: 78px; left: 50
|
|
290
|
+
position: fixed; top: 78px; left: calc(50% - var(--pf-rail-w) / 2); transform: translateX(-50%);
|
|
250
291
|
z-index: 30; max-width: min(56ch, calc(100vw - 24px));
|
|
251
292
|
padding: 10px 36px 10px 12px;
|
|
252
293
|
background: var(--pf-surface); color: var(--pf-text);
|
|
253
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
254
|
-
box-shadow:
|
|
294
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-pill);
|
|
295
|
+
box-shadow: var(--pf-shadow-float);
|
|
255
296
|
font-size: 12px; line-height: 1.45;
|
|
256
297
|
animation: pf-pick-in .18s ease;
|
|
257
298
|
}
|
|
@@ -264,6 +305,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
264
305
|
#pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
265
306
|
#pf-pick-banner .pf-pick-label { color: var(--pf-muted); font-size: 11px; }
|
|
266
307
|
#pf-pick-banner .pf-pick-prompt { color: var(--pf-text-strong); font-weight: 600; }
|
|
308
|
+
/* Below the rail's breakpoint the rail stacks under the viewer, so the stage is
|
|
309
|
+
full width again and the banner centres on the window. */
|
|
310
|
+
@media (max-width: 719px) {
|
|
311
|
+
#pf-pick-banner { left: 50%; }
|
|
312
|
+
}
|
|
267
313
|
#pf-pick-close {
|
|
268
314
|
position: absolute; top: 6px; right: 6px; appearance: none;
|
|
269
315
|
width: 20px; height: 20px; padding: 0;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* Reusable chrome LAYOUT for a partforge app: the shell, the viewer column
|
|
2
|
+
("stage"), the full-height controls rail, its resize seam, and the two
|
|
3
|
+
floating pill groups. This file owns the shell's layout and its structural
|
|
4
|
+
surfaces — the rail's/head's/foot's background, border, shadow, and text
|
|
5
|
+
color, and the seam pill's background — all expressed through overridable
|
|
6
|
+
--pf-* tokens. Appearance of the controls INSIDE the rail (the panel
|
|
7
|
+
widgets themselves) stays in app.css.
|
|
8
|
+
|
|
9
|
+
Class-based on purpose. partforge-cloud's sandbox builds its own DOM
|
|
10
|
+
(#viewer / #pfc-controls) and could never reuse an id-keyed sheet, so the
|
|
11
|
+
layout is expressed as .pf-* classes and exported standalone as
|
|
12
|
+
"partforge/chrome.css". app.css keeps :not(.pf-*) fallbacks so legacy
|
|
13
|
+
id-only markup renders its previous floating look untouched.
|
|
14
|
+
|
|
15
|
+
Prerequisite: this sheet consumes --pf-* custom properties (--pf-rail-w,
|
|
16
|
+
--pf-rail-pad, --pf-border, --pf-surface, --pf-bg, --pf-text, --pf-muted,
|
|
17
|
+
--pf-shadow-rail) but does not import them — a standalone consumer must
|
|
18
|
+
also load "partforge/tokens.css" (kept separate so the two stay
|
|
19
|
+
independently composable). The host is also responsible for giving
|
|
20
|
+
.pf-shell a height (e.g. `height: 100%` on an ancestor chain rooted at
|
|
21
|
+
`html, body`, or `position: absolute; inset: 0`); this sheet does not size
|
|
22
|
+
the shell itself.
|
|
23
|
+
|
|
24
|
+
See docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md. */
|
|
25
|
+
|
|
26
|
+
/* ---- shell: viewer column + rail, side by side --------------------------- */
|
|
27
|
+
.pf-shell {
|
|
28
|
+
display: flex;
|
|
29
|
+
/* containing block for the absolutely-positioned seam */
|
|
30
|
+
position: relative;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ---- stage: the viewer column, which owns its floating chrome ------------
|
|
35
|
+
min-width: 0 lets the column shrink past the canvas's intrinsic width, so
|
|
36
|
+
dragging the rail wider actually narrows the viewer instead of overflowing. */
|
|
37
|
+
.pf-stage {
|
|
38
|
+
flex: 1;
|
|
39
|
+
position: relative;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
background: var(--pf-bg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ---- rail: a full-height right edge, set back from the viewer ------------
|
|
45
|
+
Square-cornered: it is an edge, not a card. The shadow is INSET on its left
|
|
46
|
+
side — the viewer casts onto the rail, which is what makes the rail read as
|
|
47
|
+
set back. An outer shadow would read as floating above the viewer. */
|
|
48
|
+
.pf-rail {
|
|
49
|
+
flex: none;
|
|
50
|
+
width: var(--pf-rail-w);
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
min-height: 0;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
background: var(--pf-surface);
|
|
56
|
+
border-left: 1px solid var(--pf-border);
|
|
57
|
+
box-shadow: var(--pf-shadow-rail);
|
|
58
|
+
color: var(--pf-text);
|
|
59
|
+
/* Discrete changes (toggle, Home/End, double-click) animate; a drag never
|
|
60
|
+
does — an animated width fights the pointer and costs an extra WebGL
|
|
61
|
+
buffer reallocation every frame. */
|
|
62
|
+
transition: width .15s ease;
|
|
63
|
+
}
|
|
64
|
+
.pf-rail[inert] { border-left-width: 0; }
|
|
65
|
+
|
|
66
|
+
/* Head and foot are flex-fixed rather than sticky, so the scroll container is
|
|
67
|
+
exactly .pf-rail-body. On a full-height rail the export buttons must never
|
|
68
|
+
scroll out of reach. */
|
|
69
|
+
.pf-rail-head, .pf-rail-foot { flex: none; }
|
|
70
|
+
.pf-rail-head { padding: 12px var(--pf-rail-pad); border-bottom: 1px solid var(--pf-border); }
|
|
71
|
+
.pf-rail-foot { padding: 12px var(--pf-rail-pad); border-top: 1px solid var(--pf-border); }
|
|
72
|
+
.pf-rail-body {
|
|
73
|
+
flex: 1;
|
|
74
|
+
min-height: 0;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
overscroll-behavior: contain;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Rail-head title/subtitle typography — class-only (see this file's header:
|
|
80
|
+
no host-supplied id can be assumed), so a host that adopts this sheet with
|
|
81
|
+
its own ids (e.g. partforge-cloud) gets a correctly-shaped rail head with
|
|
82
|
+
correctly-styled text, not just a correctly-shaped empty box. Legacy
|
|
83
|
+
id-only markup (a bare <h1>/.sub with no .pf-rail-head wrapper) can't be
|
|
84
|
+
reached from here without an id selector, so app.css restates the same
|
|
85
|
+
two rules scoped to #panel for that one case — see the comment there. */
|
|
86
|
+
.pf-rail-head h1 {
|
|
87
|
+
font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em;
|
|
88
|
+
}
|
|
89
|
+
.pf-rail-head .sub {
|
|
90
|
+
font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
|
|
91
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
92
|
+
margin: 2px 0 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- resize seam --------------------------------------------------------
|
|
96
|
+
An OVERLAY, not a flex item. partforge-cloud's seam is a real 12px column
|
|
97
|
+
because its card is inset from the window with a gutter to live in; this rail
|
|
98
|
+
is flush against the viewer behind a hairline, so a flex item would open a
|
|
99
|
+
visible stripe of page background.
|
|
100
|
+
|
|
101
|
+
max(0px, …) is what parks the seam flush at the window edge when the rail is
|
|
102
|
+
collapsed, so a fresh drag can pull it back out. The element is created by
|
|
103
|
+
rail.js — no host markup declares it. */
|
|
104
|
+
.pf-rail-seam {
|
|
105
|
+
position: absolute;
|
|
106
|
+
top: 0; bottom: 0;
|
|
107
|
+
right: max(0px, calc(var(--pf-rail-w) - 6px));
|
|
108
|
+
z-index: 20;
|
|
109
|
+
width: 12px;
|
|
110
|
+
display: flex; align-items: center; justify-content: center;
|
|
111
|
+
touch-action: none;
|
|
112
|
+
cursor: ew-resize;
|
|
113
|
+
}
|
|
114
|
+
/* Collapsed, the only legal direction is left. */
|
|
115
|
+
.pf-rail-seam[data-collapsed] { cursor: w-resize; }
|
|
116
|
+
.pf-rail-seam:focus-visible { outline: none; }
|
|
117
|
+
/* Invisible at rest; the affordance is a short centred pill that appears only
|
|
118
|
+
on hover, keyboard focus, or during a drag. */
|
|
119
|
+
.pf-rail-seam > span {
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
width: 3px; height: 100px; border-radius: 999px;
|
|
122
|
+
background: transparent;
|
|
123
|
+
transition: background-color .12s ease;
|
|
124
|
+
/* Nudges only the pill 5px off the divider hairline it otherwise sits flush
|
|
125
|
+
against — the seam's own 12px hit target stays centred on the boundary
|
|
126
|
+
(that line is what a user aims at to drag) and must NOT move. */
|
|
127
|
+
transform: translateX(-5px);
|
|
128
|
+
}
|
|
129
|
+
.pf-rail-seam:hover > span,
|
|
130
|
+
[data-pf-dragging] .pf-rail-seam > span { background: var(--pf-muted); }
|
|
131
|
+
/* Keyboard focus must read as distinct from hover/drag, not just present —
|
|
132
|
+
the accent pill, not the muted one, is the only thing that says "focus
|
|
133
|
+
landed here" for a keyboard user. */
|
|
134
|
+
.pf-rail-seam:focus-visible > span { background: var(--pf-accent); }
|
|
135
|
+
|
|
136
|
+
/* While dragging, the cursor must stay correct even when the pointer is out
|
|
137
|
+
over the viewer, and the viewer must not react to it. Pointer capture keeps
|
|
138
|
+
the events coming; these two rules are the second belt. */
|
|
139
|
+
[data-pf-dragging] { cursor: ew-resize; user-select: none; }
|
|
140
|
+
[data-pf-dragging] .pf-stage { pointer-events: none; }
|
|
141
|
+
[data-pf-dragging] .pf-rail,
|
|
142
|
+
[data-pf-key-resizing] .pf-rail { transition: none; }
|
|
143
|
+
|
|
144
|
+
/* ---- floating chrome: PLACEMENT ONLY, absolute within the stage ----------
|
|
145
|
+
Deliberately no appearance here. partforge-cloud's sandbox.css re-anchors
|
|
146
|
+
#viewbar's position with its own rules but inherits the pill's chrome
|
|
147
|
+
(background/border/radius/shadow) from app.css, so that chrome must live in
|
|
148
|
+
app.css ungated — not be duplicated into a class the cloud never sets. This
|
|
149
|
+
file owns where things sit; app.css owns what they look like. */
|
|
150
|
+
.pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
|
|
151
|
+
.pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
|
|
152
|
+
.pf-float-viewbar { bottom: 12px; right: 12px; }
|
|
153
|
+
|
|
154
|
+
/* ---- stacked layout: no room for a rail beside the viewer ----------------
|
|
155
|
+
The seam is hidden and resize is absent at this width (rail.js also refuses
|
|
156
|
+
to start a drag); the #rail-toggle still collapses and restores. */
|
|
157
|
+
@media (max-width: 719px) {
|
|
158
|
+
.pf-shell { flex-direction: column; }
|
|
159
|
+
.pf-rail {
|
|
160
|
+
width: auto; height: 45vh;
|
|
161
|
+
border-left: 0; border-top: 1px solid var(--pf-border);
|
|
162
|
+
box-shadow: none;
|
|
163
|
+
}
|
|
164
|
+
.pf-rail-seam { display: none; }
|
|
165
|
+
/* Collapsed, --pf-rail-w has no effect here (width is auto, height is 45vh),
|
|
166
|
+
so hide the rail outright — otherwise the toggle's first tap leaves a
|
|
167
|
+
full-size rail on screen that is inert and unresponsive. */
|
|
168
|
+
.pf-rail[inert] { display: none; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---- reduced motion -----------------------------------------------------
|
|
172
|
+
Collapsing the rail slides 288px of layout across the screen — the first
|
|
173
|
+
layout-scale animation in the framework, and the kind of movement a
|
|
174
|
+
vestibular-sensitive user actually feels. Honour the preference: the rail
|
|
175
|
+
still collapses and still resizes, it just arrives instead of travelling.
|
|
176
|
+
Scoped to what this layout introduces; the pre-existing busy spinner is a
|
|
177
|
+
state indicator and is left alone. */
|
|
178
|
+
@media (prefers-reduced-motion: reduce) {
|
|
179
|
+
.pf-rail, .pf-rail-seam > span { transition: none; }
|
|
180
|
+
}
|
|
@@ -5,8 +5,23 @@
|
|
|
5
5
|
export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
6
6
|
const box = document.createElement("div");
|
|
7
7
|
box.id = "pf-debug";
|
|
8
|
+
// Left edge, below the top tab row: #viewbar is bottom-right and
|
|
9
|
+
// #pf-pick/#pf-pick-toast are bottom-left, so the top-left corner has no
|
|
10
|
+
// OTHER fixed element competing for it — but #topbar's tabs are centred in
|
|
11
|
+
// the stage, and the stage starts flush at the window's left edge, so a top
|
|
12
|
+
// offset of 12px (matching the tabs') is not actually free of them: at
|
|
13
|
+
// narrow/medium widths this ~240px-wide box reaches past the stage's
|
|
14
|
+
// horizontal centre and touches the tabs no matter which top corner it
|
|
15
|
+
// anchors to (measured — see .superpowers/sdd/debug-overlay-fix.md).
|
|
16
|
+
// #topbar's pill is a fixed 38px tall regardless of viewport or label width
|
|
17
|
+
// (12px top + 38px), so clearing it VERTICALLY is what's actually
|
|
18
|
+
// width-independent: sit below the tabs instead of racing them
|
|
19
|
+
// horizontally. That also drops any dependency on --pf-rail-w, so no media
|
|
20
|
+
// query is needed for the stacked layout below 720px.
|
|
21
|
+
// Positioned inline (not in chrome.css) because this box is dev-only chrome
|
|
22
|
+
// specific to partforge itself, never exported for a host to skin or reuse.
|
|
8
23
|
Object.assign(box.style, {
|
|
9
|
-
position: "fixed",
|
|
24
|
+
position: "fixed", top: "58px", left: "12px", zIndex: "9999",
|
|
10
25
|
font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
|
|
11
26
|
color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
|
|
12
27
|
lineHeight: "1.5", whiteSpace: "pre",
|
package/src/framework/mount.js
CHANGED
|
@@ -3,6 +3,7 @@ import { triggerDownload, downloadParts } from "./download.js";
|
|
|
3
3
|
import { createViewer } from "./viewer.js";
|
|
4
4
|
import { attachViewerControls } from "./viewer-controls.js";
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
|
+
import { attachRail } from "./rail.js";
|
|
6
7
|
import { createTooltipPresenter } from "./tooltip.js";
|
|
7
8
|
import { loadCamera } from "./view-state.js";
|
|
8
9
|
import { buildControls } from "./controls.js";
|
|
@@ -68,6 +69,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
|
68
69
|
const els = {
|
|
69
70
|
viewer: elements.viewer ?? legacyContainer ?? byId("app"),
|
|
70
71
|
controls: elements.controls ?? legacyControls ?? byId("controls"),
|
|
72
|
+
rail: elements.rail ?? byId("panel"),
|
|
73
|
+
// No id fallback: attachRail defaults shell to rail.parentElement, which is
|
|
74
|
+
// right for the standard markup (rail is a direct child of .pf-shell). A
|
|
75
|
+
// host that wraps its rail in an extra element (e.g. a React layout div)
|
|
76
|
+
// must pass this explicitly, or the seam ends up positioned against the
|
|
77
|
+
// wrong ancestor. Left undefined (not null) when unsupplied so rail.js's
|
|
78
|
+
// own default still applies.
|
|
79
|
+
shell: elements.shell,
|
|
71
80
|
status: {
|
|
72
81
|
status: elements.status?.status ?? byId("status"),
|
|
73
82
|
busy: elements.status?.busy ?? byId("busy"),
|
|
@@ -84,6 +93,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
|
84
93
|
reframe: elements.chrome?.reframe ?? byId("reframe"),
|
|
85
94
|
theme: elements.chrome?.theme ?? byId("theme"),
|
|
86
95
|
cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
|
|
96
|
+
railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
|
|
87
97
|
},
|
|
88
98
|
};
|
|
89
99
|
|
|
@@ -97,6 +107,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
|
97
107
|
cutaway: els.chrome.cutaway,
|
|
98
108
|
}, { tooltip });
|
|
99
109
|
cleanup.defer(() => cutawayChrome.detach());
|
|
110
|
+
// Resizable/collapsible controls rail. No-ops when the host lays out the
|
|
111
|
+
// framework itself (no #panel / no elements.rail).
|
|
112
|
+
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
|
|
113
|
+
cleanup.defer(() => railChrome.detach());
|
|
100
114
|
const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
|
|
101
115
|
cleanup.defer(() => hover.detach());
|
|
102
116
|
const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure state for the controls rail: width clamping, the drag state machine
|
|
2
|
+
// (including snap-to-collapsed), and the stored preference. No DOM here on
|
|
3
|
+
// purpose — this is the part worth testing exhaustively, and a pointer drag in a
|
|
4
|
+
// headless DOM proves very little (scripts/check-app.mjs covers that path in
|
|
5
|
+
// real Chromium).
|
|
6
|
+
//
|
|
7
|
+
// Mirror image of partforge-cloud's left-hand chat pane: this rail is on the
|
|
8
|
+
// RIGHT, so callers convert a pointer position into an intended rail WIDTH
|
|
9
|
+
// (shellRect.right - clientX, grab-offset corrected) before calling in. Nothing
|
|
10
|
+
// here ever sees a raw clientX.
|
|
11
|
+
export const RAIL_DEFAULT_WIDTH = 288;
|
|
12
|
+
export const RAIL_MIN_WIDTH = 240; // a slider label + its numeric field, still readable
|
|
13
|
+
export const RAIL_MAX_WIDTH = 560;
|
|
14
|
+
// Two thresholds rather than one: the 60px between them is hysteresis, so a
|
|
15
|
+
// shaky hand at the boundary can't flap the rail open and shut. Kept
|
|
16
|
+
// PROPORTIONAL to RAIL_MIN_WIDTH (58%-83%) rather than copying the cloud's
|
|
17
|
+
// literals, which are sized against its wider 280px floor.
|
|
18
|
+
export const RAIL_COLLAPSE_AT = 140;
|
|
19
|
+
export const RAIL_REOPEN_AT = 200;
|
|
20
|
+
// Below this the rail stacks under the viewer and resize is absent entirely.
|
|
21
|
+
export const RAIL_NARROW_BREAKPOINT = 720;
|
|
22
|
+
export const RAIL_STORAGE_KEY = "partforge:rail";
|
|
23
|
+
|
|
24
|
+
// The rail may never take more than half the shell, so the viewer can't be
|
|
25
|
+
// squeezed narrower than the rail. Floored at RAIL_MIN_WIDTH so the function
|
|
26
|
+
// stays total (and max >= min) for a transient zero-width measurement.
|
|
27
|
+
export function railMaxWidth(shellWidth) {
|
|
28
|
+
const half = Number.isFinite(shellWidth) ? Math.floor(shellWidth / 2) : RAIL_MAX_WIDTH;
|
|
29
|
+
return Math.max(RAIL_MIN_WIDTH, Math.min(RAIL_MAX_WIDTH, half));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clampRailWidth(width, shellWidth) {
|
|
33
|
+
const w = Number.isFinite(width) ? Math.round(width) : RAIL_DEFAULT_WIDTH;
|
|
34
|
+
return Math.min(railMaxWidth(shellWidth), Math.max(RAIL_MIN_WIDTH, w));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// railX is the pointer's intended rail width. Returns the SAME state object
|
|
38
|
+
// when nothing changes, so a caller can cheaply skip redundant DOM writes.
|
|
39
|
+
export function resolveRailDrag(railX, state, shellWidth) {
|
|
40
|
+
const open = () => ({ collapsed: false, width: clampRailWidth(railX, shellWidth) });
|
|
41
|
+
if (state.collapsed) {
|
|
42
|
+
// Reopening takes a deliberate push past the far threshold.
|
|
43
|
+
return railX < RAIL_REOPEN_AT ? state : open();
|
|
44
|
+
}
|
|
45
|
+
// Collapsing keeps the last open width, so the toggle restores it later.
|
|
46
|
+
if (railX < RAIL_COLLAPSE_AT) return { collapsed: true, width: state.width };
|
|
47
|
+
return open();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readRailPref(storage, shellWidth) {
|
|
51
|
+
const fallback = { width: RAIL_DEFAULT_WIDTH, collapsed: false };
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = storage.getItem(RAIL_STORAGE_KEY); } catch { return fallback; }
|
|
54
|
+
if (!raw) return fallback;
|
|
55
|
+
let parsed;
|
|
56
|
+
try { parsed = JSON.parse(raw); } catch { return fallback; }
|
|
57
|
+
if (!parsed || typeof parsed !== "object") return fallback;
|
|
58
|
+
return {
|
|
59
|
+
// Re-clamp on read: a width saved on a wide monitor must not leave a laptop
|
|
60
|
+
// with a 560px rail and no room for the viewer.
|
|
61
|
+
width: clampRailWidth(parsed.width, shellWidth),
|
|
62
|
+
collapsed: parsed.collapsed === true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function writeRailPref(state, storage) {
|
|
67
|
+
try {
|
|
68
|
+
storage.setItem(RAIL_STORAGE_KEY, JSON.stringify({
|
|
69
|
+
width: state.width,
|
|
70
|
+
collapsed: state.collapsed,
|
|
71
|
+
}));
|
|
72
|
+
} catch { /* storage unavailable — no-op, matching view-state.js */ }
|
|
73
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RAIL_DEFAULT_WIDTH, RAIL_MIN_WIDTH, RAIL_NARROW_BREAKPOINT,
|
|
3
|
+
clampRailWidth, railMaxWidth, readRailPref, resolveRailDrag, writeRailPref,
|
|
4
|
+
} from "./rail-state.js";
|
|
5
|
+
|
|
6
|
+
const KEY_STEP = 16;
|
|
7
|
+
const KEY_STEP_SHIFT = 64;
|
|
8
|
+
// A held arrow-key repeat must not animate either, so the suppression flag
|
|
9
|
+
// covers the whole repeat window rather than just the instant of a keydown.
|
|
10
|
+
const KEY_SETTLE_MS = 200;
|
|
11
|
+
|
|
12
|
+
// lucide v1.25.0 `panel-right-close` / `panel-right-open` node data (ISC
|
|
13
|
+
// licence) - inlined rather than adding an icon-library dependency for two
|
|
14
|
+
// paths. Both icons share the same 18x18 rounded rect and vertical divider at
|
|
15
|
+
// x=15; they differ only in the chevron, so the SVG is built once (below) and
|
|
16
|
+
// only this `d` is swapped on state change.
|
|
17
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
18
|
+
// panel-right-close: rail OPEN, chevron points right (toward the divider) -
|
|
19
|
+
// clicking pushes the rail shut.
|
|
20
|
+
const CHEVRON_RAIL_OPEN = "m8 9 3 3-3 3";
|
|
21
|
+
// panel-right-open: rail COLLAPSED, chevron points left (away from the
|
|
22
|
+
// divider) - clicking pulls the rail back open.
|
|
23
|
+
const CHEVRON_RAIL_COLLAPSED = "m10 15-3-3 3-3";
|
|
24
|
+
|
|
25
|
+
// Builds the toggle's icon once; apply() swaps the returned chevron path's
|
|
26
|
+
// `d` between CHEVRON_RAIL_OPEN and CHEVRON_RAIL_COLLAPSED. `stroke="currentColor"`
|
|
27
|
+
// is load-bearing: it's how the icon inherits the button's themed colour and
|
|
28
|
+
// its `.on` active state for free, with no icon-specific CSS anywhere.
|
|
29
|
+
function buildToggleIcon() {
|
|
30
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
31
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
32
|
+
svg.setAttribute("width", "16");
|
|
33
|
+
svg.setAttribute("height", "16");
|
|
34
|
+
svg.setAttribute("fill", "none");
|
|
35
|
+
svg.setAttribute("stroke", "currentColor");
|
|
36
|
+
svg.setAttribute("stroke-width", "2");
|
|
37
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
38
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
39
|
+
svg.setAttribute("aria-hidden", "true");
|
|
40
|
+
|
|
41
|
+
const rect = document.createElementNS(SVG_NS, "rect");
|
|
42
|
+
rect.setAttribute("width", "18");
|
|
43
|
+
rect.setAttribute("height", "18");
|
|
44
|
+
rect.setAttribute("x", "3");
|
|
45
|
+
rect.setAttribute("y", "3");
|
|
46
|
+
rect.setAttribute("rx", "2");
|
|
47
|
+
svg.append(rect);
|
|
48
|
+
|
|
49
|
+
const divider = document.createElementNS(SVG_NS, "path");
|
|
50
|
+
divider.setAttribute("d", "M15 3v18");
|
|
51
|
+
svg.append(divider);
|
|
52
|
+
|
|
53
|
+
const chevron = document.createElementNS(SVG_NS, "path");
|
|
54
|
+
svg.append(chevron);
|
|
55
|
+
|
|
56
|
+
return { svg, chevron };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Touching the localStorage PROPERTY can throw (opaque origin, blocked site
|
|
60
|
+
// data) — not just its methods. view-state.js guards it the same way; the
|
|
61
|
+
// framework runs in an iframe in production, where this is reachable.
|
|
62
|
+
function safeStorage() {
|
|
63
|
+
try { return globalThis.localStorage ?? null; } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Make the controls rail resizable and collapsible, with partforge-cloud's seam
|
|
67
|
+
// affordance: a 12px hit target holding a pill that is invisible until hover,
|
|
68
|
+
// keyboard focus, or a drag.
|
|
69
|
+
//
|
|
70
|
+
// The seam is created here, so no host markup declares it. Width is written
|
|
71
|
+
// straight onto :root as --pf-rail-w during a drag with no state layer in
|
|
72
|
+
// between: every width change resizes the viewer, whose ResizeObserver
|
|
73
|
+
// reallocates the WebGL drawing buffer. One reallocation per frame is inherent
|
|
74
|
+
// to live resizing; anything on top of it is not.
|
|
75
|
+
//
|
|
76
|
+
// Everything is optional. With no rail this returns a no-op, so hosts that lay
|
|
77
|
+
// the framework out themselves (see embed-test.html) are unaffected.
|
|
78
|
+
export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage() } = {}) {
|
|
79
|
+
if (!rail || !shell) {
|
|
80
|
+
// No rail to resolve in this document: --pf-rail-w still defaults to 288px
|
|
81
|
+
// from tokens.css, but nothing is reserving that space, so anything that
|
|
82
|
+
// reads the token to centre itself against the rail (app.css's
|
|
83
|
+
// #pf-pick-banner) would otherwise sit 144px off-centre for no reason.
|
|
84
|
+
// Zero is the truth here — a legacy id-only page or a custom host running
|
|
85
|
+
// ?pickserver with no #panel/elements.rail hits this path.
|
|
86
|
+
const root = document.documentElement;
|
|
87
|
+
root.style.setProperty("--pf-rail-w", "0px");
|
|
88
|
+
return { detach: () => root.style.removeProperty("--pf-rail-w") };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const root = document.documentElement;
|
|
92
|
+
const shellBox = () => shell.getBoundingClientRect();
|
|
93
|
+
const shellWidth = () => shellBox().width;
|
|
94
|
+
let state = readRailPref(storage, shellWidth());
|
|
95
|
+
// Captured before the first apply() mutates the toggle, so detach() can
|
|
96
|
+
// hand back a plain, unwired button rather than a dead "Show controls" one.
|
|
97
|
+
// innerHTML (not textContent) so a host's original content - markup, not
|
|
98
|
+
// just text - genuinely round-trips; the icon apply() writes is markup too.
|
|
99
|
+
const toggleOriginal = toggle ? { html: toggle.innerHTML, title: toggle.title } : null;
|
|
100
|
+
// Built once here rather than inside apply() (which reruns on every resize/
|
|
101
|
+
// key/drag tick); apply() only ever swaps toggleChevron's `d`.
|
|
102
|
+
let toggleChevron = null;
|
|
103
|
+
if (toggle) {
|
|
104
|
+
const { svg, chevron } = buildToggleIcon();
|
|
105
|
+
toggle.replaceChildren(svg);
|
|
106
|
+
toggleChevron = chevron;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const seam = document.createElement("div");
|
|
110
|
+
seam.className = "pf-rail-seam";
|
|
111
|
+
seam.setAttribute("role", "separator");
|
|
112
|
+
seam.setAttribute("aria-orientation", "vertical");
|
|
113
|
+
seam.setAttribute("aria-label", "Resize controls");
|
|
114
|
+
seam.setAttribute("aria-valuemin", "0");
|
|
115
|
+
seam.tabIndex = 0;
|
|
116
|
+
seam.append(document.createElement("span")); // the hover/focus affordance
|
|
117
|
+
rail.before(seam);
|
|
118
|
+
|
|
119
|
+
// shellW lets a caller that already measured the shell this frame (the
|
|
120
|
+
// pointermove handler) thread that width through instead of forcing a
|
|
121
|
+
// second synchronous layout here — apply() writes --pf-rail-w onto :root
|
|
122
|
+
// just below, so any un-measured read after that point is a style+layout
|
|
123
|
+
// flush, not a cached value.
|
|
124
|
+
function apply({ persist = false, shellW } = {}) {
|
|
125
|
+
const sw = shellW ?? shellWidth();
|
|
126
|
+
const width = state.collapsed ? 0 : clampRailWidth(state.width, sw);
|
|
127
|
+
// Written on :root, not the rail/shell, so body-appended overlays (the
|
|
128
|
+
// pick banner, the ?debug overlay) inherit it — see spec §4.4. This
|
|
129
|
+
// assumes ONE rail per document: attachRail is written for a single
|
|
130
|
+
// instance, and mounting two on one page would fight over --pf-rail-w
|
|
131
|
+
// (both write :root; whichever's detach() runs last wins, clearing the
|
|
132
|
+
// survivor's width too). README's "multiple mounts" claim is about
|
|
133
|
+
// multiple mount() calls in general (e.g. cross-fade swaps), which is
|
|
134
|
+
// fine as long as at most one has a resolvable rail at a time.
|
|
135
|
+
root.style.setProperty("--pf-rail-w", `${width}px`);
|
|
136
|
+
rail.toggleAttribute("inert", state.collapsed);
|
|
137
|
+
seam.toggleAttribute("data-collapsed", state.collapsed);
|
|
138
|
+
seam.setAttribute("aria-valuenow", String(width));
|
|
139
|
+
seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
|
|
140
|
+
if (toggle) {
|
|
141
|
+
toggleChevron?.setAttribute("d", state.collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
|
|
142
|
+
const label = state.collapsed ? "Show controls" : "Hide controls";
|
|
143
|
+
toggle.setAttribute("aria-expanded", String(!state.collapsed));
|
|
144
|
+
toggle.setAttribute("aria-label", label);
|
|
145
|
+
toggle.title = label;
|
|
146
|
+
toggle.classList.toggle("on", state.collapsed);
|
|
147
|
+
}
|
|
148
|
+
if (persist) writeRailPref(state, storage);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- discrete changes: animate, and commit immediately ---
|
|
152
|
+
// The same "transient, debounced" flag suppresses the width transition for
|
|
153
|
+
// both a held arrow-key repeat and a window resize (below) — either can
|
|
154
|
+
// fire several times in a burst, and an animated width would fight the
|
|
155
|
+
// last one before the previous animation finishes.
|
|
156
|
+
let keyTimer = 0;
|
|
157
|
+
function settleKeys() {
|
|
158
|
+
clearTimeout(keyTimer);
|
|
159
|
+
shell.removeAttribute("data-pf-key-resizing");
|
|
160
|
+
}
|
|
161
|
+
function markTransientResize() {
|
|
162
|
+
shell.toggleAttribute("data-pf-key-resizing", true);
|
|
163
|
+
clearTimeout(keyTimer);
|
|
164
|
+
keyTimer = setTimeout(settleKeys, KEY_SETTLE_MS);
|
|
165
|
+
}
|
|
166
|
+
function commit(next) {
|
|
167
|
+
settleKeys(); // a discrete change interrupting a key repeat animates normally
|
|
168
|
+
state = next;
|
|
169
|
+
apply({ persist: true });
|
|
170
|
+
}
|
|
171
|
+
const toggleCollapsed = () => commit({ collapsed: !state.collapsed, width: state.width });
|
|
172
|
+
|
|
173
|
+
// --- keyboard: move the SEPARATOR (standard role="separator" semantics), so
|
|
174
|
+
// ArrowLeft widens a right-hand rail. Arrows clamp at the minimum and never
|
|
175
|
+
// collapse; Enter/Space is the collapse gesture.
|
|
176
|
+
function onKeyDown(e) {
|
|
177
|
+
// Cmd/Alt/Ctrl+Arrow are browser/OS reserved (back, tab switch, ...); don't
|
|
178
|
+
// eat them just because the seam happens to hold focus.
|
|
179
|
+
if (e.metaKey || e.altKey || e.ctrlKey) return;
|
|
180
|
+
// Enter/Space is the deliberate reopen gesture (below); arrows must not
|
|
181
|
+
// also reopen a collapsed rail — that would clamp it to the 240px minimum
|
|
182
|
+
// and silently discard the remembered width, and "narrower" reopening the
|
|
183
|
+
// rail at all is backwards.
|
|
184
|
+
if (state.collapsed && (e.key === "ArrowLeft" || e.key === "ArrowRight")) return;
|
|
185
|
+
const step = e.shiftKey ? KEY_STEP_SHIFT : KEY_STEP;
|
|
186
|
+
// Re-clamp on READ rather than reconciling state.width on resize: a width
|
|
187
|
+
// that no longer fits the current shell is still the user's preference and
|
|
188
|
+
// should come back if the window grows again, so only the transient value
|
|
189
|
+
// used for this keypress is clamped.
|
|
190
|
+
const from = state.collapsed ? 0 : clampRailWidth(state.width, shellWidth());
|
|
191
|
+
let width;
|
|
192
|
+
switch (e.key) {
|
|
193
|
+
case "ArrowLeft": width = from + step; break;
|
|
194
|
+
case "ArrowRight": width = from - step; break;
|
|
195
|
+
case "Home": case "End": {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
// Route through commit() — the path onDoubleClick uses — so these
|
|
198
|
+
// jumps animate. Only a debounced arrow-key repeat (below) suppresses
|
|
199
|
+
// the width transition.
|
|
200
|
+
commit({
|
|
201
|
+
collapsed: false,
|
|
202
|
+
width: e.key === "Home" ? RAIL_MIN_WIDTH : railMaxWidth(shellWidth()),
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case "Enter": case " ": e.preventDefault(); toggleCollapsed(); return;
|
|
207
|
+
default: return;
|
|
208
|
+
}
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
state = { collapsed: false, width: clampRailWidth(width, shellWidth()) };
|
|
211
|
+
shell.toggleAttribute("data-pf-key-resizing", true);
|
|
212
|
+
clearTimeout(keyTimer);
|
|
213
|
+
keyTimer = setTimeout(() => {
|
|
214
|
+
shell.removeAttribute("data-pf-key-resizing");
|
|
215
|
+
writeRailPref(state, storage);
|
|
216
|
+
}, KEY_SETTLE_MS);
|
|
217
|
+
apply();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const onDoubleClick = () => commit({ collapsed: false, width: RAIL_DEFAULT_WIDTH });
|
|
221
|
+
const onToggleClick = () => toggleCollapsed();
|
|
222
|
+
|
|
223
|
+
// --- drag ---
|
|
224
|
+
let grabOffset = 0;
|
|
225
|
+
function onPointerDown(e) {
|
|
226
|
+
if (e.button !== 0) return;
|
|
227
|
+
// Stacked layout: the rail is under the viewer, so there is no vertical seam
|
|
228
|
+
// to drag (chrome.css hides it). The toggle still works.
|
|
229
|
+
if (window.innerWidth < RAIL_NARROW_BREAKPOINT) return;
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
// setPointerCapture is load-bearing: without it the pointer crosses into the
|
|
232
|
+
// viewer (an iframe, in the cloud editor) whose document eats the move
|
|
233
|
+
// events, and the drag dies the moment it reaches the thing being resized.
|
|
234
|
+
seam.setPointerCapture?.(e.pointerId);
|
|
235
|
+
const box = seam.getBoundingClientRect();
|
|
236
|
+
// Where inside the 12px seam the grab landed, so the rail edge doesn't jump.
|
|
237
|
+
grabOffset = e.clientX - (box.left + box.width / 2);
|
|
238
|
+
shell.toggleAttribute("data-pf-dragging", true);
|
|
239
|
+
// Safety net: setPointerCapture is load-bearing (see above), but it is
|
|
240
|
+
// still an optional call — if it's unavailable or the browser fails to
|
|
241
|
+
// honour it, the pointerup can land on the canvas instead of the seam,
|
|
242
|
+
// onPointerUp never runs, data-pf-dragging is stuck forever, and
|
|
243
|
+
// chrome.css's [data-pf-dragging] .pf-stage { pointer-events: none } dead-
|
|
244
|
+
// locks the viewer with no recovery. Also listening on window guarantees
|
|
245
|
+
// the drag always terminates regardless of where the pointer ends up.
|
|
246
|
+
// Bound only for the duration of a drag (removed in onPointerUp/detach)
|
|
247
|
+
// so there is never a permanently-bound window listener.
|
|
248
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
249
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
250
|
+
}
|
|
251
|
+
function onPointerMove(e) {
|
|
252
|
+
if (!shell.hasAttribute("data-pf-dragging")) return;
|
|
253
|
+
// Measure the shell once per move and thread it through resolveRailDrag
|
|
254
|
+
// and apply(), instead of re-measuring after apply() has already mutated
|
|
255
|
+
// :root's style (which would force a synchronous style+layout flush).
|
|
256
|
+
const box = shellBox();
|
|
257
|
+
const railX = box.right - (e.clientX - grabOffset);
|
|
258
|
+
const next = resolveRailDrag(railX, state, box.width);
|
|
259
|
+
if (next === state) return; // unchanged — skip the redundant DOM writes
|
|
260
|
+
state = next;
|
|
261
|
+
apply({ shellW: box.width });
|
|
262
|
+
}
|
|
263
|
+
function onPointerUp(e) {
|
|
264
|
+
// Guards against running end-of-drag logic twice for one gesture: whichever
|
|
265
|
+
// of the seam's own pointerup and the window safety-net fires first clears
|
|
266
|
+
// the attribute, so the second (if it fires at all) is a no-op.
|
|
267
|
+
if (!shell.hasAttribute("data-pf-dragging")) return;
|
|
268
|
+
seam.releasePointerCapture?.(e.pointerId);
|
|
269
|
+
shell.removeAttribute("data-pf-dragging");
|
|
270
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
271
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
272
|
+
apply({ persist: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
seam.addEventListener("pointerdown", onPointerDown);
|
|
276
|
+
seam.addEventListener("pointermove", onPointerMove);
|
|
277
|
+
seam.addEventListener("pointerup", onPointerUp);
|
|
278
|
+
seam.addEventListener("pointercancel", onPointerUp);
|
|
279
|
+
seam.addEventListener("keydown", onKeyDown);
|
|
280
|
+
seam.addEventListener("dblclick", onDoubleClick);
|
|
281
|
+
toggle?.addEventListener("click", onToggleClick);
|
|
282
|
+
// A window resize can invalidate the clamp (max is half the shell). Narrowing
|
|
283
|
+
// the window with a maxed-out rail can genuinely change --pf-rail-w, and
|
|
284
|
+
// without suppression that change would animate — fighting the resize with
|
|
285
|
+
// a trailing 150ms transition and an extra WebGL buffer reallocation.
|
|
286
|
+
const onResize = () => {
|
|
287
|
+
markTransientResize();
|
|
288
|
+
apply();
|
|
289
|
+
};
|
|
290
|
+
window.addEventListener("resize", onResize);
|
|
291
|
+
|
|
292
|
+
apply();
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
detach: () => {
|
|
296
|
+
settleKeys();
|
|
297
|
+
seam.removeEventListener("pointerdown", onPointerDown);
|
|
298
|
+
seam.removeEventListener("pointermove", onPointerMove);
|
|
299
|
+
seam.removeEventListener("pointerup", onPointerUp);
|
|
300
|
+
seam.removeEventListener("pointercancel", onPointerUp);
|
|
301
|
+
seam.removeEventListener("keydown", onKeyDown);
|
|
302
|
+
seam.removeEventListener("dblclick", onDoubleClick);
|
|
303
|
+
toggle?.removeEventListener("click", onToggleClick);
|
|
304
|
+
window.removeEventListener("resize", onResize);
|
|
305
|
+
// In case detach() happens mid-drag, so the safety net doesn't outlive it.
|
|
306
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
307
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
308
|
+
seam.remove();
|
|
309
|
+
shell.removeAttribute("data-pf-dragging");
|
|
310
|
+
rail.removeAttribute("inert");
|
|
311
|
+
root.style.removeProperty("--pf-rail-w");
|
|
312
|
+
if (toggle) {
|
|
313
|
+
toggle.innerHTML = toggleOriginal.html;
|
|
314
|
+
toggle.title = toggleOriginal.title;
|
|
315
|
+
toggle.removeAttribute("aria-expanded");
|
|
316
|
+
toggle.removeAttribute("aria-label");
|
|
317
|
+
toggle.classList.remove("on");
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
package/src/framework/tokens.css
CHANGED
|
@@ -7,7 +7,19 @@
|
|
|
7
7
|
--pf-text: oklch(0.985 0 0); --pf-text-strong: oklch(1 0 0); --pf-text-2: oklch(0.92 0.004 286.32);
|
|
8
8
|
--pf-muted: oklch(0.705 0.015 286.067); --pf-muted-2: oklch(0.80 0.01 286); --pf-status: oklch(0.705 0.015 286.067); --pf-hint: oklch(0.552 0.016 285.938);
|
|
9
9
|
--pf-accent: #3f7bf0; --pf-accent-soft: #26314a; --pf-on-accent: #fff; --pf-input-bg: oklch(0.165 0.009 262); --pf-err: oklch(0.704 0.191 22.216);
|
|
10
|
-
--pf-
|
|
10
|
+
--pf-sans: "Geist Variable", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
11
|
+
--pf-mono: "Geist Mono Variable", ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
12
|
+
/* Layout + shape, matching partforge-cloud's control scale (--r-control /
|
|
13
|
+
--r-pill) so a rail nested in the cloud editor reads as one product. */
|
|
14
|
+
--pf-rail-w: 288px; --pf-rail-pad: 14px;
|
|
15
|
+
--pf-radius-control: 7px; --pf-radius-pill: 12px;
|
|
16
|
+
/* Floating pills: cloud's --shadow-editor. Larger and near-even, so a pill
|
|
17
|
+
doesn't pool weight at its bottom edge the way a downward shadow does. */
|
|
18
|
+
--pf-shadow-float: 0 0 6px rgb(0 0 0 / .04), 0 2px 14px rgb(0 0 0 / .072);
|
|
19
|
+
/* The rail is SET BACK: the viewer casts onto it, so this is inset on the
|
|
20
|
+
rail's left edge. An outer shadow would read as the rail floating above the
|
|
21
|
+
viewer — the opposite. Deeper in dark, where black reads weaker. */
|
|
22
|
+
--pf-shadow-rail: inset 9px 0 16px -10px rgb(0 0 0 / .38);
|
|
11
23
|
}
|
|
12
24
|
:root[data-theme="light"] {
|
|
13
25
|
color-scheme: light;
|
|
@@ -15,4 +27,5 @@
|
|
|
15
27
|
--pf-text: oklch(0.141 0.005 285.823); --pf-text-strong: oklch(0.10 0.005 285.823); --pf-text-2: oklch(0.21 0.006 285.885);
|
|
16
28
|
--pf-muted: oklch(0.552 0.016 285.938); --pf-muted-2: oklch(0.44 0.015 285.9); --pf-status: oklch(0.552 0.016 285.938); --pf-hint: oklch(0.705 0.015 286.067);
|
|
17
29
|
--pf-accent: #1f5bd6; --pf-accent-soft: #e6edfc; --pf-on-accent: #fff; --pf-input-bg: #ffffff; --pf-err: oklch(0.577 0.245 27.325);
|
|
30
|
+
--pf-shadow-rail: inset 9px 0 14px -10px rgb(0 0 0 / .12);
|
|
18
31
|
}
|