@columenlabs/lumen-ui 0.1.129 → 0.1.131

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.
@@ -8,6 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  import { useQuery } from '@tanstack/react-query';
9
9
  import { ArrowUpRightIcon, ChevronsUpDownIcon } from 'lucide-react';
10
10
  import { Fragment, useState } from 'react';
11
+ import { fetchWithin } from './fetch-within.js';
11
12
  import { cn } from './lib/utils.js';
12
13
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
13
14
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar.js';
@@ -80,7 +81,7 @@ export function LumenSuiteSwitcher({ source, current, product, menuLabel, }) {
80
81
  queryKey: ['lumen-suite', source],
81
82
  enabled: !!source,
82
83
  queryFn: async () => {
83
- const res = await fetch(source, { credentials: 'include' });
84
+ const res = await fetchWithin(source, { credentials: 'include' });
84
85
  if (!res.ok)
85
86
  throw new Error(`suite list ${res.status}`);
86
87
  return res.json();
@@ -0,0 +1,8 @@
1
+ /** How long a call to the app's own server may take before it gives up (ms). */
2
+ export declare const FETCH_MS = 15000;
3
+ /**
4
+ * `fetch` that never waits forever: it gives up after `ms` with a `TimeoutError` (the caller's own
5
+ * `signal`, if any, still aborts it as before), so a hung connection becomes an error state or a
6
+ * fallback instead of a spinner that never ends.
7
+ */
8
+ export declare function fetchWithin(url: string, init?: RequestInit, ms?: number): Promise<Response>;
@@ -0,0 +1,14 @@
1
+ /** How long a call to the app's own server may take before it gives up (ms). */
2
+ export const FETCH_MS = 15_000;
3
+ /**
4
+ * `fetch` that never waits forever: it gives up after `ms` with a `TimeoutError` (the caller's own
5
+ * `signal`, if any, still aborts it as before), so a hung connection becomes an error state or a
6
+ * fallback instead of a spinner that never ends.
7
+ */
8
+ export function fetchWithin(url, init = {}, ms = FETCH_MS) {
9
+ const timeout = AbortSignal.timeout(ms);
10
+ const signal = init.signal && typeof AbortSignal.any === 'function'
11
+ ? AbortSignal.any([init.signal, timeout])
12
+ : (init.signal ?? timeout);
13
+ return fetch(url, { ...init, signal });
14
+ }
package/dist/index.d.ts CHANGED
@@ -76,6 +76,7 @@ export { type McpServer, McpSettings } from './mcp-settings.js';
76
76
  export { lumenToast } from './lumen-toast.js';
77
77
  export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
78
78
  export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
79
+ export { FETCH_MS, fetchWithin } from './fetch-within.js';
79
80
  export { savedAnswers, type SavedAnswersOptions, type SavedAnswersPersister, } from './saved-answers.js';
80
81
  export { cn } from './lib/utils.js';
81
82
  export { type HealthProbe, StatusHealth } from './status-health.js';
package/dist/index.js CHANGED
@@ -83,6 +83,7 @@ export { McpSettings } from './mcp-settings.js';
83
83
  export { lumenToast } from './lumen-toast.js';
84
84
  export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
85
85
  export { API_TIMEOUT_MS, apiJson } from './api-fetch.js';
86
+ export { FETCH_MS, fetchWithin } from './fetch-within.js';
86
87
  export { savedAnswers, } from './saved-answers.js';
87
88
  export { cn } from './lib/utils.js';
88
89
  export { StatusHealth } from './status-health.js';
@@ -42,7 +42,7 @@ export function StackedColumns({ rows, x, parts, label, height = 220, tick = Str
42
42
  const total = (r) => parts.reduce((n, p) => n + (Number(r[p.key]) || 0), 0);
43
43
  // The highest part with a value carries the column's rounded cap.
44
44
  const top = (r) => parts.findLast((p) => Number(r[p.key]) > 0)?.key;
45
- const ticks = niceTicks(Math.max(0, ...rows.map(total)));
45
+ const ticks = niceTicks(Math.max(0, ...rows.map(total)), 4, rows.every((r) => Number.isInteger(total(r))));
46
46
  return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ChartContainer, { config: config, role: "img", "aria-label": label, className: "aspect-auto w-full", style: { height }, children: _jsxs(BarChart, { data: rows, margin: { left: 0, right: 0, top: 8, bottom: 0 }, children: [_jsx(CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: x, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 12, tickFormatter: (v) => tick(String(v)) }), _jsx(YAxis, { tickLine: false, axisLine: false, domain: [0, ticks.at(-1) ?? 'auto'], ticks: ticks, width: axisWidth(ticks.map(fmt)), tick: axisTick(fmt) }), _jsx(ChartTooltip, { cursor: { fill: 'var(--muted)', opacity: 0.5 }, content: _jsx(ChartTooltipContent, { labelFormatter: (_, payload) => {
47
47
  const r = payload?.[0]?.payload;
48
48
  return r ? (_jsxs("span", { className: "flex w-full gap-3", children: [_jsx("span", { children: tick(String(r[x])) }), _jsx("span", { className: "num ml-auto", children: fmt(total(r)) })] })) : null;
@@ -54,7 +54,8 @@ export function TimeLines({ rows, x, lines, label, height = 220, tick = String,
54
54
  const config = Object.fromEntries(lines.map((l) => [l.key, { label: l.name, color: l.fill }]));
55
55
  const axis = (right) => {
56
56
  const keys = lines.filter((l) => !!l.right === right).map((l) => l.key);
57
- return niceTicks(Math.max(0, ...rows.flatMap((r) => keys.map((k) => Number(r[k]) || 0))));
57
+ const values = rows.flatMap((r) => keys.map((k) => Number(r[k]) || 0));
58
+ return niceTicks(Math.max(0, ...values), 4, values.every(Number.isInteger));
58
59
  };
59
60
  const left = axis(false);
60
61
  const hasRight = lines.some((l) => l.right);
@@ -113,14 +114,16 @@ export function AllocationBar({ items, total, config, tooltip, share, }) {
113
114
  }
114
115
  /**
115
116
  * Round axis steps (1, 2, 2.5 or 5 times a power of ten) from 0 to just above `max`, about four
116
- * of them, so a value axis reads 0, 100k, 200k, 300k rather than 95k, 190k, 285k.
117
+ * of them, so a value axis reads 0, 100k, 200k, 300k rather than 95k, 190k, 285k. When every value
118
+ * is a whole number (a count), so is every step: 0, 1, 2 rather than 0, 0.5, 1, 1.5.
117
119
  */
118
- function niceTicks(max, count = 4) {
120
+ function niceTicks(max, count = 4, whole = false) {
119
121
  if (!(max > 0))
120
122
  return [0, 1];
121
123
  const raw = max / count;
122
124
  const pow = 10 ** Math.floor(Math.log10(raw));
123
- const step = ([1, 2, 2.5, 5, 10].find((m) => m * pow >= raw) ?? 10) * pow;
125
+ const steps = whole ? [1, 2, 5, 10] : [1, 2, 2.5, 5, 10];
126
+ const step = Math.max(whole ? 1 : 0, (steps.find((m) => m * pow >= raw) ?? 10) * pow);
124
127
  const n = Math.ceil(max / step - 1e-9);
125
128
  return Array.from({ length: n + 1 }, (_, i) => Math.round(i * step * 1e6) / 1e6);
126
129
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { EXTENDED_LABEL, } from '@columenlabs/lumen-ui-types/market';
12
12
  import { createContext, createElement, useContext, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
13
+ import { fetchWithin } from '../fetch-within.js';
13
14
  const HIDDEN_CLOSE_MS = 30_000;
14
15
  const CLOSED_CHECK_MS = 60_000;
15
16
  /** Folds changed bars into a series (same minute replaced, new ones appended in order). */
@@ -63,7 +64,7 @@ class MarketHub {
63
64
  const k = keys.join(',');
64
65
  let p = this.days.get(k);
65
66
  if (!p) {
66
- p = fetch(`${this.api}/daily?symbols=${encodeURIComponent(k)}`, {
67
+ p = fetchWithin(`${this.api}/daily?symbols=${encodeURIComponent(k)}`, {
67
68
  credentials: 'same-origin',
68
69
  }).then(async (r) => {
69
70
  if (!r.ok)
@@ -77,7 +78,7 @@ class MarketHub {
77
78
  }
78
79
  /** Whether the app serves market data at all (a 404 means no feed here), read once. */
79
80
  available() {
80
- this.probe ??= fetch(`${this.api}/session`, { credentials: 'same-origin' })
81
+ this.probe ??= fetchWithin(`${this.api}/session`, { credentials: 'same-origin' })
81
82
  .then((r) => r.status !== 404)
82
83
  .catch(() => true);
83
84
  return this.probe;
@@ -7,6 +7,7 @@ import { useState } from 'react';
7
7
  // own paid ntfy.sh token) and webhooks, then which event goes to which target. The app passes its
8
8
  // API base (`/api/notifications`), its product's name and the address email comes from.
9
9
  import { LumenConfirm } from './confirm.js';
10
+ import { fetchWithin } from './fetch-within.js';
10
11
  import { ErrorLine, IntegrationRow, KeyTestLine, TestBadge, } from './integrations.js';
11
12
  import { IconButton } from './page.js';
12
13
  import { Badge } from './ui/badge.js';
@@ -18,7 +19,7 @@ import { Label } from './ui/label.js';
18
19
  const NTFY_IOS = 'https://apps.apple.com/us/app/ntfy/id1625396347';
19
20
  const NTFY_ANDROID = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
20
21
  async function call(url, method = 'GET', body) {
21
- const res = await fetch(url, {
22
+ const res = await fetchWithin(url, {
22
23
  method,
23
24
  headers: body === undefined ? {} : { 'content-type': 'application/json' },
24
25
  body: body === undefined ? undefined : JSON.stringify(body),
package/dist/page.d.ts CHANGED
@@ -21,7 +21,10 @@ export interface PageHeaderProps {
21
21
  }
22
22
  /** The fixed-height header; `Page` renders it, or a page with several states renders it itself. */
23
23
  export declare function PageHeader({ crumbs, actions, layout, meta, status }: PageHeaderProps): import("react").JSX.Element;
24
- /** The page body: the shell's gutter, section gap and max width. */
24
+ /**
25
+ * The page body: the shell's gutter, section gap and max width. A plain block, since the shell's
26
+ * inset is the page's one `main` landmark.
27
+ */
25
28
  export declare function PageBody({ className, children }: {
26
29
  className?: string;
27
30
  children: ReactNode;
package/dist/page.js CHANGED
@@ -36,9 +36,12 @@ export function PageHeader({ crumbs, actions, layout, meta, status }) {
36
36
  isMobile &&
37
37
  '[&_[data-slot=button]]:size-8 [&_[data-slot=button]]:gap-0 [&_[data-slot=button]]:px-0 [&_[data-slot=button]]:text-[0px]'), children: [actions && (_jsxs(_Fragment, { children: [_jsx("div", { "data-slot": "shell-page-actions", className: "flex items-center gap-0.5", children: actions }), _jsx(Separator, { orientation: "vertical", className: "mx-2 data-vertical:h-5 data-vertical:self-center" })] })), _jsxs("div", { "data-slot": "shell-actions", className: "flex items-center gap-0.5", children: [layout, _jsx(LumenFinderButton, {}), hideAmounts && (_jsx(IconButton, { icon: hidden ? EyeOffIcon : EyeIcon, label: hidden ? 'Show amounts' : 'Hide amounts', "aria-pressed": hidden, onClick: () => setPref('hideAmounts', !hidden) })), refresh && (_jsx(IconButton, { icon: RefreshCwIcon, busy: refreshing, label: "Refresh (skip cache)", disabled: refreshing, onClick: () => void refresh() })), headerActions] })] })] }), status && statusSlot && createPortal(status, statusSlot)] }));
38
38
  }
39
- /** The page body: the shell's gutter, section gap and max width. */
39
+ /**
40
+ * The page body: the shell's gutter, section gap and max width. A plain block, since the shell's
41
+ * inset is the page's one `main` landmark.
42
+ */
40
43
  export function PageBody({ className, children }) {
41
- return (_jsx("main", { className: cn('mx-auto flex w-full max-w-(--shell-max-width) min-w-0 flex-1 flex-col gap-(--shell-section-gap) p-(--shell-gutter)', className), children: children }));
44
+ return (_jsx("div", { className: cn('mx-auto flex w-full max-w-(--shell-max-width) min-w-0 flex-1 flex-col gap-(--shell-section-gap) p-(--shell-gutter)', className), children: children }));
42
45
  }
43
46
  export function Page({ className, children, ...header }) {
44
47
  return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...header }), _jsx(PageBody, { className: className, children: children })] }));
@@ -16,6 +16,7 @@ import { useRouter } from '@tanstack/react-router';
16
16
  import { ChevronRight, RefreshCw, RotateCcw, TriangleAlert } from 'lucide-react';
17
17
  import { useEffect, useState } from 'react';
18
18
  import { toast } from 'sonner';
19
+ import { fetchWithin } from './fetch-within.js';
19
20
  import { reportClientError } from './telemetry.js';
20
21
  import { Button } from './ui/button.js';
21
22
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
@@ -113,7 +114,7 @@ async function newBuildOut() {
113
114
  if (!mine)
114
115
  return false;
115
116
  try {
116
- const res = await fetch(import.meta.env.BASE_URL ?? '/', { cache: 'no-store' });
117
+ const res = await fetchWithin(import.meta.env.BASE_URL ?? '/', { cache: 'no-store' }, 5000);
117
118
  if (!res.ok || !res.headers.get('content-type')?.includes('text/html'))
118
119
  return false;
119
120
  const live = entryOf(await res.text());
package/dist/tasks.js CHANGED
@@ -15,6 +15,7 @@ import { toast } from 'sonner';
15
15
  // otherwise it reads again on focus. The product mounts `TasksProvider` and `TasksButton` in the
16
16
  // shell's slots; where the API answers 404 nothing shows.
17
17
  import { LumenConfirm } from './confirm.js';
18
+ import { fetchWithin } from './fetch-within.js';
18
19
  import { useIsMobile } from './hooks/use-mobile.js';
19
20
  import { cn } from './lib/utils.js';
20
21
  import { IconButton } from './page.js';
@@ -80,7 +81,7 @@ function TasksLive({ api, apps, children, }) {
80
81
  // Reads already on the wire share one request.
81
82
  const inflight = useRef(null);
82
83
  const read = useCallback(async () => {
83
- const res = await fetch(api, { headers: { accept: 'application/json' } }).catch(() => null);
84
+ const res = await fetchWithin(api, { headers: { accept: 'application/json' } }).catch(() => null);
84
85
  if (!res)
85
86
  return;
86
87
  if (res.status === 404) {
@@ -121,7 +122,7 @@ function TasksLive({ api, apps, children, }) {
121
122
  void refresh();
122
123
  }, [refresh]);
123
124
  const post = useCallback(async (path) => {
124
- await fetch(`${api}${path}`, {
125
+ await fetchWithin(`${api}${path}`, {
125
126
  method: 'POST',
126
127
  headers: { 'content-type': 'application/json' },
127
128
  body: '{}',
package/dist/whats-new.js CHANGED
@@ -12,6 +12,7 @@ import { SparklesIcon } from 'lucide-react';
12
12
  import { useState } from 'react';
13
13
  import Markdown from 'react-markdown';
14
14
  import { DrawerSection } from './drawer-section.js';
15
+ import { fetchWithin } from './fetch-within.js';
15
16
  import { LumenDrawer } from './lumen-drawer.js';
16
17
  import { usePrefs } from './prefs.js';
17
18
  import { Badge } from './ui/badge.js';
@@ -34,7 +35,7 @@ async function page(api, before) {
34
35
  const q = new URLSearchParams({ limit: String(PAGE) });
35
36
  if (before)
36
37
  q.set('before', before);
37
- const r = await fetch(`${api}?${q}`, { credentials: 'include' });
38
+ const r = await fetchWithin(`${api}?${q}`, { credentials: 'include' });
38
39
  if (r.status === 404)
39
40
  return null;
40
41
  if (!r.ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.129",
3
+ "version": "0.1.131",
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.129",
52
+ "@columenlabs/lumen-ui-types": "0.1.131",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",