@columenlabs/lumen-ui 0.1.151 → 0.1.153
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/add-column.js +2 -2
- package/dist/assistant/settings.js +2 -2
- package/dist/form-issues.d.ts +4 -1
- package/dist/form-issues.js +7 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lumen-drawer.d.ts +1 -1
- package/dist/lumen-drawer.js +4 -7
- package/dist/page.d.ts +1 -1
- package/dist/shell.d.ts +6 -2
- package/dist/shell.js +3 -2
- package/dist/status-bar.d.ts +70 -0
- package/dist/status-bar.js +224 -0
- package/dist/status-messages.d.ts +13 -0
- package/dist/status-messages.js +4 -4
- package/package.json +2 -2
package/dist/add-column.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { HashIcon, TypeIcon } from 'lucide-react';
|
|
2
|
+
import { HashIcon, PlusIcon, TypeIcon } from 'lucide-react';
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { useAssistantName } from './assistant-context.js';
|
|
5
5
|
import { SaveButton } from './form-issues.js';
|
|
@@ -26,7 +26,7 @@ export function AddFormulaDialog({ open, onOpenChange, domain, formulas, onAdd,
|
|
|
26
26
|
}, [open]);
|
|
27
27
|
const issue = formulaIssues([...formulas, draft], domain)[formulas.length] ?? null;
|
|
28
28
|
const readable = formulaOptions(domain, formulas);
|
|
29
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "flex max-h-[calc(100svh-2rem)] flex-col gap-0 [--dialog-pad:0px] sm:max-w-2xl max-md:h-svh max-md:max-h-svh max-md:max-w-full max-md:rounded-none", children: [_jsxs(DialogHeader, { className: "border-b px-4 py-3", children: [_jsx(DialogTitle, { className: "text-sm", children: "Add a formula column" }), _jsx(DialogDescription, { children: "A column computed from the other columns, like weight or upside. Saved with the view." })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(FormulaForm, { formula: draft, issue: issue, fields: readable, onChange: (p) => setDraft({ ...draft, ...p }) }) }), _jsxs(DialogFooter, { className: "border-t px-4 py-3", children: [_jsx(Button, { variant: "outline", size: "sm", onClick: () => onOpenChange(false), children: "Cancel" }), _jsx(SaveButton, { label: "Add column", icon:
|
|
29
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "flex max-h-[calc(100svh-2rem)] flex-col gap-0 [--dialog-pad:0px] sm:max-w-2xl max-md:h-svh max-md:max-h-svh max-md:max-w-full max-md:rounded-none", children: [_jsxs(DialogHeader, { className: "border-b px-4 py-3", children: [_jsx(DialogTitle, { className: "text-sm", children: "Add a formula column" }), _jsx(DialogDescription, { children: "A column computed from the other columns, like weight or upside. Saved with the view." })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(FormulaForm, { formula: draft, issue: issue, fields: readable, onChange: (p) => setDraft({ ...draft, ...p }) }) }), _jsxs(DialogFooter, { className: "border-t px-4 py-3", children: [_jsx(Button, { variant: "outline", size: "sm", onClick: () => onOpenChange(false), children: "Cancel" }), _jsx(SaveButton, { label: "Add column", icon: PlusIcon, issues: issue
|
|
30
30
|
? [
|
|
31
31
|
{
|
|
32
32
|
where: `Formula ${draft.label || draft.key}`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, ReceiptIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
|
|
3
|
+
import { CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, Link2Icon, LogInIcon, ReceiptIcon, SendIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
|
|
4
4
|
import { useId, useState } from 'react';
|
|
5
5
|
import { ConnectionLogo, LumenConnection, } from '../connection.js';
|
|
6
6
|
import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
|
|
@@ -298,7 +298,7 @@ function Allowance({ client }) {
|
|
|
298
298
|
: [{ where: 'What for', message: 'Say what it is for', target: whyId }]),
|
|
299
299
|
];
|
|
300
300
|
const ask = () => void apply('request', () => requestMore(Number(pick), why.trim()), 'Request sent').then((ok) => ok && setWhy(''));
|
|
301
|
-
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex flex-col gap-2 border-b pb-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "font-semibold text-2xl tabular-nums", children: n(a.left) }), _jsxs("span", { className: "text-muted-foreground text-sm", children: ["left of ", n(a.total), a.period === 'month' ? ' this month' : ''] })] }), _jsx(ShareMeter, { value: a.total > 0 ? a.used / a.total : 1, wide: true })] }), r && (_jsx(Row, { label: `Asked for ${n(r.amount)} · ${day(r.at)}`, info: r.why || undefined, children: _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [r.status === 'granted' && r.granted !== undefined && (_jsxs("span", { className: "tabular-nums", children: ["+", n(r.granted)] })), _jsx(RequestBadge, { r: r })] }) })), r?.status === 'open' ? (_jsx("p", { className: "pt-3 text-muted-foreground text-sm", children: "You can ask again once this one is decided." })) : (_jsxs("div", { className: "flex flex-col gap-2 pt-3", children: [_jsx(Input, { id: whyId, "aria-label": "What for", placeholder: "What it is for", className: "h-8 w-full", maxLength: 500, value: why, onChange: (e) => setWhy(e.target.value) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(LumenSelector, { label: "Amount", size: "sm", className: "w-40", value: pick, options: a.amounts.map((x) => ({ value: String(x), label: n(x) })), onChange: (v) => setAmount(v) }), _jsx(SaveButton, { issues: issues, label: "Request more", icon:
|
|
301
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex flex-col gap-2 border-b pb-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "font-semibold text-2xl tabular-nums", children: n(a.left) }), _jsxs("span", { className: "text-muted-foreground text-sm", children: ["left of ", n(a.total), a.period === 'month' ? ' this month' : ''] })] }), _jsx(ShareMeter, { value: a.total > 0 ? a.used / a.total : 1, wide: true })] }), r && (_jsx(Row, { label: `Asked for ${n(r.amount)} · ${day(r.at)}`, info: r.why || undefined, children: _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [r.status === 'granted' && r.granted !== undefined && (_jsxs("span", { className: "tabular-nums", children: ["+", n(r.granted)] })), _jsx(RequestBadge, { r: r })] }) })), r?.status === 'open' ? (_jsx("p", { className: "pt-3 text-muted-foreground text-sm", children: "You can ask again once this one is decided." })) : (_jsxs("div", { className: "flex flex-col gap-2 pt-3", children: [_jsx(Input, { id: whyId, "aria-label": "What for", placeholder: "What it is for", className: "h-8 w-full", maxLength: 500, value: why, onChange: (e) => setWhy(e.target.value) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(LumenSelector, { label: "Amount", size: "sm", className: "w-40", value: pick, options: a.amounts.map((x) => ({ value: String(x), label: n(x) })), onChange: (v) => setAmount(v) }), _jsx(SaveButton, { issues: issues, label: "Request more", icon: SendIcon, busy: busy === 'request', onSave: ask })] })] }))] }));
|
|
302
302
|
} }));
|
|
303
303
|
}
|
|
304
304
|
/** One request, opened in place to grant (any amount) or decline. */
|
package/dist/form-issues.d.ts
CHANGED
|
@@ -13,12 +13,15 @@ export interface FormIssue {
|
|
|
13
13
|
export declare function goToIssue(issue: FormIssue): void;
|
|
14
14
|
/**
|
|
15
15
|
* Save, and before it, while anything needs fixing, a chip with the count that lists each issue.
|
|
16
|
-
* Save stays tappable then and takes the person to the first issue instead of saving.
|
|
16
|
+
* Save stays tappable then and takes the person to the first issue instead of saving. Save is an
|
|
17
|
+
* icon (DESIGN.md, Actions: icon first): `label` is its tooltip and accessible name, never text.
|
|
17
18
|
*/
|
|
18
19
|
export declare function SaveButton({ issues, onSave, label, icon: Icon, busy, disabled, type, className, }: {
|
|
19
20
|
issues: FormIssue[];
|
|
20
21
|
onSave?: () => void;
|
|
22
|
+
/** The tooltip and accessible name. */
|
|
21
23
|
label?: string;
|
|
24
|
+
/** `null` is the check, as left out. */
|
|
22
25
|
icon?: ComponentType<{
|
|
23
26
|
className?: string;
|
|
24
27
|
}> | null;
|
package/dist/form-issues.js
CHANGED
|
@@ -4,8 +4,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
// issue: the editor opens its section or tab (`reveal`), then the field scrolls into view, takes
|
|
5
5
|
// the cursor and flashes red once. Nothing saves while an issue stands. Every editor and form in
|
|
6
6
|
// the suite uses this in place of a silent disabled Save.
|
|
7
|
-
import { CheckIcon, TriangleAlertIcon } from 'lucide-react';
|
|
7
|
+
import { CheckIcon, LoaderCircleIcon, TriangleAlertIcon } from 'lucide-react';
|
|
8
8
|
import { useState } from 'react';
|
|
9
|
+
import { IconButton } from './icon-button.js';
|
|
9
10
|
import { cn } from './lib/utils.js';
|
|
10
11
|
import { Button } from './ui/button.js';
|
|
11
12
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
|
|
@@ -37,7 +38,8 @@ export function goToIssue(issue) {
|
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* Save, and before it, while anything needs fixing, a chip with the count that lists each issue.
|
|
40
|
-
* Save stays tappable then and takes the person to the first issue instead of saving.
|
|
41
|
+
* Save stays tappable then and takes the person to the first issue instead of saving. Save is an
|
|
42
|
+
* icon (DESIGN.md, Actions: icon first): `label` is its tooltip and accessible name, never text.
|
|
41
43
|
*/
|
|
42
44
|
export function SaveButton({ issues, onSave, label = 'Save', icon: Icon = CheckIcon, busy = false, disabled = false, type = 'button', className, }) {
|
|
43
45
|
const [open, setOpen] = useState(false);
|
|
@@ -47,14 +49,14 @@ export function SaveButton({ issues, onSave, label = 'Save', icon: Icon = CheckI
|
|
|
47
49
|
setOpen(false);
|
|
48
50
|
goToIssue(issue);
|
|
49
51
|
};
|
|
50
|
-
return (_jsxs(_Fragment, { children: [blocked && (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "text-destructive hover:text-destructive", children: [_jsx(TriangleAlertIcon, {}), issues.length
|
|
52
|
+
return (_jsxs(_Fragment, { children: [blocked && (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": `${issues.length} to fix`, title: `${issues.length} to fix`, className: "num text-destructive hover:text-destructive", children: [_jsx(TriangleAlertIcon, {}), issues.length] }) }), _jsxs(PopoverContent, { align: "end", className: "w-80 gap-1 p-1", children: [_jsxs("p", { className: "px-2 pt-1 text-xs text-muted-foreground", children: ["Fix ", issues.length === 1 ? 'this' : 'these', " to save. Tap one to go to it."] }), issues.map((issue, i) => (_jsxs(Button
|
|
51
53
|
// biome-ignore lint/suspicious/noArrayIndexKey: two issues can share a place and message.
|
|
52
|
-
, { type: "button", variant: "ghost", size: "sm", className: "h-auto w-full flex-col items-start gap-0 px-2 py-1.5 text-left font-normal whitespace-normal", onClick: () => go(issue), children: [_jsx("span", { className: "text-xs font-medium", children: issue.where }), _jsx("span", { className: "text-xs text-destructive", children: issue.message })] }, i)))] })] })),
|
|
54
|
+
, { type: "button", variant: "ghost", size: "sm", className: "h-auto w-full flex-col items-start gap-0 px-2 py-1.5 text-left font-normal whitespace-normal", onClick: () => go(issue), children: [_jsx("span", { className: "text-xs font-medium", children: issue.where }), _jsx("span", { className: "text-xs text-destructive", children: issue.message })] }, i)))] })] })), _jsx(IconButton, { icon: busy ? LoaderCircleIcon : (Icon ?? CheckIcon), label: blocked ? `${label}: ${issues.length} to fix` : label, variant: "default", size: "icon-sm", type: blocked ? 'button' : type, busy: busy, disabled: disabled || busy, "aria-disabled": blocked || undefined, className: cn(blocked && 'opacity-50', className), onClick: (e) => {
|
|
53
55
|
if (first) {
|
|
54
56
|
e.preventDefault();
|
|
55
57
|
go(first);
|
|
56
58
|
}
|
|
57
59
|
else if (type === 'button')
|
|
58
60
|
onSave?.();
|
|
59
|
-
}
|
|
61
|
+
} })] }));
|
|
60
62
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ export { cn } from './lib/utils.js';
|
|
|
82
82
|
export { type HealthProbe, StatusHealth } from './status-health.js';
|
|
83
83
|
export { type StatusMessage, type StatusMessageTone, StatusMessages, groupStatusMessages, } from './status-messages.js';
|
|
84
84
|
export { TimeZoneSelect } from './status.js';
|
|
85
|
+
export { LumenStatusBar, type StatusBarModel, StatusBarProvider, type StatusFact, type StatusPart, type StatusSync, type StatusSyncState, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
85
86
|
export { type AccountFacts, type JoinReaders, JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
|
|
86
87
|
export { OriginLegend, originsOf } from './origin-legend.js';
|
|
87
88
|
export { type ConnectedDomain, ConnectOffer, connectionFields, linkGroupId } from './connections.js';
|
package/dist/index.js
CHANGED
|
@@ -89,6 +89,7 @@ export { cn } from './lib/utils.js';
|
|
|
89
89
|
export { StatusHealth } from './status-health.js';
|
|
90
90
|
export { StatusMessages, groupStatusMessages, } from './status-messages.js';
|
|
91
91
|
export { TimeZoneSelect } from './status.js';
|
|
92
|
+
export { LumenStatusBar, StatusBarProvider, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
92
93
|
export { JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
|
|
93
94
|
export { OriginLegend, originsOf } from './origin-legend.js';
|
|
94
95
|
export { ConnectOffer, connectionFields, linkGroupId } from './connections.js';
|
package/dist/lumen-drawer.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface LumenDrawerProps {
|
|
|
9
9
|
description?: ReactNode;
|
|
10
10
|
/** Stacked DrawerSections; the drawer pads and scrolls them. */
|
|
11
11
|
children: ReactNode;
|
|
12
|
-
/** The footer's actions, right-aligned
|
|
12
|
+
/** The footer's actions, right-aligned, icon first (`IconButton`, `SaveButton`); no footer when left out. */
|
|
13
13
|
footer?: ReactNode;
|
|
14
14
|
/** Left side of the footer: a count or status line ("12 releases"). */
|
|
15
15
|
footerStart?: ReactNode;
|
package/dist/lumen-drawer.js
CHANGED
|
@@ -6,7 +6,6 @@ import { useIsMobile } from './hooks/use-mobile.js';
|
|
|
6
6
|
import { IconButton } from './icon-button.js';
|
|
7
7
|
import { lazyPart } from './lazy-part.js';
|
|
8
8
|
import { ResizeEdge, tabState, useDockSlot } from './side-dock.js';
|
|
9
|
-
import { Button } from './ui/button.js';
|
|
10
9
|
import { pickerOpen } from './ui/overlay-guard.js';
|
|
11
10
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from './ui/sheet.js';
|
|
12
11
|
/** The resizable panels load with the first drawer opened; until then it opens at its default width. */
|
|
@@ -34,16 +33,14 @@ export function LumenDrawer({ open, onClose, title, description, children, foote
|
|
|
34
33
|
}, [isMobile]);
|
|
35
34
|
const head = (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx(SheetTitle, { className: "truncate", title: typeof title === 'string' ? title : undefined, children: title }), _jsx(SheetDescription, { className: description ? 'truncate text-muted-foreground text-xs' : 'sr-only', title: typeof description === 'string' ? description : undefined, children: description ?? 'Details' })] }));
|
|
36
35
|
const body = _jsx("div", { className: "flex flex-col px-4 pb-4", children: children });
|
|
37
|
-
//
|
|
38
|
-
// ends on a clear edge. Without actions
|
|
39
|
-
|
|
40
|
-
const plain = !footer && !footerStart;
|
|
41
|
-
const foot = (_jsxs("footer", { "data-slot": "drawer-footer", className: "flex shrink-0 items-center gap-2 border-t bg-muted/50 px-4 py-2.5", children: [footerStart && (_jsx("div", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: footerStart })), _jsx("div", { className: "ml-auto flex items-center gap-2", children: footer ?? (_jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: onClose, children: "Close" })) })] }));
|
|
36
|
+
// A drawer with actions ends on a footer, styled like a dialog's: the body scrolls above it and
|
|
37
|
+
// ends on a clear edge. Without actions it has none: the header's × already closes it.
|
|
38
|
+
const foot = (footer || footerStart) && (_jsxs("footer", { "data-slot": "drawer-footer", className: "flex shrink-0 items-center gap-2 border-t bg-muted/50 px-4 py-2.5", children: [footerStart && (_jsx("div", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: footerStart })), _jsx("div", { className: "ml-auto flex items-center gap-2", children: footer })] }));
|
|
42
39
|
if (dock && !isMobile && slot)
|
|
43
40
|
return open
|
|
44
41
|
? createPortal(_jsx(DockedDrawer, { dock: dock, title: title, description: description, onClose: onClose, defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, body: body, foot: foot }), slot)
|
|
45
42
|
: null;
|
|
46
|
-
return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b pt-3", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }),
|
|
43
|
+
return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b pt-3", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] })) : (_jsx(SheetContent, { side: "right", showCloseButton: false,
|
|
47
44
|
// The resize handle's grab zone is wider than the handle, so a drag can start on the
|
|
48
45
|
// overlay; the panels library marks that pointerdown handled, and then it doesn't close.
|
|
49
46
|
onPointerDownOutside: (e) => e.detail.originalEvent.defaultPrevented && e.preventDefault(), className: "pointer-events-none! gap-0 border-0 bg-transparent shadow-none data-[side=right]:left-0 data-[side=right]:w-auto data-[side=right]:border-l-0 data-[side=right]:sm:max-w-none", children: _jsx(DrawerPanels, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, children: _jsxs("section", { className: "flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-4 py-3", children: [head, _jsx(SheetClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close", className: "ml-auto" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] }) }) })) }));
|
package/dist/page.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface PageHeaderProps {
|
|
|
16
16
|
layout?: ReactNode;
|
|
17
17
|
/** A short status line beside the breadcrumb (under the title on phone). */
|
|
18
18
|
meta?: ReactNode;
|
|
19
|
-
/**
|
|
19
|
+
/** @deprecated Old JSX items, desktop only; call `useStatus` with data instead. */
|
|
20
20
|
status?: ReactNode;
|
|
21
21
|
}
|
|
22
22
|
/** The fixed-height header; `Page` renders it, or a page with several states renders it itself. */
|
package/dist/shell.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type LumenFinderConfig } from './lumen-finder.js';
|
|
3
3
|
import { type SettingsGroupDef, type SettingsSectionDef } from './settings.js';
|
|
4
|
+
import { type StatusBarModel } from './status-bar.js';
|
|
4
5
|
interface ShellContext {
|
|
5
6
|
/** The app this page renders in (`data-app` on the root `<html>`). */
|
|
6
7
|
app: string;
|
|
@@ -53,7 +54,10 @@ export interface LumenShellProps {
|
|
|
53
54
|
brand: ReactNode;
|
|
54
55
|
/** The app's sidebar content: `NavGroup`s and `SidebarSection`s. */
|
|
55
56
|
nav: ReactNode;
|
|
56
|
-
/**
|
|
57
|
+
/** The app's status bar (DESIGN.md, Status bar): its sync and messages as data; pages add theirs
|
|
58
|
+
* with `useStatus`. Running Tasks of this app show by themselves. */
|
|
59
|
+
bar?: StatusBarModel;
|
|
60
|
+
/** @deprecated Old JSX items, desktop only; pass `bar`, and pages call `useStatus`. */
|
|
57
61
|
status?: ReactNode;
|
|
58
62
|
/** The services the app checks, as one `StatusHealth` chip at the status bar's end. */
|
|
59
63
|
health?: ReactNode;
|
|
@@ -97,7 +101,7 @@ export interface LumenShellProps {
|
|
|
97
101
|
/** The routed page (usually `<Outlet />`). */
|
|
98
102
|
children: ReactNode;
|
|
99
103
|
}
|
|
100
|
-
export declare function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user, signOutUrl, profile, security, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
|
|
104
|
+
export declare function LumenShell({ app, appLabel, brand, nav, bar, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user, signOutUrl, profile, security, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
|
|
101
105
|
/** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
|
|
102
106
|
export declare function StatusItem({ label, value, ok, className, }: {
|
|
103
107
|
label: ReactNode;
|
package/dist/shell.js
CHANGED
|
@@ -16,6 +16,7 @@ import { LumenSettings, ThemeToggle, allSettings, } from './settings.js';
|
|
|
16
16
|
import { DockSlot } from './side-dock.js';
|
|
17
17
|
import { SourceHealthProvider } from './source-health.js';
|
|
18
18
|
import { StatusTimeZone } from './status.js';
|
|
19
|
+
import { LumenStatusBar, StatusBarProvider } from './status-bar.js';
|
|
19
20
|
import { Avatar, AvatarFallback } from './ui/avatar.js';
|
|
20
21
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
21
22
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, useSidebar, } from './ui/sidebar.js';
|
|
@@ -95,7 +96,7 @@ const settingsFromUrl = (() => {
|
|
|
95
96
|
window.history.replaceState(window.history.state, '', url);
|
|
96
97
|
return at || 'profile';
|
|
97
98
|
})();
|
|
98
|
-
export function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
|
|
99
|
+
export function LumenShell({ app, appLabel, brand, nav, bar, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
|
|
99
100
|
const [statusSlot, setStatusSlot] = useState(null);
|
|
100
101
|
const [dockSlot, setDockSlot] = useState(null);
|
|
101
102
|
const [refreshing, setRefreshing] = useState(false);
|
|
@@ -141,7 +142,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
|
|
|
141
142
|
refreshing,
|
|
142
143
|
hideAmounts,
|
|
143
144
|
headerActions,
|
|
144
|
-
}, children: _jsx(DockSlot.Provider, { value: dockSlot, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch),
|
|
145
|
+
}, children: _jsx(StatusBarProvider, { children: _jsx(DockSlot.Provider, { value: dockSlot, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsx(LumenStatusBar, { app: app, model: bar, legacy: status, slot: setStatusSlot, end: _jsxs(_Fragment, { children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] }) })] }), _jsx("div", { ref: setDockSlot, "data-slot": "shell-dock", className: "contents" }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel, groups: settingsGroups }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }) }) }));
|
|
145
146
|
}
|
|
146
147
|
/** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
|
|
147
148
|
export function StatusItem({ label, value, ok, className, }) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type StatusMessage } from './status-messages.js';
|
|
3
|
+
/** How fresh the page's data is: up to date, reading now, an old copy, no network, or failed. */
|
|
4
|
+
export type StatusSyncState = 'ok' | 'syncing' | 'stale' | 'offline' | 'failed';
|
|
5
|
+
export interface StatusSync {
|
|
6
|
+
state: StatusSyncState;
|
|
7
|
+
/** What it syncs with (`Bank feed`); left out, the line reads "Updated 3m ago". */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** When the data shown was last read (ms or ISO); null when never. */
|
|
10
|
+
at?: number | string | null;
|
|
11
|
+
/** Progress of a sync that counts (`40` of `112`). */
|
|
12
|
+
done?: number | null;
|
|
13
|
+
total?: number | null;
|
|
14
|
+
/** More on hover or tap (why it failed). */
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
/** A fact this page has nowhere else to show (`Next run in 12m`): plain data, at most two. */
|
|
18
|
+
export interface StatusFact {
|
|
19
|
+
label: string;
|
|
20
|
+
value?: string | number;
|
|
21
|
+
/** A time shown as `in 12m` or `3m ago` after the value, kept current. */
|
|
22
|
+
at?: number | string | null;
|
|
23
|
+
/** Green when true, red when false, grey while unknown; no dot when left out. */
|
|
24
|
+
ok?: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
/** What a page (or any part) adds to the bar through `useStatus`. */
|
|
27
|
+
export interface StatusPart {
|
|
28
|
+
sync?: StatusSync | null;
|
|
29
|
+
/** Newest first. */
|
|
30
|
+
messages?: StatusMessage[];
|
|
31
|
+
facts?: StatusFact[];
|
|
32
|
+
}
|
|
33
|
+
/** The app's own bar on `LumenShell` (`bar`): its sync and messages, and whether running Tasks show. */
|
|
34
|
+
export interface StatusBarModel {
|
|
35
|
+
sync?: StatusSync | null;
|
|
36
|
+
messages?: StatusMessage[];
|
|
37
|
+
/** App-wide facts (`Next run in 12m`); a page's own come first, two show in all. */
|
|
38
|
+
facts?: StatusFact[];
|
|
39
|
+
/** Shows this app's running task from the Tasks list (default true where Tasks is mounted). */
|
|
40
|
+
work?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** Holds every part posted with `useStatus`; the shell mounts it around the page and the bar. */
|
|
43
|
+
export declare function StatusBarProvider({ children }: {
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
46
|
+
/**
|
|
47
|
+
* Adds this page's sync, messages and facts to the status bar while it is mounted. Plain data
|
|
48
|
+
* only; the newest part's sync wins over the app's, messages add up, the first two facts show.
|
|
49
|
+
*/
|
|
50
|
+
export declare function useStatus(part: StatusPart): void;
|
|
51
|
+
/** A TanStack Query result's freshness as a `StatusSync`: the page's main read, with no app code. */
|
|
52
|
+
export declare function querySync(q: {
|
|
53
|
+
status: 'pending' | 'error' | 'success';
|
|
54
|
+
fetchStatus: 'fetching' | 'paused' | 'idle';
|
|
55
|
+
dataUpdatedAt: number;
|
|
56
|
+
error?: unknown;
|
|
57
|
+
}, label?: string): StatusSync;
|
|
58
|
+
/** `just now`, `3m ago`, `in 12m`, `2h ago`, `3d ago`. */
|
|
59
|
+
export declare function statusWhen(at: number | string | null | undefined, now?: number): string;
|
|
60
|
+
/** The bar itself; `LumenShell` renders it. */
|
|
61
|
+
export declare function LumenStatusBar({ app, model, legacy, slot, end, }: {
|
|
62
|
+
app: string;
|
|
63
|
+
model?: StatusBarModel;
|
|
64
|
+
/** Items from the old `status` prop and pages' `status` (desktop only). */
|
|
65
|
+
legacy?: ReactNode;
|
|
66
|
+
/** The pages' old portal slot. */
|
|
67
|
+
slot: (el: HTMLElement | null) => void;
|
|
68
|
+
/** Health, What's new, the app's end items and the time zone, in that order. */
|
|
69
|
+
end: ReactNode;
|
|
70
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The status bar's model: what is true right now, at a glance, that nobody has to act on
|
|
3
|
+
// (DESIGN.md, Status bar). A banner is for what must be acted on, a toast for what someone just
|
|
4
|
+
// did, a badge for one row or card. Apps and pages pass data, never JSX; the bar draws five slots
|
|
5
|
+
// in a fixed order: Sync · Work · Message · Page facts · End (health, What's new, time zone).
|
|
6
|
+
// On a phone it shows the one most urgent item and a chip that opens the rest in a sheet, so a
|
|
7
|
+
// row never scrolls sideways. Nothing here waits: it draws from what the page already holds.
|
|
8
|
+
import { isActive } from '@columenlabs/lumen-ui-types/tasks';
|
|
9
|
+
import { InfoIcon } from 'lucide-react';
|
|
10
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, } from 'react';
|
|
11
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
12
|
+
import { cn } from './lib/utils.js';
|
|
13
|
+
import { STATUS_TRIGGER, StatusMessages, StatusPop, groupStatusMessages, } from './status-messages.js';
|
|
14
|
+
import { useTasks } from './tasks.js';
|
|
15
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from './ui/sheet.js';
|
|
16
|
+
const MAX_FACTS = 2;
|
|
17
|
+
const RegistryCtx = createContext(null);
|
|
18
|
+
const PartsCtx = createContext([]);
|
|
19
|
+
/** Holds every part posted with `useStatus`; the shell mounts it around the page and the bar. */
|
|
20
|
+
export function StatusBarProvider({ children }) {
|
|
21
|
+
const [parts, setParts] = useState(() => new Map());
|
|
22
|
+
const set = useCallback((id, part) => {
|
|
23
|
+
setParts((prev) => {
|
|
24
|
+
const next = new Map(prev);
|
|
25
|
+
if (part)
|
|
26
|
+
next.set(id, part);
|
|
27
|
+
else
|
|
28
|
+
next.delete(id);
|
|
29
|
+
return next;
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
const registry = useMemo(() => ({ set }), [set]);
|
|
33
|
+
const list = useMemo(() => [...parts.values()], [parts]);
|
|
34
|
+
return (_jsx(RegistryCtx.Provider, { value: registry, children: _jsx(PartsCtx.Provider, { value: list, children: children }) }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Adds this page's sync, messages and facts to the status bar while it is mounted. Plain data
|
|
38
|
+
* only; the newest part's sync wins over the app's, messages add up, the first two facts show.
|
|
39
|
+
*/
|
|
40
|
+
export function useStatus(part) {
|
|
41
|
+
const registry = useContext(RegistryCtx);
|
|
42
|
+
const id = useId();
|
|
43
|
+
const key = JSON.stringify(part);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!registry)
|
|
46
|
+
return;
|
|
47
|
+
registry.set(id, JSON.parse(key));
|
|
48
|
+
return () => registry.set(id, null);
|
|
49
|
+
}, [registry, id, key]);
|
|
50
|
+
}
|
|
51
|
+
/** A TanStack Query result's freshness as a `StatusSync`: the page's main read, with no app code. */
|
|
52
|
+
export function querySync(q, label) {
|
|
53
|
+
const at = q.dataUpdatedAt || null;
|
|
54
|
+
if (q.fetchStatus === 'paused')
|
|
55
|
+
return { state: 'offline', label, at };
|
|
56
|
+
if (q.fetchStatus === 'fetching')
|
|
57
|
+
return { state: 'syncing', label, at };
|
|
58
|
+
if (q.status === 'error') {
|
|
59
|
+
const note = q.error instanceof Error ? q.error.message : undefined;
|
|
60
|
+
return { state: at ? 'stale' : 'failed', label, at, note };
|
|
61
|
+
}
|
|
62
|
+
return { state: q.status === 'success' ? 'ok' : 'syncing', label, at };
|
|
63
|
+
}
|
|
64
|
+
const toMs = (at) => at == null ? null : typeof at === 'number' ? at : Date.parse(at) || null;
|
|
65
|
+
/** `just now`, `3m ago`, `in 12m`, `2h ago`, `3d ago`. */
|
|
66
|
+
export function statusWhen(at, now = Date.now()) {
|
|
67
|
+
const ms = toMs(at);
|
|
68
|
+
if (ms === null)
|
|
69
|
+
return '—';
|
|
70
|
+
const d = ms - now;
|
|
71
|
+
const abs = Math.abs(d);
|
|
72
|
+
if (abs < 45_000)
|
|
73
|
+
return 'just now';
|
|
74
|
+
const n = abs < 3_600_000
|
|
75
|
+
? `${Math.round(abs / 60_000)}m`
|
|
76
|
+
: abs < 86_400_000
|
|
77
|
+
? `${Math.round(abs / 3_600_000)}h`
|
|
78
|
+
: `${Math.round(abs / 86_400_000)}d`;
|
|
79
|
+
return d > 0 ? `in ${n}` : `${n} ago`;
|
|
80
|
+
}
|
|
81
|
+
/** The clock relative times read, ticking every 30 s only while the tab shows. */
|
|
82
|
+
function useNow() {
|
|
83
|
+
const [now, setNow] = useState(() => Date.now());
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let timer;
|
|
86
|
+
const start = () => {
|
|
87
|
+
setNow(Date.now());
|
|
88
|
+
timer = setInterval(() => setNow(Date.now()), 30_000);
|
|
89
|
+
};
|
|
90
|
+
const stop = () => clearInterval(timer);
|
|
91
|
+
const onVis = () => (document.hidden ? stop() : start());
|
|
92
|
+
if (!document.hidden)
|
|
93
|
+
start();
|
|
94
|
+
document.addEventListener('visibilitychange', onVis);
|
|
95
|
+
return () => {
|
|
96
|
+
stop();
|
|
97
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
return now;
|
|
101
|
+
}
|
|
102
|
+
function Mark({ tone }) {
|
|
103
|
+
if (tone === 'none')
|
|
104
|
+
return null;
|
|
105
|
+
if (tone === 'busy')
|
|
106
|
+
return (_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 animate-spin rounded-full border-2 border-primary border-r-transparent motion-reduce:animate-none" }));
|
|
107
|
+
return (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', tone === 'ok' && 'bg-gain', tone === 'warning' && 'bg-warning', tone === 'error' && 'bg-destructive', tone === 'idle' && 'bg-muted-foreground/40') }));
|
|
108
|
+
}
|
|
109
|
+
const TONE_TEXT = {
|
|
110
|
+
warning: 'text-warning',
|
|
111
|
+
error: 'text-destructive',
|
|
112
|
+
};
|
|
113
|
+
function syncEntry(s, now) {
|
|
114
|
+
const when = statusWhen(s.at, now);
|
|
115
|
+
const count = s.done != null && s.total ? `${s.done.toLocaleString()} of ${s.total.toLocaleString()}` : '';
|
|
116
|
+
const base = { id: 'sync', detail: s.note };
|
|
117
|
+
switch (s.state) {
|
|
118
|
+
case 'syncing':
|
|
119
|
+
return {
|
|
120
|
+
...base,
|
|
121
|
+
rank: 5,
|
|
122
|
+
tone: 'busy',
|
|
123
|
+
text: s.label ? `Syncing ${s.label}` : 'Updating',
|
|
124
|
+
side: count || undefined,
|
|
125
|
+
};
|
|
126
|
+
case 'stale':
|
|
127
|
+
return { ...base, rank: 3, tone: 'warning', text: 'Saved copy', side: when };
|
|
128
|
+
case 'offline':
|
|
129
|
+
return { ...base, rank: 3, tone: 'warning', text: 'Offline · saved copy', side: when };
|
|
130
|
+
case 'failed':
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
rank: 1,
|
|
134
|
+
tone: 'error',
|
|
135
|
+
text: s.label ? `${s.label} did not update` : 'Did not update',
|
|
136
|
+
};
|
|
137
|
+
default:
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
rank: 8,
|
|
141
|
+
tone: 'ok',
|
|
142
|
+
text: s.label ? `${s.label} synced` : 'Updated',
|
|
143
|
+
side: when,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function factEntry(f, i, now) {
|
|
148
|
+
const when = f.at != null ? statusWhen(f.at, now) : '';
|
|
149
|
+
const value = [f.value, when].filter((v) => v !== undefined && v !== '').join(' ');
|
|
150
|
+
return {
|
|
151
|
+
id: `fact:${i}`,
|
|
152
|
+
rank: 6,
|
|
153
|
+
tone: f.ok === undefined ? 'none' : f.ok === null ? 'idle' : f.ok ? 'ok' : 'error',
|
|
154
|
+
text: f.label,
|
|
155
|
+
side: value || undefined,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Builds the bar's entries from the app's model and every posted part. */
|
|
159
|
+
function useEntries(model, app) {
|
|
160
|
+
const parts = useContext(PartsCtx);
|
|
161
|
+
const tasks = useTasks();
|
|
162
|
+
const now = useNow();
|
|
163
|
+
const entries = [];
|
|
164
|
+
const sync = [...parts].reverse().find((p) => p.sync)?.sync ?? model?.sync ?? null;
|
|
165
|
+
if (sync)
|
|
166
|
+
entries.push(syncEntry(sync, now));
|
|
167
|
+
if (model?.work !== false && tasks?.list) {
|
|
168
|
+
const mine = tasks.list.tasks.filter((t) => isActive(t) && (t.app === app || !t.app));
|
|
169
|
+
const t = mine[0];
|
|
170
|
+
if (t) {
|
|
171
|
+
const share = t.total ? Math.round(((t.done ?? 0) / t.total) * 100) : null;
|
|
172
|
+
entries.push({
|
|
173
|
+
id: 'work',
|
|
174
|
+
rank: 4,
|
|
175
|
+
tone: 'busy',
|
|
176
|
+
text: mine.length > 1 ? `${t.title} +${mine.length - 1}` : t.title,
|
|
177
|
+
side: share !== null
|
|
178
|
+
? `${share}%`
|
|
179
|
+
: t.done != null
|
|
180
|
+
? `${t.done.toLocaleString()} ${t.unit ?? ''}`.trim()
|
|
181
|
+
: undefined,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const messages = [...parts.flatMap((p) => p.messages ?? []), ...(model?.messages ?? [])];
|
|
186
|
+
const groups = groupStatusMessages(messages);
|
|
187
|
+
const top = groups[0];
|
|
188
|
+
if (top) {
|
|
189
|
+
const tone = top.tone === 'error' ? 'error' : top.tone === 'warning' ? 'warning' : 'none';
|
|
190
|
+
entries.push({
|
|
191
|
+
id: 'messages',
|
|
192
|
+
rank: tone === 'error' ? 0 : tone === 'warning' ? 2 : 7,
|
|
193
|
+
tone,
|
|
194
|
+
text: top.count > 1 ? `${top.text} ×${top.count}` : top.text,
|
|
195
|
+
side: groups.length > 1 ? `+${groups.length - 1}` : undefined,
|
|
196
|
+
bar: _jsx(StatusMessages, { messages: messages }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
[...parts.flatMap((p) => p.facts ?? []), ...(model?.facts ?? [])]
|
|
200
|
+
.slice(0, MAX_FACTS)
|
|
201
|
+
.forEach((f, i) => entries.push(factEntry(f, i, now)));
|
|
202
|
+
return entries;
|
|
203
|
+
}
|
|
204
|
+
function EntryLine({ e }) {
|
|
205
|
+
if (e.bar)
|
|
206
|
+
return _jsx(_Fragment, { children: e.bar });
|
|
207
|
+
const line = (_jsxs(_Fragment, { children: [_jsx(Mark, { tone: e.tone }), _jsx("span", { className: cn('min-w-0 truncate', TONE_TEXT[e.tone]), children: e.text }), e.side && _jsx("span", { className: "shrink-0 tabular-nums text-foreground/80", children: e.side })] }));
|
|
208
|
+
if (!e.detail)
|
|
209
|
+
return _jsx("span", { className: "flex h-6 min-w-0 items-center gap-1.5 px-1.5", children: line });
|
|
210
|
+
return (_jsx(StatusPop, { className: cn(STATUS_TRIGGER, 'min-w-0'), content: _jsx("p", { className: TONE_TEXT[e.tone], children: e.detail }), children: line }));
|
|
211
|
+
}
|
|
212
|
+
function SheetRow({ e }) {
|
|
213
|
+
return (_jsxs("li", { className: "flex items-start gap-2 border-b py-2 last:border-b-0", children: [_jsx("span", { className: "flex h-5 items-center", children: _jsx(Mark, { tone: e.tone === 'none' ? 'idle' : e.tone }) }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: cn('break-words', TONE_TEXT[e.tone]), children: e.text }), e.detail && _jsx("span", { className: "block text-muted-foreground", children: e.detail })] }), e.side && _jsx("span", { className: "shrink-0 tabular-nums text-muted-foreground", children: e.side })] }));
|
|
214
|
+
}
|
|
215
|
+
/** The bar itself; `LumenShell` renders it. */
|
|
216
|
+
export function LumenStatusBar({ app, model, legacy, slot, end, }) {
|
|
217
|
+
const mobile = useIsMobile();
|
|
218
|
+
const entries = useEntries(model, app);
|
|
219
|
+
const [open, setOpen] = useState(false);
|
|
220
|
+
const urgent = [...entries].sort((a, b) => a.rank - b.rank)[0];
|
|
221
|
+
return (_jsx("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-3 overflow-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: mobile ? (_jsxs(_Fragment, { children: [urgent && (_jsx("span", { "aria-live": "polite", className: "flex min-w-0 items-center", children: _jsx(EntryLine, { e: { ...urgent, bar: undefined, detail: undefined } }) })), _jsx("div", { ref: slot, hidden: true }), entries.length > 0 ? (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [_jsxs(SheetTrigger, { className: cn(STATUS_TRIGGER, 'num ml-auto shrink-0 border text-foreground/80'), "aria-label": `Status, ${entries.length} items`, children: [_jsx(InfoIcon, { className: "size-3.5" }), entries.length] }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[75svh] gap-0 rounded-t-2xl p-0 text-sm", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "border-b px-4 py-3", children: [_jsx(SheetTitle, { children: "Status" }), _jsx(SheetDescription, { className: "sr-only", children: "What is true right now" })] }), _jsx("ul", { className: "overflow-y-auto px-4", children: [...entries]
|
|
222
|
+
.sort((a, b) => a.rank - b.rank)
|
|
223
|
+
.map((e) => (_jsx(SheetRow, { e: e }, e.id))) }), _jsx("div", { className: "flex items-center justify-end gap-1 border-t px-4 py-2 text-xs text-muted-foreground", children: end })] })] })) : (_jsx("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: end }))] })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-live": "polite", className: "flex min-w-0 items-center gap-3", children: entries.map((e) => (_jsx(EntryLine, { e: e }, e.id))) }), legacy, _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-4" }), _jsx("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: end })] })) }));
|
|
224
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
export type StatusMessageTone = 'info' | 'success' | 'warning' | 'error';
|
|
2
3
|
export interface StatusMessage {
|
|
3
4
|
text: string;
|
|
@@ -11,6 +12,18 @@ interface Group extends Required<StatusMessage> {
|
|
|
11
12
|
}
|
|
12
13
|
/** One entry per distinct message (same tone, same words), counted, in the order given. */
|
|
13
14
|
export declare function groupStatusMessages(messages: readonly StatusMessage[]): Group[];
|
|
15
|
+
/**
|
|
16
|
+
* A status bar trigger whose pop-up opens on hover with a mouse and on tap with a finger or
|
|
17
|
+
* keyboard, like the services chip, so a phone can read the full text too (a tooltip never opens
|
|
18
|
+
* on touch). Styled on its own classes only: a trigger owns `data-state`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function StatusPop({ label: name, className, children, content, }: {
|
|
21
|
+
label?: string;
|
|
22
|
+
className: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
content: ReactNode;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
26
|
+
export declare const STATUS_TRIGGER = "flex h-6 items-center gap-1.5 rounded-md px-1.5 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none";
|
|
14
27
|
/**
|
|
15
28
|
* The app's messages in the status bar, newest first. Only the latest shows, on one line that ends
|
|
16
29
|
* in an ellipsis past `--status-message-max`, with its full text on hover or tap; a new message
|
package/dist/status-messages.js
CHANGED
|
@@ -45,13 +45,13 @@ const COLLISION_PAD = 8;
|
|
|
45
45
|
* keyboard, like the services chip, so a phone can read the full text too (a tooltip never opens
|
|
46
46
|
* on touch). Styled on its own classes only: a trigger owns `data-state`.
|
|
47
47
|
*/
|
|
48
|
-
function
|
|
48
|
+
export function StatusPop({ label: name, className, children, content, }) {
|
|
49
49
|
const { open, setOpen, hover, content: pop } = useHoverOpen();
|
|
50
50
|
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { "aria-label": name, className: className, ...hover, children: children }), _jsx(PopoverContent, { side: "top", align: "start", collisionPadding: COLLISION_PAD, className: "w-auto max-w-sm gap-1.5 text-xs break-words", ...pop, children: content })] }));
|
|
51
51
|
}
|
|
52
|
-
const
|
|
52
|
+
export const STATUS_TRIGGER = 'flex h-6 items-center gap-1.5 rounded-md px-1.5 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none';
|
|
53
53
|
function Line({ group }) {
|
|
54
|
-
return (_jsxs(
|
|
54
|
+
return (_jsxs(StatusPop, { className: cn(STATUS_TRIGGER, 'min-w-0 max-w-(--status-message-max)'), content: _jsx("p", { className: TEXT[group.tone], children: label(group) }), children: [_jsx(Dot, { tone: group.tone }), _jsx("span", { className: cn('min-w-0 truncate', TEXT[group.tone]), children: group.text }), group.count > 1 && (_jsxs("span", { className: "num shrink-0 text-foreground/80", "aria-label": `${group.count} times`, children: ["\u00D7", group.count] }))] }));
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* The app's messages in the status bar, newest first. Only the latest shows, on one line that ends
|
|
@@ -63,5 +63,5 @@ export function StatusMessages({ messages, className, }) {
|
|
|
63
63
|
const [latest, ...earlier] = groupStatusMessages(messages);
|
|
64
64
|
if (!latest)
|
|
65
65
|
return null;
|
|
66
|
-
return (_jsxs("span", { "aria-live": "polite", className: cn('flex min-w-0 items-center gap-2', className), children: [_jsx(Line, { group: latest }), earlier.length > 0 && (_jsxs(
|
|
66
|
+
return (_jsxs("span", { "aria-live": "polite", className: cn('flex min-w-0 items-center gap-2', className), children: [_jsx(Line, { group: latest }), earlier.length > 0 && (_jsxs(StatusPop, { label: `${earlier.length} earlier messages`, className: cn(STATUS_TRIGGER, 'num shrink-0 text-foreground/80'), content: _jsx("ul", { className: "flex flex-col gap-1", children: earlier.map((g) => (_jsxs("li", { className: "flex items-start gap-1.5", children: [_jsx("span", { className: "flex h-4 items-center", children: _jsx(Dot, { tone: g.tone }) }), _jsx("span", { className: TEXT[g.tone], children: label(g) })] }, `${g.tone}:${g.text}`))) }), children: ["+", earlier.length] }))] }));
|
|
67
67
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.153",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@codemirror/lang-javascript": "6.2.5",
|
|
50
50
|
"@codemirror/lang-json": "6.0.2",
|
|
51
51
|
"@codemirror/lang-sql": "6.10.0",
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.153",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|