@forgeax/app-shell 0.3.0 → 0.5.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 CHANGED
@@ -33,6 +33,37 @@ registry.register(createShellContribution({
33
33
  }));
34
34
  ```
35
35
 
36
+ React shell and slot-diagnostic primitives are available from the explicit
37
+ React entry point:
38
+
39
+ ```tsx
40
+ import {
41
+ ResizeHandle,
42
+ ShellSlot,
43
+ SlotDebugOverlay,
44
+ isSlotDebugEnabled,
45
+ useLocalSize,
46
+ } from '@forgeax/app-shell/react';
47
+
48
+ export function ProductShell() {
49
+ return (
50
+ <ShellSlot as="main" name="Workspace">
51
+ {isSlotDebugEnabled() ? <SlotDebugOverlay /> : null}
52
+ </ShellSlot>
53
+ );
54
+ }
55
+ ```
56
+
57
+ Generic persisted sizing and pointer-capture handles use the same public entry:
58
+
59
+ ```tsx
60
+ const [width, setWidth] = useLocalSize('product.sidebar.width', 280, 180, 640);
61
+
62
+ <aside style={{ width }}>
63
+ <ResizeHandle orientation="col" onDrag={(delta) => setWidth((value) => value + delta)} />
64
+ </aside>
65
+ ```
66
+
36
67
  ## Ownership boundary
37
68
 
38
69
  | Package | Owns |
package/dist/react.d.ts CHANGED
@@ -1,4 +1,20 @@
1
- import { HTMLAttributes, ReactElement } from 'react';
1
+ import * as react from 'react';
2
+ import { ReactElement, HTMLAttributes } from 'react';
3
+
4
+ /** Stable FNV-1a color bucket for a shell slot name. */
5
+ declare function hashSlotHue(name: string): number;
6
+ /** Whether a composable `debug` query contains the exact `slots` token. */
7
+ declare function isSlotDebugEnabled(search?: string): boolean;
8
+ /** Dev-time visualization derived only from live `data-fx-slot` markers. */
9
+ declare function SlotDebugOverlay(): ReactElement;
10
+
11
+ declare function useLocalSize(key: string, initial: number, min: number, max: number): readonly [number, (next: number | ((previous: number) => number)) => void];
12
+ interface ResizeHandleProps {
13
+ orientation: 'col' | 'row';
14
+ onDrag: (delta: number) => void;
15
+ title?: string;
16
+ }
17
+ declare function ResizeHandle({ orientation, onDrag, title }: ResizeHandleProps): react.JSX.Element;
2
18
 
3
19
  type ShellSlotElement = 'aside' | 'div' | 'main' | 'section';
4
20
  interface ShellSlotProps extends HTMLAttributes<HTMLElement> {
@@ -8,4 +24,4 @@ interface ShellSlotProps extends HTMLAttributes<HTMLElement> {
8
24
  /** Structural shell marker that preserves the caller's semantic element. */
9
25
  declare function ShellSlot({ as, name, ...props }: ShellSlotProps): ReactElement;
10
26
 
11
- export { ShellSlot, type ShellSlotElement, type ShellSlotProps };
27
+ export { ResizeHandle, type ResizeHandleProps, ShellSlot, type ShellSlotElement, type ShellSlotProps, SlotDebugOverlay, hashSlotHue, isSlotDebugEnabled, useLocalSize };
package/dist/react.js CHANGED
@@ -1,5 +1,245 @@
1
1
  // src/react.tsx
2
2
  import { createElement } from "react";
3
+
4
+ // src/slot-debug.tsx
5
+ import { useEffect, useState } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ var OVERLAY_Z = 2147483e3;
8
+ var LABEL_CORNERS = [
9
+ { top: 2, left: 2 },
10
+ { top: 2, right: 2 },
11
+ { bottom: 2, left: 2 },
12
+ { bottom: 2, right: 2 }
13
+ ];
14
+ function hashSlotHue(name) {
15
+ let hash = 2166136261;
16
+ for (let index = 0; index < name.length; index += 1) {
17
+ hash ^= name.charCodeAt(index);
18
+ hash = Math.imul(hash, 16777619);
19
+ }
20
+ return (hash >>> 0) % 360;
21
+ }
22
+ function isSlotDebugEnabled(search = typeof window !== "undefined" ? window.location.search : "") {
23
+ try {
24
+ const debug = new URLSearchParams(search).get("debug");
25
+ return debug?.split(",").some((flag) => flag.trim() === "slots") ?? false;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+ function measureSlots() {
31
+ const elements = Array.from(document.querySelectorAll("[data-fx-slot]"));
32
+ const boxes = [];
33
+ for (const element of elements) {
34
+ const name = element.getAttribute("data-fx-slot") ?? "";
35
+ if (!name) continue;
36
+ let rect;
37
+ if (element.firstChild) {
38
+ const range = document.createRange();
39
+ range.selectNodeContents(element);
40
+ rect = range.getBoundingClientRect();
41
+ } else {
42
+ rect = element.getBoundingClientRect();
43
+ }
44
+ if (rect.width === 0 && rect.height === 0) continue;
45
+ let depth = 0;
46
+ let parentName = null;
47
+ let cursor = element.parentElement;
48
+ while (cursor) {
49
+ if (cursor.hasAttribute("data-fx-slot")) {
50
+ parentName ??= cursor.getAttribute("data-fx-slot");
51
+ depth += 1;
52
+ }
53
+ cursor = cursor.parentElement;
54
+ }
55
+ boxes.push({
56
+ name,
57
+ parentName,
58
+ depth,
59
+ left: rect.left,
60
+ top: rect.top,
61
+ width: rect.width,
62
+ height: rect.height
63
+ });
64
+ }
65
+ return boxes;
66
+ }
67
+ function SlotDebugOverlay() {
68
+ const [boxes, setBoxes] = useState([]);
69
+ useEffect(() => {
70
+ if (typeof document === "undefined") return;
71
+ let frame = 0;
72
+ const schedule = () => {
73
+ cancelAnimationFrame(frame);
74
+ frame = requestAnimationFrame(() => setBoxes(measureSlots()));
75
+ };
76
+ setBoxes(measureSlots());
77
+ const resizeObserver = new ResizeObserver(schedule);
78
+ const attach = () => {
79
+ resizeObserver.disconnect();
80
+ document.querySelectorAll("[data-fx-slot]").forEach((element) => resizeObserver.observe(element));
81
+ };
82
+ attach();
83
+ const mutationObserver = new MutationObserver((records) => {
84
+ let markerSetChanged = false;
85
+ for (const record of records) {
86
+ if (record.type === "attributes" && record.attributeName === "data-fx-slot") {
87
+ markerSetChanged = true;
88
+ break;
89
+ }
90
+ if (record.type !== "childList") continue;
91
+ const nodes = [...record.addedNodes, ...record.removedNodes];
92
+ markerSetChanged = nodes.some((node) => node instanceof Element && (node.matches("[data-fx-slot]") || node.querySelector("[data-fx-slot]")));
93
+ if (markerSetChanged) break;
94
+ }
95
+ if (markerSetChanged) attach();
96
+ schedule();
97
+ });
98
+ mutationObserver.observe(document.body, {
99
+ subtree: true,
100
+ childList: true,
101
+ attributes: true,
102
+ attributeFilter: ["data-fx-slot"]
103
+ });
104
+ window.addEventListener("resize", schedule);
105
+ window.addEventListener("scroll", schedule, true);
106
+ return () => {
107
+ cancelAnimationFrame(frame);
108
+ resizeObserver.disconnect();
109
+ mutationObserver.disconnect();
110
+ window.removeEventListener("resize", schedule);
111
+ window.removeEventListener("scroll", schedule, true);
112
+ };
113
+ }, []);
114
+ return /* @__PURE__ */ jsx(
115
+ "div",
116
+ {
117
+ style: {
118
+ position: "fixed",
119
+ inset: 0,
120
+ pointerEvents: "none",
121
+ zIndex: OVERLAY_Z
122
+ },
123
+ "data-fx-slot-overlay": "",
124
+ children: boxes.map((box, index) => {
125
+ const hue = hashSlotHue(box.name);
126
+ const corner = LABEL_CORNERS[box.depth % LABEL_CORNERS.length];
127
+ const label = box.parentName ? `${box.parentName} \u2192 ${box.name}` : box.name;
128
+ return /* @__PURE__ */ jsx(
129
+ "div",
130
+ {
131
+ style: {
132
+ position: "fixed",
133
+ left: box.left,
134
+ top: box.top,
135
+ width: box.width,
136
+ height: box.height,
137
+ background: `hsla(${hue}, 65%, 55%, ${Math.min(0.35, 0.1 + box.depth * 0.06)})`,
138
+ outline: `${Math.max(1, 3 - box.depth)}px solid hsla(${hue}, 65%, 55%, 0.7)`,
139
+ pointerEvents: "none"
140
+ },
141
+ children: /* @__PURE__ */ jsx(
142
+ "span",
143
+ {
144
+ style: {
145
+ position: "absolute",
146
+ ...corner,
147
+ background: `hsla(${hue}, 65%, 25%, 0.9)`,
148
+ color: "#fff",
149
+ font: "10px/14px ui-monospace, monospace",
150
+ padding: "1px 4px",
151
+ borderRadius: 2,
152
+ whiteSpace: "nowrap"
153
+ },
154
+ children: label
155
+ }
156
+ )
157
+ },
158
+ `${box.name}:${index}`
159
+ );
160
+ })
161
+ }
162
+ );
163
+ }
164
+
165
+ // src/resize.tsx
166
+ import { useEffect as useEffect2, useRef, useState as useState2 } from "react";
167
+ import { jsx as jsx2 } from "react/jsx-runtime";
168
+ function useLocalSize(key, initial, min, max) {
169
+ const clamp = (value2) => Math.min(max, Math.max(min, value2));
170
+ const [value, setValueRaw] = useState2(() => {
171
+ if (typeof window === "undefined") return initial;
172
+ try {
173
+ const raw = window.localStorage.getItem(key);
174
+ if (!raw) return initial;
175
+ const persisted = Number(raw);
176
+ if (!Number.isFinite(persisted)) return initial;
177
+ return clamp(persisted);
178
+ } catch {
179
+ return initial;
180
+ }
181
+ });
182
+ useEffect2(() => {
183
+ try {
184
+ window.localStorage.setItem(key, String(value));
185
+ } catch {
186
+ }
187
+ }, [key, value]);
188
+ const setValue = (next) => {
189
+ setValueRaw((previous) => clamp(
190
+ typeof next === "function" ? next(previous) : next
191
+ ));
192
+ };
193
+ return [value, setValue];
194
+ }
195
+ function ResizeHandle({ orientation, onDrag, title }) {
196
+ const startRef = useRef(null);
197
+ const resetGlobalDragStyles = () => {
198
+ document.body.style.cursor = "";
199
+ document.body.style.userSelect = "";
200
+ };
201
+ useEffect2(() => () => {
202
+ if (startRef.current) resetGlobalDragStyles();
203
+ }, []);
204
+ const onPointerDown = (event) => {
205
+ event.preventDefault();
206
+ event.currentTarget.setPointerCapture(event.pointerId);
207
+ startRef.current = { x: event.clientX, y: event.clientY };
208
+ document.body.style.cursor = orientation === "col" ? "col-resize" : "row-resize";
209
+ document.body.style.userSelect = "none";
210
+ };
211
+ const onPointerMove = (event) => {
212
+ if (!startRef.current) return;
213
+ const deltaX = event.clientX - startRef.current.x;
214
+ const deltaY = event.clientY - startRef.current.y;
215
+ startRef.current = { x: event.clientX, y: event.clientY };
216
+ onDrag(orientation === "col" ? deltaX : deltaY);
217
+ };
218
+ const finish = (event) => {
219
+ if (!startRef.current) return;
220
+ startRef.current = null;
221
+ try {
222
+ event.currentTarget.releasePointerCapture(event.pointerId);
223
+ } catch {
224
+ }
225
+ resetGlobalDragStyles();
226
+ };
227
+ return /* @__PURE__ */ jsx2(
228
+ "div",
229
+ {
230
+ className: `resize-handle resize-handle-${orientation}`,
231
+ onPointerDown,
232
+ onPointerMove,
233
+ onPointerUp: finish,
234
+ onPointerCancel: finish,
235
+ title,
236
+ role: "separator",
237
+ "aria-orientation": orientation === "col" ? "vertical" : "horizontal"
238
+ }
239
+ );
240
+ }
241
+
242
+ // src/react.tsx
3
243
  function ShellSlot({
4
244
  as = "div",
5
245
  name,
@@ -8,5 +248,10 @@ function ShellSlot({
8
248
  return createElement(as, { ...props, "data-fx-slot": name });
9
249
  }
10
250
  export {
11
- ShellSlot
251
+ ResizeHandle,
252
+ ShellSlot,
253
+ SlotDebugOverlay,
254
+ hashSlotHue,
255
+ isSlotDebugEnabled,
256
+ useLocalSize
12
257
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/app-shell",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generic Dock, Panel, Window, and Slot composition primitives",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "react": "^19.0.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@happy-dom/global-registrator": "^20.10.2",
39
40
  "@types/react": "^19.0.0",
40
41
  "@types/react-dom": "^19.0.0",
41
42
  "bun-types": "^1.3.14",