@hanzo/build 0.2.56 → 0.2.57

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/lib/index.d.ts CHANGED
@@ -45,4 +45,5 @@ export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useCh
45
45
  export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar, type PromptOptions } from './prompt.tsx';
46
46
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker, type Artifact, type Listen } from './reply.tsx';
47
47
  export { frames, Page, Panel, PanelToggle, type Kind, type PageProps, type PanelProps, type Suggestion } from './panel.tsx';
48
+ export { kinds, Summary, useKinds, type Entry, type Section } from './summary.tsx';
48
49
  export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
package/lib/index.js CHANGED
@@ -40,4 +40,5 @@ export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useCh
40
40
  export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar } from './prompt.js';
41
41
  export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker } from './reply.js';
42
42
  export { frames, Page, Panel, PanelToggle } from './panel.js';
43
+ export { kinds, Summary, useKinds } from './summary.js';
43
44
  export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown } from './tabs.js';
package/lib/panel.d.ts CHANGED
@@ -63,6 +63,8 @@ export interface PageProps extends Acts {
63
63
  onForward?: () => void;
64
64
  /** The title the page gave itself. */
65
65
  onTitle?: (title: string) => void;
66
+ /** Pin the tab, or let it go. */
67
+ onPin?: (on: boolean) => void;
66
68
  suggested?: readonly Suggestion[];
67
69
  }
68
70
  /**
@@ -73,7 +75,7 @@ export interface PageProps extends Acts {
73
75
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
74
76
  * remote page takes its own origin, and withholding same-origin breaks it.
75
77
  */
76
- export declare function Page({ tab, onGo, onBack, onForward, onTitle, suggested, ...acts }: PageProps): import("react").JSX.Element;
78
+ export declare function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested, ...acts }: PageProps): import("react").JSX.Element;
77
79
  export interface PanelProps extends Acts {
78
80
  side: Deck;
79
81
  kinds: readonly Kind[];
package/lib/panel.js CHANGED
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  * the panel is a sheet over the page instead (`sheet`).
22
22
  */
23
23
  import { SizableText, XStack, YStack } from '@hanzo/gui';
24
- import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
24
+ import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
25
25
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
26
26
  import { useEffect, useMemo, useRef, useState } from 'react';
27
27
  import { createPortal } from 'react-dom';
@@ -163,7 +163,7 @@ function Skills({ skills, here }) {
163
163
  * page itself for a frame it may reach — and one that needs a page is dim on
164
164
  * an empty tab.
165
165
  */
166
- function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, }) {
166
+ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, onPin, }) {
167
167
  const shown = here ? { id: tab.id, ...here } : null;
168
168
  const none = !shown;
169
169
  const pick = () => {
@@ -197,6 +197,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
197
197
  acts.skills?.length ? _jsx(Skills, { skills: acts.skills, here: here }, "skills") : null,
198
198
  _jsx(Row, { label: "Open in a new window", disabled: none, onSelect: () => popOut(tab) }, "out"),
199
199
  _jsx(Row, { label: "Copy link", disabled: none || !tab.url, onSelect: () => tab.url && void navigator.clipboard?.writeText(tab.url) }, "copy"),
200
+ onPin ? _jsx(Row, { label: tab.pinned ? 'Unpin tab' : 'Pin tab', onSelect: () => onPin(!tab.pinned) }, "pin") : null,
200
201
  ].filter(Boolean);
201
202
  const page = [
202
203
  browser?.find ? _jsx(Row, { label: "Find in page", disabled: none, onSelect: () => shown && browser.find?.(shown) }, "find") : null,
@@ -223,7 +224,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
223
224
  * an answer wrote runs without `allow-same-origin`, never with our storage. A
224
225
  * remote page takes its own origin, and withholding same-origin breaks it.
225
226
  */
226
- export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ...acts }) {
227
+ export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested = [], ...acts }) {
227
228
  const Browse = useBrowse();
228
229
  const browser = useBrowser();
229
230
  const frame = useRef(null);
@@ -288,10 +289,10 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ..
288
289
  fontSize: 14,
289
290
  // The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
290
291
  textAlign: typing || typed ? 'left' : 'center',
291
- } }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
292
+ } }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser, onPin: onPin })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
292
293
  }
