@hanzo/rooms 0.1.97 → 0.1.99

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/dist/App.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The web app as the whole window, in three modes: Chat, a conversation with
4
4
  // the product's AI; Dev, @hanzo/build's `<Builder>` without a rail of its own;
5
5
  // and Studio, @hanzo/studio's `<Studio>` the same way, its places (`nav`) in the
@@ -47,7 +47,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
47
47
  // nothing fills it. So Dev's pane is cut on its own and the tabs are Chat's.
48
48
  //
49
49
  // EVERY MOVE PUTS THE DRAWER AWAY, the one to where the reader already is
50
- // included.
50
+ // included. A phone's drawer (Drawer.tsx) is the menu behind the pane, the pane
51
+ // sliding toward the end side over it; a move made from it takes its place in
52
+ // the history (`depart`), so Back never reopens it.
51
53
  //
52
54
  // STUDIO LOADS WITH ITS MODE. Its module (and three.js behind a 3D tab) is
53
55
  // fetched the first time the reader stands in Studio; its places are a few
@@ -70,6 +72,7 @@ import { Orgs } from './Orgs.js';
70
72
  import { useRooms } from './host.js';
71
73
  import { useKept } from './kept.js';
72
74
  import { Mark, Rail, spot, Toggle } from './Rail.js';
75
+ import { Foot, Layer, Lift, RAISED, useDrawer } from './Drawer.js';
73
76
  import { empty, openThread, useOpen } from './open.js';
74
77
  import { Paned } from './Shell.js';
75
78
  import { Palette } from './Palette.js';
@@ -87,18 +90,6 @@ const Terminals = lazy(() => import('./Terminals.js'));
87
90
  const HOME = { mode: 'chat', thread: null };
88
91
  /** The bar's height, px: the reference window's title bar. */
89
92
  const BAR = 40;
90
- /**
91
- * The dim under the phone's drawer: design's scrim at two fifths, so the page
92
- * still reads through the drawer's glass. gui carries a `color-mix()` to the
93
- * page unchanged; its types only name custom properties.
94
- */
95
- const DIM = 'color-mix(in srgb, var(--surface-scrim) 40%, transparent)';
96
- /** The phone's drawer stands the gutter off each edge, or the device's inset where that is wider. */
97
- const INSET = {
98
- top: 'max(var(--pane-gap), var(--safe-top))',
99
- bottom: 'max(var(--pane-gap), var(--safe-bottom))',
100
- left: 'max(var(--pane-gap), var(--safe-left))',
101
- };
102
93
  /** Where each mode was last, so the rail goes back to it. */
103
94
  const last = { chat: '', dev: '', studio: '', tabs: '' };
104
95
  /** Whether a key press is the reader typing, where the app's keys are the field's. */
@@ -113,24 +104,26 @@ export function App({ bar } = {}) {
113
104
  }
114
105
  /** The window itself, drawn once the organization is settled. */
115
106
  function Window({ bar }) {
116
- const [drawer, setDrawer] = useState(false);
107
+ const drawer = useDrawer();
108
+ const { close: shut, depart } = drawer;
117
109
  // On a phone the window stands in the visual viewport, so the keyboard never covers the composer (view.ts).
118
110
  const view = useView();
119
- const shut = useCallback(() => setDrawer(false), []);
111
+ const hold = drawer.window;
112
+ const windowRef = useCallback((node) => {
113
+ view(node);
114
+ hold(node);
115
+ }, [view, hold]);
120
116
  // The sidebar shut, kept per browser under the key the builder's rail kept it under.
121
117
  const [closed, setClosed] = useKept('hanzo.build.rail', false);
122
118
  // The palette is open (the sidebar's search; ⌘K toggles it).
123
119
  const [finding, setFinding] = useState(false);
124
120
  const found = useCallback(() => setFinding(false), []);
125
121
  const dev = useDevHost();
126
- // The builder's moves put the drawer away first.
122
+ // The builder's moves put the drawer away, in its place in the history.
127
123
  const host = useMemo(() => ({
128
124
  ...dev,
129
- go: (to, how) => {
130
- shut();
131
- dev.go(to, how);
132
- },
133
- }), [dev, shut]);
125
+ go: (to, how) => depart((replace) => dev.go(to, replace ? { ...how, replace } : how)),
126
+ }), [dev, depart]);
134
127
  const { client } = useAi();
135
128
  const { router, path } = useRooms();
136
129
  const here = path ?? '/';
@@ -144,15 +137,12 @@ function Window({ bar }) {
144
137
  ...host,
145
138
  name: 'Hanzo Studio',
146
139
  path: made,
147
- go: (to, how) => {
148
- shut();
149
- go(studio(to), how?.replace);
150
- },
140
+ go: (to, how) => depart((replace) => go(studio(to), replace || how?.replace)),
151
141
  chooseOrg: (next) => {
152
142
  pick(next);
153
143
  window.location.assign(studio(''));
154
144
  },
155
- }), [host, made, shut]);
145
+ }), [host, made, depart]);
156
146
  // A run floats two panes of its own, so the pane it stands in is not cut.
157
147
  const run = where !== null && route(where).kind === 'run';
158
148
  const asked = at.mode === 'chat' ? at.thread : null;
@@ -161,8 +151,8 @@ function Window({ bar }) {
161
151
  // the address does not move (the reader was on that page already).
162
152
  useEffect(() => {
163
153
  if (settings)
164
- setDrawer(false);
165
- }, [settings]);
154
+ shut();
155
+ }, [settings, shut]);
166
156
  const plans = where !== null && isPlans(where);
167
157
  useEffect(() => {
168
158
  if (plans)
@@ -195,10 +185,12 @@ function Window({ bar }) {
195
185
  // The selection writes the address only when it moves.
196
186
  // eslint-disable-next-line react-hooks/exhaustive-deps
197
187
  }, [mode, thread]);
188
+ // The address moved under the drawer: it goes, and the history keeps the move.
189
+ const drop = drawer.drop;
198
190
  useEffect(() => {
199
191
  last[mode] = `${window.location.pathname}${window.location.search}`;
200
- setDrawer(false);
201
- }, [mode, here]);
192
+ drop();
193
+ }, [mode, here, drop]);
202
194
  // Where the reader was before a place that takes the sidebar (Library, Scheduled
203
195
  // tasks, Customize, Settings): its Back returns there, Chat or Dev alike.
204
196
  const away = useRef('');
@@ -207,11 +199,8 @@ function Window({ bar }) {
207
199
  if (!inPlace)
208
200
  away.current = `${window.location.pathname}${window.location.search}`;
209
201
  }, [inPlace, here]);
210
- /** A move of the app's own: the drawer away, then the address. */
211
- const move = useCallback((address) => {
212
- shut();
213
- go(address);
214
- }, [shut]);
202
+ /** A move of the app's own: the drawer away, the address in its place. */
203
+ const move = useCallback((address) => depart((replace) => go(address, replace)), [depart]);
215
204
  // The app's keys.
