phonux 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/DESIGN.md +985 -0
  2. package/LICENSE +21 -0
  3. package/Panel.d.ts +76 -0
  4. package/Panel.js +45 -0
  5. package/PanelFrame.d.ts +154 -0
  6. package/PanelFrame.js +160 -0
  7. package/PanelRow.d.ts +46 -0
  8. package/PanelRow.js +98 -0
  9. package/PanelRowSlot.d.ts +74 -0
  10. package/PanelRowSlot.js +68 -0
  11. package/PhoneDetectPrompt.d.ts +18 -0
  12. package/PhoneDetectPrompt.js +52 -0
  13. package/README.md +86 -0
  14. package/Workspace.d.ts +65 -0
  15. package/Workspace.js +47 -0
  16. package/defaultTheme.d.ts +10 -0
  17. package/defaultTheme.js +26 -0
  18. package/directionalTransition.d.ts +33 -0
  19. package/directionalTransition.js +24 -0
  20. package/dragToClose.d.ts +60 -0
  21. package/dragToClose.js +151 -0
  22. package/fakeHost.d.ts +33 -0
  23. package/fakeHost.js +85 -0
  24. package/hostApi.d.ts +247 -0
  25. package/hostApi.js +54 -0
  26. package/index.d.ts +46 -0
  27. package/index.js +30 -0
  28. package/package.json +30 -0
  29. package/panelCapacity.d.ts +15 -0
  30. package/panelCapacity.js +19 -0
  31. package/panelRowEntry.d.ts +37 -0
  32. package/panelRowEntry.js +11 -0
  33. package/panelRowLayout.d.ts +57 -0
  34. package/panelRowLayout.js +52 -0
  35. package/panelRowOrder.d.ts +46 -0
  36. package/panelRowOrder.js +105 -0
  37. package/panelTiers.d.ts +24 -0
  38. package/panelTiers.js +29 -0
  39. package/panelWindow.d.ts +170 -0
  40. package/panelWindow.js +243 -0
  41. package/panels/usePanelClosing.d.ts +58 -0
  42. package/panels/usePanelClosing.js +140 -0
  43. package/panels/usePanelManager.d.ts +74 -0
  44. package/panels/usePanelManager.js +403 -0
  45. package/panels/useProvidePanels.d.ts +82 -0
  46. package/panels/useProvidePanels.js +142 -0
  47. package/panels/useRowScrollGesture.d.ts +2 -0
  48. package/panels/useRowScrollGesture.js +70 -0
  49. package/panels/useWorkspacePersistence.d.ts +14 -0
  50. package/panels/useWorkspacePersistence.js +74 -0
  51. package/phoneModels.d.ts +26 -0
  52. package/phoneModels.js +43 -0
  53. package/snapshots.d.ts +58 -0
  54. package/snapshots.js +22 -0
  55. package/viewRegistry.d.ts +23 -0
  56. package/viewRegistry.js +30 -0
  57. package/viewState.d.ts +32 -0
  58. package/viewState.js +135 -0
  59. package/windowOverlay.d.ts +87 -0
  60. package/windowOverlay.js +137 -0
  61. package/workspaceState.d.ts +63 -0
  62. package/workspaceState.js +95 -0