293
294
  const DRAG = 'text/x-hanzo-tab';
294
- /** One tab in the strip: its mark, its title, and its cross. */
295
+ /** One tab in the strip: its mark, its title, and its cross, or its pin when it is pinned. */
295
296
  function Leaf({ tab, on, side, index, label, icon }) {
296
297
  return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$1", pl: "$2.5", pr: "$1", height: 30,
297
298
  // As wide as a title wants up to a browser tab's width, sharing the strip
@@ -299,7 +300,7 @@ function Leaf({ tab, on, side, index, label, icon }) {
299
300
  flexBasis: 236, shrink: 1, minW: 56, maxW: 236, rounded: 8, borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
300
301
  // A middle press shuts any tab, as a browser's does; the chosen one also has its cross.
301
302
  onAuxClick: (e) => {
302
- if (e.button !== 1)
303
+ if (e.button !== 1 || tab.pinned)
303
304
  return;
304
305
  e.preventDefault();
305
306
  side.shut(tab.id);
@@ -337,13 +338,13 @@ function Leaf({ tab, on, side, index, label, icon }) {
337
338
  queueMicrotask(() => document.getElementById(`side-tab-${next.id}`)?.focus());
338
339
  return;
339
340
  }
340
- if (e.key === 'Delete' || e.key === 'Backspace') {
341
+ if ((e.key === 'Delete' || e.key === 'Backspace') && !tab.pinned) {
341
342
  e.preventDefault?.();
342
343
  side.shut(tab.id);
343
344
  }
344
- }, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
345
+ }, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), tab.pinned ? (_jsx(XStack, { render: "button", role: "button", "aria-label": `Unpin ${label}`, tabIndex: -1, onPress: () => side.unpin(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.55, hoverStyle: { bg: '$hover', opacity: 1 }, children: _jsx(Pin, { size: 12 }) })) : (_jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
345
346
  // The chosen tab's cross: the others keep their room for their titles when the strip is shared.
346
- display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) })] }));
347
+ display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) }))] }));
347
348
  }
348
349
  /** The panel: the strip, and the chosen tab. */
349
350
  export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
@@ -366,7 +367,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
366
367
  { key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
367
368
  ] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
368
369
  // What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
369
- position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
370
+ position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), onPin: (on) => (on ? side.pin(tab.id) : side.unpin(tab.id)), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
370
371
  if (!sheet || typeof document === 'undefined')
371
372
  return body;
372
373
  // A phone: a sheet from the foot of the window over a dim, the same panel in it.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A surface's summary: what it made and what went into it, as sections of
