partforge 0.27.0 → 0.31.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.
@@ -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
+ }
@@ -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-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
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
  }