216
205
  useEffect(() => {
217
206
  const key = (e) => {
@@ -248,6 +237,11 @@ function Window({ bar }) {
248
237
  const toDev = () => move(last.dev || app(''));
249
238
  const toStudio = () => move(last.studio || studio(''));
250
239
  const toTabs = () => move(TERMINALS);
240
+ /** The drawer's New chat. */
241
+ const fresh = () => {
242
+ empty();
243
+ move(talk());
244
+ };
251
245
  // Another organization: picked, and the app opened in it at the mode's New.
252
246
  const org = (o) => {
253
247
  pick(o);
@@ -259,5 +253,7 @@ function Window({ bar }) {
259
253
  shut();
260
254
  setFinding(true);
261
255
  };
262
- return (_jsx(AiProvider, { client: client ?? undefined, children: _jsx(HostProvider, { host: host, children: _jsx(PrefsProvider, { children: _jsxs(YStack, { ref: view, "data-slot": "window", ...(bar ? floor : null), flex: 1, minW: 0, height: "100dvh", overflow: "hidden", "$max-md": { position: 'fixed', t: TOP, l: 0, r: 0, height: HEIGHT }, children: [bar ? (_jsxs(XStack, { "data-slot": "title-bar", height: BAR, shrink: 0, items: "center", gap: 2, pl: (bar.inset ?? 0) + 6, pr: "$2", ...bar.drag, children: [_jsx(XStack, { "$max-md": { display: 'none' }, children: _jsx(Toggle, { open: !closed, onPress: flip }) }), _jsx(XStack, { $md: { display: 'none' }, children: _jsx(Toggle, { open: drawer, onPress: () => setDrawer(true), label: "Open sidebar" }) }), _jsx(Mark, { label: "Back", "data-slot": "back", onPress: () => router.back(), children: _jsx(ArrowLeft, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Forward", "data-slot": "forward", onPress: () => router.forward(), children: _jsx(ArrowRight, { size: 18, "aria-hidden": true }) }), _jsx(XStack, { flex: 1, self: "stretch", ...bar.drag })] })) : null, _jsxs(XStack, { "data-room": "", "data-app": mode, "data-slot": "frame", ...(bar ? null : floor), flex: 1, minW: 0, minH: 0, pr: "var(--pane-gap)", pb: "var(--pane-gap)", pt: bar ? 0 : 'var(--pane-gap)', "$max-md": { p: 0 }, children: [_jsx(YStack, { height: "100%", "$max-md": { display: 'none' }, children: _jsx(Rail, { host: host, mode: mode, at: where, open: !closed, toggle: bar ? undefined : flip, onChat: toChat, onDev: toDev, onStudio: toStudio, onTabs: toTabs, go: move, onOrg: org }) }), closed || mode === 'tabs' ? null : (_jsx(YStack, { "$max-md": { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: () => setClosed(true), onSeek: seek, onBack: back, places: sidebar }) })), _jsxs(YStack, { "data-slot": "stage", flex: 1, minW: 0, minH: 0, ml: "var(--pane-gap)", "$max-md": { ml: 0, pb: FOOT }, children: [bar ? null : _jsx(Top, { host: host, mode: mode, places: sidebar, go: move, onMenu: () => setDrawer(true), onChat: toChat, onDev: toDev }), _jsx(XStack, { "data-slot": "panes", flex: 1, minW: 0, minH: 0, children: mode === 'chat' ? (_jsx(Paned, { framed: true, children: _jsx(Chat, {}) })) : mode === 'tabs' ? (_jsx(YStack, { render: "main", "data-slot": "pane", ...pane(), overflow: "hidden", flex: 1, minW: 0, minH: 0, children: _jsx(Suspense, { fallback: null, children: _jsx(Terminals, {}) }) })) : mode === 'studio' ? (_jsx(YStack, { render: "main", "data-slot": "pane", ...pane(), overflow: "hidden", flex: 1, minW: 0, minH: 0, children: _jsx(YStack, { flex: 1, minH: 0, height: "100%", children: _jsx(Suspense, { fallback: null, children: _jsx(Studio, { host: maker, rail: false }) }) }) })) : (_jsx(YStack, { render: "main", "data-slot": "pane", ...(run ? null : { ...pane(), overflow: 'hidden' }), flex: 1, minW: 0, minH: 0, children: plans ? null : _jsx(Builder, { host: host, rail: false }) })) })] })] }), drawer ? (_jsxs(_Fragment, { children: [_jsx(YStack, { render: "button", "aria-label": "Close sidebar", onPress: () => setDrawer(false), bg: DIM, position: "absolute", t: 0, r: 0, b: 0, l: 0, z: "var(--z-overlay)", $md: { display: 'none' } }), _jsx(YStack, { "data-slot": "drawer", position: "absolute", t: INSET.top, b: INSET.bottom, l: INSET.left, width: "min(320px, calc(100% - 2 * var(--pane-gap) - 48px))", z: "var(--z-modal)", $md: { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: shut, onSeek: seek, onBack: back, drawer: true, places: sidebar, foot: _jsx(Rail, { host: host, mode: mode, at: where, open: true, flat: true, onChat: toChat, onDev: toDev, onStudio: toStudio, onTabs: toTabs, go: move, onOrg: org }) }) })] })) : null, finding ? _jsx(Palette, { go: move, onClose: found, mode: mode, host: host }) : null] }) }) }) }));
256
+ return (_jsx(AiProvider, { client: client ?? undefined, children: _jsx(HostProvider, { host: host, children: _jsx(PrefsProvider, { children: _jsxs(YStack, { ref: windowRef, "data-slot": "window", ...(bar ? floor : null), flex: 1, minW: 0, height: "100dvh", overflow: "hidden", "$max-md": { position: 'fixed', t: TOP, l: 0, r: 0, height: HEIGHT, ...{ overflow: 'clip' } }, children: [bar ? (_jsxs(XStack, { "data-slot": "title-bar", height: BAR, shrink: 0, items: "center", gap: 2, pl: (bar.inset ?? 0) + 6, pr: "$2", ...bar.drag, children: [_jsx(XStack, { "$max-md": { display: 'none' }, children: _jsx(Toggle, { open: !closed, onPress: flip }) }), _jsx(XStack, { $md: { display: 'none' }, children: _jsx(Toggle, { open: drawer.state === 'open', onPress: drawer.open, label: "Open sidebar" }) }), _jsx(Mark, { label: "Back", "data-slot": "back", onPress: () => router.back(), children: _jsx(ArrowLeft, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: "Forward", "data-slot": "forward", onPress: () => router.forward(), children: _jsx(ArrowRight, { size: 18, "aria-hidden": true }) }), _jsx(XStack, { flex: 1, self: "stretch", ...bar.drag })] })) : null, _jsxs(XStack, { ref: drawer.pane, "data-room": "", "data-app": mode, "data-slot": "frame", ...(bar ? null : floor), ...(drawer.slide ? RAISED : null),
257
+ // inline-style: the slide, a transform with no logical form whose sign and distance the drawer writes on the element itself, a drag every frame (Drawer.tsx).
258
+ style: drawer.slide ?? undefined, flex: 1, minW: 0, minH: 0, pr: "var(--pane-gap)", pb: "var(--pane-gap)", pt: bar ? 0 : 'var(--pane-gap)', "$max-md": { p: 0 }, children: [drawer.slide ? _jsx(Lift, {}) : null, _jsx(YStack, { height: "100%", "$max-md": { display: 'none' }, children: _jsx(Rail, { host: host, mode: mode, at: where, open: !closed, toggle: bar ? undefined : flip, onChat: toChat, onDev: toDev, onStudio: toStudio, onTabs: toTabs, go: move, onOrg: org }) }), closed || mode === 'tabs' ? null : (_jsx(YStack, { "$max-md": { display: 'none' }, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: () => setClosed(true), onSeek: seek, onBack: back, places: sidebar }) })), _jsxs(YStack, { "data-slot": "stage", flex: 1, minW: 0, minH: 0, ml: "var(--pane-gap)", "$max-md": { ml: 0, pb: FOOT }, children: [bar ? null : _jsx(Top, { host: host, mode: mode, places: sidebar, go: move, onMenu: drawer.open, onChat: toChat, onDev: toDev }), _jsx(XStack, { "data-slot": "panes", flex: 1, minW: 0, minH: 0, children: mode === 'chat' ? (_jsx(Paned, { framed: true, children: _jsx(Chat, {}) })) : mode === 'tabs' ? (_jsx(YStack, { render: "main", "data-slot": "pane", ...pane(), overflow: "hidden", flex: 1, minW: 0, minH: 0, children: _jsx(Suspense, { fallback: null, children: _jsx(Terminals, {}) }) })) : mode === 'studio' ? (_jsx(YStack, { render: "main", "data-slot": "pane", ...pane(), overflow: "hidden", flex: 1, minW: 0, minH: 0, children: _jsx(YStack, { flex: 1, minH: 0, height: "100%", children: _jsx(Suspense, { fallback: null, children: _jsx(Studio, { host: maker, rail: false }) }) }) })) : (_jsx(YStack, { render: "main", "data-slot": "pane", ...(run ? null : { ...pane(), overflow: 'hidden' }), flex: 1, minW: 0, minH: 0, children: plans ? null : _jsx(Builder, { host: host, rail: false }) })) })] })] }), drawer.state === 'shut' ? null : (_jsx(Layer, { drawer: drawer, top: bar ? BAR : 0, children: _jsx(Column, { host: host, mode: mode, thread: thread, go: move, onShut: shut, onSeek: seek, onBack: back, drawer: true, places: sidebar, rail: _jsx(Rail, { host: host, mode: mode, at: where, open: true, rows: true, onChat: toChat, onDev: toDev, onStudio: toStudio, onTabs: toTabs, go: move, onOrg: org }), foot: _jsx(Foot, { host: host, onChat: fresh, onOrg: org }) }) })), finding ? _jsx(Palette, { go: move, onClose: found, mode: mode, host: host }) : null] }) }) }) }));
263
259
  }
package/dist/Column.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Host } from '@hanzo/build';
2
2
  import { type RailLink } from '@hanzo/ui/chat';
3
3
  import { type ReactNode } from 'react';
4
4
  import { type Place } from './lib/host.js';
5
- export declare function Column({ host, mode, thread, go, onShut, onSeek, onBack, drawer, foot, places, }: {
5
+ export declare function Column({ host, mode, thread, go, onShut, onSeek, onBack, drawer, rail, foot, places, }: {
6
6
  host: Host;
7
7
  mode: Place['mode'];
8
8
  /** The Chat conversation the pane holds, or null. */
@@ -15,9 +15,11 @@ export declare function Column({ host, mode, thread, go, onShut, onSeek, onBack,
15
15
  onSeek?: () => void;
16
16
  /** Leaves a place that took the sidebar, for where the reader was before it. */
17
17
  onBack: () => void;
18
- /** Drawn in the phone's drawer: no edge, cut as a floating pane. */
18
+ /** Drawn in the phone's drawer (Drawer.tsx): on the floor behind the pane, no edge, no handle. */
19
19
  drawer?: boolean;
20
- /** What stands at its foot: the drawer's rail, laid flat. */
20
+ /** What stands under the mode's own rows: the drawer's places, the rail's as rows. */
21
+ rail?: ReactNode;
22
+ /** What stands at its foot: the drawer's account and New chat. */
21
23
  foot?: ReactNode;
22
24
  /** The mode's places, where they are not Dev's (Studio's `nav`). */
23
25
  places?: {
package/dist/Column.js CHANGED
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The app's sidebar, beside the rail on the window's floor (App.tsx): the
4
4
  // mode's name with the bell and the search at its right, New, Dev's places in
5
5
  // Dev, the reader's Recents (Pinned first), and the first-run cards. Which
6
- // product it lists is the rail's to say; the account is at the rail's foot.
6
+ // product it lists is the rail's to say; the account is at the rail's foot, or
7
+ // on a phone at the foot of its menu (Drawer.tsx), where this column is the menu.
7
8
  //
8
9
  // It is the window, not a panel: no fill, no edge, the floor's own ground. The
9
10
  // content beside it is the panel, inset by the gutter.
@@ -35,22 +36,11 @@ import { Setup } from './Setup.js';
35
36
  import { ago } from './recent.js';
36
37
  import { empty } from './open.js';
37
38
  import { useSpan } from './span.js';
38
- import { pane } from './ground.js';
39
39
  import { talk } from './lib/host.js';
40
40
  import { brand, wordmark } from './where.js';
41
41
  /** The sidebar fills the column. Its `width` is typed in px alone, though its frame takes any gui size — the type is
42
42
  * @hanzo/ui's to widen. */
43
43
  const FILL = { width: '100%' };
44
- /**
45
- * The phone's drawer floats over the page under the browser's own glass bar, so
46
- * it is cut like that bar: the composer's corner, the strong hairline, the lit
47
- * top edge and the floating drop. Its fill and blur stay the pane's.
48
- */
49
- const FLOAT = {
50
- borderRadius: 'var(--radius-composer)',
51
- borderColor: 'var(--border-strong)',
52
- boxShadow: 'var(--edge-highlight), var(--shadow-floating)',
53
- };
54
44
  /** The sidebar's width, px: its default, and the two a drag holds it between. */
55
45
  const WIDTH = 240;
56
46
  const FLOOR = 200;
@@ -66,14 +56,14 @@ const keys = (press) => ({
66
56
  press();
67
57
  },
68
58
  });
69
- /** A row of the sidebar: a pill on a 31px pitch, lit where the reader is. */
70
- const ROW = { rounded: '$4', minH: 31, py: 0, px: '$2' };
59
+ /** A row of the sidebar: a pill on a 31px pitch, a thumb's 44 on a touch screen, lit where the reader is. */
60
+ const ROW = { rounded: '$4', minH: 31, py: 0, px: '$2', $touchable: { minH: 44 } };
71
61
  /** The run activity behind the bell: the runs that moved last, newest first. */
72
62
  const RECENT = 6;
73
63
  /** What a place that takes the sidebar is called at its head. */
74
64
  const TITLE = { library: 'Library', scheduled: 'Scheduled tasks', customize: 'Customize', settings: 'Settings' };
75
- /** A header mark: a 16px glyph in a 32px target. */
76
- const HEAD = { width: 32, height: 32, rounded: '$3' };
65
+ /** A header mark: a 16px glyph in a 32px target, a thumb's 44 on a touch screen. */
66
+ const HEAD = { width: 32, height: 32, rounded: '$3', $touchable: { width: 44, height: 44 } };
77
67
  function Activity({ host }) {
78
68
  const { t } = useI18n();
79
69
  const store = useSessions(host);
@@ -94,7 +84,7 @@ function Activity({ host }) {
94
84
  ];
95
85
  return (_jsx(DropdownMenu, { minWidth: 280, trigger: _jsx(XStack, { render: "button", "aria-label": t("Activity"), ...{ title: 'Activity' }, ...HEAD, items: "center", justify: "center", cursor: "pointer", opacity: 0.7, hoverStyle: { bg: '$hover', opacity: 1 }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(Bell, { size: 16, "aria-hidden": true }) }), items: items }));
96
86
  }
97
- export function Column({ host, mode, thread, go, onShut, onSeek, onBack, drawer = false, foot, places, }) {
87
+ export function Column({ host, mode, thread, go, onShut, onSeek, onBack, drawer = false, rail, foot, places, }) {
98
88
  const { t } = useI18n();
99
89
  // The desktop sidebar and a phone's drawer can both be in the page: each names its own group labels.
100
90
  const uid = useId();
@@ -121,5 +111,5 @@ export function Column({ host, mode, thread, go, onShut, onSeek, onBack, drawer
121
111
  };
122
112
  return (_jsxs(YStack, { "data-slot": "app-column", ref: drawer ? undefined : span.ref,
123
113
  // inline-style: `--span`, the width a drag rewrites on the element itself (span.ts); no prop sets a variable.
124
- style: drawer ? undefined : span.style, position: "relative", flex: drawer ? 1 : undefined, shrink: drawer ? 1 : 0, minW: 0, height: "100%", width: drawer ? undefined : 'var(--span)', children: [_jsxs(Sidebar, { "data-slot": "app-rail", role: "navigation", "aria-label": brand(), ...(drawer ? { ...pane(), ...FLOAT } : { bg: 'transparent', borderWidth: 0 }), ...FILL, px: "$2", pt: drawer ? '$2' : 0, pb: "$2", gap: "$0", children: [at ? (_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: 2, pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(SidebarIconButton, { label: t("Back"), "data-slot": "place-back", onPress: onBack, ...HEAD, children: _jsx(ArrowLeft, { size: 16, "aria-hidden": true }) }), _jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, "data-slot": "place-name", children: TITLE[at] })] })) : (_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: "$2", pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) }), _jsx(Activity, { host: host }), _jsx(SidebarIconButton, { label: t("Search"), onPress: onSeek, "aria-haspopup": "dialog", ...HEAD, children: _jsx(Search, { size: 16, "aria-hidden": true }) })] })), mode === 'chat' ? (_jsx(YStack, { role: "list", gap: 2, shrink: 0, children: _jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: !thread, bg: "transparent", hoverStyle: { bg: '$hover' }, icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: _jsx(Text, { fontSize: "$3", color: "$ink", children: t("New chat") }) }) }) })) : (_jsx(YStack, { "data-slot": "app-places", shrink: 0, children: groups.map((g, i) => (_jsxs(YStack, { pt: i ? '$2' : 0, children: [i && g.label ? (_jsx(Text, { id: `${uid}-${g.id}`, pl: "$2", pb: 2, fontSize: "$3", color: "$soft", children: g.label })) : null, _jsx(YStack, { role: "list", "aria-labelledby": i && g.label ? `${uid}-${g.id}` : undefined, "aria-label": i && g.label ? undefined : g.label || (at ? TITLE[at] : g.id), gap: 1, children: g.links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, ...(link.id === NEW ? { 'data-slot': 'app-new' } : {}), active: link.active, icon: link.id === NEW ? _jsx(SquarePen, { size: 16, "aria-hidden": true }) : link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: _jsx(Text, { fontSize: "$3", color: link.active ? '$ink' : '$quiet', children: link.label }) }) }, link.id))) })] }, g.id))) })), mode === 'studio' || mode === 'tabs' || at ? null : _jsx(Recents, { host: host, mode: mode, thread: thread, onOpen: open, onNew: create }), at ? null : (_jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] })), foot] }), drawer ? null : (_jsx(Gutter, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: onShut, label: t("Resize sidebar") }))] }));
114
+ style: drawer ? undefined : span.style, position: "relative", flex: drawer ? 1 : undefined, shrink: drawer ? 1 : 0, minW: 0, height: "100%", width: drawer ? undefined : 'var(--span)', children: [_jsxs(Sidebar, { "data-slot": "app-rail", role: "navigation", "aria-label": brand(), bg: "transparent", borderWidth: 0, ...FILL, px: "$2", pt: drawer ? '$2' : 0, pb: "$2", gap: "$0", children: [at ? (_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: 2, pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(SidebarIconButton, { label: t("Back"), "data-slot": "place-back", onPress: onBack, ...HEAD, children: _jsx(ArrowLeft, { size: 16, "aria-hidden": true }) }), _jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, "data-slot": "place-name", children: TITLE[at] })] })) : (_jsxs(XStack, { "data-slot": "app-head", items: "center", gap: "$1", pl: "$2", pr: 2, height: 52, shrink: 0, minW: 0, children: [_jsx(Text, { flex: 1, minW: 0, fontSize: 18, lineHeight: 24, fontWeight: "600", color: "$ink", numberOfLines: 1, children: wordmark(mode) }), _jsx(Activity, { host: host }), _jsx(SidebarIconButton, { label: t("Search"), onPress: onSeek, "aria-haspopup": "dialog", ...HEAD, children: _jsx(Search, { size: 16, "aria-hidden": true }) })] })), mode === 'chat' ? (_jsx(YStack, { role: "list", gap: 2, shrink: 0, children: _jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { "data-slot": "app-new", ...ROW, active: !thread, bg: "transparent", hoverStyle: { bg: '$hover' }, icon: _jsx(SquarePen, { size: 16, "aria-hidden": true }), onPress: create, ...keys(create), children: _jsx(Text, { fontSize: "$3", color: "$ink", children: t("New chat") }) }) }) })) : (_jsx(YStack, { "data-slot": "app-places", shrink: 0, children: groups.map((g, i) => (_jsxs(YStack, { pt: i ? '$2' : 0, children: [i && g.label ? (_jsx(Text, { id: `${uid}-${g.id}`, pl: "$2", pb: 2, fontSize: "$3", color: "$soft", children: g.label })) : null, _jsx(YStack, { role: "list", "aria-labelledby": i && g.label ? `${uid}-${g.id}` : undefined, "aria-label": i && g.label ? undefined : g.label || (at ? TITLE[at] : g.id), gap: 1, children: g.links.map((link) => (_jsx(YStack, { role: "listitem", children: _jsx(SidebarItem, { ...ROW, ...(link.id === NEW ? { 'data-slot': 'app-new' } : {}), active: link.active, icon: link.id === NEW ? _jsx(SquarePen, { size: 16, "aria-hidden": true }) : link.icon, onPress: link.onPress, ...keys(() => link.onPress?.()), children: _jsx(Text, { fontSize: "$3", color: link.active ? '$ink' : '$quiet', children: link.label }) }) }, link.id))) })] }, g.id))) })), rail, mode === 'studio' || mode === 'tabs' || at ? null : _jsx(Recents, { host: host, mode: mode, thread: thread, onOpen: open, onNew: create }), at ? null : (_jsxs(YStack, { gap: "$2", pt: "$2", shrink: 0, children: [_jsx(Made, {}), _jsx(Setup, {})] })), foot] }), drawer ? null : (_jsx(Gutter, { side: "left", span: span.span, floor: FLOOR, ceil: CEIL, reset: WIDTH, onSpan: span.move, onKeep: span.keep, onShut: onShut, label: t("Resize sidebar") }))] }));
125
115
  }
