@columenlabs/lumen-ui 0.1.175 → 0.1.177
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 +2 -1
- package/dist/index.js +2 -1
- package/dist/shell.js +3 -2
- package/dist/signals.d.ts +98 -0
- package/dist/signals.js +275 -0
- package/dist/status-bar.d.ts +15 -0
- package/dist/status-bar.js +38 -9
- package/dist/theme.css +29 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -85,8 +85,9 @@ export { savedAnswers, type SavedAnswersOptions, type SavedAnswersPersister, } f
|
|
|
85
85
|
export { cn } from './lib/utils.js';
|
|
86
86
|
export { type HealthProbe, StatusHealth } from './status-health.js';
|
|
87
87
|
export { type StatusMessage, type StatusMessageTone, StatusMessages, groupStatusMessages, } from './status-messages.js';
|
|
88
|
+
export { SIGNAL_LEVELS, type Signal, type SignalLevel, type SignalMute, type SignalsOptions, SignalBanners, SignalsChip, groupSignals, signalAge, signalShort, useSignals, } from './signals.js';
|
|
88
89
|
export { TimeZoneSelect } from './status.js';
|
|
89
|
-
export { LumenStatusBar, type StatusBarModel, StatusBarProvider, type StatusFact, type StatusPart, type StatusSync, type StatusSyncState, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
90
|
+
export { LumenStatusBar, type StatusBarModel, StatusBarProvider, StatusSignals, type StatusFact, type StatusPart, type StatusSync, type StatusSyncState, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
90
91
|
export { type AccountFacts, type JoinReaders, JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
|
|
91
92
|
export { OriginLegend, originsOf } from './origin-legend.js';
|
|
92
93
|
export { type ConnectedDomain, ConnectOffer, connectionFields, linkGroupId } from './connections.js';
|
package/dist/index.js
CHANGED
|
@@ -92,8 +92,9 @@ export { savedAnswers, } from './saved-answers.js';
|
|
|
92
92
|
export { cn } from './lib/utils.js';
|
|
93
93
|
export { StatusHealth } from './status-health.js';
|
|
94
94
|
export { StatusMessages, groupStatusMessages, } from './status-messages.js';
|
|
95
|
+
export { SIGNAL_LEVELS, SignalBanners, SignalsChip, groupSignals, signalAge, signalShort, useSignals, } from './signals.js';
|
|
95
96
|
export { TimeZoneSelect } from './status.js';
|
|
96
|
-
export { LumenStatusBar, StatusBarProvider, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
97
|
+
export { LumenStatusBar, StatusBarProvider, StatusSignals, querySync, statusWhen, useStatus, } from './status-bar.js';
|
|
97
98
|
export { JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
|
|
98
99
|
export { OriginLegend, originsOf } from './origin-legend.js';
|
|
99
100
|
export { ConnectOffer, connectionFields, linkGroupId } from './connections.js';
|
package/dist/shell.js
CHANGED
|
@@ -15,9 +15,10 @@ import { usePrefs } from './prefs.js';
|
|
|
15
15
|
import { withRefreshToken } from './refresh.js';
|
|
16
16
|
import { LumenSettings, ThemeToggle, allSettings, } from './settings.js';
|
|
17
17
|
import { DockSlot } from './side-dock.js';
|
|
18
|
+
import { SignalBanners } from './signals.js';
|
|
18
19
|
import { SourceHealthProvider } from './source-health.js';
|
|
19
20
|
import { StatusTimeZone } from './status.js';
|
|
20
|
-
import { LumenStatusBar, StatusBarProvider } from './status-bar.js';
|
|
21
|
+
import { LumenStatusBar, StatusBarProvider, StatusSignals, } from './status-bar.js';
|
|
21
22
|
import { Avatar, AvatarFallback } from './ui/avatar.js';
|
|
22
23
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
23
24
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarRail, useSidebar, } from './ui/sidebar.js';
|
|
@@ -143,7 +144,7 @@ export function LumenShell({ app, appLabel, brand, nav, tabs, bar, status, healt
|
|
|
143
144
|
refreshing,
|
|
144
145
|
hideAmounts,
|
|
145
146
|
headerActions,
|
|
146
|
-
}, 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, {})] }) }), tabs && _jsx(LumenTabBar, { items: tabs })] }), _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" })] }) }) })) }) }) }) }));
|
|
147
|
+
}, children: _jsx(StatusBarProvider, { children: _jsx(DockSlot.Provider, { value: dockSlot, children: _jsx(StatusSignals, { app: app, model: bar, openSettings: openSettings, 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(SignalBanners, {}), _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, {})] }) }), tabs && _jsx(LumenTabBar, { items: tabs })] }), _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" })] }) }) })) }) }) }) }) }));
|
|
147
148
|
}
|
|
148
149
|
/** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
|
|
149
150
|
export function StatusItem({ label, value, ok, className, }) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** Needs action, Worth a look, Harmless. */
|
|
3
|
+
export type SignalLevel = 'act' | 'look' | 'fyi';
|
|
4
|
+
export interface Signal {
|
|
5
|
+
/** One row per key (kind + scope + meter + resource): a repeat adds to its count. */
|
|
6
|
+
key: string;
|
|
7
|
+
level: SignalLevel;
|
|
8
|
+
/** What kind of event, short (`Burning fast`, `Over a limit`, `Can't see`). */
|
|
9
|
+
kind: string;
|
|
10
|
+
/** The meter or thing it is about (`KV reads`). */
|
|
11
|
+
what: string;
|
|
12
|
+
/** The resource or place (`usageflare`). */
|
|
13
|
+
where?: string;
|
|
14
|
+
/** What it belongs to (an account, a desk); the panel can group by it. */
|
|
15
|
+
group?: string;
|
|
16
|
+
/** How fast, as a share of the limit in a window (`11% in 15m`), or the plain numbers (`1,040 / 1,000`). */
|
|
17
|
+
pace?: string;
|
|
18
|
+
/** Share of the limit used now (0 to 1). */
|
|
19
|
+
used?: number;
|
|
20
|
+
/** Share of the limit at reset at this pace. */
|
|
21
|
+
lands?: number;
|
|
22
|
+
/** When the limit runs out at this pace (`2h`); shown instead of `lands`. */
|
|
23
|
+
runsOut?: string;
|
|
24
|
+
/** When it last happened (ms or ISO). */
|
|
25
|
+
at: number | string;
|
|
26
|
+
/** How many times it happened; repeats in the list add up too. */
|
|
27
|
+
count?: number;
|
|
28
|
+
/** Where Open goes: an in-app route (`to`, the router's link) or any address (`href`). */
|
|
29
|
+
to?: string;
|
|
30
|
+
href?: string;
|
|
31
|
+
/** One or two plain lines for the hover and the phone's detail. */
|
|
32
|
+
why?: string;
|
|
33
|
+
/** The one action that ends it (`Fix token`), on the banner and the row. */
|
|
34
|
+
fix?: {
|
|
35
|
+
label: string;
|
|
36
|
+
href?: string;
|
|
37
|
+
onClick?: () => void /** A Settings section to open. */;
|
|
38
|
+
settings?: string;
|
|
39
|
+
};
|
|
40
|
+
/** Shows above the page until it is fixed (only for Needs action: blind, over a limit). */
|
|
41
|
+
banner?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** How long a mute lasts: an hour, until the limit resets (00:00 UTC), or for good (the app keeps it). */
|
|
44
|
+
export type SignalMute = 'hour' | 'reset' | 'always';
|
|
45
|
+
/** The app's hooks into the panel, on `LumenShell bar`. */
|
|
46
|
+
export interface SignalsOptions {
|
|
47
|
+
/** A mute the app keeps (an alert rule). Left out, only the hour and until-reset mutes show, kept on this device. */
|
|
48
|
+
onMute?: (s: Signal, until: SignalMute) => void;
|
|
49
|
+
/** The panel's gear: the Settings section where the app's alerts live (`alerts`). */
|
|
50
|
+
settings?: string;
|
|
51
|
+
/** The name of `group` in the panel's Group by (`Account`). */
|
|
52
|
+
groupLabel?: string;
|
|
53
|
+
}
|
|
54
|
+
export declare const SIGNAL_LEVELS: Record<SignalLevel, {
|
|
55
|
+
label: string;
|
|
56
|
+
short: string;
|
|
57
|
+
dot: string;
|
|
58
|
+
text?: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** `3m`, `2h`, `4d`: how long ago. */
|
|
61
|
+
export declare function signalAge(at: number | string, now?: number): string;
|
|
62
|
+
/** One entry per key, counted, latest numbers kept; ordered by level, then by where it lands. */
|
|
63
|
+
export declare function groupSignals(list: readonly Signal[]): Signal[];
|
|
64
|
+
interface SignalsValue {
|
|
65
|
+
/** Open signals, grouped and ordered. */
|
|
66
|
+
items: Signal[];
|
|
67
|
+
/** Muted ones, still listed under Muted. */
|
|
68
|
+
muted: Signal[];
|
|
69
|
+
banners: Signal[];
|
|
70
|
+
open: boolean;
|
|
71
|
+
setOpen: (open: boolean) => void;
|
|
72
|
+
dismiss: (list: Signal[]) => void;
|
|
73
|
+
mute: (s: Signal, until: SignalMute) => void;
|
|
74
|
+
unmute: (s: Signal) => void;
|
|
75
|
+
hideBanner: (s: Signal) => void;
|
|
76
|
+
options: SignalsOptions;
|
|
77
|
+
openSettings?: (section?: string) => void;
|
|
78
|
+
}
|
|
79
|
+
/** The open signals and the panel's state; `null` outside the shell. */
|
|
80
|
+
export declare function useSignals(): SignalsValue | null;
|
|
81
|
+
/** Holds the app's and pages' signals and what this device dismissed or muted. The shell mounts it. */
|
|
82
|
+
export declare function SignalsProvider({ app, signals, options, openSettings, children, }: {
|
|
83
|
+
app: string;
|
|
84
|
+
signals: readonly Signal[];
|
|
85
|
+
options?: SignalsOptions;
|
|
86
|
+
/** The shell's Settings opener, for a fix or the gear that names a section. */
|
|
87
|
+
openSettings?: (section?: string) => void;
|
|
88
|
+
children: ReactNode;
|
|
89
|
+
}): import("react").JSX.Element;
|
|
90
|
+
/** The top signal in short form, for the bar. */
|
|
91
|
+
export declare function signalShort(s: Signal): string;
|
|
92
|
+
/** The status bar's chip: counts by level and the top signal; opens the panel. */
|
|
93
|
+
export declare function SignalsChip({ className }: {
|
|
94
|
+
className?: string;
|
|
95
|
+
}): import("react").JSX.Element | null;
|
|
96
|
+
/** Banners for signals that block the page's job (an unreadable account, a limit already passed). */
|
|
97
|
+
export declare function SignalBanners(): import("react").JSX.Element | null;
|
|
98
|
+
export {};
|
package/dist/signals.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Signals: every warning and error an app raises, as data, in one place (DESIGN.md, Signals).
|
|
3
|
+
// A signal names what happened (kind · what · where) and its numbers (pace, share of a limit used
|
|
4
|
+
// and where it lands, when it runs out), never a sentence. Three levels: Needs action (something
|
|
5
|
+
// is broken or a limit is burning fast), Worth a look (climbing toward a limit), Harmless (unusual,
|
|
6
|
+
// lands low). The status bar shows the counts and the top signal; a tap opens the Signals panel,
|
|
7
|
+
// grouped and one line a row, with Open, Mute and Dismiss. Signals marked `banner` show above the
|
|
8
|
+
// page until fixed. Dismiss and the short mutes are kept on this device; a repeat of the same key
|
|
9
|
+
// shows once with its count, and a later occurrence (a newer `at`) reopens a dismissed one.
|
|
10
|
+
import { ArrowLeftIcon, BellOffIcon, ExternalLinkIcon, SettingsIcon, TriangleAlertIcon, XIcon, } from 'lucide-react';
|
|
11
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
12
|
+
import { useIsMobile } from './hooks/use-mobile.js';
|
|
13
|
+
import { IconButton } from './icon-button.js';
|
|
14
|
+
import { cn } from './lib/utils.js';
|
|
15
|
+
import { LumenDrawer } from './lumen-drawer.js';
|
|
16
|
+
import { lumenToast } from './lumen-toast.js';
|
|
17
|
+
import { METER_LEVEL_COLOR, meterLevel } from './meter-level.js';
|
|
18
|
+
import { Button } from './ui/button.js';
|
|
19
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
20
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card.js';
|
|
21
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
|
|
22
|
+
import { TooltipProvider } from './ui/tooltip.js';
|
|
23
|
+
export const SIGNAL_LEVELS = {
|
|
24
|
+
act: { label: 'Needs action', short: 'Act', dot: 'bg-destructive', text: 'text-destructive' },
|
|
25
|
+
look: { label: 'Worth a look', short: 'Look', dot: 'bg-warning', text: 'text-warning' },
|
|
26
|
+
fyi: { label: 'Harmless', short: 'Harmless', dot: 'bg-muted-foreground/40' },
|
|
27
|
+
};
|
|
28
|
+
const ORDER = ['act', 'look', 'fyi'];
|
|
29
|
+
const HOUR = 3_600_000;
|
|
30
|
+
const toMs = (at) => (typeof at === 'number' ? at : Date.parse(at) || 0);
|
|
31
|
+
const nextReset = (now) => {
|
|
32
|
+
const d = new Date(now);
|
|
33
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
34
|
+
};
|
|
35
|
+
const pct = (v) => (v >= 10 ? '>999%' : `${Math.round(v * 100)}%`);
|
|
36
|
+
/** `3m`, `2h`, `4d`: how long ago. */
|
|
37
|
+
export function signalAge(at, now = Date.now()) {
|
|
38
|
+
const d = Math.max(0, now - toMs(at));
|
|
39
|
+
if (d < 60_000)
|
|
40
|
+
return 'now';
|
|
41
|
+
if (d < HOUR)
|
|
42
|
+
return `${Math.round(d / 60_000)}m`;
|
|
43
|
+
if (d < 24 * HOUR)
|
|
44
|
+
return `${Math.round(d / HOUR)}h`;
|
|
45
|
+
return `${Math.round(d / (24 * HOUR))}d`;
|
|
46
|
+
}
|
|
47
|
+
/** One entry per key, counted, latest numbers kept; ordered by level, then by where it lands. */
|
|
48
|
+
export function groupSignals(list) {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const s of list) {
|
|
51
|
+
const prev = map.get(s.key);
|
|
52
|
+
const count = Math.max(1, s.count ?? 1);
|
|
53
|
+
if (!prev)
|
|
54
|
+
map.set(s.key, { ...s, count });
|
|
55
|
+
else {
|
|
56
|
+
const newer = toMs(s.at) > toMs(prev.at) ? s : prev;
|
|
57
|
+
map.set(s.key, { ...newer, count: (prev.count ?? 1) + count });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...map.values()].sort((a, b) => ORDER.indexOf(a.level) - ORDER.indexOf(b.level) ||
|
|
61
|
+
(b.runsOut ? 99 : (b.lands ?? -1)) - (a.runsOut ? 99 : (a.lands ?? -1)) ||
|
|
62
|
+
toMs(b.at) - toMs(a.at));
|
|
63
|
+
}
|
|
64
|
+
const EMPTY = { dismissed: {}, muted: {}, banner: {} };
|
|
65
|
+
const storeKey = (app) => `lumen:signals:${app}`;
|
|
66
|
+
function readKept(app) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = window.localStorage.getItem(storeKey(app));
|
|
69
|
+
return raw ? { ...EMPTY, ...JSON.parse(raw) } : EMPTY;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return EMPTY;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const Ctx = createContext(null);
|
|
76
|
+
/** The open signals and the panel's state; `null` outside the shell. */
|
|
77
|
+
export function useSignals() {
|
|
78
|
+
return useContext(Ctx);
|
|
79
|
+
}
|
|
80
|
+
/** Holds the app's and pages' signals and what this device dismissed or muted. The shell mounts it. */
|
|
81
|
+
export function SignalsProvider({ app, signals, options, openSettings, children, }) {
|
|
82
|
+
const [kept, setKept] = useState(EMPTY);
|
|
83
|
+
const [open, setOpen] = useState(false);
|
|
84
|
+
const [now, setNow] = useState(() => Date.now());
|
|
85
|
+
useEffect(() => setKept(readKept(app)), [app]);
|
|
86
|
+
// Mutes end on their own: look again each minute while the tab shows.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const t = setInterval(() => !document.hidden && setNow(Date.now()), 60_000);
|
|
89
|
+
return () => clearInterval(t);
|
|
90
|
+
}, []);
|
|
91
|
+
const save = useCallback((next) => setKept((prev) => {
|
|
92
|
+
const k = next(prev);
|
|
93
|
+
try {
|
|
94
|
+
window.localStorage.setItem(storeKey(app), JSON.stringify(k));
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
return k;
|
|
98
|
+
}), [app]);
|
|
99
|
+
const all = useMemo(() => groupSignals(signals), [signals]);
|
|
100
|
+
const live = all.filter((s) => (kept.dismissed[s.key] ?? 0) < toMs(s.at));
|
|
101
|
+
const items = live.filter((s) => (kept.muted[s.key] ?? 0) <= now);
|
|
102
|
+
const muted = live.filter((s) => (kept.muted[s.key] ?? 0) > now);
|
|
103
|
+
const banners = items.filter((s) => s.banner && s.level === 'act' && (kept.banner[s.key] ?? 0) <= now);
|
|
104
|
+
const dismiss = useCallback((list) => {
|
|
105
|
+
if (!list.length)
|
|
106
|
+
return;
|
|
107
|
+
let before = EMPTY;
|
|
108
|
+
save((k) => {
|
|
109
|
+
before = k;
|
|
110
|
+
const dismissed = { ...k.dismissed };
|
|
111
|
+
for (const s of list)
|
|
112
|
+
dismissed[s.key] = toMs(s.at);
|
|
113
|
+
return { ...k, dismissed };
|
|
114
|
+
});
|
|
115
|
+
const n = list.length;
|
|
116
|
+
lumenToast.undo(n === 1 ? `Dismissed ${list[0]?.what ?? 'signal'}` : `Dismissed ${n} signals`, () => save(() => before));
|
|
117
|
+
}, [save]);
|
|
118
|
+
const mute = useCallback((s, until) => {
|
|
119
|
+
const t = Date.now();
|
|
120
|
+
const end = until === 'hour' ? t + HOUR : until === 'reset' ? nextReset(t) : t + 365 * 24 * HOUR;
|
|
121
|
+
save((k) => ({ ...k, muted: { ...k.muted, [s.key]: end } }));
|
|
122
|
+
options?.onMute?.(s, until);
|
|
123
|
+
lumenToast.undo(`Muted ${s.what}`, () => save((k) => {
|
|
124
|
+
const { [s.key]: _, ...rest } = k.muted;
|
|
125
|
+
return { ...k, muted: rest };
|
|
126
|
+
}));
|
|
127
|
+
}, [save, options]);
|
|
128
|
+
const unmute = useCallback((s) => save((k) => {
|
|
129
|
+
const { [s.key]: _, ...rest } = k.muted;
|
|
130
|
+
return { ...k, muted: rest };
|
|
131
|
+
}), [save]);
|
|
132
|
+
const hideBanner = useCallback((s) => save((k) => ({ ...k, banner: { ...k.banner, [s.key]: Date.now() + HOUR } })), [save]);
|
|
133
|
+
const value = useMemo(() => ({
|
|
134
|
+
items,
|
|
135
|
+
muted,
|
|
136
|
+
banners,
|
|
137
|
+
open,
|
|
138
|
+
setOpen,
|
|
139
|
+
dismiss,
|
|
140
|
+
mute,
|
|
141
|
+
unmute,
|
|
142
|
+
hideBanner,
|
|
143
|
+
options: options ?? {},
|
|
144
|
+
openSettings,
|
|
145
|
+
}), [items, muted, banners, open, dismiss, mute, unmute, hideBanner, options, openSettings]);
|
|
146
|
+
return (_jsxs(Ctx.Provider, { value: value, children: [children, (items.length > 0 || muted.length > 0) && (_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SignalsPanel, {}) }))] }));
|
|
147
|
+
}
|
|
148
|
+
function Dot({ level }) {
|
|
149
|
+
return (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', SIGNAL_LEVELS[level].dot) }));
|
|
150
|
+
}
|
|
151
|
+
/** Where it lands at reset, in its meter colour. */
|
|
152
|
+
function landColor(s) {
|
|
153
|
+
const v = s.runsOut ? 2 : s.lands;
|
|
154
|
+
return v == null ? undefined : METER_LEVEL_COLOR[meterLevel(v)];
|
|
155
|
+
}
|
|
156
|
+
/** `34% → out 2h`, `88% → 96%`. */
|
|
157
|
+
function LimitCell({ s }) {
|
|
158
|
+
if (s.used == null && s.lands == null && !s.runsOut)
|
|
159
|
+
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
160
|
+
const end = s.runsOut ? `out ${s.runsOut}` : s.lands != null ? pct(s.lands) : '';
|
|
161
|
+
return (_jsxs("span", { className: "num", children: [s.used != null && _jsxs("span", { children: [pct(s.used), " \u2192 "] }), _jsx("span", { className: "font-medium", style: { color: landColor(s) }, children: end })] }));
|
|
162
|
+
}
|
|
163
|
+
/** The top signal in short form, for the bar. */
|
|
164
|
+
export function signalShort(s) {
|
|
165
|
+
return [s.kind, s.what, s.where].filter(Boolean).join(' · ');
|
|
166
|
+
}
|
|
167
|
+
/** The status bar's chip: counts by level and the top signal; opens the panel. */
|
|
168
|
+
export function SignalsChip({ className }) {
|
|
169
|
+
const ctx = useSignals();
|
|
170
|
+
if (!ctx || (!ctx.items.length && !ctx.muted.length))
|
|
171
|
+
return null;
|
|
172
|
+
const top = ctx.items.find((s) => s.kind && !s.banner) ?? ctx.items[0];
|
|
173
|
+
return (_jsxs("button", { type: "button", onClick: () => ctx.setOpen(!ctx.open), "aria-label": `Signals, ${ctx.items.length} open`, "aria-expanded": ctx.open, className: cn('flex h-6 min-w-0 overflow-hidden max-w-(--status-message-max) items-center gap-2 rounded-md border bg-muted/60 px-1.5 text-foreground hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none', className), children: [_jsxs("span", { className: "num flex shrink-0 items-center gap-1.5", children: [ORDER.map((l) => {
|
|
174
|
+
const n = ctx.items.filter((s) => s.level === l).length;
|
|
175
|
+
return n ? (_jsxs("span", { className: cn('flex items-center gap-1', SIGNAL_LEVELS[l].text), children: [_jsx(Dot, { level: l }), n] }, l)) : null;
|
|
176
|
+
}), !ctx.items.length && _jsx("span", { className: "text-muted-foreground", children: "0" })] }), top && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "h-3.5 w-px shrink-0 bg-border" }), _jsxs("span", { className: "min-w-0 truncate", children: [_jsx("span", { className: "text-muted-foreground", children: top.kind }), ' ', [top.what, top.where].filter(Boolean).join(' · ')] }), (top.pace || top.runsOut) && (_jsx("span", { className: "num min-w-0 truncate text-foreground/80", children: [top.pace, top.runsOut && `out ${top.runsOut}`].filter(Boolean).join(' · ') }))] }))] }));
|
|
177
|
+
}
|
|
178
|
+
/** Banners for signals that block the page's job (an unreadable account, a limit already passed). */
|
|
179
|
+
export function SignalBanners() {
|
|
180
|
+
const ctx = useSignals();
|
|
181
|
+
if (!ctx?.banners.length)
|
|
182
|
+
return null;
|
|
183
|
+
return (_jsx("div", { "data-slot": "signal-banners", className: "flex flex-col", children: ctx.banners.slice(0, 2).map((s) => (_jsxs("div", { role: "alert", className: "flex h-10 items-center gap-2 border-b border-destructive/25 bg-destructive/10 px-(--shell-gutter) text-sm whitespace-nowrap text-destructive", children: [_jsx(TriangleAlertIcon, { "aria-hidden": true, className: "size-4 shrink-0" }), _jsxs("span", { className: "min-w-0 truncate font-medium", title: s.why, children: [[s.what, s.where].filter(Boolean).join(' · '), s.group && s.group !== s.where && s.group !== s.what && (_jsxs("span", { className: "font-normal text-foreground/80", children: [" \u00B7 ", s.group] }))] }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [s.fix && _jsx(FixButton, { s: s, size: "sm" }), _jsx(IconButton, { icon: XIcon, label: "Hide for 1 hour", size: "icon-sm", onClick: () => ctx.hideBanner(s) })] })] }, s.key))) }));
|
|
184
|
+
}
|
|
185
|
+
function FixButton({ s, size = 'xs' }) {
|
|
186
|
+
const ctx = useSignals();
|
|
187
|
+
if (!s.fix)
|
|
188
|
+
return null;
|
|
189
|
+
const { label, href, settings } = s.fix;
|
|
190
|
+
const onClick = settings
|
|
191
|
+
? () => {
|
|
192
|
+
ctx?.setOpen(false);
|
|
193
|
+
ctx?.openSettings?.(settings);
|
|
194
|
+
}
|
|
195
|
+
: s.fix.onClick;
|
|
196
|
+
return href ? (_jsx(Button, { size: size, asChild: true, children: _jsx("a", { href: href, children: label }) })) : (_jsx(Button, { size: size, onClick: onClick, children: label }));
|
|
197
|
+
}
|
|
198
|
+
function Facts({ s }) {
|
|
199
|
+
const rows = [];
|
|
200
|
+
if (s.group)
|
|
201
|
+
rows.push(['In', s.group]);
|
|
202
|
+
if (s.where)
|
|
203
|
+
rows.push(['Where', s.where]);
|
|
204
|
+
if (s.pace)
|
|
205
|
+
rows.push(['Pace', s.pace]);
|
|
206
|
+
if (s.used != null)
|
|
207
|
+
rows.push(['Limit used', pct(s.used)]);
|
|
208
|
+
if (s.runsOut)
|
|
209
|
+
rows.push(['Runs out', `in about ${s.runsOut}`]);
|
|
210
|
+
else if (s.lands != null)
|
|
211
|
+
rows.push(['At reset', pct(s.lands)]);
|
|
212
|
+
rows.push(['Last', `${signalAge(s.at)} ago${(s.count ?? 1) > 1 ? ` · ${s.count} times` : ''}`]);
|
|
213
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-1", children: rows.map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "num truncate text-right", children: v })] }, k))) }), s.why && _jsx("p", { className: "text-muted-foreground", children: s.why })] }));
|
|
214
|
+
}
|
|
215
|
+
function MuteMenu({ s, ctx }) {
|
|
216
|
+
if (ctx.muted.includes(s))
|
|
217
|
+
return (_jsx(IconButton, { icon: BellOffIcon, label: "Unmute", size: "icon-xs", pressed: true, onClick: () => ctx.unmute(s) }));
|
|
218
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: BellOffIcon, label: "Mute", size: "icon-xs" }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsx(DropdownMenuItem, { onSelect: () => ctx.mute(s, 'hour'), children: "For 1 hour" }), _jsx(DropdownMenuItem, { onSelect: () => ctx.mute(s, 'reset'), children: "Until reset (00:00 UTC)" }), ctx.options.onMute && (_jsx(DropdownMenuItem, { onSelect: () => ctx.mute(s, 'always'), children: "Always, for this meter" }))] })] }));
|
|
219
|
+
}
|
|
220
|
+
/** One signal, one line: level, kind, what · where, pace, limit used → at reset, age; actions on hover. */
|
|
221
|
+
function SignalRow({ s, ctx, onPick, }) {
|
|
222
|
+
const isMuted = ctx.muted.includes(s);
|
|
223
|
+
const line = (_jsxs(_Fragment, { children: [_jsx(Dot, { level: s.level }), _jsx("span", { className: "truncate text-muted-foreground", "data-signal-col": "kind", children: s.kind }), _jsx(HoverCardTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex min-w-0 items-center gap-1.5 text-left", children: [_jsx("span", { className: "truncate", children: s.what }), s.where && (_jsx("span", { className: "min-w-8 shrink-[3] truncate text-muted-foreground", children: s.where })), (s.count ?? 1) > 1 && (_jsxs("span", { className: "num shrink-0 rounded-full bg-muted px-1.5 text-muted-foreground text-xs", children: ["\u00D7", s.count] }))] }) }), _jsx("span", { "data-signal-col": "pace", className: cn('num truncate text-right', s.level === 'act' ? 'text-destructive' : s.level === 'fyi' && 'text-muted-foreground'), children: s.pace ?? '—' }), _jsx("span", { className: "truncate text-right", children: _jsx(LimitCell, { s: s }) }), _jsx("span", { "data-signal-col": "age", className: "num text-right text-muted-foreground", children: signalAge(s.at) })] }));
|
|
224
|
+
if (onPick)
|
|
225
|
+
return (_jsxs("button", { type: "button", onClick: () => onPick(s), "data-signal-grid": "phone", className: cn('grid h-11 w-full items-center gap-2 border-b px-4 text-left whitespace-nowrap ', isMuted && 'opacity-60'), children: [_jsx(Dot, { level: s.level }), _jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "truncate", children: s.what }), s.where && (_jsx("span", { className: "min-w-8 shrink-[3] truncate text-muted-foreground", children: s.where })), (s.count ?? 1) > 1 && (_jsxs("span", { className: "num shrink-0 rounded-full bg-muted px-1.5 text-muted-foreground text-xs", children: ["\u00D7", s.count] }))] }), _jsx("span", { className: "num text-right text-xs", style: { color: landColor(s) }, children: s.runsOut ? `out ${s.runsOut}` : s.lands != null ? `→${pct(s.lands)}` : s.kind })] }));
|
|
226
|
+
return (_jsxs("div", { "data-signal-grid": true, className: cn('group/sig relative grid h-9 items-center gap-2 border-b px-4 whitespace-nowrap hover:bg-muted/50 focus-within:bg-muted/50', isMuted && 'opacity-60'), children: [_jsxs(HoverCard, { openDelay: 300, closeDelay: 80, children: [line, _jsxs(HoverCardContent, { side: "left", align: "start", className: "w-80 text-xs", children: [_jsxs("p", { className: "mb-2 flex items-center gap-1.5 font-medium text-sm", children: [_jsx(Dot, { level: s.level }), _jsx("span", { className: "truncate", children: signalShort(s) })] }), _jsx(Facts, { s: s })] })] }), _jsxs("span", { className: "absolute top-1 right-2 hidden items-center gap-0.5 rounded-md border bg-background px-0.5 shadow-xs group-hover/sig:flex group-focus-within/sig:flex", children: [s.fix && _jsx(FixButton, { s: s }), (s.to || s.href) && (_jsx(IconButton, { icon: ExternalLinkIcon, label: "Open", size: "icon-xs", to: s.to, href: s.to ? undefined : s.href })), _jsx(MuteMenu, { s: s, ctx: ctx }), _jsx(IconButton, { icon: XIcon, label: "Dismiss", size: "icon-xs", onClick: () => ctx.dismiss([s]) })] })] }));
|
|
227
|
+
}
|
|
228
|
+
/** The Signals panel: a docked drawer on desktop, a bottom sheet on a phone. */
|
|
229
|
+
function SignalsPanel() {
|
|
230
|
+
const ctx = useSignals();
|
|
231
|
+
const mobile = useIsMobile();
|
|
232
|
+
const [filter, setFilter] = useState('all');
|
|
233
|
+
const [by, setBy] = useState('level');
|
|
234
|
+
const [picked, setPicked] = useState(null);
|
|
235
|
+
const hasGroups = ctx.items.some((s) => s.group);
|
|
236
|
+
const list = filter === 'muted'
|
|
237
|
+
? ctx.muted
|
|
238
|
+
: ctx.items.filter((s) => filter === 'all' || s.level === filter);
|
|
239
|
+
const groups = new Map();
|
|
240
|
+
for (const s of list) {
|
|
241
|
+
const k = by === 'group' && hasGroups ? (s.group ?? 'Other') : s.level;
|
|
242
|
+
groups.set(k, [...(groups.get(k) ?? []), s]);
|
|
243
|
+
}
|
|
244
|
+
const pick = [...ctx.items, ...ctx.muted].find((s) => s.key === picked);
|
|
245
|
+
const close = () => {
|
|
246
|
+
ctx.setOpen(false);
|
|
247
|
+
setPicked(null);
|
|
248
|
+
};
|
|
249
|
+
return (_jsx(LumenDrawer, { open: ctx.open, onClose: close, dock: "signals", defaultSize: "40%", minSize: "420px", title: pick && mobile ? signalShort(pick) : 'Signals', description: `${ctx.items.length} open`, footerStart: pick && mobile ? undefined : 'Dismiss and short mutes are kept on this device', actions: [
|
|
250
|
+
...(pick && mobile
|
|
251
|
+
? [{ icon: ArrowLeftIcon, label: 'Back to the list', onClick: () => setPicked(null) }]
|
|
252
|
+
: []),
|
|
253
|
+
...(ctx.options.settings && ctx.openSettings
|
|
254
|
+
? [
|
|
255
|
+
{
|
|
256
|
+
icon: SettingsIcon,
|
|
257
|
+
label: 'Alert settings',
|
|
258
|
+
onClick: () => {
|
|
259
|
+
close();
|
|
260
|
+
ctx.openSettings?.(ctx.options.settings);
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
: []),
|
|
265
|
+
], children: pick && mobile ? (_jsxs("div", { className: "flex flex-col gap-4 pt-1 text-sm", children: [_jsxs("p", { className: "flex items-center gap-2 font-medium", children: [_jsx(Dot, { level: pick.level }), SIGNAL_LEVELS[pick.level].label] }), _jsx(Facts, { s: pick }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [pick.to || pick.href ? (_jsx(Button, { asChild: true, children: _jsx("a", { href: pick.to ?? pick.href, children: "Open" }) })) : (_jsx(FixButton, { s: pick, size: "sm" })), _jsx(Button, { variant: "outline", onClick: () => {
|
|
266
|
+
ctx.mute(pick, 'reset');
|
|
267
|
+
setPicked(null);
|
|
268
|
+
}, children: "Mute" }), _jsx(Button, { variant: "outline", onClick: () => {
|
|
269
|
+
ctx.dismiss([pick]);
|
|
270
|
+
setPicked(null);
|
|
271
|
+
}, children: "Dismiss" })] })] })) : (_jsxs("div", { "data-slot": "signals", className: "-mx-4 flex flex-col text-sm", children: [_jsxs("div", { className: "flex items-center gap-2 overflow-x-auto px-4 pb-2", children: [_jsxs(ToggleGroup, { type: "single", size: "sm", variant: "outline", value: filter, onValueChange: (v) => v && setFilter(v), "aria-label": "Show", children: [_jsxs(ToggleGroupItem, { value: "all", children: ["All ", _jsx("span", { className: "num text-muted-foreground", children: ctx.items.length })] }), ORDER.map((l) => (_jsxs(ToggleGroupItem, { value: l, children: [_jsx(Dot, { level: l }), SIGNAL_LEVELS[l].short, ' ', _jsx("span", { className: "num text-muted-foreground", children: ctx.items.filter((s) => s.level === l).length })] }, l))), ctx.muted.length > 0 && (_jsxs(ToggleGroupItem, { value: "muted", children: ["Muted ", _jsx("span", { className: "num text-muted-foreground", children: ctx.muted.length })] }))] }), hasGroups && !mobile && (_jsxs(ToggleGroup, { type: "single", size: "sm", variant: "outline", value: by, onValueChange: (v) => v && setBy(v), "aria-label": "Group by", className: "ml-auto", children: [_jsx(ToggleGroupItem, { value: "level", children: "By level" }), _jsxs(ToggleGroupItem, { value: "group", children: ["By ", ctx.options.groupLabel?.toLowerCase() ?? 'group'] })] }))] }), !mobile && list.length > 0 && (_jsxs("div", { "data-signal-grid": true, className: "sticky top-0 z-1 grid h-7 items-center gap-2 border-y bg-background px-4 text-muted-foreground text-xs whitespace-nowrap", children: [_jsx("span", {}), _jsx("span", { "data-signal-col": "kind", children: "Kind" }), _jsx("span", { children: "What \u00B7 where" }), _jsx("span", { "data-signal-col": "pace", className: "text-right", children: "Pace" }), _jsx("span", { className: "text-right", children: "Used \u2192 at reset" }), _jsx("span", { "data-signal-col": "age", className: "text-right", children: "Age" })] })), list.length === 0 && (_jsx("p", { className: "px-4 py-8 text-center text-muted-foreground", children: filter === 'muted' ? 'Nothing muted.' : 'All clear. New signals show here.' })), [...groups].map(([k, rows]) => {
|
|
272
|
+
const level = ORDER.includes(k) ? k : null;
|
|
273
|
+
return (_jsxs("section", { "aria-label": level ? SIGNAL_LEVELS[level].label : k, children: [_jsxs("div", { className: "flex h-8 items-center gap-2 bg-muted px-4 font-medium text-xs whitespace-nowrap", children: [level && _jsx(Dot, { level: level }), _jsx("span", { className: "truncate", children: level ? SIGNAL_LEVELS[level].label : k }), _jsx("span", { className: "num text-muted-foreground", children: rows.length }), filter !== 'muted' && (_jsx("button", { type: "button", onClick: () => ctx.dismiss(rows), className: "ml-auto rounded-sm text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none", children: "Dismiss all" }))] }), rows.map((s) => (_jsx(SignalRow, { s: s, ctx: ctx, onPick: mobile ? (x) => setPicked(x.key) : undefined }, s.key)))] }, k));
|
|
274
|
+
})] })) }));
|
|
275
|
+
}
|
package/dist/status-bar.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
import { type Signal, type SignalsOptions } from './signals.js';
|
|
2
3
|
import { type StatusMessage } from './status-messages.js';
|
|
3
4
|
/** How fresh the page's data is: up to date, reading now, an old copy, no network, or failed. */
|
|
4
5
|
export type StatusSyncState = 'ok' | 'syncing' | 'stale' | 'offline' | 'failed';
|
|
@@ -28,12 +29,19 @@ export interface StatusPart {
|
|
|
28
29
|
sync?: StatusSync | null;
|
|
29
30
|
/** Newest first. */
|
|
30
31
|
messages?: StatusMessage[];
|
|
32
|
+
/** This page's signals (warnings and errors as data); they join the app's in the Signals panel. */
|
|
33
|
+
signals?: Signal[];
|
|
31
34
|
facts?: StatusFact[];
|
|
32
35
|
}
|
|
33
36
|
/** The app's own bar on `LumenShell` (`bar`): its sync and messages, and whether running Tasks show. */
|
|
34
37
|
export interface StatusBarModel {
|
|
35
38
|
sync?: StatusSync | null;
|
|
39
|
+
/** Free-text notes. Warnings and errors go in `signals` instead. */
|
|
36
40
|
messages?: StatusMessage[];
|
|
41
|
+
/** Warnings and errors as data: counts and the top one in the bar, every one in the Signals panel. */
|
|
42
|
+
signals?: Signal[];
|
|
43
|
+
/** Mute that the app keeps, the panel's settings gear, the name of a signal's `group`. */
|
|
44
|
+
signalOptions?: SignalsOptions;
|
|
37
45
|
/** App-wide facts (`Next run in 12m`); a page's own come first, two show in all. */
|
|
38
46
|
facts?: StatusFact[];
|
|
39
47
|
/** Shows this app's running task from the Tasks list (default true where Tasks is mounted). */
|
|
@@ -48,6 +56,13 @@ export declare function StatusBarProvider({ children }: {
|
|
|
48
56
|
* only; the newest part's sync wins over the app's, messages add up, the first two facts show.
|
|
49
57
|
*/
|
|
50
58
|
export declare function useStatus(part: StatusPart): void;
|
|
59
|
+
/** Collects the app's and the pages' signals for the bar, the panel and the banners. The shell mounts it. */
|
|
60
|
+
export declare function StatusSignals({ app, model, openSettings, children, }: {
|
|
61
|
+
app: string;
|
|
62
|
+
model?: StatusBarModel;
|
|
63
|
+
openSettings?: (section?: string) => void;
|
|
64
|
+
children: ReactNode;
|
|
65
|
+
}): import("react").JSX.Element;
|
|
51
66
|
/** A TanStack Query result's freshness as a `StatusSync`: the page's main read, with no app code. */
|
|
52
67
|
export declare function querySync(q: {
|
|
53
68
|
status: 'pending' | 'error' | 'success';
|
package/dist/status-bar.js
CHANGED
|
@@ -10,6 +10,7 @@ import { InfoIcon } from 'lucide-react';
|
|
|
10
10
|
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, } from 'react';
|
|
11
11
|
import { useIsMobile } from './hooks/use-mobile.js';
|
|
12
12
|
import { cn } from './lib/utils.js';
|
|
13
|
+
import { SIGNAL_LEVELS, SignalsChip, SignalsProvider, signalShort, useSignals, } from './signals.js';
|
|
13
14
|
import { STATUS_TRIGGER, StatusMessages, StatusPop, groupStatusMessages, } from './status-messages.js';
|
|
14
15
|
import { useTasks } from './tasks.js';
|
|
15
16
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from './ui/sheet.js';
|
|
@@ -48,6 +49,12 @@ export function useStatus(part) {
|
|
|
48
49
|
return () => registry.set(id, null);
|
|
49
50
|
}, [registry, id, key]);
|
|
50
51
|
}
|
|
52
|
+
/** Collects the app's and the pages' signals for the bar, the panel and the banners. The shell mounts it. */
|
|
53
|
+
export function StatusSignals({ app, model, openSettings, children, }) {
|
|
54
|
+
const parts = useContext(PartsCtx);
|
|
55
|
+
const signals = useMemo(() => [...parts.flatMap((p) => p.signals ?? []), ...(model?.signals ?? [])], [parts, model?.signals]);
|
|
56
|
+
return (_jsx(SignalsProvider, { app: app, signals: signals, options: model?.signalOptions, openSettings: openSettings, children: children }));
|
|
57
|
+
}
|
|
51
58
|
/** A TanStack Query result's freshness as a `StatusSync`: the page's main read, with no app code. */
|
|
52
59
|
export function querySync(q, label) {
|
|
53
60
|
const at = q.dataUpdatedAt || null;
|
|
@@ -159,6 +166,7 @@ function factEntry(f, i, now) {
|
|
|
159
166
|
function useEntries(model, app) {
|
|
160
167
|
const parts = useContext(PartsCtx);
|
|
161
168
|
const tasks = useTasks();
|
|
169
|
+
const signals = useSignals();
|
|
162
170
|
const now = useNow();
|
|
163
171
|
const entries = [];
|
|
164
172
|
const sync = [...parts].reverse().find((p) => p.sync)?.sync ?? model?.sync ?? null;
|
|
@@ -184,18 +192,32 @@ function useEntries(model, app) {
|
|
|
184
192
|
}
|
|
185
193
|
const messages = [...parts.flatMap((p) => p.messages ?? []), ...(model?.messages ?? [])];
|
|
186
194
|
const groups = groupStatusMessages(messages);
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
const tone =
|
|
195
|
+
const first = groups[0];
|
|
196
|
+
if (first) {
|
|
197
|
+
const tone = first.tone === 'error' ? 'error' : first.tone === 'warning' ? 'warning' : 'none';
|
|
190
198
|
entries.push({
|
|
191
199
|
id: 'messages',
|
|
192
200
|
rank: tone === 'error' ? 0 : tone === 'warning' ? 2 : 7,
|
|
193
201
|
tone,
|
|
194
|
-
text:
|
|
202
|
+
text: first.count > 1 ? `${first.text} ×${first.count}` : first.text,
|
|
195
203
|
side: groups.length > 1 ? `+${groups.length - 1}` : undefined,
|
|
196
204
|
bar: _jsx(StatusMessages, { messages: messages }),
|
|
197
205
|
});
|
|
198
206
|
}
|
|
207
|
+
const top = signals?.items[0];
|
|
208
|
+
if (signals && (top || signals.muted.length)) {
|
|
209
|
+
const n = signals.items.length;
|
|
210
|
+
entries.push({
|
|
211
|
+
id: 'signals',
|
|
212
|
+
rank: top?.level === 'act' ? 0 : top?.level === 'look' ? 2 : 7,
|
|
213
|
+
tone: top?.level === 'act' ? 'error' : top?.level === 'look' ? 'warning' : 'idle',
|
|
214
|
+
text: top ? signalShort(top) : 'Signals muted',
|
|
215
|
+
side: n > 1 ? `+${n - 1}` : undefined,
|
|
216
|
+
detail: top ? SIGNAL_LEVELS[top.level].label : undefined,
|
|
217
|
+
bar: _jsx(SignalsChip, {}),
|
|
218
|
+
tap: () => signals.setOpen(true),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
199
221
|
[...parts.flatMap((p) => p.facts ?? []), ...(model?.facts ?? [])]
|
|
200
222
|
.slice(0, MAX_FACTS)
|
|
201
223
|
.forEach((f, i) => entries.push(factEntry(f, i, now)));
|
|
@@ -206,10 +228,12 @@ function EntryLine({ e }) {
|
|
|
206
228
|
return _jsx(_Fragment, { children: e.bar });
|
|
207
229
|
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
230
|
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 }));
|
|
231
|
+
return (_jsx("span", { className: "flex h-6 min-w-0 items-center gap-1.5 overflow-hidden px-1.5", children: line }));
|
|
232
|
+
return (_jsx(StatusPop, { className: cn(STATUS_TRIGGER, 'min-w-0 overflow-hidden'), content: _jsx("p", { className: TONE_TEXT[e.tone], children: e.detail }), children: line }));
|
|
211
233
|
}
|
|
212
|
-
function SheetRow({ e }) {
|
|
234
|
+
function SheetRow({ e, onTap }) {
|
|
235
|
+
if (onTap)
|
|
236
|
+
return (_jsx("li", { className: "border-b last:border-b-0", children: _jsxs("button", { type: "button", onClick: onTap, className: "flex w-full items-center gap-2 py-2 text-left", children: [_jsx(Mark, { tone: e.tone === 'none' ? 'idle' : e.tone }), _jsx("span", { className: cn('min-w-0 flex-1 truncate', TONE_TEXT[e.tone]), children: e.text }), e.side && _jsx("span", { className: "shrink-0 tabular-nums text-muted-foreground", children: e.side })] }) }));
|
|
213
237
|
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
238
|
}
|
|
215
239
|
/** The bar itself; `LumenShell` renders it. */
|
|
@@ -218,7 +242,12 @@ export function LumenStatusBar({ app, model, legacy, slot, end, }) {
|
|
|
218
242
|
const entries = useEntries(model, app);
|
|
219
243
|
const [open, setOpen] = useState(false);
|
|
220
244
|
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-(--shell-tabbar-offset) 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]
|
|
245
|
+
return (_jsx("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-(--shell-tabbar-offset) 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: urgent.tap ? (_jsx("button", { type: "button", onClick: urgent.tap, className: cn(STATUS_TRIGGER, 'min-w-0 overflow-hidden'), children: _jsx(EntryLine, { e: { ...urgent, bar: undefined, detail: undefined, side: undefined } }) })) : (_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
246
|
.sort((a, b) => a.rank - b.rank)
|
|
223
|
-
.map((e) => (_jsx(SheetRow, { e: e
|
|
247
|
+
.map((e) => (_jsx(SheetRow, { e: e, onTap: e.tap
|
|
248
|
+
? () => {
|
|
249
|
+
setOpen(false);
|
|
250
|
+
e.tap?.();
|
|
251
|
+
}
|
|
252
|
+
: undefined }, 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
253
|
}
|
package/dist/theme.css
CHANGED
|
@@ -859,3 +859,32 @@ body[data-spotlight="nav"] [data-spotlit] {
|
|
|
859
859
|
[data-slot="sidebar-inset"]:has(> [data-slot="shell-tabbar"]) {
|
|
860
860
|
--shell-tabbar-offset: calc(var(--shell-tabbar-height) + env(safe-area-inset-bottom));
|
|
861
861
|
}
|
|
862
|
+
|
|
863
|
+
/* Signals panel (signals.tsx): one line a row, columns lined up across groups. Below 34rem wide the
|
|
864
|
+
Kind and Pace columns go (the hover keeps them), so What · where keeps its room. */
|
|
865
|
+
[data-slot="signals"] {
|
|
866
|
+
container-type: inline-size;
|
|
867
|
+
}
|
|
868
|
+
[data-signal-grid] {
|
|
869
|
+
grid-template-columns: 0.5rem 5.5rem minmax(0, 1fr) 6.5rem 8.5rem 2.25rem;
|
|
870
|
+
}
|
|
871
|
+
[data-signal-grid="phone"] {
|
|
872
|
+
grid-template-columns: 0.5rem minmax(0, 1fr) auto;
|
|
873
|
+
}
|
|
874
|
+
@container (max-width: 40rem) {
|
|
875
|
+
[data-signal-grid]:not([data-signal-grid="phone"]) {
|
|
876
|
+
grid-template-columns: 0.5rem minmax(0, 1fr) 6.5rem 8.5rem 2.25rem;
|
|
877
|
+
}
|
|
878
|
+
[data-signal-col="kind"] {
|
|
879
|
+
display: none;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
@container (max-width: 34rem) {
|
|
883
|
+
[data-signal-grid]:not([data-signal-grid="phone"]) {
|
|
884
|
+
grid-template-columns: 0.5rem minmax(0, 1fr) 8.5rem 2.25rem;
|
|
885
|
+
}
|
|
886
|
+
[data-signal-col="kind"],
|
|
887
|
+
[data-signal-col="pace"] {
|
|
888
|
+
display: none;
|
|
889
|
+
}
|
|
890
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.177",
|
|
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.177",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|