package/dragToClose.js ADDED
@@ -0,0 +1,151 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Drag a view's grab handle DOWN to close it; with `reorder`, also sideways to reorder. It knows nothing about
4
+ * panels or the app. onClose MUST remove the view: a close never unlocks the scroll ancestors. Also offer a
5
+ * button calling the same onClose (WCAG 2.5.7). The full contract: DESIGN.md, "`DragToClose`".
6
+ */
7
+ import * as React from 'react';
8
+ import { animate, motion, useDragControls, useIsPresent, useMotionValue, useReducedMotion } from 'motion/react';
9
+ /** The first this many px of either axis decide which one the gesture locks to. */
10
+ const DIRECTION_LOCK_PX = 8;
11
+ /** The tunables, in one place. Distances in px, velocities in px/s, positive = downward. @public */
12
+ export const DRAG_CLOSE = {
13
+ /** Close once dragged this fraction of the view's height ... */
14
+ distanceFraction: 0.25,
15
+ /** ... but never less than this: a click that slips a few px is not a close. */
16
+ minDistancePx: 80,
17
+ /** A flick closes early: at least this fast at release ... */
18
+ flickVelocity: 700,
19
+ /** ... and at least this far. */
20
+ flickMinDistancePx: 40,
21
+ /** Released while moving back UP faster than this (negative): the user changed their mind. */
22
+ cancelVelocity: -300,
23
+ /** Seconds the box takes to leave the screen. */
24
+ exitSeconds: 0.18,
25
+ snapBack: { type: 'spring', stiffness: 520, damping: 42 },
26
+ };
27
+ /** The whole close rule, pure. Never closes on an upward or zero offset, or when released moving back up. @public */
28
+ export function shouldCloseOnRelease({ offsetY, velocityY, height }) {
29
+ if (!(offsetY > 0) || velocityY <= DRAG_CLOSE.cancelVelocity)
30
+ return false;
31
+ const distance = Math.max(DRAG_CLOSE.minDistancePx, DRAG_CLOSE.distanceFraction * (Number.isFinite(height) ? height : 0));
32
+ if (offsetY >= distance)
33
+ return true;
34
+ return offsetY >= DRAG_CLOSE.flickMinDistancePx && velocityY >= DRAG_CLOSE.flickVelocity;
35
+ }
36
+ const INTERACTIVE = 'button, a, input, select, textarea, [role="button"]';
37
+ /** @public */
38
+ export function DragToClose({ onClose, reorder, children, }) {
39
+ const controls = useDragControls();
40
+ const y = useMotionValue(0);
41
+ const x = useMotionValue(0);
42
+ const box = React.useRef(null);
43
+ const reduceMotion = useReducedMotion();
44
+ const running = React.useRef(null);
45
+ const locked = React.useRef([]);
46
+ const closeRef = React.useRef(onClose);
47
+ closeRef.current = onClose;
48
+ const closing = React.useRef(false); // a release has decided to close: the exit runs and takes no more presses
49
+ // Set once the first DIRECTION_LOCK_PX decides; null until then, and always for a vertical-only handle.
50
+ const axisLock = React.useRef(null);
51
+ // overflow-y: hidden while a drag is live: the transformed box would otherwise add a scrollbar.
52
+ const lockScrollParents = () => {
53
+ for (let el = box.current?.parentElement ?? null; el; el = el.parentElement) {
54
+ const overflowY = window.getComputedStyle(el).overflowY;
55
+ if (overflowY === 'auto' || overflowY === 'scroll') {
56
+ locked.current.push([el, el.style.overflowY]);
57
+ el.style.overflowY = 'hidden';
58
+ }
59
+ }
60
+ };
61
+ const unlockScrollParents = () => {
62
+ for (const [el, previous] of locked.current.splice(0))
63
+ el.style.overflowY = previous;
64
+ };
65
+ React.useEffect(() => () => {
66
+ running.current?.stop();
67
+ unlockScrollParents();
68
+ }, []);
69
+ // An undo while AnimatePresence still holds this box revives the SAME instance: parked, locked, refusing presses. A layout effect that
70
+ // writes the DOM itself, not on Motion's next frame: the host scrolls a revived view into view in this same commit and must find it at rest.
71
+ const isPresent = useIsPresent();
72
+ const wasPresent = React.useRef(true);
73
+ React.useLayoutEffect(() => {
74
+ const revived = isPresent && !wasPresent.current;
75
+ wasPresent.current = isPresent;
76
+ if (!revived)
77
+ return;
78
+ closing.current = false;
79
+ unlockScrollParents();
80
+ y.jump(0); // also stops an exit that is still running: its end would call onClose for a box that is back
81
+ x.jump(0);
82
+ if (box.current)
83
+ box.current.style.transform = 'none';
84
+ }, [isPresent]);
85
+ const handle = {
86
+ onPointerDown: (event) => {
87
+ if (event.button !== 0 || event.target.closest(INTERACTIVE) || closing.current)
88
+ return; // a press would stop the exit: onClose would never run
89
+ if (running.current) {
90
+ running.current.stop();
91
+ running.current = null;
92
+ unlockScrollParents();
93
+ } // stop() never settles the spring's promise, so its own unlock would not run
94
+ controls.start(event); // a real drag locks again in onDragStart
95
+ },
96
+ // pan-x: a horizontal swipe still scrolls the row; a vertical one is this gesture's.
97
+ sx: { cursor: 'grab', touchAction: 'pan-x', userSelect: 'none', '&:active': { cursor: 'grabbing' } },
98
+ };
99
+ return (_jsx(motion.div, { ref: box, "data-drag-to-close": "", drag: reorder ? true : 'y', dragControls: controls, dragListener: false, dragMomentum: false, dragConstraints: { top: 0, bottom: 0 }, dragElastic: { top: 0, bottom: 1 }, style: reorder ? { x, y } : { y }, onDragStart: () => {
100
+ axisLock.current = null;
101
+ lockScrollParents();
102
+ }, onDrag: reorder
103
+ ? (_event, info) => {
104
+ if (axisLock.current === null && (Math.abs(info.offset.x) >= DIRECTION_LOCK_PX || Math.abs(info.offset.y) >= DIRECTION_LOCK_PX)) {
105
+ axisLock.current = Math.abs(info.offset.x) >= Math.abs(info.offset.y) ? 'x' : 'y';
106
+ }
107
+ // Zero the losing axis every tick instead of toggling `drag`: Motion keeps reading the axis prop's
108
+ // value from before the gesture began.
109
+ if (axisLock.current === 'x')
110
+ y.set(0);
111
+ else if (axisLock.current === 'y')
112
+ x.set(0);
113
+ }
114
+ : undefined, onDragEnd: (_event, info) => {
115
+ const lockedAxis = axisLock.current;
116
+ axisLock.current = null;
117
+ if (reorder && lockedAxis === 'x') {
118
+ reorder.onDragEnd(info.offset.x);
119
+ if (reduceMotion) {
120
+ x.jump(0); // jump, not set: it also stops the inertia Motion itself just started on x
121
+ return unlockScrollParents();
122
+ }
123
+ const back = animate(x, 0, DRAG_CLOSE.snapBack);
124
+ running.current = back;
125
+ void back.then(unlockScrollParents);
126
+ return; // never a close: shouldCloseOnRelease is not even consulted for a horizontal-locked release
127
+ }
128
+ if (reorder)
129
+ x.jump(0); // residual sub-threshold horizontal drift from a gesture that never locked (or locked vertical)
130
+ const rect = box.current?.getBoundingClientRect();
131
+ const height = rect?.height ?? 0;
132
+ if (shouldCloseOnRelease({ offsetY: info.offset.y, velocityY: info.velocity.y, height })) {
133
+ if (reduceMotion)
134
+ return closeRef.current();
135
+ const toBottom = Math.max(height, window.innerHeight - (rect?.top ?? 0));
136
+ const exit = animate(y, toBottom, { duration: DRAG_CLOSE.exitSeconds, ease: 'easeIn' });
137
+ running.current = exit;
138
+ closing.current = true;
139
+ void exit.then(() => closeRef.current());
140
+ }
141
+ else if (reduceMotion) {
142
+ animate(y, 0, { duration: 0 }); // not y.set(0): Motion's own constraint return is animating y and would win
143
+ unlockScrollParents();
144
+ }
145
+ else {
146
+ const back = animate(y, 0, DRAG_CLOSE.snapBack);
147
+ running.current = back;
148
+ void back.then(unlockScrollParents);
149
+ }
150
+ }, children: children(handle) }));
151
+ }
package/fakeHost.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Recording fakes of every API in hostApi.tsx, for testing a view with no host and no desktop bridge:
3
+ * `createFakeHost({ panels: { panelList: [...] } })`, render under `<HostProvider>`, interact, then assert on
4
+ * `host.calls`. Every method records `{ api, method, args }` in call order and does nothing else (async ones
5
+ * resolve). The fakes hold no state: a test wanting a different live value re-renders with new overrides. An
6
+ * override replaces a whole member (a nested object such as `closedPagesViewer` too), and an overridden
7
+ * method no longer records. Why it lives here: DESIGN.md, "Testing without a host".
8
+ */
9
+ import { type Host } from './hostApi.js';
10
+ export type { Host, PhoneSize, DetectedPhone, ClosedPageEntry, JsonValue, FormFactor, PanelSize, PanelSummary, CreatePanelOptions, SettingsAPI, HistoryAPI, PanelsAPI, PhoneDeviceAPI } from './hostApi.js';
11
+ export type { SnapshotsAPI, ViewId, ViewSnapshot } from './snapshots.js';
12
+ export type { PhoneModel } from './phoneModels.js';
13
+ /** @public */
14
+ export interface FakeCall {
15
+ api: keyof Host;
16
+ method: string;
17
+ args: unknown[];
18
+ }
19
+ /** Replacement members, per API. @public */
20
+ export type FakeOverrides = {
21
+ [K in keyof Host]?: Partial<Host[K]>;
22
+ };
23
+ /** @public */
24
+ export interface FakeHost extends Host {
25
+ /** Every method call any of the fakes received, in order. */
26
+ readonly calls: FakeCall[];
27
+ }
28
+ /** @public @deprecated use FakeHost */
29
+ export type FakePhoneAPIs = FakeHost;
30
+ /** @public */
31
+ export declare function createFakeHost(overrides?: FakeOverrides): FakeHost;
32
+ /** @public @deprecated use createFakeHost -- same function, so identity checks keep working. */
33
+ export declare const createFakePhoneAPIs: typeof createFakeHost;
package/fakeHost.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Recording fakes of every API in hostApi.tsx, for testing a view with no host and no desktop bridge:
3
+ * `createFakeHost({ panels: { panelList: [...] } })`, render under `<HostProvider>`, interact, then assert on
4
+ * `host.calls`. Every method records `{ api, method, args }` in call order and does nothing else (async ones
5
+ * resolve). The fakes hold no state: a test wanting a different live value re-renders with new overrides. An
6
+ * override replaces a whole member (a nested object such as `closedPagesViewer` too), and an overridden
7
+ * method no longer records. Why it lives here: DESIGN.md, "Testing without a host".
8
+ */
9
+ import { PHONE_MODELS } from './phoneModels.js';
10
+ import { DEFAULT_PHONE_SIZE, } from './hostApi.js';
11
+ /** @public */
12
+ export function createFakeHost(overrides = {}) {
13
+ const calls = [];
14
+ const log = (api, method, args) => {
15
+ calls.push({ api, method, args });
16
+ };
17
+ const settings = {
18
+ maxPanelsOnScreen: null,
19
+ fitCount: 4,
20
+ // Matches the app's own single-fixed-column (History-only) default; a test with more permanent columns
21
+ // overrides this explicitly.
22
+ minPanelsOnScreen: 2,
23
+ setMaxPanelsOnScreen: async (n) => log('settings', 'setMaxPanelsOnScreen', [n]),
24
+ settingsColumn: {
25
+ visible: false,
26
+ show: () => log('settings', 'settingsColumn.show', []),
27
+ hide: () => log('settings', 'settingsColumn.hide', []),
28
+ },
29
+ };
30
+ const history = {
31
+ loadClosedPages: async () => {
32
+ log('history', 'loadClosedPages', []);
33
+ return [];
34
+ },
35
+ closedPagesViewer: {
36
+ isOpen: false,
37
+ loading: false,
38
+ entries: [],
39
+ open: () => log('history', 'closedPagesViewer.open', []),
40
+ close: () => log('history', 'closedPagesViewer.close', []),
41
+ },
42
+ locked: true,
43
+ closed: false,
44
+ close: () => log('history', 'close', []),
45
+ };
46
+ const panels = {
47
+ panelList: [],
48
+ // Explicitly optional (not inferred from PanelsAPI's own overloaded `create`): an inferred-required
49
+ // parameter would no longer satisfy the interface's zero-arg overload.
50
+ create: (options) => log('panels', 'create', options !== undefined ? [options] : []),
51
+ activate: (id) => log('panels', 'activate', [id]),
52
+ move: (id, direction) => log('panels', 'move', [id, direction]),
53
+ setFormFactor: (id, next) => log('panels', 'setFormFactor', [id, next]),
54
+ reanchor: (id, anchor) => log('panels', 'reanchor', [id, anchor]),
55
+ close: (id) => log('panels', 'close', [id]),
56
+ recentlyClosed: null,
57
+ undoClose: () => log('panels', 'undoClose', []),
58
+ clearAll: () => log('panels', 'clearAll', []),
59
+ };
60
+ // Never logs, unlike every other member: React calls get()/subscribe() on every render, so logging would add
61
+ // `calls` entries to every test that merely renders a capturable Panel. get() always answers null.
62
+ const snapshots = {
63
+ get: () => null,
64
+ subscribe: () => () => { },
65
+ };
66
+ const device = {
67
+ size: DEFAULT_PHONE_SIZE,
68
+ defaultSize: DEFAULT_PHONE_SIZE,
69
+ models: PHONE_MODELS,
70
+ chooseModel: async (modelId) => log('device', 'chooseModel', [modelId]),
71
+ detectedPhone: null,
72
+ dismissDetectedPhone: () => log('device', 'dismissDetectedPhone', []),
73
+ resizeForDetectedPhone: (modelId) => log('device', 'resizeForDetectedPhone', [modelId]),
74
+ };
75
+ return {
76
+ settings: { ...settings, ...overrides.settings },
77
+ history: { ...history, ...overrides.history },
78
+ panels: { ...panels, ...overrides.panels },
79
+ device: { ...device, ...overrides.device },
80
+ snapshots: { ...snapshots, ...overrides.snapshots },
81
+ calls,
82
+ };
83
+ }
84
+ /** @public @deprecated use createFakeHost -- same function, so identity checks keep working. */
85
+ export const createFakePhoneAPIs = createFakeHost;
package/hostApi.d.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The public surface a view is written against: any component rendering a phone-shaped column
3
+ * (Settings, History, a live panel, or a third party's) reads what it needs from `useHost()`,
4
+ * renders a `<Panel>`, and never touches the desktop bridge, `localStorage` or any other app
5
+ * internal (see DESIGN.md). The host mounts ONE `<HostProvider>`; a test uses the same provider
6
+ * with `fakeHost.ts`'s fakes. The provider value is REBUILT EVERY RENDER (see HostProvider).
7
+ */
8
+ import * as React from 'react';
9
+ import type { PhoneModel } from './phoneModels.js';
10
+ import type { SnapshotsAPI } from './snapshots.js';
11
+ /** A phone size. `modelId` 'default' means the app's built-in size, not a PHONE_MODELS entry. @public */
12
+ export interface PhoneSize {
13
+ modelId: string;
14
+ width: number;
15
+ height: number;
16
+ }
17
+ /** The app's built-in size: upgrading must never silently resize anyone who has not chosen a model. @public */
18
+ export declare const DEFAULT_PHONE_SIZE: PhoneSize;
19
+ /** A phone-like USB device the app has just seen for the first time. @public */
20
+ export interface DetectedPhone {
21
+ vendorId: number;
22
+ productId: number;
23
+ }
24
+ /** One entry of the persisted "Closed Pages" log. @public */
25
+ export interface ClosedPageEntry {
26
+ id: string;
27
+ title: string;
28
+ url: string;
29
+ /** ISO-8601, UTC. */
30
+ closedAt: string;
31
+ }
32
+ /** JSON-serializable by construction -- the minimum shape any future persistence layer could round-trip (see DESIGN.md). @public */
33
+ export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
34
+ readonly [key: string]: JsonValue;
35
+ };
36
+ /** A panel's own visual tier. The app gives every panel 'phone' by default. @public */
37
+ export type FormFactor = 'phone' | 'tablet' | 'desktop';
38
+ /**
39
+ * A panel's own rendered size, carrying its tier alongside the pixels so a size value stays meaningful on its
40
+ * own (e.g. passed to Panel/PanelFrame's optional `size` prop without also passing a separate formFactor).
41
+ * `height: 'fill'` lets a tier fill its container's height instead of a fixed pixel value.
42
+ * @public
43
+ */
44
+ export interface PanelSize {
45
+ formFactor: FormFactor;
46
+ width: number;
47
+ height: number | 'fill';
48
+ }
49
+ /**
50
+ * What a view may know about a panel. The app's own panel record is structurally assignable to it.
51
+ * `formFactor`/`size` are optional so a hand-built or third-party summary without them stays valid, even
52
+ * though every panel the app itself creates carries both -- see DESIGN.md.
53
+ * @public
54
+ */
55
+ export interface PanelSummary<P = JsonValue> {
56
+ readonly id: string;
57
+ readonly url: string;
58
+ readonly title?: string;
59
+ /** Mounted in the row now (false = parked, listed in History only). */
60
+ readonly live: boolean;
61
+ /** Whether a drag-down close is offered for this panel. A live/spawned panel is always false. */
62
+ readonly locked: boolean;
63
+ /** Opaque per-panel data a create() caller supplied; undefined for built-ins. */
64
+ readonly params?: P;
65
+ /** This panel's own visual tier. */
66
+ readonly formFactor?: FormFactor;
67
+ /** This panel's own rendered size. Panel/PanelFrame do not read it: a view passes it to their separate `size` prop for a non-phone tier (a phone panel follows useHost().device.size instead). */
68
+ readonly size?: PanelSize;
69
+ }
70
+ /** @public */
71
+ export interface SettingsAPI {
72
+ /** The stored "max panels on screen" choice: `null` means "follow the fit count" (the default). Live: a view re-renders when it changes. */
73
+ readonly maxPanelsOnScreen: number | null;
74
+ /** How many panels (History plus web panels together) the window can currently show -- `null` while unmeasured (no cap yet). */
75
+ readonly fitCount: number | null;
76
+ /** The lowest legal `maxPanelsOnScreen` choice: every permanent column (`collapsible:false`) plus at least one web panel. Never below 2 (History alone already counts as one). */
77
+ readonly minPanelsOnScreen: number;
78
+ /** Clamped to [minPanelsOnScreen, fitCount] on read; `null` resets to "follow the fit count". Applies at once, then persists. Never rejects: with no desktop bridge the choice lasts this session only. */
79
+ setMaxPanelsOnScreen(n: number | null): Promise<void>;
80
+ /** Settings' own sections render inside History's body when this is true (History's gear toggles it), rather than as a separate column. Starts false: a fresh session opens on History's own row list. `show()`/`hide()` last for the session. */
81
+ readonly settingsColumn: {
82
+ readonly visible: boolean;
83
+ show(): void;
84
+ hide(): void;
85
+ };
86
+ }
87
+ /** @public */
88
+ export interface HistoryAPI {
89
+ /** The persisted Closed Pages log (every view ever closed, oldest first). NEVER rejects: any failure, including no desktop bridge, resolves to []. */
90
+ loadClosedPages(): Promise<readonly ClosedPageEntry[]>;
91
+ /** The Closed Pages overlay. `open()` (re)fetches through loadClosedPages(). */
92
+ readonly closedPagesViewer: {
93
+ readonly isOpen: boolean;
94
+ readonly loading: boolean;
95
+ readonly entries: readonly ClosedPageEntry[];
96
+ open(): void;
97
+ close(): void;
98
+ };
99
+ /** Whether a drag-down close or an API close does anything for History; defaults true. */
100
+ readonly locked: boolean;
101
+ readonly closed: boolean;
102
+ /** A no-op while `locked` (today, always -- see `locked`). */
103
+ close(): void;
104
+ }
105
+ /**
106
+ * `PanelsAPI.create`'s options bag, named so useProvidePanels.ts and fakeHost.ts can each annotate
107
+ * their own `options` parameter explicitly (see `create`'s overload comment).
108
+ * @public
109
+ */
110
+ export interface CreatePanelOptions {
111
+ view?: string;
112
+ title?: string;
113
+ params?: JsonValue;
114
+ listed?: boolean;
115
+ /** Where the new entry sits: on `position`'s side of `panel` (default History), `priority` lower = nearer (default: past the far end of that side). Default `{ position: 'right' }`. */
116
+ anchor?: {
117
+ panel?: string;
118
+ position: 'left' | 'right';
119
+ priority?: number;
120
+ };
121
+ /** Which stacking tier the new entry's row slot sits in. Default 'base'. */
122
+ layer?: 'base' | 'raised';
123
+ /** Whether the new entry's reappearance always resolves through the anchor formula instead of unconditionally entering from the right. Default false. */
124
+ anchoredEnter?: boolean;
125
+ /**
126
+ * Makes the new entry a permanent column: `collapsible:false` (never parked by a row gesture or capacity,
127
+ * never a drag source, and counted in `minPanelsOnScreen`'s floor) and `listed:false`, forced regardless of
128
+ * any `listed` also passed -- a permanent column must supply its own close control (History's own
129
+ * precedent), never the framework's History-row trash. `locked` stays false always: closing it through its
130
+ * own control still works. Default false.
131
+ */
132
+ permanent?: boolean;
133
+ /** The new panel's tier (see `setFormFactor`). Ignored with `permanent`: a permanent column is always 'phone'. Default 'phone'. */
134
+ formFactor?: FormFactor;
135
+ }
136
+ /** @public */
137
+ export interface PanelsAPI {
138
+ /** Every panel, live or parked, in array order. History lists them as given; the row shows the live ones. */
139
+ readonly panelList: readonly PanelSummary[];
140
+ /**
141
+ * Appends a live panel; zero-arg is the blank-web-panel default. `view` selects the rendered component
142
+ * (registerView/resolveView) -- an unregistered key renders a placeholder, never a crash. No raw
143
+ * `collapsible`/`locked` key: `permanent` (CreatePanelOptions) is the only door to a permanent column, and
144
+ * `locked` is never settable at all. At capacity, the live panel farthest from it is parked, as for any
145
+ * non-permanent panel.
146
+ */
147
+ create(): void;
148
+ /**
149
+ * Two overloads, not one `create(options?: CreatePanelOptions)` -- see DESIGN.md for why a single
150
+ * optional-arg signature would collide with a bare `onClick={panels.create}` reference.
151
+ */
152
+ create(options: CreatePanelOptions): void;
153
+ /** Make a panel live in place, without reordering the row. May park the live panel farthest from it. */
154
+ activate(id: string): void;
155
+ /**
156
+ * Moves `id` one row-slot toward `direction`, swapping past whichever OTHER currently-shown panel sits there,
157
+ * History and any permanent column included -- the row's drag-to-reorder gesture calls this underneath. Every
158
+ * panel is then re-anchored to History where it stands. No-op for an id that is unknown, parked, itself a
159
+ * permanent column (e.g. History), or already at that edge of the row.
160
+ */
161
+ move(id: string, direction: 'left' | 'right'): void;
162
+ /**
163
+ * Resizes `id` to `next`'s tier in place: it stays mounted and live, its content the same node, and the row
164
+ * reflows around it. If the row no longer fits, OTHER panels park, never this one -- also when one handler
165
+ * resizes, opens or activates several panels, unless the earlier of them already fill the row. No-op for an
166
+ * unknown id, a permanent column (e.g. History), or a panel already at `next`. See DESIGN.md for the tiers.
167
+ */
168
+ setFormFactor(id: string, next: FormFactor): void;
169
+ /**
170
+ * Re-places `id`, live or parked, by `anchor` (defaults as for CreatePanelOptions.anchor) and reorders the row
171
+ * to match. On its own it never parks or reveals: which panels fit depends on their widths, never their order.
172
+ * No-op for an unknown id or a permanent column (e.g. History). See DESIGN.md for the anchor model.
173
+ */
174
+ reanchor(id: string, anchor: {
175
+ panel?: string;
176
+ position: 'left' | 'right';
177
+ priority?: number;
178
+ }): void;
179
+ /** Close a view completely, live or parked: it leaves the row and History at once, a closed event is appended to the persisted log (HistoryAPI), and it can be undone for 10 s (recentlyClosed, undoClose). Also accepts History's own fixed-panel id (HistoryAPI.close is the same mechanism, offered per-panel so a view need not know the id) -- an id not in `panelList` and not History's id, or a LOCKED fixed panel's id, does nothing. */
180
+ close(id: string): void;
181
+ /** The views closed within the current undo window, oldest first; non-null exactly while it is open (10 s after the LAST close). Drives the "Closed N pages / Undo" toast. */
182
+ readonly recentlyClosed: readonly PanelSummary[] | null;
183
+ /** Undo every close in `recentlyClosed`: each view returns at its old place (a live one comes back live, parking the live one farthest from it if the row is full) and its log entry is removed. Does nothing when the window is closed. */
184
+ undoClose(): void;
185
+ /** Forget every panel. Not logged and not undoable. */
186
+ clearAll(): void;
187
+ }
188
+ /** @public */
189
+ export interface PhoneDeviceAPI {
190
+ /** The size every PanelFrame renders at. Live. */
191
+ readonly size: PhoneSize;
192
+ /** The built-in size (`modelId` 'default'), for a "Default (390 × 700)" choice. */
193
+ readonly defaultSize: PhoneSize;
194
+ /** Selectable models. 'default' is not among them. */
195
+ readonly models: readonly PhoneModel[];
196
+ /** Applies at once, then persists. 'default' resets. Never rejects: with no desktop bridge the choice lasts this session only. */
197
+ chooseModel(modelId: string): Promise<void>;
198
+ /** Non-null exactly while the one-time "phone connected" notice should show. */
199
+ readonly detectedPhone: DetectedPhone | null;
200
+ dismissDetectedPhone(): void;
201
+ /** Resize to `modelId` and dismiss the notice. */
202
+ resizeForDetectedPhone(modelId: string): void;
203
+ }
204
+ /** @public @deprecated use PhoneDeviceAPI */
205
+ export type DeviceAPI = PhoneDeviceAPI;
206
+ /** @public */
207
+ export interface Host {
208
+ readonly settings: SettingsAPI;
209
+ readonly history: HistoryAPI;
210
+ readonly panels: PanelsAPI;
211
+ readonly device: PhoneDeviceAPI;
212
+ /** Optional, unlike every other member: without it useViewSnapshot (snapshots.ts) shows no picture instead
213
+ * of throwing, so a host that does not capture need not supply a no-op. */
214
+ readonly snapshots?: SnapshotsAPI;
215
+ }
216
+ /** @public @deprecated use Host */
217
+ export type PhoneAPIs = Host;
218
+ /** Thrown by useHost() when no provider is mounted, so a view rendered without a host fails loudly instead of rendering defaults. @public */
219
+ export declare class HostMissingProviderError extends Error {
220
+ constructor(hook?: string);
221
+ }
222
+ /** @public @deprecated use HostMissingProviderError -- same class, so `instanceof` works under either name. */
223
+ export declare const PhoneAPIsMissingProviderError: typeof HostMissingProviderError;
224
+ /**
225
+ * Mount once, at the host. `host` is passed straight through as the context
226
+ * value: NEVER memoise it here or where it is built. The handlers behind it
227
+ * close over render-time state (the panel list, the column count), so a
228
+ * memoised bag hands views stale closures.
229
+ * @public
230
+ */
231
+ export declare function HostProvider({ host, children }: {
232
+ host: Host;
233
+ children: React.ReactNode;
234
+ }): React.JSX.Element;
235
+ /**
236
+ * @deprecated use HostProvider (its prop is `host`, not `apis`). This is a distinct thin wrapper, not the
237
+ * same value as HostProvider, because the prop itself renamed.
238
+ * @public
239
+ */
240
+ export declare function PhoneAPIsProvider({ apis, children }: {
241
+ apis: Host;
242
+ children: React.ReactNode;
243
+ }): React.JSX.Element;
244
+ /** The one hook a view uses to reach the rest of the app. Call API methods from event handlers; do not capture one in an effect with empty deps. @public */
245
+ export declare function useHost(): Host;
246
+ /** @public @deprecated use useHost -- same function, so identity checks keep working. */
247
+ export declare const usePhoneAPIs: typeof useHost;
package/hostApi.js ADDED
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The public surface a view is written against: any component rendering a phone-shaped column
4
+ * (Settings, History, a live panel, or a third party's) reads what it needs from `useHost()`,
5
+ * renders a `<Panel>`, and never touches the desktop bridge, `localStorage` or any other app
6
+ * internal (see DESIGN.md). The host mounts ONE `<HostProvider>`; a test uses the same provider
7
+ * with `fakeHost.ts`'s fakes. The provider value is REBUILT EVERY RENDER (see HostProvider).
8
+ */
9
+ import * as React from 'react';
10
+ import { OverlayProvider } from './windowOverlay.js';
11
+ import { ViewStateProvider } from './viewState.js';
12
+ /** The app's built-in size: upgrading must never silently resize anyone who has not chosen a model. @public */
13
+ export const DEFAULT_PHONE_SIZE = { modelId: 'default', width: 390, height: 700 };
14
+ // ---------------------------------------------------------------------------
15
+ // Provider and hook
16
+ // ---------------------------------------------------------------------------
17
+ /** Thrown by useHost() when no provider is mounted, so a view rendered without a host fails loudly instead of rendering defaults. @public */
18
+ export class HostMissingProviderError extends Error {
19
+ constructor(hook = 'useHost') {
20
+ super(`${hook}() was called outside a <HostProvider>. A view must render under the provider the host mounts; ` +
21
+ 'in a test, wrap it in <HostProvider host={createFakeHost()}> (createFakeHost comes from the phonux/testing entry point).');
22
+ this.name = 'HostMissingProviderError';
23
+ }
24
+ }
25
+ /** @public @deprecated use HostMissingProviderError -- same class, so `instanceof` works under either name. */
26
+ export const PhoneAPIsMissingProviderError = HostMissingProviderError;
27
+ const HostContext = React.createContext(null);
28
+ /**
29
+ * Mount once, at the host. `host` is passed straight through as the context
30
+ * value: NEVER memoise it here or where it is built. The handlers behind it
31
+ * close over render-time state (the panel list, the column count), so a
32
+ * memoised bag hands views stale closures.
33
+ * @public
34
+ */
35
+ export function HostProvider({ host, children }) {
36
+ return (_jsx(HostContext.Provider, { value: host, children: _jsx(OverlayProvider, { children: _jsx(ViewStateProvider, { children: children }) }) }));
37
+ }
38
+ /**
39
+ * @deprecated use HostProvider (its prop is `host`, not `apis`). This is a distinct thin wrapper, not the
40
+ * same value as HostProvider, because the prop itself renamed.
41
+ * @public
42
+ */
43
+ export function PhoneAPIsProvider({ apis, children }) {
44
+ return _jsx(HostProvider, { host: apis, children: children });
45
+ }
46
+ /** The one hook a view uses to reach the rest of the app. Call API methods from event handlers; do not capture one in an effect with empty deps. @public */
47
+ export function useHost() {
48
+ const host = React.useContext(HostContext);
49
+ if (host === null)
50
+ throw new HostMissingProviderError();
51
+ return host;
52
+ }
53
+ /** @public @deprecated use useHost -- same function, so identity checks keep working. */
54
+ export const usePhoneAPIs = useHost;
package/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ export { DEFAULT_PHONE_SIZE, HostMissingProviderError, HostProvider, useHost, } from './hostApi.js';
2
+ export type { PhoneSize, DetectedPhone, ClosedPageEntry, JsonValue, FormFactor, PanelSize, PanelSummary, CreatePanelOptions, SettingsAPI, HistoryAPI, PanelsAPI, PhoneDeviceAPI, Host, } from './hostApi.js';
3
+ export { useViewSnapshot, CaptureRegistryContext, CaptureRegistryProvider, noopCaptureTrigger } from './snapshots.js';
4
+ export type { ViewId, ViewSnapshot, SnapshotsAPI, CaptureRegistry, CaptureTrigger } from './snapshots.js';
5
+ export { useViewState, ViewStateMissingProviderError } from './viewState.js';
6
+ export type { UseViewState } from './viewState.js';
7
+ export { PhoneAPIsMissingProviderError, PhoneAPIsProvider, usePhoneAPIs, } from './hostApi.js';
8
+ export type { DeviceAPI, PhoneAPIs } from './hostApi.js';
9
+ export { registerView, resolveView } from './viewRegistry.js';
10
+ export type { ViewComponent, ViewProps } from './viewRegistry.js';
11
+ /** @deprecated use registerView/resolveView/ViewComponent */
12
+ export { registerPanelView, resolvePanelView } from './viewRegistry.js';
13
+ export type { PanelComponent } from './viewRegistry.js';
14
+ export { Panel, DRAG_CLOSE, DragToClose, shouldCloseOnRelease, WindowOverlay, OverlaySlot, OVERLAY_Z, OverlayProvider, OverlayStore, } from './Panel.js';
15
+ export type { PanelProps, DragHandleProps, ReleaseMeasure, ReorderHandle, OverlaySlotName, WindowOverlayProps, Item, Entry, } from './Panel.js';
16
+ /** @deprecated use Panel/PanelProps */
17
+ export { PhoneView } from './Panel.js';
18
+ export type { PhoneViewProps } from './Panel.js';
19
+ export { default as PanelFrame } from './PanelFrame.js';
20
+ export { PANEL_CORNER_RADIUS_UNITS, panelCornerRadiusPx, panelFrameScrollSx, panelFrameBareSx, PanelHeader, PanelHeaderRow, } from './PanelFrame.js';
21
+ export type { PanelFrameProps, PanelHeaderProps, PanelHeaderRowProps, } from './PanelFrame.js';
22
+ /** @deprecated use PanelFrame, PanelHeader, PanelHeaderRow and their PANEL_ and panel-prefixed siblings above */
23
+ export { default as PhoneFrame } from './PanelFrame.js';
24
+ export { PHONE_CORNER_RADIUS_UNITS, phoneCornerRadiusPx, phoneFrameScrollSx, phoneFrameBareSx, PhoneFrameHeader, PhoneFrameHeaderRow, } from './PanelFrame.js';
25
+ export type { PhoneFrameProps, PhoneFrameHeaderProps, PhoneFrameHeaderRowProps, } from './PanelFrame.js';
26
+ export { PHONE_MODELS, findPhoneModel, vendorLabelForUsbVendorId } from './phoneModels.js';
27
+ export type { PhoneModel } from './phoneModels.js';
28
+ export { PhoneDetectPrompt } from './PhoneDetectPrompt.js';
29
+ export type { PhoneDetectPromptProps } from './PhoneDetectPrompt.js';
30
+ export { computeRowOrder, deriveAnchorsFromOrder } from './panelRowOrder.js';
31
+ export type { PanelAnchor } from './panelRowOrder.js';
32
+ export { summarisePanel } from './panelRowEntry.js';
33
+ export type { NeighborWidths, PanelRowEntry } from './panelRowEntry.js';
34
+ export { DEFAULT_PANEL_SIZE, TIER_SIZES, panelWidthOf } from './panelTiers.js';
35
+ export { computeMaxLiveWidth, computeRowLayout } from './panelRowLayout.js';
36
+ export type { RowLayoutInput, RowLayoutResult } from './panelRowLayout.js';
37
+ export { PanelRow, usePanelCloseEligible, usePanelNeighborWidths, usePanelPinned, usePanelReorderEligible } from './PanelRow.js';
38
+ export { GAP, PANEL_TRANSITION, PanelRowSlot, PanelSizeBox } from './PanelRowSlot.js';
39
+ export { Workspace } from './Workspace.js';
40
+ export type { WorkspaceProps } from './Workspace.js';
41
+ export { useProvidePanels } from './panels/useProvidePanels.js';
42
+ export type { PanelsInput, ProvidedPanels } from './panels/useProvidePanels.js';
43
+ export type { PanelWindow } from './panelWindow.js';
44
+ export { reorderStepsForOffset } from './panelWindow.js';
45
+ export { createDefaultTheme } from './defaultTheme.js';
46
+ export type { WorkspacePanelState, WorkspaceState, WorkspaceStorage } from './workspaceState.js';
package/index.js ADDED
@@ -0,0 +1,30 @@
1
+ // The public surface of phonux: a name that moves or changes kind here breaks every consumer. test/phonux-barrel.test.ts pins the value
2
+ // names; only tsc checks type names (releaseMeasure.typecheck.ts pins one). DESIGN.md says why some exports stay off this list.
3
+ export { DEFAULT_PHONE_SIZE, HostMissingProviderError, HostProvider, useHost, } from './hostApi.js';
4
+ export { useViewSnapshot, CaptureRegistryContext, CaptureRegistryProvider, noopCaptureTrigger } from './snapshots.js';
5
+ export { useViewState, ViewStateMissingProviderError } from './viewState.js';
6
+ // Deprecated aliases: same value as their new-name counterpart, kept until removed.
7
+ export { PhoneAPIsMissingProviderError, PhoneAPIsProvider, usePhoneAPIs, } from './hostApi.js';
8
+ export { registerView, resolveView } from './viewRegistry.js';
9
+ /** @deprecated use registerView/resolveView/ViewComponent */
10
+ export { registerPanelView, resolvePanelView } from './viewRegistry.js';
11
+ export { Panel, DRAG_CLOSE, DragToClose, shouldCloseOnRelease, WindowOverlay, OverlaySlot, OVERLAY_Z, OverlayProvider, OverlayStore, } from './Panel.js';
12
+ /** @deprecated use Panel/PanelProps */
13
+ export { PhoneView } from './Panel.js';
14
+ export { default as PanelFrame } from './PanelFrame.js';
15
+ export { PANEL_CORNER_RADIUS_UNITS, panelCornerRadiusPx, panelFrameScrollSx, panelFrameBareSx, PanelHeader, PanelHeaderRow, } from './PanelFrame.js';
16
+ /** @deprecated use PanelFrame, PanelHeader, PanelHeaderRow and their PANEL_ and panel-prefixed siblings above */
17
+ export { default as PhoneFrame } from './PanelFrame.js';
18
+ export { PHONE_CORNER_RADIUS_UNITS, phoneCornerRadiusPx, phoneFrameScrollSx, phoneFrameBareSx, PhoneFrameHeader, PhoneFrameHeaderRow, } from './PanelFrame.js';
19
+ export { PHONE_MODELS, findPhoneModel, vendorLabelForUsbVendorId } from './phoneModels.js';
20
+ export { PhoneDetectPrompt } from './PhoneDetectPrompt.js';
21
+ export { computeRowOrder, deriveAnchorsFromOrder } from './panelRowOrder.js';
22
+ export { summarisePanel } from './panelRowEntry.js';
23
+ export { DEFAULT_PANEL_SIZE, TIER_SIZES, panelWidthOf } from './panelTiers.js';
24
+ export { computeMaxLiveWidth, computeRowLayout } from './panelRowLayout.js';
25
+ export { PanelRow, usePanelCloseEligible, usePanelNeighborWidths, usePanelPinned, usePanelReorderEligible } from './PanelRow.js';
26
+ export { GAP, PANEL_TRANSITION, PanelRowSlot, PanelSizeBox } from './PanelRowSlot.js';
27
+ export { Workspace } from './Workspace.js';
28
+ export { useProvidePanels } from './panels/useProvidePanels.js';
29
+ export { reorderStepsForOffset } from './panelWindow.js';
30
+ export { createDefaultTheme } from './defaultTheme.js';