3
+ * rows, each one press from a tab beside it. Chat draws its Outputs and Sources
4
+ * here; Studio its artifacts. The rows are the caller's: this draws them and
5
+ * knows nothing of a conversation.
6
+ *
7
+ * `Summary` is the popover off the header's outline mark: each section's
8
+ * heading with what it adds at its end, its leading action (Chat's Create file
9
+ * or site), its rows, and one quiet line when it has none. `useKinds` makes
10
+ * each section a kind of tab for `Panel`, the same rows in the panel.
11
+ */
12
+ import { type ReactNode } from 'react';
13
+ import type { Kind } from './panel.tsx';
14
+ /** One row: what it is called, its mark, and what pressing it opens. */
15
+ export interface Entry {
16
+ id: string;
17
+ title: string;
18
+ icon?: ReactNode;
19
+ /** A quieter second line: what a lookup asked, what came back. */
20
+ detail?: string;
21
+ open?: () => void;
22
+ }
23
+ /** A section: its heading, its rows, and what it says with none. */
24
+ export interface Section {
25
+ id: string;
26
+ title: string;
27
+ entries: readonly Entry[];
28
+ /** The line drawn when it has no rows; none, and it draws only its heading. */
29
+ empty?: string;
30
+ /** At the heading's end: what adds to it (Chat's `+` on Sources). */
31
+ action?: ReactNode;
32
+ /** A row before the others that does something rather than opens something. */
33
+ lead?: {
34
+ title: string;
35
+ run: () => void;
36
+ };
37
+ }
38
+ /**
39
+ * The summary, from the outline mark in a header. `onOpen` runs after a row is
40
+ * pressed, for the host to show the panel the row opened a tab in.
41
+ */
42
+ export declare function Summary({ sections, onOpen, label }: {
43
+ sections: readonly Section[];
44
+ onOpen?: () => void;
45
+ label?: string;
46
+ }): import("react").JSX.Element;
47
+ /** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
48
+ export declare function kinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
49
+ /** `kinds`, kept while the sections are the same. */
50
+ export declare function useKinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
package/lib/summary.js ADDED
@@ -0,0 +1,60 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A surface's summary: what it made and what went into it, as sections of
4
+ * rows, each one press from a tab beside it. Chat draws its Outputs and Sources
5
+ * here; Studio its artifacts. The rows are the caller's: this draws them and
6
+ * knows nothing of a conversation.
7
+ *
8
+ * `Summary` is the popover off the header's outline mark: each section's
9
+ * heading with what it adds at its end, its leading action (Chat's Create file
10
+ * or site), its rows, and one quiet line when it has none. `useKinds` makes
11
+ * each section a kind of tab for `Panel`, the same rows in the panel.
12
+ */
13
+ import { useMemo, useState } from 'react';
14
+ import { SizableText, XStack, YStack } from '@hanzo/gui';
15
+ import { ListTodo } from '@hanzogui/lucide-icons-2';
16
+ import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/ui';
17
+ /** A row of the summary: its mark and its name, one press from a tab. */
18
+ function Line({ icon, children, detail, onPress, quiet = false }) {
19
+ return (_jsxs(XStack, { render: onPress ? 'button' : undefined, role: onPress ? 'button' : undefined, onPress: onPress, items: "center", gap: "$2.5", px: "$2", minH: 30, width: "100%", rounded: "$3", cursor: onPress ? 'pointer' : undefined, hoverStyle: onPress ? { bg: '$hover' } : undefined, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [icon ? (_jsx(XStack, { width: 16, justify: "center", shrink: 0, opacity: 0.75, children: icon })) : null, _jsxs(YStack, { flex: 1, minW: 0, py: detail ? '$1' : 0, children: [_jsx(SizableText, { fontSize: 13, color: quiet ? '$soft' : '$ink', numberOfLines: 1, children: children }), detail ? (_jsx(SizableText, { fontSize: 12, color: "$soft", numberOfLines: 2, children: detail })) : null] })] }));
20
+ }
21
+ /** A section's heading, with what it adds at its end. */
22
+ const Head = ({ children, action }) => (_jsxs(XStack, { items: "center", justify: "space-between", px: "$2", pt: "$1.5", pb: "$1", minH: 28, children: [_jsx(SizableText, { fontSize: 13, color: "$soft", children: children }), action] }));
23
+ /** One section's rows; `done` runs after a row is pressed (the popover puts itself away). */
24
+ function Rows({ section, done, details = false }) {
25
+ const { entries, empty, lead } = section;
26
+ return (_jsxs(_Fragment, { children: [lead ? (_jsx(Line, { quiet: true, onPress: () => {
27
+ done?.();
28
+ lead.run();
29
+ }, children: lead.title })) : null, entries.map((e) => (_jsx(Line, { icon: e.icon, detail: details ? e.detail : undefined, onPress: e.open
30
+ ? () => {
31
+ done?.();
32
+ e.open?.();
33
+ }
34
+ : undefined, children: e.title }, e.id))), !entries.length && empty ? _jsx(Line, { quiet: true, children: empty }) : null] }));
35
+ }
36
+ /**
37
+ * The summary, from the outline mark in a header. `onOpen` runs after a row is
38
+ * pressed, for the host to show the panel the row opened a tab in.
39
+ */
40
+ export function Summary({ sections, onOpen, label = 'Summary' }) {
41
+ const [open, setOpen] = useState(false);
42
+ const done = () => {
43
+ setOpen(false);
44
+ onOpen?.();
45
+ };
46
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { children: _jsx(XStack, { render: "button", "data-slot": "summary-toggle", "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": open, ...{ title: label }, width: 32, height: 32, rounded: "$3", items: "center", justify: "center", cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(ListTodo, { size: 16, "aria-hidden": true }) }) }), _jsx(PopoverContent, { solid: true, align: "end", p: 6, width: 300, children: _jsx(YStack, { "data-slot": "summary", role: "dialog", "aria-label": label, gap: 1, self: "stretch", width: "100%", maxH: "min(70dvh, 520px)", overflowY: "auto", overflowX: "hidden", children: sections.map((s, i) => (_jsxs(YStack, { gap: 1, children: [i ? _jsx(XStack, { height: 1, bg: "$borderColor", my: "$1.5", mx: "$1" }) : null, _jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, done: done })] }, s.id))) }) })] }));
47
+ }
48
+ /** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
49
+ export function kinds(sections, icons = {}) {
50
+ return sections.map((s) => ({
51
+ id: s.id,
52
+ label: s.title,
53
+ icon: icons[s.id],
54
+ render: () => (_jsxs(YStack, { gap: 1, children: [_jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, details: true })] })),
55
+ }));
56
+ }
57
+ /** `kinds`, kept while the sections are the same. */
58
+ export function useKinds(sections, icons) {
59
+ return useMemo(() => kinds(sections, icons), [sections, icons]);
60
+ }
package/lib/tabs.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface Tab {
11
11
  /** The addresses a page came from, oldest first, and those Back stepped over, nearest first. */
12
12
  back?: string[];
13
13
  ahead?: string[];
14
+ /** Leads the strip, and closes only on purpose. */
15
+ pinned?: boolean;
14
16
  }