@@ -0,0 +1,55 @@
1
+ import { type CSSProperties, type ReactNode } from 'react';
2
+ import { type Who } from './Me.js';
3
+ import { type State } from './slide.js';
4
+ /** A press that moves the app: told whether it should take the menu's place in history. */
5
+ export type Move = (replace: boolean) => void;
6
+ export interface Drawer {
7
+ state: State;
8
+ open: () => void;
9
+ close: () => void;
10
+ /** Moves the app from the menu: the move replaces the menu's history entry, and the menu goes. */
11
+ depart: (move: Move) => void;
12
+ /** The address moved under the menu, by a way that was not `depart`: the menu goes, and its entry stays behind the move. */
13
+ drop: () => void;
14
+ /** The window: everything in it but the menu is inert while the menu is out. */
15
+ window: (node: unknown) => void;
16
+ /** The pane that slides. */
17
+ pane: (node: unknown) => void;
18
+ /** The menu's layer. */
19
+ layer: (node: unknown) => void;
20
+ /** The pane's slide, for its `style`: null while shut, so nothing is transformed and nothing is raised. */
21
+ slide: CSSProperties | null;
22
+ }
23
+ export declare function useDrawer(): Drawer;
24
+ /**
25
+ * The pane while the menu is out: raised over the floor, its edge drawn and a
26
+ * drop falling on the menu, clipped to its corners. Spread on the pane beside
27
+ * `slide`; `Lift` is its fill.
28
+ */
29
+ export declare const RAISED: {
30
+ readonly z: 'var(--z-raised)';
31
+ readonly overflow: 'hidden';
32
+ readonly boxShadow: '0 0 0 1px var(--pane-edge), var(--shadow-floating)';
33
+ };
34
+ /**
35
+ * The raised pane's fill: the pane's own material composited opaque, a step
36
+ * brighter than the floor the menu stands on, in both themes. Drawn UNDER the
37
+ * pane's content (`--z-under` inside the pane's own stacking) rather than as the
38
+ * pane's background, which is the frame's and a host may paint it at rest.
39
+ */
40
+ export declare function Lift(): import("react").JSX.Element;
41
+ /**
42
+ * The menu's layer: the menu at the start on the floor, and over the strip of
43
+ * pane left in sight the press that closes it. Drawn while the drawer is not shut.
44
+ */
45
+ export declare function Layer({ drawer, top, children }: {
46
+ drawer: Drawer;
47
+ top?: number;
48
+ children: ReactNode;
49
+ }): import("react").JSX.Element;
50
+ /** The menu's foot: the account (its menu opens above it) and the accent New chat. */
51
+ export declare function Foot({ host, onChat, onOrg }: {
52
+ host: Who;
53
+ onChat: () => void;
54
+ onOrg: (org: string) => void;
55
+ }): import("react").JSX.Element;
package/dist/Drawer.js ADDED
@@ -0,0 +1,334 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // A phone's menu (App.tsx, under gui's `md`), drawn the way the ChatGPT iOS app
4
+ // draws its own: the menu stands BEHIND, on the window's subdued floor, and the
5
+ // pane slides toward the END side over it, raised — the pane's own brighter
6
+ // material, its start corners rounded, a drop falling on the menu. A strip of
7
+ // the pane stays in sight; a press on it, or a pull back toward the start, slides
8
+ // it home.
9
+ //
10
+ // ┌──────────────────────────┬────╮
11
+ // │ Hanzo AI 🔔 ⌕ │ │ the pane: STRIP px of it, inert but for
12
+ // │ ✎ New chat │ │ the press that closes (`drawer-close`)
13
+ // │ ◇ Studio ⌨ Tabs ⋯ More │ │
14
+ // │ Pinned … Recents … │ │
15
+ // │ (ZK) (✎ Chat) │ │ the foot: the account and the accent New chat
16
+ // └──────────────────────────┴────╯
17
+ //
18
+ // Three states, because the slide home has to be seen: shut (no menu, the pane
19
+ // as it always is), open, and closing (the menu still drawn, the pane sliding
20
+ // home), after which the menu unmounts. The pane's offset is a transform, so the
21
+ // slide is the compositor's; under reduced motion it is not animated at all.
22
+ //
23
+ // THE SLIDE IS LOGICAL but for its sign (slide.ts): the menu stands at the
24
+ // start, the strip at the end, both by logical insets; the transform's sign is
25
+ // read off the pane's own `direction` as the menu opens, so an RTL page (by
26
+ // @hanzo/i18n or the host's own `dir`) slides the pane left, and a drag reads
27
+ // along the line the same way.
28
+ //
29
+ // WHILE IT IS OUT the window behind it is inert (`inert` on every other child of
30
+ // the window) and the menu is a modal dialog: focus moves into it as it opens and
31
+ // back to what opened it as it shuts; Escape closes it, unless a menu or dialog
32
+ // it opened holds the focus; Back closes it, because opening it pushed an entry
33
+ // of the same address that Back pops. A move made from inside it takes that
34
+ // entry's place (`depart`), so the history holds the move and never the menu.
35
+ // Its list's scroll stays its own (Recents' `overscrollBehavior`), and the pane,
36
+ // never unmounted, keeps its own.
37
+ import { useI18n } from '@hanzo/i18n/react';
38
+ import { Text, XStack, YStack, useMedia } from '@hanzo/gui';
39
+ import { SquarePen } from 'lucide-react';
40
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
41
+ import { Me } from './Me.js';
42
+ import { axis, OPEN, pull, settle, sign } from './slide.js';
43
+ /**
44
+ * The pane's offset as a transform: `--drawer-way` the end side's sign,
45
+ * `--drawer-at` the distance, both written on the pane by the drawer (a drag
46
+ * rewrites the distance every frame, no React commit). Its start corners round.
47
+ */
48
+ const SLIDE = {
49
+ transform: 'translateX(calc(var(--drawer-way, 1) * var(--drawer-at, 0px)))',
50
+ borderStartStartRadius: 'var(--radius-composer)',
51
+ borderEndStartRadius: 'var(--radius-composer)',
52
+ };
53
+ /** The slide's motion: the system's base duration on its entrance curve. */
54
+ const MOTION = 'transform var(--duration-base) var(--ease-emphasis)';
55
+ /** The element a gui stack hands its ref, on the web. */
56
+ const element = (node) => (node instanceof HTMLElement ? node : null);
57
+ /** The reader asked for no motion. */
58
+ function useStill() {
59
+ const [still, setStill] = useState(false);
60
+ useEffect(() => {
61
+ const query = window.matchMedia?.('(prefers-reduced-motion: reduce)');
62
+ if (!query)
63
+ return;
64
+ const read = () => setStill(query.matches);
65
+ read();
66
+ query.addEventListener('change', read);
67
+ return () => query.removeEventListener('change', read);
68
+ }, []);
69
+ return still;
70
+ }
71
+ /** The pane's distance from home, px, measured: where it stands against the layer, which stands where it rests. */
72
+ const travel = (pane, layer) => Math.abs(pane.getBoundingClientRect().left - layer.getBoundingClientRect().left);
73
+ export function useDrawer() {
74
+ const [state, setState] = useState('shut');
75
+ // Which way the end side is; read as the menu opens.
76
+ const [way, setWay] = useState(1);
77
+ const still = useStill();
78
+ const media = useMedia();
79
+ const win = useRef(null);
80
+ const pane = useRef(null);
81
+ const layer = useRef(null);
82
+ const opener = useRef(null);
83
+ // The entry opening pushed is the one the history stands on.
84
+ const marked = useRef(false);
85
+ const home = useCallback((now = false) => setState((s) => (s === 'shut' || now || still ? 'shut' : 'closing')), [still]);
86
+ const open = useCallback(() => {
87
+ const el = pane.current;
88
+ setWay(sign(el ? getComputedStyle(el).direction : 'ltr'));
89
+ const at = document.activeElement;
90
+ if (at instanceof HTMLElement && at !== document.body)
91
+ opener.current = at;
92
+ if (!marked.current) {
93
+ window.history.pushState(window.history.state, '');
94
+ marked.current = true;
95
+ }
96
+ setState('open');
97
+ }, []);
98
+ const close = useCallback(() => {
99
+ if (marked.current) {
100
+ marked.current = false;
101
+ window.history.back();
102
+ }
103
+ home();
104
+ }, [home]);
105
+ const depart = useCallback((move) => {
106
+ const replace = marked.current;
107
+ marked.current = false;
108
+ const before = window.location.href;
109
+ move(replace);
110
+ // Nothing moved (the reader was there already): the menu's entry is popped instead.
111
+ if (replace && window.location.href === before)
112
+ window.history.back();
113
+ home();
114
+ }, [home]);
115
+ const drop = useCallback(() => {
116
+ marked.current = false;
117
+ home();
118
+ }, [home]);
119
+ // Back closes it: the entry it pushed is the one popped.
120
+ useEffect(() => {
121
+ if (state !== 'open')
122
+ return;
123
+ const back = () => {
124
+ if (!marked.current)
125
+ return;
126
+ marked.current = false;
127
+ home();
128
+ };
129
+ window.addEventListener('popstate', back);
130
+ return () => window.removeEventListener('popstate', back);
131
+ }, [state, home]);
132
+ // Escape closes it, unless a menu or dialog it opened holds the focus: that one closes first.
133
+ useEffect(() => {
134
+ if (state !== 'open')
135
+ return;
136
+ const key = (e) => {
137
+ if (e.key !== 'Escape' || e.defaultPrevented)
138
+ return;
139
+ const at = document.activeElement;
140
+ if (at && at !== document.body && !layer.current?.contains(at))
141
+ return;
142
+ e.preventDefault();
143
+ close();
144
+ };
145
+ document.addEventListener('keydown', key);
146
+ return () => document.removeEventListener('keydown', key);
147
+ }, [state, close]);
148
+ // A window that grows past a phone has no drawer: it goes at once.
149
+ useEffect(() => {
150
+ if (!media.md || state === 'shut')
151
+ return;
152
+ if (marked.current) {
153
+ marked.current = false;
154
+ window.history.back();
155
+ }
156
+ home(true);
157
+ }, [media.md, state, home]);
158
+ // Everything in the window but the menu is inert while it is out, and the focus goes in.
159
+ useLayoutEffect(() => {
160
+ const w = win.current;
161
+ const out = state !== 'shut';
162
+ if (w)
163
+ for (const child of Array.from(w.children))
164
+ if (child !== layer.current && child instanceof HTMLElement)
165
+ child.inert = out;
166
+ if (state === 'open')
167
+ layer.current?.focus({ preventScroll: true });
168
+ if (state !== 'shut')
169
+ return;
170
+ // Shut: the focus goes back to what opened it, where nothing else took it.
171
+ const back = opener.current;
172
+ opener.current = null;
173
+ const at = document.activeElement;
174
+ if (back?.isConnected && (!at || at === document.body))
175
+ back.focus({ preventScroll: true });
176
+ }, [state]);
177
+ // The slide, written on the pane: its way, its motion, and how far it stands from home.
178
+ useLayoutEffect(() => {
179
+ const s = pane.current?.style;
180
+ if (!s)
181
+ return;
182
+ if (state === 'shut') {
183
+ for (const p of ['--drawer-at', '--drawer-way', 'transition'])
184
+ s.removeProperty(p);
185
+ return;
186
+ }
187
+ s.setProperty('--drawer-way', String(way));
188
+ s.setProperty('transition', still ? 'none' : MOTION);
189
+ s.setProperty('--drawer-at', state === 'open' ? OPEN : '0px');
190
+ }, [state, way, still]);
191
+ // Closing ends when the slide does, or after it would have.
192
+ useEffect(() => {
193
+ if (state !== 'closing')
194
+ return;
195
+ const el = pane.current;
196
+ const done = () => setState((s) => (s === 'closing' ? 'shut' : s));
197
+ const end = (e) => {
198
+ if (e.target === el && e.propertyName === 'transform')
199
+ done();
200
+ };
201
+ el?.addEventListener('transitionend', end);
202
+ const late = window.setTimeout(done, 700);
203
+ return () => {
204
+ el?.removeEventListener('transitionend', end);
205
+ window.clearTimeout(late);
206
+ };
207
+ }, [state]);
208
+ // A finger on the menu or the strip pulls the pane along the line; a scroll stays a scroll.
209
+ useEffect(() => {
210
+ const el = pane.current;
211
+ const over = layer.current;
212
+ if (state !== 'open' || !el || !over)
213
+ return;
214
+ let grip = null;
215
+ let pulling = false;
216
+ let at = 0;
217
+ let last = { along: 0, t: 0 };
218
+ let speed = 0;
219
+ const down = (e) => {
220
+ if (e.pointerType === 'mouse' && e.button !== 0)
221
+ return;
222
+ grip = { id: e.pointerId, x: e.clientX, y: e.clientY, reach: travel(el, over) };
223
+ pulling = false;
224
+ at = grip.reach;
225
+ last = { along: 0, t: e.timeStamp };
226
+ speed = 0;
227
+ };
228
+ const move = (e) => {
229
+ if (!grip || e.pointerId !== grip.id)
230
+ return;
231
+ const dx = e.clientX - grip.x;
232
+ if (!pulling) {
233
+ const a = axis(dx, e.clientY - grip.y);
234
+ if (a === null)
235
+ return;
236
+ if (a === 'y') {
237
+ grip = null;
238
+ return;
239
+ }
240
+ pulling = true;
241
+ over.setPointerCapture?.(e.pointerId);
242
+ el.style.setProperty('transition', 'none');
243
+ }
244
+ e.preventDefault();
245
+ at = pull(grip.reach, dx, way, grip.reach);
246
+ const along = dx * way;
247
+ const dt = e.timeStamp - last.t;
248
+ if (dt > 0)
249
+ speed = (along - last.along) / dt;
250
+ last = { along, t: e.timeStamp };
251
+ el.style.setProperty('--drawer-at', `${at}px`);
252
+ };
253
+ const up = (e) => {
254
+ if (!grip || e.pointerId !== grip.id)
255
+ return;
256
+ const reach = grip.reach;
257
+ grip = null;
258
+ if (!pulling)
259
+ return;
260
+ pulling = false;
261
+ el.style.setProperty('transition', still ? 'none' : MOTION);
262
+ // The press that ends a pull is not a tap on the strip.
263
+ const swallow = (c) => {
264
+ c.stopPropagation();
265
+ c.preventDefault();
266
+ };
267
+ window.addEventListener('click', swallow, { capture: true, once: true });
268
+ window.setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
269
+ if (e.type === 'pointerup' && !settle(at, reach, speed))
270
+ close();
271
+ else
272
+ el.style.setProperty('--drawer-at', OPEN);
273
+ };
274
+ over.addEventListener('pointerdown', down);
275
+ over.addEventListener('pointermove', move);
276
+ over.addEventListener('pointerup', up);
277
+ over.addEventListener('pointercancel', up);
278
+ return () => {
279
+ over.removeEventListener('pointerdown', down);
280
+ over.removeEventListener('pointermove', move);
281
+ over.removeEventListener('pointerup', up);
282
+ over.removeEventListener('pointercancel', up);
283
+ };
284
+ }, [state, way, still, close]);
285
+ return {
286
+ state,
287
+ open,
288
+ close,
289
+ depart,
290
+ drop,
291
+ window: useCallback((node) => {
292
+ win.current = element(node);
293
+ }, []),
294
+ pane: useCallback((node) => {
295
+ pane.current = element(node);
296
+ }, []),
297
+ layer: useCallback((node) => {
298
+ layer.current = element(node);
299
+ }, []),
300
+ slide: state === 'shut' ? null : SLIDE,
301
+ };
302
+ }
303
+ /**
304
+ * The pane while the menu is out: raised over the floor, its edge drawn and a
305
+ * drop falling on the menu, clipped to its corners. Spread on the pane beside
306
+ * `slide`; `Lift` is its fill.
307
+ */
308
+ export const RAISED = {
309
+ z: 'var(--z-raised)',
310
+ overflow: 'hidden',
311
+ boxShadow: '0 0 0 1px var(--pane-edge), var(--shadow-floating)',
312
+ };
313
+ /**
314
+ * The raised pane's fill: the pane's own material composited opaque, a step
315
+ * brighter than the floor the menu stands on, in both themes. Drawn UNDER the
316
+ * pane's content (`--z-under` inside the pane's own stacking) rather than as the
317
+ * pane's background, which is the frame's and a host may paint it at rest.
318
+ */
319
+ export function Lift() {
320
+ return _jsx(YStack, { "data-slot": "lift", position: "absolute", inset: 0, bg: "var(--pane-solid)", z: "var(--z-under)", pointerEvents: "none" });
321
+ }
322
+ /**
323
+ * The menu's layer: the menu at the start on the floor, and over the strip of
324
+ * pane left in sight the press that closes it. Drawn while the drawer is not shut.
325
+ */
326
+ export function Layer({ drawer, top = 0, children }) {
327
+ const { t } = useI18n();
328
+ return (_jsxs(YStack, { ref: drawer.layer, role: "dialog", "aria-modal": true, "aria-label": t('Menu'), tabIndex: -1, "data-slot": "drawer", "data-state": drawer.state, position: "absolute", t: top, b: 0, insetInlineStart: 0, insetInlineEnd: 0, outlineStyle: "none", $md: { display: 'none' }, children: [_jsx(YStack, { "data-slot": "menu", position: "absolute", t: 0, b: 0, insetInlineStart: 0, width: OPEN, bg: "var(--pane-floor)", pt: "var(--safe-top)", pb: "max(var(--pane-gap), var(--safe-bottom))", ...{ paddingInlineStart: 'max(var(--safe-left), var(--safe-right))', touchAction: 'pan-y' }, children: children }), drawer.state === 'open' ? (_jsx(YStack, { render: "button", "data-slot": "drawer-close", "aria-label": t('Close sidebar'), onPress: drawer.close, position: "absolute", t: 0, b: 0, insetInlineEnd: 0, z: "var(--z-panel)", bg: "transparent", borderWidth: 0, cursor: "pointer", ...{ insetInlineStart: OPEN, touchAction: 'none' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' } })) : null] }));
329
+ }
330
+ /** The menu's foot: the account (its menu opens above it) and the accent New chat. */
331
+ export function Foot({ host, onChat, onOrg }) {
332
+ const { t } = useI18n();
333
+ return (_jsxs(XStack, { "data-slot": "drawer-foot", items: "center", justify: "space-between", gap: "$2", pt: "$2", px: "$1", shrink: 0, children: [_jsx(Me, { host: host, narrow: true, above: true, onOrg: onOrg }), _jsxs(XStack, { render: "button", "data-slot": "drawer-chat", "aria-label": t('New chat'), onPress: onChat, height: 44, px: "$4", gap: "$2", rounded: 9999, items: "center", justify: "center", bg: "var(--accent)", borderWidth: 0, cursor: "pointer", hoverStyle: { bg: 'var(--accent-hover)' }, pressStyle: { bg: 'var(--accent-hover)' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(SquarePen, { size: 18, color: "var(--accent-foreground)", "aria-hidden": true }), _jsx(Text, { fontSize: "$4", fontWeight: "600", color: "var(--accent-foreground)", children: t('Chat') })] })] }));
334
+ }
package/dist/Me.d.ts CHANGED
@@ -26,8 +26,8 @@ export declare function Me({ host, narrow, above, onOrg, }: {
26
26
  /** The rail's: the face alone, the menu opening beside it. */
27
27
  narrow?: boolean;
28
28
  /**
29
- * The menu opens above the face, not beside it: the rail laid along a phone
30
- * drawer's foot. That copy leaves ⌘/ to the rail's own, so one press opens one dialog.
29
+ * The menu opens above the face, not beside it: the foot of a phone's menu
30
+ * (Drawer.tsx). That copy leaves ⌘/ to the rail's own, so one press opens one dialog.
31
31
  */
32
32
  above?: boolean;
33
33
  /** Work in another organization: picked, then the app opened in it. */
package/dist/Me.js CHANGED
@@ -199,7 +199,9 @@ export function Me({ host, narrow = false, above = false, onOrg, }) {
199
199
  // Clear of the rail's mark (or the row) it opens from, and flush with its foot.
200
200
  offset: 8, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(XStack, { ref: (el) => {
201
201
  row.current = el instanceof HTMLElement ? el : null;
202
- }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), working ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: working })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": t("Account"), onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: host.person.email || working })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: t("Organizations") }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: t("Create organization") }) }), _jsx(DropdownMenuItem, { onSelect: () => out(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: t("Organization settings") }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: t("Open in Hanzo Team") }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: t("Create a team") }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: choose, children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: t("All organizations") }) })) : null] })) : null] }), team ? (_jsx(DropdownMenuItem, { "data-slot": "me-team", onSelect: () => team.name !== current && onOrg(team.name), children: _jsx(Line, { icon: _jsx(Mark, { org: team }), trail: team.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: `${team.display} · ${teamPlan || 'No plan'}` }) })) : null, personal ? (_jsx(DropdownMenuItem, { "data-slot": "me-personal", onSelect: () => personal.name !== current && onOrg(personal.name), actionsVisibility: "always", actions: ownPlan ? undefined : (_jsx(Button, { size: "xs", variant: "outline", onPress: getPro, children: t("Get Pro") })), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), trail: personal.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: `Personal · ${ownPlan || 'No plan'}` }) })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: t("Personalization") }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: t("Profile") }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: t("Settings") }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site('/beta/status')), children: _jsx(Line, { icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), children: t("Beta") }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: t("Help") }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: t("Keyboard shortcuts") }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: t("Log out") }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple })] }));
202
+ }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48,
203
+ // A thumb's 44 on a touch screen.
204
+ $touchable: narrow ? { width: 44, minH: 44 } : undefined, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), working ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: working })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": t("Account"), onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: host.person.email || working })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: t("Organizations") }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: t("Create organization") }) }), _jsx(DropdownMenuItem, { onSelect: () => out(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: t("Organization settings") }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: t("Open in Hanzo Team") }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: t("Create a team") }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: choose, children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: t("All organizations") }) })) : null] })) : null] }), team ? (_jsx(DropdownMenuItem, { "data-slot": "me-team", onSelect: () => team.name !== current && onOrg(team.name), children: _jsx(Line, { icon: _jsx(Mark, { org: team }), trail: team.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: `${team.display} · ${teamPlan || 'No plan'}` }) })) : null, personal ? (_jsx(DropdownMenuItem, { "data-slot": "me-personal", onSelect: () => personal.name !== current && onOrg(personal.name), actionsVisibility: "always", actions: ownPlan ? undefined : (_jsx(Button, { size: "xs", variant: "outline", onPress: getPro, children: t("Get Pro") })), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), trail: personal.name === current ? _jsx(Check, { size: 16, "aria-label": t("Current") }) : null, children: `Personal · ${ownPlan || 'No plan'}` }) })) : null, _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: t("Personalization") }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: t("Profile") }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: t("Settings") }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site('/beta/status')), children: _jsx(Line, { icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), children: t("Beta") }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: t("Help") }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: t("Keyboard shortcuts") }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: t("Log out") }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple })] }));
203
205
  }
204
206
  /** The app's keys (help.ts `KEYS`), one row each, spelled for this platform. */
205
207
  function Shortcuts({ open, onOpenChange, apple }) {
package/dist/Palette.js CHANGED
@@ -164,6 +164,12 @@ const obj = (v) => (v && typeof v === 'object' ? v : {});
164
164
  const str = (v) => (typeof v === 'string' ? v : '');
165
165
  const list = (v) => (Array.isArray(v) ? v : []);
166
166
  /** `/v1/projects`: a project by its slug, found by its name, slug and repository. */
167
+ /**
168
+ * The builder as a frame without it holds it (hanzo.team's Shell): nobody signed
169
+ * in to it, so `useSessions` reads no runs. The hook reads `api`, `token`, `org`
170
+ * and `person` off whatever it is handed, and handed nothing it throws.
171
+ */
172
+ const NOBODY = { api: '', token: () => null, org: null, person: null };
167
173
  const projects = (body) => (Array.isArray(body) ? body : list(obj(body).data))
168
174
  .map(obj)
169
175
  .filter((p) => str(p.slug))
@@ -187,7 +193,7 @@ export function Palette({ onClose, go, actions: frame = [], mode = 'chat', host,
187
193
  const known = useRead('/v1/tool/skills?activated=true', skills);
188
194
  const plugged = useRead('/v1/tool/mcp/servers', connectors);
189
195
  const signOut = useSignOut();
190
- const runs = useSessions(host ?? null);
196
+ const runs = useSessions(host ?? NOBODY);
191
197
  const { open: opened } = useBetas();
192
198
  const groups = useMemo(() => {
193
199
  const { top, deep } = places(host, go);
package/dist/Rail.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare function Toggle({ open, onPress, label }: {
31
31
  onPress: () => void;
32
32
  label?: string;
33
33
  }): import("react").JSX.Element;
34
- export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev, onStudio, onTabs, go, onOrg, }: {
34
+ export declare function Rail({ host, mode, at, open, toggle, rows, onChat, onDev, onStudio, onTabs, go, onOrg, }: {
35
35
  host: Host;
36
36
  mode: Place['mode'];
37
37
  /** Dev's builder address, or null outside Dev. */
@@ -40,8 +40,8 @@ export declare function Rail({ host, mode, at, open, toggle, flat, onChat, onDev
40
40
  open: boolean;
41
41
  /** Lead with the sidebar's toggle; absent where the host's bar holds it. */
42
42
  toggle?: () => void;
43
- /** Laid along the foot of the phone's drawer rather than down the window's edge. */
44
- flat?: boolean;
43
+ /** Listed as rows in a phone's menu rather than marks down the window's edge. */
44
+ rows?: boolean;
45
45
  /** To Chat, Dev and Studio, each where the reader left it. */
46
46
  onChat: () => void;
47
47
  onDev: () => void;
package/dist/Rail.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The app's rail: its products as marks down the window's left edge, in one
4
4
  // order — Chat, Dev, Studio, Tabs, Library, Scheduled tasks, Customize — then
5
5
  // More (Team, on a host of its own, then the apps in beta this reader may open
@@ -24,16 +24,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
24
24
  //
25
25
  // The sidebar's toggle leads it (`toggle`) where the host has no bar of its own:
26
26
  // a browser tab. A host that is a window draws the toggle in its bar beside Back
27
- // and Forward (App.tsx `bar`), and its rail starts with Chat. On a phone the
28
- // rail lies along the foot of the drawer (`flat`), the account at its end; the
29
- // drawer is too narrow for every mark, so Library, Scheduled tasks and
30
- // Customize lead More there.
27
+ // and Forward (App.tsx `bar`), and its rail starts with Chat. A phone has no
28
+ // rail: its menu (Drawer.tsx) lists the places as rows (`rows`) under the mode's
29
+ // own — Studio, Tabs, then More, which Library, Scheduled tasks and Customize
30
+ // lead there. Chat and Dev are the phone's bar's (Top.tsx), and the account is
31
+ // the menu's foot.
31
32
  import { useI18n } from '@hanzo/i18n/react';
32
33
  import { Clock, Code2, Ellipsis, FlaskConical, Library, MessageSquare, PanelLeft, Puzzle, Sparkles, SquareTerminal, Users, WandSparkles } from 'lucide-react';
33
34
  import { useState } from 'react';
34
- import { XStack, YStack } from '@hanzo/gui';
35
+ import { Text, XStack, YStack } from '@hanzo/gui';
35
36
  import { route } from '@hanzo/build';
36
37
  import { DropdownMenu } from '@hanzo/ui';
38
+ import { SidebarItem } from '@hanzo/ui/chat';
37
39
  import { BetasDialog, useBetas } from './Betas.js';
38
40
  import { out } from './help.js';
39
41
  import { BETAS } from './lib/betas.js';
@@ -67,11 +69,21 @@ export function Mark({ label, on = false, onPress, children, ...rest }) {
67
69
  // A thumb's 44px on a touch screen.
68
70
  $touchable: { width: 44, height: 44 }, ...rest, children: children }));
69
71
  }
72
+ /** A place as a phone's menu lists it: a row on the sidebar's pitch, a thumb's 44 on a touch screen, lit where the reader is. */
73
+ function Row({ label, on = false, onPress, icon, ...rest }) {
74
+ const { t } = useI18n();
75
+ return (_jsx(SidebarItem, { rounded: "$4", minH: 31, py: 0, px: "$2", $touchable: { minH: 44 }, active: on, icon: icon, onPress: onPress, onKeyDown: (e) => {
76
+ if (!onPress || (e.key !== 'Enter' && e.key !== ' '))
77
+ return;
78
+ e.preventDefault?.();
79
+ onPress();
80
+ }, ...rest, children: _jsx(Text, { fontSize: "$3", color: on ? '$ink' : '$quiet', children: t(label) }) }));
81
+ }
70
82
  /** The sidebar's toggle: the same mark wherever a host draws it. */
71
83
  export function Toggle({ open, onPress, label }) {
72
84
  return (_jsx(Mark, { label: label ?? (open ? 'Close sidebar' : 'Open sidebar'), "data-slot": "sidebar-toggle", "aria-expanded": open, onPress: onPress, children: _jsx(PanelLeft, { size: 18, "aria-hidden": true }) }));
73
85
  }
74
- export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev, onStudio, onTabs, go, onOrg, }) {
86
+ export function Rail({ host, mode, at, open, toggle, rows = false, onChat, onDev, onStudio, onTabs, go, onOrg, }) {
75
87
  const { t } = useI18n();
76
88
  const here = mode === 'dev' ? spot(at) : null;
77
89
  const to = (p) => () => go(app(p));
@@ -83,23 +95,25 @@ export function Rail({ host, mode, at, open, toggle, flat = false, onChat, onDev
83
95
  // The apps in beta this reader may open, each where it lives.
84
96
  const granted = BETAS.filter((b) => b.href && opened(b.id));
85
97
  const rest = [
86
- ...(flat
98
+ ...(rows
87
99
  ? [
88
- { key: 'library', label: 'Library', icon: _jsx(Library, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.library) },
89
- { key: 'scheduled', label: 'Scheduled tasks', icon: _jsx(Clock, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.scheduled) },
90
- { key: 'customize', label: 'Customize', icon: _jsx(Puzzle, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.customize) },
100
+ { key: 'library', label: t('Library'), icon: _jsx(Library, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.library) },
101
+ { key: 'scheduled', label: t('Scheduled tasks'), icon: _jsx(Clock, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.scheduled) },
102
+ { key: 'customize', label: t('Customize'), icon: _jsx(Puzzle, { size: 16, "aria-hidden": true }), onSelect: to(RAISED.customize) },
91
103
  ...(team ? [{ type: 'separator', key: 'raised' }] : []),
92
104
  ]
93
105
  : []),
94
106
  ...(team
95
107
  ? [
96
- { key: 'team', label: 'Team', icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) },
108
+ { key: 'team', label: t('Team'), icon: _jsx(Users, { size: 16, "aria-hidden": true }), onSelect: () => out(WORKSPACE) },
97
109
  ...granted.map((b) => ({ key: `beta-${b.id}`, label: b.name, icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), onSelect: () => out(b.href) })),
98
110
  { type: 'separator', key: 'betas-rule' },
99
- { key: 'betas', label: 'Betas', icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), onSelect: () => setBetas(true) },
111
+ { key: 'betas', label: t('Betas'), icon: _jsx(FlaskConical, { size: 16, "aria-hidden": true }), onSelect: () => setBetas(true) },
100
112
  ]
101
113
  : []),
102
114
  ];
103
- const Stack = flat ? XStack : YStack;
104
- return (_jsxs(Stack, { "data-slot": "rail", render: "nav", "aria-label": t("Products"), ...(flat ? { pt: '$2', mt: '$2', borderTopWidth: 1, borderColor: '$borderColor', gap: '$1' } : { width: RAIL, flex: 1, pt: '$2', pb: '$2', gap: 8 }), shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: t("Chat"), on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Dev"), on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Studio"), on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Tabs"), on: mode === 'tabs', onPress: onTabs, "data-slot": "rail-tabs", children: _jsx(SquareTerminal, { size: 18, "aria-hidden": true }) }), flat ? null : (_jsxs(_Fragment, { children: [_jsx(Mark, { label: t("Library"), on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Scheduled tasks"), on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Customize"), on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) })] })), _jsx(BetasDialog, { open: betas, onOpenChange: setBetas, go: out }), rest.length ? (_jsx(DropdownMenu, { placement: flat ? 'top-start' : 'right-start', minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": t("More"), ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest })) : null, _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, above: flat, onOrg: onOrg })] }));
115
+ const betasDialog = _jsx(BetasDialog, { open: betas, onOpenChange: setBetas, go: out });
116
+ if (rows)
117
+ return (_jsxs(YStack, { "data-slot": "places", render: "nav", "aria-label": t('Products'), pt: "$2", shrink: 0, children: [_jsxs(YStack, { role: "list", gap: 2, children: [_jsx(YStack, { role: "listitem", children: _jsx(Row, { label: "Studio", on: mode === 'studio', onPress: onStudio, "data-slot": "place-studio", icon: _jsx(WandSparkles, { size: 16, "aria-hidden": true }) }) }), _jsx(YStack, { role: "listitem", children: _jsx(Row, { label: "Tabs", on: mode === 'tabs', onPress: onTabs, "data-slot": "place-tabs", icon: _jsx(SquareTerminal, { size: 16, "aria-hidden": true }) }) }), _jsx(YStack, { role: "listitem", children: _jsx(DropdownMenu, { placement: "bottom-start", minWidth: 220, trigger: _jsx(Row, { label: "More", "data-slot": "place-more", "aria-haspopup": "menu", icon: _jsx(Ellipsis, { size: 16, "aria-hidden": true }) }), items: rest }) })] }), betasDialog] }));
118
+ return (_jsxs(YStack, { "data-slot": "rail", render: "nav", "aria-label": t('Products'), width: RAIL, flex: 1, pt: "$2", pb: "$2", gap: 8, shrink: 0, items: "center", children: [toggle ? (_jsx(YStack, { pb: 8, children: _jsx(Toggle, { open: open, onPress: toggle }) })) : null, _jsx(Mark, { label: t("Chat"), on: mode === 'chat', onPress: onChat, "data-slot": "rail-chat", children: _jsx(MessageSquare, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Dev"), on: mode === 'dev' && !here, onPress: onDev, "data-slot": "rail-dev", children: _jsx(Code2, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Studio"), on: mode === 'studio', onPress: onStudio, "data-slot": "rail-studio", children: _jsx(WandSparkles, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Tabs"), on: mode === 'tabs', onPress: onTabs, "data-slot": "rail-tabs", children: _jsx(SquareTerminal, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Library"), on: here === 'library', onPress: to(RAISED.library), "data-slot": "rail-library", children: _jsx(Library, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Scheduled tasks"), on: here === 'scheduled', onPress: to(RAISED.scheduled), "data-slot": "rail-scheduled", children: _jsx(Clock, { size: 18, "aria-hidden": true }) }), _jsx(Mark, { label: t("Customize"), on: here === 'customize', onPress: to(RAISED.customize), "data-slot": "rail-customize", children: _jsx(Puzzle, { size: 18, "aria-hidden": true }) }), betasDialog, rest.length ? (_jsx(DropdownMenu, { placement: "right-start", minWidth: 220, trigger: _jsx(XStack, { render: "button", "aria-label": t("More"), ...{ title: 'More' }, "data-slot": "rail-more", width: MARK, height: MARK, rounded: "$4", items: "center", justify: "center", cursor: "pointer", opacity: 0.62, hoverStyle: { bg: '$hover', opacity: 1 }, pressStyle: { bg: '$raised' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, $touchable: { width: 44, height: 44 }, children: _jsx(Ellipsis, { size: 18, "aria-hidden": true }) }), items: rest })) : null, _jsx(YStack, { flex: 1 }), _jsx(Me, { host: host, narrow: true, onOrg: onOrg })] }));
105
119
  }
package/dist/Recents.js CHANGED
@@ -73,7 +73,9 @@ function List({ mode, rows, view, keep, open, reading, error, onOpen, threads, h
73
73
  // shows on hover and focus, a run's state is a dot at its end.
74
74
  const row = (r) => {
75
75
  const t = threads?.get(r.id);
76
- const item = (_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 31, px: "$2", pr: t || r.kind === 'dev' ? '$7' : '$2', ...(t ? { $touchable: { pr: 52 } } : null), onKeyDown: (e) => {
76
+ const item = (_jsx(SidebarItem, { "data-kind": r.kind, active: open(r), rounded: "$4", minH: 31, px: "$2", pr: t || r.kind === 'dev' ? '$7' : '$2',
77
+ // A thumb's 44 on a touch screen; a conversation's ⋯ is a thumb wide there and always there: the title stops short of it.
78
+ $touchable: t ? { minH: 44, pr: 52 } : { minH: 44 }, onKeyDown: (e) => {
77
79
  if (e.key !== 'Enter' && e.key !== ' ')
78
80
  return;
79
81
  e.preventDefault?.();
@@ -98,5 +100,5 @@ function List({ mode, rows, view, keep, open, reading, error, onOpen, threads, h
98
100
  : pinned
99
101
  ? null
100
102
  : note;
101
- return (_jsx(YStack, { "data-slot": "recents", flex: 1, minH: 0, children: _jsxs(ScrollView, { flex: 1, showsVerticalScrollIndicator: false, children: [error ? _jsx(Note, { children: error.message }) : null, _jsxs(YStack, { role: "list", "aria-labelledby": label, gap: 1, children: [pinned ? (_jsxs(YStack, { role: "listitem", gap: 1, children: [heading(PINNED), menus(_jsx(YStack, { role: "list", "aria-label": PINNED, gap: 1, children: pinned.rows.map(row) }))] })) : null, _jsxs(YStack, { role: "listitem", gap: 1, children: [heading('Recents', label, true), loose.length ? menus(rest) : rest] })] })] }) }));
103
+ return (_jsx(YStack, { "data-slot": "recents", flex: 1, minH: 0, children: _jsxs(ScrollView, { flex: 1, showsVerticalScrollIndicator: false, ...{ overscrollBehavior: 'contain' }, children: [error ? _jsx(Note, { children: error.message }) : null, _jsxs(YStack, { role: "list", "aria-labelledby": label, gap: 1, children: [pinned ? (_jsxs(YStack, { role: "listitem", gap: 1, children: [heading(PINNED), menus(_jsx(YStack, { role: "list", "aria-label": PINNED, gap: 1, children: pinned.rows.map(row) }))] })) : null, _jsxs(YStack, { role: "listitem", gap: 1, children: [heading('Recents', label, true), loose.length ? menus(rest) : rest] })] })] }) }));
102
104
  }
@@ -0,0 +1,24 @@
1
+ /** The pane left in sight at the end edge while the menu is open, px: a thumb's 44 and room around it. */
2
+ export declare const STRIP = 64;
3
+ /** The menu's widest, px: a phone held sideways, or a small tablet, keeps the menu a menu. */
4
+ export declare const REACH = 360;
5
+ /** How far the pane slides, as CSS: the window less the strip, up to `REACH`. `%` is the window's width wherever it is read. */
6
+ export declare const OPEN = "min(100% - 64px, 360px)";
7
+ /** Shut (no menu), open, or sliding home before the menu goes. */
8
+ export type State = 'shut' | 'open' | 'closing';
9
+ /** Which way the end side is, as a sign on the x axis. */
10
+ export declare const sign: (direction: string) => 1 | -1;
11
+ /** px a finger moves before a drag is a drag, and not a tap or a scroll. */
12
+ export declare const SLOP = 8;
13
+ /**
14
+ * What a finger's first movement is: a pull along the line (`x`), a scroll
15
+ * (`y`), or nothing yet. Along the line has to win clearly: a list scrolls on a
16
+ * slant.
17
+ */
18
+ export declare function axis(dx: number, dy: number): 'x' | 'y' | null;
19
+ /** Where the pane stands while a finger holds it, px from home: where it was, moved along the line, never past home or open. */
20
+ export declare function pull(from: number, dx: number, way: 1 | -1, reach: number): number;
21
+ /** A flick, px per ms along the line: faster than this decides by its way, whatever the distance. */
22
+ export declare const FLICK = 0.4;
23
+ /** Whether a pane let go at `at` px from home, moving `speed` px/ms along the line, stays open. */
24
+ export declare function settle(at: number, reach: number, speed: number): boolean;
package/dist/slide.js ADDED
@@ -0,0 +1,48 @@
1
+ // The phone's drawer as numbers (Drawer.tsx draws it): how far the pane slides,
2
+ // which way, and where a drag lets it go.
3
+ //
4
+ // The pane slides toward the END side of the line: right where text runs left to
5
+ // right, left under `dir="rtl"`. A transform has no logical form, so its sign is
6
+ // the one physical fact in the drawer, and it is read off the page
7
+ // (`getComputedStyle(…).direction`), never assumed. Everything else is logical:
8
+ // the menu stands at the start, the strip of pane left in sight at the end.
9
+ //
10
+ // A drag is measured ALONG the line: positive toward the end, whichever way the
11
+ // line runs, so the same numbers close an LTR drawer pulled left and an RTL one
12
+ // pulled right.
13
+ /** The pane left in sight at the end edge while the menu is open, px: a thumb's 44 and room around it. */
14
+ export const STRIP = 64;
15
+ /** The menu's widest, px: a phone held sideways, or a small tablet, keeps the menu a menu. */
16
+ export const REACH = 360;
17
+ /** How far the pane slides, as CSS: the window less the strip, up to `REACH`. `%` is the window's width wherever it is read. */
18
+ export const OPEN = `min(100% - ${STRIP}px, ${REACH}px)`;
19
+ /** Which way the end side is, as a sign on the x axis. */
20
+ export const sign = (direction) => (direction === 'rtl' ? -1 : 1);
21
+ /** px a finger moves before a drag is a drag, and not a tap or a scroll. */
22
+ export const SLOP = 8;
23
+ /**
24
+ * What a finger's first movement is: a pull along the line (`x`), a scroll
25
+ * (`y`), or nothing yet. Along the line has to win clearly: a list scrolls on a
26
+ * slant.
27
+ */
28
+ export function axis(dx, dy) {
29
+ const x = Math.abs(dx);
30
+ const y = Math.abs(dy);
31
+ if (x < SLOP && y < SLOP)
32
+ return null;
33
+ return x > y * 1.2 ? 'x' : 'y';
34
+ }
35
+ /** Where the pane stands while a finger holds it, px from home: where it was, moved along the line, never past home or open. */
36
+ export function pull(from, dx, way, reach) {
37
+ return Math.min(reach, Math.max(0, from + dx * way));
38
+ }
39
+ /** A flick, px per ms along the line: faster than this decides by its way, whatever the distance. */
40
+ export const FLICK = 0.4;
41
+ /** Whether a pane let go at `at` px from home, moving `speed` px/ms along the line, stays open. */
42
+ export function settle(at, reach, speed) {
43
+ if (speed <= -FLICK)
44
+ return false;
45
+ if (speed >= FLICK)
46
+ return true;
47
+ return at > reach / 2;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.97",
3
+ "version": "0.1.99",
4
4
  "description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",