@columenlabs/lumen-ui 0.1.150 → 0.1.152
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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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/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/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.152",
|
|
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.152",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|