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/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "phonux",
3
+ "version": "0.1.0",
4
+ "description": "A phone-shaped view host for React: write a view against a small set of host APIs, register it, and render it in one HostProvider.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "peerDependencies": {
12
+ "react": "^19.2.8",
13
+ "react-dom": "^19.2.8",
14
+ "@mui/material": "^9.3.1",
15
+ "@mui/icons-material": "^9.3.1",
16
+ "@emotion/react": "^11.14.0",
17
+ "@emotion/styled": "^11.14.1",
18
+ "motion": "^13.3.0"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./index.d.ts",
23
+ "import": "./index.js"
24
+ },
25
+ "./testing": {
26
+ "types": "./fakeHost.d.ts",
27
+ "import": "./fakeHost.js"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How many panels the row keeps live at once, from the stored setting, the fit count and the seated
3
+ * permanent-column count. Pure and free of device state, so it is testable without a window.
4
+ */
5
+ /**
6
+ * The live cap on how many panels (every permanent column plus web panels together) may be live at once,
7
+ * from the stored setting, the current fit count and how many permanent columns (`collapsible:false`, e.g.
8
+ * History) are seated right now. `fitCount === null` means unmeasured: no cap at all (Infinity) -- the first
9
+ * commit, and every jsdom test that never calls measure(). Otherwise the floor of `fixedColumnCount + 1` wins
10
+ * over a stored value or a fit count below it (every permanent column plus at least one web panel stays on
11
+ * screen even at the narrowest legal window), and a stored value above the fit count is capped at the fit
12
+ * count. At `fixedColumnCount === 1` (History alone) this floor is 2.
13
+ * @internal
14
+ */
15
+ export declare function effectiveMaxPanels(stored: number | null, fitCount: number | null, fixedColumnCount: number): number;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * How many panels the row keeps live at once, from the stored setting, the fit count and the seated
3
+ * permanent-column count. Pure and free of device state, so it is testable without a window.
4
+ */
5
+ /**
6
+ * The live cap on how many panels (every permanent column plus web panels together) may be live at once,
7
+ * from the stored setting, the current fit count and how many permanent columns (`collapsible:false`, e.g.
8
+ * History) are seated right now. `fitCount === null` means unmeasured: no cap at all (Infinity) -- the first
9
+ * commit, and every jsdom test that never calls measure(). Otherwise the floor of `fixedColumnCount + 1` wins
10
+ * over a stored value or a fit count below it (every permanent column plus at least one web panel stays on
11
+ * screen even at the narrowest legal window), and a stored value above the fit count is capped at the fit
12
+ * count. At `fixedColumnCount === 1` (History alone) this floor is 2.
13
+ * @internal
14
+ */
15
+ export function effectiveMaxPanels(stored, fitCount, fixedColumnCount) {
16
+ if (fitCount === null)
17
+ return Number.POSITIVE_INFINITY;
18
+ return Math.max(fixedColumnCount + 1, Math.min(stored ?? fitCount, fitCount));
19
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The row's own record of one panel, and the one projection from it to what a view may see. The row needs
3
+ * layout fields a view must never read, so the two are separate types (see DESIGN.md, PanelSummary).
4
+ */
5
+ import type { JsonValue, PanelSummary } from './hostApi.js';
6
+ import type { PanelAnchor } from './panelRowOrder.js';
7
+ /**
8
+ * One entry of a panel row: everything a view sees (PanelSummary) plus the layout fields only the row reads.
9
+ * @public
10
+ */
11
+ export interface PanelRowEntry<P = JsonValue> extends PanelSummary<P> {
12
+ /** The registered view that renders this entry. An unregistered name renders a placeholder, never a crash. */
13
+ readonly view: string;
14
+ /** False for an always-shown column: the row never parks it and it is never a drag source. */
15
+ readonly collapsible: boolean;
16
+ /** Where this entry sits in the row. Undefined for the row's root; any other entry without one stays where it stands. */
17
+ readonly anchor?: PanelAnchor;
18
+ /** Which of the row's two stacking tiers this entry's slot sits in. Default 'base'. */
19
+ readonly layer?: 'base' | 'raised';
20
+ /** Whether a reappearance enters from its anchor's side instead of from the right. Default false. */
21
+ readonly anchoredEnter?: boolean;
22
+ }
23
+ /**
24
+ * The widths of the present panels a drag can cross, nearest first on each side. An always-shown column is
25
+ * included: only the dragged panel must be collapsible.
26
+ * @public
27
+ */
28
+ export interface NeighborWidths {
29
+ readonly left: readonly number[];
30
+ readonly right: readonly number[];
31
+ }
32
+ /**
33
+ * A row entry narrowed to what a view may see. Every place that hands a panel to a view goes through this one
34
+ * function, so no view ever sees a layout field; `formFactor` and `size` are forwarded.
35
+ * @public
36
+ */
37
+ export declare const summarisePanel: ({ id, url, title, live, locked, params, formFactor, size }: PanelRowEntry) => PanelSummary;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A row entry narrowed to what a view may see. Every place that hands a panel to a view goes through this one
3
+ * function, so no view ever sees a layout field; `formFactor` and `size` are forwarded.
4
+ * @public
5
+ */
6
+ export const summarisePanel = ({ id, url, title, live, locked, params, formFactor, size }) => ({
7
+ id, url, title, live, locked, params,
8
+ // Absent, not undefined, when the record lacks them: a summary never lists a key its record never had.
9
+ ...(formFactor !== undefined ? { formFactor } : {}),
10
+ ...(size !== undefined ? { size } : {}),
11
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure row-layout arithmetic: the row's centring margin, how many live panels fit, and how wide one may be.
3
+ * Zero DOM imports, so test/panel-layout.test.ts runs it under plain node.
4
+ */
5
+ /** One row's measurements: what computeRowLayout and computeMaxLiveWidth read. @public */
6
+ export interface RowLayoutInput {
7
+ /** The row container's measured width. The caller measures it, so this function touches no DOM. */
8
+ containerWidth: number;
9
+ /** The default/live phone width -- every FIXED row item is exactly this wide (a permanent column never
10
+ * changes form factor), and it is also the width assumed for a hypothetical slot beyond `liveWidths` --
11
+ * see `maxLiveThatFit`'s own doc comment. */
12
+ phoneWidth: number;
13
+ /** The row's own gap between adjacent items (PanelRowSlot's `GAP`). */
14
+ gap: number;
15
+ /** How many always-rendered (non-collapsible) slots the row holds: a plain count, so this function stays
16
+ * free of the caller's panel state. */
17
+ fixedItemCount: number;
18
+ /**
19
+ * Each LIVE collapsible panel's own width, in row order -- a 'tablet'/'desktop' panel contributes its own
20
+ * (wider) width instead of `phoneWidth`, so it alone lowers how many more panels fit. An empty array (no
21
+ * live panel known yet) still yields the full `phoneWidth`-based ceiling -- see `maxLiveThatFit`.
22
+ */
23
+ liveWidths: readonly number[];
24
+ }
25
+ /** How many live panels fit in one row, and where that row starts so it is centered. @public */
26
+ export interface RowLayoutResult {
27
+ /**
28
+ * How many live panels the row can show at most: those of `liveWidths` that fit in row order, plus, when all
29
+ * of them fit, however many more `phoneWidth` slots fit in the budget left. With every entry exactly
30
+ * `phoneWidth` this does not depend on `liveWidths.length`, so `[]` (nothing live yet) gives the same ceiling.
31
+ * The host's panel capacity and SettingsAPI's `fitCount` both derive from this.
32
+ */
33
+ maxLiveThatFit: number;
34
+ /** `liveWidths.length`, capped at `maxLiveThatFit`: the centering math's view of what renders. Which
35
+ * entries render is decided by `live`, not here. */
36
+ effectiveLiveCount: number;
37
+ /** The fixed items' width plus the first `effectiveLiveCount` entries' own widths (each plus one gap). */
38
+ rowTotalWidth: number;
39
+ /** The `marginLeft` that centers `rowTotalWidth` in the row's available width, floored at 0 when the fixed
40
+ * items alone are wider. */
41
+ computedRowMarginLeft: number;
42
+ }
43
+ /**
44
+ * The row's centering math. `fixedItemCount` must come from the same array the row renders, never a
45
+ * hand-mirrored copy of its conditions, or a drifted count silently mis-centers the row by half an item.
46
+ * `maxLiveThatFit` is the one "how many fit" answer, so a wider panel lowers the count instead of overflowing.
47
+ * @public
48
+ */
49
+ export declare function computeRowLayout({ containerWidth, phoneWidth, gap, fixedItemCount, liveWidths }: RowLayoutInput): RowLayoutResult;
50
+ /**
51
+ * The widest ONE live panel can be beside the fixed items and still fit: `computeRowLayout`'s live budget less
52
+ * that panel's gap. `panelWidthOf` caps a tablet/desktop panel at this, so its tier width is a maximum and a
53
+ * wide panel never overflows a narrow window. Never below `phoneWidth`: when not even a phone fits, a narrower
54
+ * "tablet" would not fit either. Infinity while unmeasured (`containerWidth` 0), as every reader treats 0.
55
+ * @public
56
+ */
57
+ export declare function computeMaxLiveWidth(input: Omit<RowLayoutInput, 'liveWidths'>): number;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure row-layout arithmetic: the row's centring margin, how many live panels fit, and how wide one may be.
3
+ * Zero DOM imports, so test/panel-layout.test.ts runs it under plain node.
4
+ */
5
+ /**
6
+ * The row's centering math. `fixedItemCount` must come from the same array the row renders, never a
7
+ * hand-mirrored copy of its conditions, or a drifted count silently mis-centers the row by half an item.
8
+ * `maxLiveThatFit` is the one "how many fit" answer, so a wider panel lowers the count instead of overflowing.
9
+ * @public
10
+ */
11
+ export function computeRowLayout({ containerWidth, phoneWidth, gap, fixedItemCount, liveWidths }) {
12
+ const { availableForRow, fixedWidth, usedBudget } = rowBudget({ containerWidth, phoneWidth, gap, fixedItemCount });
13
+ // Stopping at the first width that does not fit answers "how many of THESE fit", which the host's overflow
14
+ // enforcement reads. Only when all fit are phoneWidth slots added: without them an empty `liveWidths` would
15
+ // report 0 rather than how many phone panels fit.
16
+ let runningWidth = 0;
17
+ let fittingCount = 0;
18
+ for (const width of liveWidths) {
19
+ const itemSpan = width + gap;
20
+ if (runningWidth + itemSpan > usedBudget)
21
+ break;
22
+ runningWidth += itemSpan;
23
+ fittingCount++;
24
+ }
25
+ const extraSlots = fittingCount === liveWidths.length ? Math.max(0, Math.floor((usedBudget - runningWidth) / (phoneWidth + gap))) : 0;
26
+ const maxLiveThatFit = Math.max(0, fittingCount + extraSlots);
27
+ const effectiveLiveCount = Math.min(liveWidths.length, maxLiveThatFit);
28
+ let effectiveLiveWidth = 0;
29
+ for (let i = 0; i < effectiveLiveCount; i++)
30
+ effectiveLiveWidth += liveWidths[i] + gap;
31
+ const rowTotalWidth = fixedWidth + effectiveLiveWidth;
32
+ const computedRowMarginLeft = Math.max(0, (availableForRow - rowTotalWidth) / 2);
33
+ return { maxLiveThatFit, effectiveLiveCount, rowTotalWidth, computedRowMarginLeft };
34
+ }
35
+ /** The row's width, the fixed items' share of it, and what is left for live panels (each counted with its own gap). */
36
+ function rowBudget({ containerWidth, phoneWidth, gap, fixedItemCount }) {
37
+ const availableForRow = Math.max(0, containerWidth - 2 * gap);
38
+ const fixedWidth = fixedItemCount * phoneWidth + (fixedItemCount - 1) * gap;
39
+ return { availableForRow, fixedWidth, usedBudget: availableForRow - fixedWidth };
40
+ }
41
+ /**
42
+ * The widest ONE live panel can be beside the fixed items and still fit: `computeRowLayout`'s live budget less
43
+ * that panel's gap. `panelWidthOf` caps a tablet/desktop panel at this, so its tier width is a maximum and a
44
+ * wide panel never overflows a narrow window. Never below `phoneWidth`: when not even a phone fits, a narrower
45
+ * "tablet" would not fit either. Infinity while unmeasured (`containerWidth` 0), as every reader treats 0.
46
+ * @public
47
+ */
48
+ export function computeMaxLiveWidth(input) {
49
+ if (input.containerWidth === 0)
50
+ return Number.POSITIVE_INFINITY;
51
+ return Math.max(input.phoneWidth, rowBudget(input).usedBudget - input.gap);
52
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Row order as anchor metadata: every entry but the row's root names the entry it sits beside, the side, and
3
+ * how near. computeRowOrder turns anchors into an order; deriveAnchorsFromOrder turns an order back into anchors.
4
+ * The root's id is always a parameter, never a constant here: which entry is the root is the app's choice.
5
+ */
6
+ /**
7
+ * An entry's place in the row: on `position`'s side of the entry `panel`, ranked by `priority` among the
8
+ * entries on that same side of that same entry. Lower is nearer; ties keep the current row order.
9
+ * @public
10
+ */
11
+ export interface PanelAnchor {
12
+ panel: string;
13
+ position: 'left' | 'right';
14
+ priority: number;
15
+ }
16
+ /**
17
+ * The entries in the order their anchors describe: a walk from the root, each entry's left side (farthest
18
+ * first), then the entry, then its right side (nearest first). Priorities come back renumbered from 0 per side
19
+ * of each entry, so a priority of -1 always lands nearest. An anchor naming a missing entry, or one in a cycle,
20
+ * falls back to the root's right side (keeping its priority) instead of recursing forever or dropping the entry.
21
+ * @public
22
+ */
23
+ export declare function computeRowOrder<T extends {
24
+ id: string;
25
+ anchor?: PanelAnchor;
26
+ }>(panels: readonly T[], rootId: string): T[];
27
+ /**
28
+ * Every non-root entry re-anchored to the root from its current array position, so computeRowOrder reproduces
29
+ * this exact order.
30
+ * @public
31
+ */
32
+ export declare function deriveAnchorsFromOrder<T extends {
33
+ id: string;
34
+ anchor?: PanelAnchor;
35
+ }>(panels: readonly T[], rootId: string): T[];
36
+ export type EnterDirection = 'left' | 'right';
37
+ /** The id of the entry `panel` orients its enter animation against: its anchor's, or the root's when it has none. */
38
+ export declare function anchorTargetOf(panel: {
39
+ anchor?: PanelAnchor;
40
+ }, rootId: string): string;
41
+ /**
42
+ * Which side a reappearing entry enters from: the side its anchor entry sits on. Both indexes must count in the
43
+ * SAME present list the row renders, never the raw array, which miscounts whenever an entry is parked. -1 for
44
+ * either (not present) falls back to 'right', the row's default; the root against itself compares equal, so 'right'.
45
+ */
46
+ export declare function resolveEnterDirection(anchorIndex: number, ownIndex: number): EnterDirection;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Row order as anchor metadata: every entry but the row's root names the entry it sits beside, the side, and
3
+ * how near. computeRowOrder turns anchors into an order; deriveAnchorsFromOrder turns an order back into anchors.
4
+ * The root's id is always a parameter, never a constant here: which entry is the root is the app's choice.
5
+ */
6
+ const sameAnchor = (a, b) => !!a && a.panel === b.panel && a.position === b.position && a.priority === b.priority;
7
+ /** Spreads the whole record: every other field must survive a re-anchor untouched. */
8
+ const withAnchor = (p, anchor) => (sameAnchor(p.anchor, anchor) ? p : { ...p, anchor });
9
+ /** Every non-root entry's anchor as read off its array position alone: the root's side, 0 = nearest. With no root present, every entry reads 'right'. */
10
+ function positionalAnchors(panels, rootId) {
11
+ const root = panels.findIndex((p) => p.id === rootId);
12
+ const out = new Map();
13
+ panels.forEach((p, i) => {
14
+ if (p.id !== rootId)
15
+ out.set(p.id, i < root ? { panel: rootId, position: 'left', priority: root - 1 - i } : { panel: rootId, position: 'right', priority: i - root - 1 });
16
+ });
17
+ return out;
18
+ }
19
+ /**
20
+ * The entries in the order their anchors describe: a walk from the root, each entry's left side (farthest
21
+ * first), then the entry, then its right side (nearest first). Priorities come back renumbered from 0 per side
22
+ * of each entry, so a priority of -1 always lands nearest. An anchor naming a missing entry, or one in a cycle,
23
+ * falls back to the root's right side (keeping its priority) instead of recursing forever or dropping the entry.
24
+ * @public
25
+ */
26
+ export function computeRowOrder(panels, rootId) {
27
+ const byId = new Map(panels.map((p) => [p.id, p]));
28
+ const rowIndex = new Map(panels.map((p, i) => [p.id, i]));
29
+ const positional = positionalAnchors(panels, rootId);
30
+ const declared = (p) => p.anchor ?? positional.get(p.id);
31
+ const fallback = new Set();
32
+ const visiting = new Set();
33
+ const resolved = new Set();
34
+ const resolve = (id, path) => {
35
+ if (resolved.has(id))
36
+ return;
37
+ if (visiting.has(id)) {
38
+ for (const member of path.slice(path.indexOf(id)))
39
+ fallback.add(member);
40
+ return;
41
+ }
42
+ visiting.add(id);
43
+ const target = declared(byId.get(id)).panel;
44
+ if (target !== rootId) {
45
+ if (byId.has(target))
46
+ resolve(target, [...path, id]);
47
+ else
48
+ fallback.add(id);
49
+ }
50
+ visiting.delete(id);
51
+ resolved.add(id);
52
+ };
53
+ const effective = (p) => (fallback.has(p.id) ? { panel: rootId, position: 'right', priority: declared(p).priority } : declared(p));
54
+ const sides = new Map();
55
+ for (const p of panels) {
56
+ if (p.id === rootId)
57
+ continue;
58
+ resolve(p.id, []);
59
+ }
60
+ for (const p of panels) {
61
+ if (p.id === rootId)
62
+ continue;
63
+ const { panel, position } = effective(p);
64
+ const side = sides.get(panel) ?? { left: [], right: [] };
65
+ side[position].push(p);
66
+ sides.set(panel, side);
67
+ }
68
+ // Nearest first; on a tie the entry nearer in the current row wins (later on the left, earlier on the right).
69
+ const nearestFirst = (list, position) => [...list].sort((a, b) => effective(a).priority - effective(b).priority || (position === 'right' ? 1 : -1) * (rowIndex.get(a.id) - rowIndex.get(b.id)));
70
+ const out = [];
71
+ const walk = (id, self) => {
72
+ const side = sides.get(id);
73
+ const left = nearestFirst(side?.left ?? [], 'left');
74
+ for (let k = left.length - 1; k >= 0; k--)
75
+ walk(left[k].id, withAnchor(left[k], { panel: id, position: 'left', priority: k }));
76
+ if (self)
77
+ out.push(self);
78
+ nearestFirst(side?.right ?? [], 'right').forEach((p, k) => walk(p.id, withAnchor(p, { panel: id, position: 'right', priority: k })));
79
+ };
80
+ walk(rootId, byId.get(rootId));
81
+ return out;
82
+ }
83
+ /**
84
+ * Every non-root entry re-anchored to the root from its current array position, so computeRowOrder reproduces
85
+ * this exact order.
86
+ * @public
87
+ */
88
+ export function deriveAnchorsFromOrder(panels, rootId) {
89
+ const positional = positionalAnchors(panels, rootId);
90
+ return panels.map((p) => (p.id === rootId ? p : withAnchor(p, positional.get(p.id))));
91
+ }
92
+ /** The id of the entry `panel` orients its enter animation against: its anchor's, or the root's when it has none. */
93
+ export function anchorTargetOf(panel, rootId) {
94
+ return panel.anchor?.panel ?? rootId;
95
+ }
96
+ /**
97
+ * Which side a reappearing entry enters from: the side its anchor entry sits on. Both indexes must count in the
98
+ * SAME present list the row renders, never the raw array, which miscounts whenever an entry is parked. -1 for
99
+ * either (not present) falls back to 'right', the row's default; the root against itself compares equal, so 'right'.
100
+ */
101
+ export function resolveEnterDirection(anchorIndex, ownIndex) {
102
+ if (anchorIndex < 0 || ownIndex < 0)
103
+ return 'right';
104
+ return anchorIndex < ownIndex ? 'left' : 'right';
105
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Panel tiers: each form factor's own size, and the one rule for how wide a panel is in the row. Pure, and
3
+ * free of live device state, so the row's state functions stay pure too.
4
+ */
5
+ import { type FormFactor, type PanelSize } from './hostApi.js';
6
+ import type { PanelRowEntry } from './panelRowEntry.js';
7
+ /**
8
+ * The size every panel starts at: the phone tier at the built-in phone size.
9
+ * @public
10
+ */
11
+ export declare const DEFAULT_PANEL_SIZE: PanelSize;
12
+ /**
13
+ * Each tier's own size. The 'phone' entry is only a placeholder: a phone panel renders at the live phone size.
14
+ * 'fill' is the row's own height. Frozen because every panel starts out referencing these same objects.
15
+ * @public
16
+ */
17
+ export declare const TIER_SIZES: Readonly<Record<FormFactor, PanelSize>>;
18
+ /**
19
+ * A panel's own width in the row. A phone panel always takes the live `phoneWidth`, never its stored size, which
20
+ * is only a placeholder. A tablet or desktop panel takes its stored width, capped at `maxLiveWidth` so it always
21
+ * fits beside the row's fixed columns.
22
+ * @public
23
+ */
24
+ export declare function panelWidthOf(p: Pick<PanelRowEntry, 'formFactor' | 'size'>, phoneWidth: number, maxLiveWidth: number): number;
package/panelTiers.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Panel tiers: each form factor's own size, and the one rule for how wide a panel is in the row. Pure, and
3
+ * free of live device state, so the row's state functions stay pure too.
4
+ */
5
+ import { DEFAULT_PHONE_SIZE } from './hostApi.js';
6
+ /**
7
+ * The size every panel starts at: the phone tier at the built-in phone size.
8
+ * @public
9
+ */
10
+ export const DEFAULT_PANEL_SIZE = Object.freeze({ formFactor: 'phone', width: DEFAULT_PHONE_SIZE.width, height: DEFAULT_PHONE_SIZE.height });
11
+ /**
12
+ * Each tier's own size. The 'phone' entry is only a placeholder: a phone panel renders at the live phone size.
13
+ * 'fill' is the row's own height. Frozen because every panel starts out referencing these same objects.
14
+ * @public
15
+ */
16
+ export const TIER_SIZES = Object.freeze({
17
+ phone: DEFAULT_PANEL_SIZE,
18
+ tablet: Object.freeze({ formFactor: 'tablet', width: 768, height: 'fill' }),
19
+ desktop: Object.freeze({ formFactor: 'desktop', width: 1200, height: 'fill' }),
20
+ });
21
+ /**
22
+ * A panel's own width in the row. A phone panel always takes the live `phoneWidth`, never its stored size, which
23
+ * is only a placeholder. A tablet or desktop panel takes its stored width, capped at `maxLiveWidth` so it always
24
+ * fits beside the row's fixed columns.
25
+ * @public
26
+ */
27
+ export function panelWidthOf(p, phoneWidth, maxLiveWidth) {
28
+ return (p.formFactor ?? 'phone') === 'phone' ? phoneWidth : Math.min(p.size?.width ?? phoneWidth, maxLiveWidth);
29
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The `PanelWindow` model -- one browsing panel in the unified row -- and every pure state-transition
3
+ * function that operates on a `PanelWindow[]` array.
4
+ */
5
+ import type { FormFactor, JsonValue, PanelSize } from './hostApi.js';
6
+ import type { NeighborWidths } from './panelRowEntry.js';
7
+ import type { PanelAnchor } from './panelRowOrder.js';
8
+ export type { FormFactor, JsonValue, PanelSize };
9
+ export type { PanelAnchor };
10
+ export type { NeighborWidths };
11
+ export { effectiveMaxPanels } from './panelCapacity.js';
12
+ /**
13
+ * One browsing panel in the unified row: either live in the row or parked, never both. Parking only flips
14
+ * `live`; it never removes the entry from the array, only `closePanel` does.
15
+ * @public
16
+ */
17
+ export interface PanelWindow {
18
+ id: string;
19
+ url: string;
20
+ title?: string;
21
+ /** Host-supplied and opaque: every function here only copies it through, never reads or branches on it. Its
22
+ * shape is a host-side agreement `JsonValue` cannot enforce. */
23
+ hostData?: JsonValue;
24
+ /** Mounted in the row right now. `false` means parked: still in this array, just not rendered. */
25
+ live: boolean;
26
+ /** Whether DragToClose's drag-down gesture may close this panel at all. Every panel created by `appendPanel` is unlocked. */
27
+ locked: boolean;
28
+ /** Which component renders this entry (phonux's registry, resolveView) -- read ONLY by the row (PanelRow); every park/close site reads `collapsible`/`listed`/`locked` instead, never this. An open string, not a closed union: an unregistered value renders a placeholder, never a crash. */
29
+ view: string;
30
+ /** Capacity parks (an open, a revive, the row gesture, a capacity drop) may park this entry; `false` marks an always-shown column exempt from all of them. */
31
+ collapsible: boolean;
32
+ /** Appears in the public `panelList` (History's own row list, and PanelsAPI). */
33
+ listed: boolean;
34
+ /** Opaque per-panel data a create() caller supplied (PanelsAPI.create's `params`); undefined for built-ins. summarisePanel threads it to PanelSummary.params; no behavior site here reads it. */
35
+ params?: JsonValue;
36
+ /** Where this entry sits in the row (computeRowOrder). Undefined only for History, the root; a hand-built record without one reads as anchored where it already stands, and computeRowOrder writes that anchor. */
37
+ anchor?: PanelAnchor;
38
+ /** Which of the row's two stacking tiers this entry's slot sits in -- read ONLY by PanelRow's per-slot zIndex. Default 'base'. */
39
+ layer?: 'base' | 'raised';
40
+ /** Whether this entry's reappearance always resolves through the anchor formula (resolveEnterDirection) instead of unconditionally entering from the right -- read ONLY by PanelRow's enterDirection. Default false. */
41
+ anchoredEnter?: boolean;
42
+ /** This panel's own visual tier; createHistoryPanel/appendPanel always set it. Optional so a record built by hand without it still typechecks. */
43
+ formFactor?: FormFactor;
44
+ /** This panel's own rendered size (a host's panel component passes it to Panel/PanelFrame for a non-phone tier). Optional for the same reason as `formFactor`. */
45
+ size?: PanelSize;
46
+ }
47
+ /** History's own seeded entry: never collapsible (an always-shown column), never listed (it must not list itself), always live, locked by default (the host's close path honours `locked`); raised, so a left-exiting park slides under it; anchoredEnter, which against itself resolves 'right'; no anchor: it is the root every anchor leads to. Same default tier/size as every other entry: a permanent column carries a tier like any other. @internal */
48
+ export declare function createHistoryPanel(rootId: string): PanelWindow;
49
+ /**
50
+ * Per-call metadata create() may set beyond a bare url/source. No raw `collapsible`/`locked` key: `permanent`
51
+ * is the only door to `collapsible:false` (see `appendPanel`'s own doc comment for what it forces), and
52
+ * `locked` is never settable at all -- create() can never make an unclosable panel.
53
+ * @internal
54
+ */
55
+ export interface AppendPanelOptions {
56
+ view?: string;
57
+ title?: string;
58
+ params?: JsonValue;
59
+ /** Copied through to the new entry's `hostData` unchanged; absent means the entry carries no `hostData` key at all (never an explicit `undefined`). */
60
+ hostData?: JsonValue;
61
+ listed?: boolean;
62
+ /** `panel` defaults to History, `priority` to one past the far end of that side. Default \{ position: 'right' \}. */
63
+ anchor?: {
64
+ panel?: string;
65
+ position: 'left' | 'right';
66
+ priority?: number;
67
+ };
68
+ layer?: 'base' | 'raised';
69
+ anchoredEnter?: boolean;
70
+ /** See `CreatePanelOptions.permanent` (hostApi.tsx) for the full contract; `appendPanel` is what enforces it. */
71
+ permanent?: boolean;
72
+ /** The new panel's tier. Ignored for a permanent column, which is always 'phone'. Default 'phone'. */
73
+ formFactor?: FormFactor;
74
+ }
75
+ /** @internal */
76
+ export type AnchorRequest = NonNullable<AppendPanelOptions['anchor']>;
77
+ /** `id` re-anchored per `request` (defaults as for appendPanel), the row re-ordered to match. The SAME array for an unknown id or a `collapsible: false` entry. @internal */
78
+ export declare function reanchorPanel(panels: PanelWindow[], id: string, request: AnchorRequest, rootId: string): PanelWindow[];
79
+ /**
80
+ * Builds a brand-new, live 'extra' panel immutably and places it by `info.anchor` (default: right of
81
+ * History, past every panel already there -- the row's far right end). History is seeded directly, never
82
+ * through this function. `permanent` forces `collapsible:false` and `listed:false` (overriding any `info.listed`
83
+ * passed) since such a column must supply its own close control, never the row's trash; `locked`
84
+ * stays false regardless so that control keeps working with no extra plumbing (the host's close path gates
85
+ * only on `locked`).
86
+ * @internal
87
+ */
88
+ export declare function appendPanel(panels: PanelWindow[], info: {
89
+ url: string;
90
+ } & AppendPanelOptions, id: string, rootId: string): PanelWindow[];
91
+ /**
92
+ * The id of the live collapsible panel farthest (by array index) from `fromIndex`, or null with none live.
93
+ * Ties go to the lower index (the strict `>` keeps the first). From an append (`fromIndex` = the new length)
94
+ * that is the LEFT end of the live range; from an open nearest History or a revive left of the range, the
95
+ * RIGHT end. promoteToLive and the host's open/restore park both pick through this. `exclude`
96
+ * exempts the panel being opened or restored, which is already live in the array the caller passes, and any
97
+ * panel another action in the same update must keep live.
98
+ * @internal
99
+ */
100
+ export declare function farthestLiveCollapsibleId(panels: PanelWindow[], fromIndex: number, exclude?: ReadonlySet<string>): string | null;
101
+ /**
102
+ * Which side of the row a park/reveal at `id` reads as, for the exit/enter animation: 'left' when `id`'s
103
+ * own array index sits below every OTHER live collapsible panel's index, 'right' otherwise (a parked panel
104
+ * between two live ones included). Computed fresh from array position every time, never a persisted flag,
105
+ * so an append's park always reads 'left' and a park for an open anchored nearest History reads 'right'.
106
+ * @internal
107
+ */
108
+ export declare function sideOfLiveRange(panels: PanelWindow[], id: string): 'left' | 'right';
109
+ /** The row-slot index of the LEFTMOST (lowest-index) live collapsible panel, or -1 if none are live. @internal */
110
+ export declare function leftmostLiveIndex(panels: PanelWindow[]): number;
111
+ /** The row-slot index of the RIGHTMOST (highest-index) live collapsible panel, or -1 if none are live. @internal */
112
+ export declare function rightmostLiveIndex(panels: PanelWindow[]): number;
113
+ /** The first PARKED collapsible panel strictly after `index` -- a rightward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
114
+ export declare function firstParkedAfter(panels: PanelWindow[], index: number): PanelWindow | null;
115
+ /** The last PARKED collapsible panel strictly before `index` -- a leftward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
116
+ export declare function lastParkedBefore(panels: PanelWindow[], index: number): PanelWindow | null;
117
+ /**
118
+ * Activating ANY panel (live or parked) flips `live` in place, never moving the entry's array position.
119
+ * Returns `null` for an unknown id or one already live (a legitimate no-op, not an error). When promoting
120
+ * `id` would exceed `capacity`, demotes exactly one OTHER live panel: whichever `farthestLiveCollapsibleId`
121
+ * picks from `id`'s own index, the same far rule an open/restore parks by. Never demotes a
122
+ * `collapsible: false` entry or any id in `keep`.
123
+ * @internal
124
+ */
125
+ export declare function promoteToLive(panels: PanelWindow[], id: string, capacity: number, keep?: ReadonlySet<string>): PanelWindow[] | null;
126
+ /**
127
+ * Closes one panel COMPLETELY: it leaves the array, live or parked, so it leaves the row and History
128
+ * together. Returns what a caller needs to log it and to undo it -- the array without it, the panel itself
129
+ * and the index it had -- or `null` for an unknown `id` (already closed, cleared): nothing to do, not an
130
+ * error. Every panel anchored to it takes over its own anchor, so they close up in the place it held.
131
+ * @internal
132
+ */
133
+ export declare function closePanel(panels: PanelWindow[], id: string, rootId: string): {
134
+ remaining: PanelWindow[];
135
+ closed: PanelWindow;
136
+ index: number;
137
+ } | null;
138
+ /**
139
+ * Moves `id` one PRESENT slot (`!collapsible || live`, PanelRow's filter) toward `direction`, past
140
+ * whichever present panel sits there, History included; a parked panel between the two shifts one array slot,
141
+ * keeping firstParkedAfter/lastParkedBefore/sideOfLiveRange meaningful. Every anchor is then re-read from the
142
+ * new order. The SAME array for every no-op: unknown id, a `collapsible: false` source (an always-shown column
143
+ * is never dragged), a parked panel, or one already at that edge.
144
+ * @internal
145
+ */
146
+ export declare function movePanel(panels: PanelWindow[], id: string, direction: 'left' | 'right', rootId: string): PanelWindow[];
147
+ /**
148
+ * How many movePanel hops a horizontal drag of `offsetX` px asks for (+right/-left). A hop past a neighbour
149
+ * moves the dragged panel by that NEIGHBOUR's span (its width plus `gap`), so each hop lands half-way across
150
+ * the next neighbour's span, counted from where the previous hop ended; the dragged panel's own width never
151
+ * enters it. Exactly half a span is a hop either way, and this never returns -0.
152
+ * @public
153
+ */
154
+ export declare function reorderStepsForOffset(offsetX: number, neighbors: NeighborWidths, gap: number): number;
155
+ /**
156
+ * `id` at `next`'s tier, its `size` REPLACED by a fresh copy of that tier's TIER_SIZES entry. Returns the SAME
157
+ * array reference for every no-op: an unknown id, a `collapsible: false` entry (History or a permanent column,
158
+ * movePanel's own source guard), or a panel already at `next`. A parked panel may change tier and stays parked.
159
+ * @internal
160
+ */
161
+ export declare function setFormFactor(panels: PanelWindow[], id: string, next: FormFactor): PanelWindow[];
162
+ /**
163
+ * Reverses closePanel for one panel: puts `panel` back at `index` (clamped to the current length, so a shorter
164
+ * array still takes it), unchanged -- a pure spread, no `hostData` reset (the host's undo does that itself: only
165
+ * it knows what "unmounted when it closed" means for the opaque blob). An id already present leaves `panels`
166
+ * unchanged (a double undo never duplicates). Live or parked is whatever `panel.live` says. Anchors are
167
+ * re-read from the result: its old anchor may no longer describe that index.
168
+ * @internal
169
+ */
170
+ export declare function restorePanel(panels: PanelWindow[], panel: PanelWindow, index: number, rootId: string): PanelWindow[];