15
17
  /** The tabs in a scope, in order, and the chosen one. */
16
18
  export interface Tabs {
@@ -26,8 +28,12 @@ export declare function opened(was: Tabs, tab: Omit<Tab, 'id'> & {
26
28
  }): Tabs;
27
29
  /** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
28
30
  export declare function shut(was: Tabs, id: string): Tabs;
29
- /** A tab moved to `to`, the others closing up around it. */
31
+ /** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
30
32
  export declare function moved(was: Tabs, id: string, to: number): Tabs;
33
+ /** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
34
+ export declare function pinned(was: Tabs, id: string, on: boolean): Tabs;
35
+ /** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
36
+ export declare function cleared(was: Tabs, keep?: (t: Tab) => boolean): Tabs;
31
37
  export interface Deck extends Tabs {
32
38
  open: (tab: Omit<Tab, 'id'> & {
33
39
  id?: string;
@@ -35,6 +41,11 @@ export interface Deck extends Tabs {
35
41
  pick: (id: string) => void;
36
42
  shut: (id: string) => void;
37
43
  move: (id: string, to: number) => void;
44
+ /** Pin a tab: it leads the strip and closes only on purpose. */
45
+ pin: (id: string) => void;
46
+ unpin: (id: string) => void;
47
+ /** Close every tab but the pinned ones and those `keep` names. */
48
+ clear: (keep?: (t: Tab) => boolean) => void;
38
49
  /** Point a page somewhere else: where it was goes on its Back, and Forward is forgotten. */
39
50
  go: (id: string, url: string) => void;
40
51
  /** A page's Back and Forward. */
package/lib/tabs.js CHANGED
@@ -15,6 +15,11 @@
15
15
  * A page keeps the addresses it went through (`back`, `ahead`), so its bar's
16
16
  * Back and Forward walk them as a browser's do.
17
17
  *
18
+ * A PINNED tab leads the strip and closes only on purpose: its own close (`shut`)
19
+ * takes it, and nothing that closes tabs in bulk (`clear`) does. Pinned tabs
20
+ * keep their order among themselves, the others theirs; a tab moved stays in
21
+ * its group.
22
+ *
18
23
  */
19
24
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
20
25
  export const PAGE = 'page';
@@ -53,7 +58,7 @@ export function read(scope) {
53
58
  if (raw) {
54
59
  const v = JSON.parse(raw);
55
60
  // An object URL dies with the document that minted it: a page holding only one is not kept.
56
- const tabs = (Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:'));
61
+ const tabs = ranked((Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:')));
57
62
  held = { tabs, at: tabs.some((t) => t.id === v.at) ? v.at : (tabs[0]?.id ?? null) };
58
63
  }
59
64
  }
@@ -74,6 +79,10 @@ function write(scope, next) {
74
79
  }
75
80
  announce();
76
81
  }
82
+ /** Pinned tabs first, each group in the order it had. */
83
+ function ranked(tabs) {
84
+ return [...tabs.filter((t) => t.pinned), ...tabs.filter((t) => !t.pinned)];
85
+ }
77
86
  let minted = 0;
78
87
  const mint = () => `p${Date.now().toString(36)}${(minted++).toString(36)}`;
79
88
  /** A tab opened: a kind already open is chosen, a page already open on the same thing is chosen. */
@@ -82,7 +91,7 @@ export function opened(was, tab) {
82
91
  if (same)
83
92
  return { ...was, at: same.id };
84
93
  const id = tab.id ?? (tab.kind === PAGE ? mint() : tab.kind);
85
- return { tabs: [...was.tabs, { ...tab, id }], at: id };
94
+ return { tabs: ranked([...was.tabs, { ...tab, id }]), at: id };
86
95
  }
87
96
  /** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
88
97
  export function shut(was, id) {
@@ -94,16 +103,37 @@ export function shut(was, id) {
94
103
  return { ...was, tabs };
95
104
  return { tabs, at: (tabs[gone] ?? tabs[gone - 1])?.id ?? null };
96
105
  }
97
- /** A tab moved to `to`, the others closing up around it. */
106
+ /** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
98
107
  export function moved(was, id, to) {
99
108
  const from = was.tabs.findIndex((t) => t.id === id);
100
109
  if (from < 0)
101
110
  return was;
102
111
  const tabs = was.tabs.slice();
103
112
  const [tab] = tabs.splice(from, 1);
104
- tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, tab);
113
+ const pins = tabs.filter((t) => t.pinned).length;
114
+ const [low, high] = tab.pinned ? [0, pins] : [pins, tabs.length];
115
+ tabs.splice(Math.max(low, Math.min(high, to)), 0, tab);
105
116
  return { ...was, tabs };
106
117
  }
118
+ /** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
119
+ export function pinned(was, id, on) {
120
+ const tab = was.tabs.find((t) => t.id === id);
121
+ if (!tab || Boolean(tab.pinned) === on)
122
+ return was;
123
+ const rest = was.tabs.filter((t) => t.id !== id);
124
+ const pins = rest.filter((t) => t.pinned);
125
+ const others = rest.filter((t) => !t.pinned);
126
+ const plain = { ...tab };
127
+ delete plain.pinned;
128
+ return { ...was, tabs: on ? [...pins, { ...tab, pinned: true }, ...others] : [...pins, plain, ...others] };
129
+ }
130
+ /** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
131
+ export function cleared(was, keep = () => false) {
132
+ const tabs = was.tabs.filter((t) => t.pinned || keep(t));
133
+ if (tabs.length === was.tabs.length)
134
+ return was;
135
+ return { tabs, at: tabs.some((t) => t.id === was.at) ? was.at : (tabs[0]?.id ?? null) };
136
+ }
107
137
  /** How many addresses a page's Back keeps. */
108
138
  const DEPTH = 50;
109
139
  /** A page pointed at `url`, where it was on its Back. */
@@ -136,6 +166,9 @@ export function useDeck(scope, seed) {
136
166
  pick: (id) => change((was) => ({ ...was, at: id })),
137
167
  shut: (id) => change((was) => shut(was, id)),
138
168
  move: (id, to) => change((was) => moved(was, id, to)),
169
+ pin: (id) => change((was) => pinned(was, id, true)),
170
+ unpin: (id) => change((was) => pinned(was, id, false)),
171
+ clear: (keep) => change((was) => cleared(was, keep)),
139
172
  go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? went(t, url) : t)) })),
140
173
  back: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'back') : t)) })),
141
174
  forward: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'forward') : t)) })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.56",
3
+ "version": "0.2.57",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",