@corbits/react-ui 0.1.0
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/LICENSE +501 -0
- package/README.md +125 -0
- package/dist/blocks/login/auth-layout.d.ts +38 -0
- package/dist/blocks/login/auth-layout.js +52 -0
- package/dist/blocks/login/login-form.d.ts +60 -0
- package/dist/blocks/login/login-form.js +151 -0
- package/dist/hooks/use-command-palette-navigation.d.ts +25 -0
- package/dist/hooks/use-command-palette-navigation.js +52 -0
- package/dist/hooks/use-controllable-state.d.ts +28 -0
- package/dist/hooks/use-controllable-state.js +44 -0
- package/dist/hooks/use-delayed-autofocus.d.ts +9 -0
- package/dist/hooks/use-delayed-autofocus.js +20 -0
- package/dist/hooks/use-dismissable-popover.d.ts +37 -0
- package/dist/hooks/use-dismissable-popover.js +64 -0
- package/dist/hooks/use-list-selection.d.ts +66 -0
- package/dist/hooks/use-list-selection.js +147 -0
- package/dist/hooks/use-prefers-reduced-motion.d.ts +13 -0
- package/dist/hooks/use-prefers-reduced-motion.js +28 -0
- package/dist/hooks/use-scroll-current-into-view.d.ts +19 -0
- package/dist/hooks/use-scroll-current-into-view.js +33 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +131 -0
- package/dist/lib/artifact.d.ts +72 -0
- package/dist/lib/artifact.js +67 -0
- package/dist/lib/chart-geometry.d.ts +62 -0
- package/dist/lib/chart-geometry.js +86 -0
- package/dist/lib/chart-palette.d.ts +76 -0
- package/dist/lib/chart-palette.js +92 -0
- package/dist/lib/chat-message.d.ts +115 -0
- package/dist/lib/chat-message.js +41 -0
- package/dist/lib/chat-parts.d.ts +64 -0
- package/dist/lib/chat-parts.js +16 -0
- package/dist/lib/csv.d.ts +60 -0
- package/dist/lib/csv.js +108 -0
- package/dist/lib/metrics.d.ts +70 -0
- package/dist/lib/metrics.js +101 -0
- package/dist/lib/now-item.d.ts +65 -0
- package/dist/lib/now-item.js +59 -0
- package/dist/lib/relative-time.d.ts +9 -0
- package/dist/lib/relative-time.js +47 -0
- package/dist/lib/theme.d.ts +30 -0
- package/dist/lib/theme.js +77 -0
- package/dist/lib/url.d.ts +26 -0
- package/dist/lib/url.js +37 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lib/workflow-display-flow.d.ts +83 -0
- package/dist/lib/workflow-display-flow.js +104 -0
- package/dist/lib/workflow-registry.d.ts +90 -0
- package/dist/lib/workflow-registry.js +56 -0
- package/dist/lib/workflow-run-error.d.ts +31 -0
- package/dist/lib/workflow-run-error.js +105 -0
- package/dist/lib/workflow-run-progress.d.ts +97 -0
- package/dist/lib/workflow-run-progress.js +135 -0
- package/dist/lib/workflow-run.d.ts +43 -0
- package/dist/lib/workflow-run.js +56 -0
- package/dist/styles.css +3080 -0
- package/dist/test/render-hook.d.ts +14 -0
- package/dist/test/render-hook.js +38 -0
- package/dist/theme.css +628 -0
- package/dist/ui/agent-turn.d.ts +26 -0
- package/dist/ui/agent-turn.js +60 -0
- package/dist/ui/animated-number.d.ts +25 -0
- package/dist/ui/animated-number.js +63 -0
- package/dist/ui/approval-card.d.ts +46 -0
- package/dist/ui/approval-card.js +116 -0
- package/dist/ui/artifact-body.d.ts +26 -0
- package/dist/ui/artifact-body.js +170 -0
- package/dist/ui/artifact-notice.d.ts +21 -0
- package/dist/ui/artifact-notice.js +36 -0
- package/dist/ui/avatar.d.ts +37 -0
- package/dist/ui/avatar.js +73 -0
- package/dist/ui/badge.d.ts +10 -0
- package/dist/ui/badge.js +35 -0
- package/dist/ui/bar-chart.d.ts +38 -0
- package/dist/ui/bar-chart.js +118 -0
- package/dist/ui/block-card.d.ts +29 -0
- package/dist/ui/block-card.js +66 -0
- package/dist/ui/boot-screen.d.ts +23 -0
- package/dist/ui/boot-screen.js +49 -0
- package/dist/ui/bulk-action-bar.d.ts +37 -0
- package/dist/ui/bulk-action-bar.js +64 -0
- package/dist/ui/button.d.ts +12 -0
- package/dist/ui/button.js +73 -0
- package/dist/ui/card.d.ts +6 -0
- package/dist/ui/card.js +37 -0
- package/dist/ui/category-bars.d.ts +20 -0
- package/dist/ui/category-bars.js +69 -0
- package/dist/ui/chart-frame.d.ts +54 -0
- package/dist/ui/chart-frame.js +108 -0
- package/dist/ui/chat-composer.d.ts +24 -0
- package/dist/ui/chat-composer.js +32 -0
- package/dist/ui/chat-dock-timing.d.ts +15 -0
- package/dist/ui/chat-dock-timing.js +11 -0
- package/dist/ui/chat-input.d.ts +38 -0
- package/dist/ui/chat-input.js +138 -0
- package/dist/ui/chat-panel.d.ts +20 -0
- package/dist/ui/chat-panel.js +56 -0
- package/dist/ui/chat-thread.d.ts +28 -0
- package/dist/ui/chat-thread.js +61 -0
- package/dist/ui/checkbox.d.ts +25 -0
- package/dist/ui/checkbox.js +63 -0
- package/dist/ui/command-palette.d.ts +100 -0
- package/dist/ui/command-palette.js +341 -0
- package/dist/ui/compare-body.d.ts +52 -0
- package/dist/ui/compare-body.js +140 -0
- package/dist/ui/confirm-button.d.ts +29 -0
- package/dist/ui/confirm-button.js +41 -0
- package/dist/ui/corbits-mark.d.ts +29 -0
- package/dist/ui/corbits-mark.js +33 -0
- package/dist/ui/csv-table.d.ts +22 -0
- package/dist/ui/csv-table.js +85 -0
- package/dist/ui/dialog.d.ts +37 -0
- package/dist/ui/dialog.js +105 -0
- package/dist/ui/dictation-status-line.d.ts +20 -0
- package/dist/ui/dictation-status-line.js +38 -0
- package/dist/ui/dither-canvas.d.ts +31 -0
- package/dist/ui/dither-canvas.js +169 -0
- package/dist/ui/embed-body.d.ts +30 -0
- package/dist/ui/embed-body.js +92 -0
- package/dist/ui/empty-state.d.ts +26 -0
- package/dist/ui/empty-state.js +42 -0
- package/dist/ui/file-input.d.ts +25 -0
- package/dist/ui/file-input.js +66 -0
- package/dist/ui/filter-bar.d.ts +42 -0
- package/dist/ui/filter-bar.js +95 -0
- package/dist/ui/filter-chip.d.ts +14 -0
- package/dist/ui/filter-chip.js +23 -0
- package/dist/ui/gate-block.d.ts +16 -0
- package/dist/ui/gate-block.js +37 -0
- package/dist/ui/horizontal-stepper.d.ts +13 -0
- package/dist/ui/horizontal-stepper.js +65 -0
- package/dist/ui/input.d.ts +2 -0
- package/dist/ui/input.js +14 -0
- package/dist/ui/inspector-shell.d.ts +47 -0
- package/dist/ui/inspector-shell.js +98 -0
- package/dist/ui/intake-form.d.ts +39 -0
- package/dist/ui/intake-form.js +119 -0
- package/dist/ui/kind-card-grid.d.ts +23 -0
- package/dist/ui/kind-card-grid.js +43 -0
- package/dist/ui/library-page-header.d.ts +20 -0
- package/dist/ui/library-page-header.js +43 -0
- package/dist/ui/list-detail.d.ts +28 -0
- package/dist/ui/list-detail.js +55 -0
- package/dist/ui/live-run-banner.d.ts +13 -0
- package/dist/ui/live-run-banner.js +23 -0
- package/dist/ui/live-status-line.d.ts +22 -0
- package/dist/ui/live-status-line.js +37 -0
- package/dist/ui/menu.d.ts +18 -0
- package/dist/ui/menu.js +45 -0
- package/dist/ui/message-bubble.d.ts +24 -0
- package/dist/ui/message-bubble.js +64 -0
- package/dist/ui/mic-button.d.ts +35 -0
- package/dist/ui/mic-button.js +61 -0
- package/dist/ui/mic-permission-dialog.d.ts +20 -0
- package/dist/ui/mic-permission-dialog.js +50 -0
- package/dist/ui/notifications-bell.d.ts +28 -0
- package/dist/ui/notifications-bell.js +69 -0
- package/dist/ui/now-cards.d.ts +37 -0
- package/dist/ui/now-cards.js +116 -0
- package/dist/ui/page-panel.d.ts +37 -0
- package/dist/ui/page-panel.js +23 -0
- package/dist/ui/page-shell.d.ts +21 -0
- package/dist/ui/page-shell.js +26 -0
- package/dist/ui/parts-renderer.d.ts +22 -0
- package/dist/ui/parts-renderer.js +234 -0
- package/dist/ui/profile-card.d.ts +39 -0
- package/dist/ui/profile-card.js +124 -0
- package/dist/ui/progress-checklist.d.ts +25 -0
- package/dist/ui/progress-checklist.js +70 -0
- package/dist/ui/provider-mark.d.ts +32 -0
- package/dist/ui/provider-mark.js +31 -0
- package/dist/ui/quick-reply-chips.d.ts +14 -0
- package/dist/ui/quick-reply-chips.js +24 -0
- package/dist/ui/quote-card.d.ts +39 -0
- package/dist/ui/quote-card.js +66 -0
- package/dist/ui/reasoning-block.d.ts +35 -0
- package/dist/ui/reasoning-block.js +60 -0
- package/dist/ui/research-body.d.ts +70 -0
- package/dist/ui/research-body.js +270 -0
- package/dist/ui/rich-empty-state.d.ts +35 -0
- package/dist/ui/rich-empty-state.js +65 -0
- package/dist/ui/run-now-button.d.ts +23 -0
- package/dist/ui/run-now-button.js +42 -0
- package/dist/ui/section.d.ts +20 -0
- package/dist/ui/section.js +48 -0
- package/dist/ui/select.d.ts +2 -0
- package/dist/ui/select.js +22 -0
- package/dist/ui/selection-checkbox.d.ts +42 -0
- package/dist/ui/selection-checkbox.js +54 -0
- package/dist/ui/settings-panel.d.ts +35 -0
- package/dist/ui/settings-panel.js +68 -0
- package/dist/ui/shimmer-text.d.ts +19 -0
- package/dist/ui/shimmer-text.js +20 -0
- package/dist/ui/sidebar-item-row.d.ts +40 -0
- package/dist/ui/sidebar-item-row.js +64 -0
- package/dist/ui/sidebar-panel.d.ts +25 -0
- package/dist/ui/sidebar-panel.js +56 -0
- package/dist/ui/sidebar.d.ts +46 -0
- package/dist/ui/sidebar.js +111 -0
- package/dist/ui/skeleton.d.ts +13 -0
- package/dist/ui/skeleton.js +20 -0
- package/dist/ui/sortable-table.d.ts +43 -0
- package/dist/ui/sortable-table.js +103 -0
- package/dist/ui/sparkline.d.ts +27 -0
- package/dist/ui/sparkline.js +68 -0
- package/dist/ui/stat-grid.d.ts +55 -0
- package/dist/ui/stat-grid.js +87 -0
- package/dist/ui/status-dot.d.ts +13 -0
- package/dist/ui/status-dot.js +51 -0
- package/dist/ui/step-list.d.ts +21 -0
- package/dist/ui/step-list.js +77 -0
- package/dist/ui/switch.d.ts +23 -0
- package/dist/ui/switch.js +29 -0
- package/dist/ui/table.d.ts +8 -0
- package/dist/ui/table.js +48 -0
- package/dist/ui/tabs.d.ts +44 -0
- package/dist/ui/tabs.js +101 -0
- package/dist/ui/textarea.d.ts +8 -0
- package/dist/ui/textarea.js +32 -0
- package/dist/ui/theme-provider.d.ts +36 -0
- package/dist/ui/theme-provider.js +126 -0
- package/dist/ui/theme-toggle.d.ts +11 -0
- package/dist/ui/theme-toggle.js +32 -0
- package/dist/ui/thinking-indicator.d.ts +7 -0
- package/dist/ui/thinking-indicator.js +19 -0
- package/dist/ui/thinking-label.d.ts +19 -0
- package/dist/ui/thinking-label.js +54 -0
- package/dist/ui/thinking-mark.d.ts +10 -0
- package/dist/ui/thinking-mark.js +153 -0
- package/dist/ui/time-series-chart.d.ts +55 -0
- package/dist/ui/time-series-chart.js +249 -0
- package/dist/ui/toast.d.ts +16 -0
- package/dist/ui/toast.js +41 -0
- package/dist/ui/token-mosaic.d.ts +22 -0
- package/dist/ui/token-mosaic.js +65 -0
- package/dist/ui/tool-block.d.ts +54 -0
- package/dist/ui/tool-block.js +170 -0
- package/dist/ui/tool-narrative.d.ts +22 -0
- package/dist/ui/tool-narrative.js +77 -0
- package/dist/ui/tool-picker.d.ts +44 -0
- package/dist/ui/tool-picker.js +200 -0
- package/dist/ui/tooltip.d.ts +21 -0
- package/dist/ui/tooltip.js +48 -0
- package/dist/ui/top-bar.d.ts +53 -0
- package/dist/ui/top-bar.js +98 -0
- package/dist/ui/trace-waterfall.d.ts +38 -0
- package/dist/ui/trace-waterfall.js +121 -0
- package/dist/ui/typing-indicator.d.ts +9 -0
- package/dist/ui/typing-indicator.js +25 -0
- package/dist/ui/view-toggle.d.ts +17 -0
- package/dist/ui/view-toggle.js +46 -0
- package/dist/ui/voice-waveform.d.ts +16 -0
- package/dist/ui/voice-waveform.js +23 -0
- package/package.json +583 -0
package/dist/lib/csv.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CSV parser sized for previewing a file in a browser tab, not for ingesting
|
|
3
|
+
* one.
|
|
4
|
+
*
|
|
5
|
+
* The distinction matters and drives every decision here. A preview has a
|
|
6
|
+
* hostile input (a user's arbitrary file), a hard latency budget (the tab must
|
|
7
|
+
* stay responsive), and a low cost of being approximate (the user can always
|
|
8
|
+
* download the original). So this refuses oversized input up front instead of
|
|
9
|
+
* streaming it, caps what it hands back instead of virtualising, and reports
|
|
10
|
+
* "not tabular" instead of guessing — a ragged file rendered as a grid puts
|
|
11
|
+
* cells under the wrong headers, which is a worse failure than showing raw text.
|
|
12
|
+
*/ /** Rows past this are dropped; the caller must say so in the UI. */ export const CSV_ROW_CAP = 500;
|
|
13
|
+
/** Columns past this are dropped; the caller must say so in the UI. */ export const CSV_COLUMN_CAP = 40;
|
|
14
|
+
/**
|
|
15
|
+
* Refuse to parse beyond this. 2 MiB of CSV is roughly 20k rows — already far
|
|
16
|
+
* past what anyone reads on screen, and enough DOM to lock the tab.
|
|
17
|
+
*/ export const CSV_MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* True UTF-8 length, not `string.length`.
|
|
20
|
+
*
|
|
21
|
+
* `"é".length` is 1 but it costs 2 bytes, and a file of CJK text is three times
|
|
22
|
+
* its character count — sizing the guard by character count would let a file
|
|
23
|
+
* three times over budget through.
|
|
24
|
+
*/ export function utf8ByteLength(text) {
|
|
25
|
+
return new TextEncoder().encode(text).length;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Splits one CSV line, honouring RFC 4180 quoting: `"a,b"` is one field, and a
|
|
29
|
+
* doubled `""` inside quotes is a literal quote.
|
|
30
|
+
*
|
|
31
|
+
* Line-at-a-time, so a quoted field containing a newline will split across two
|
|
32
|
+
* records. That is a deliberate limit rather than an oversight: handling it
|
|
33
|
+
* needs a character-level state machine over the whole document, and embedded
|
|
34
|
+
* newlines show up as an extra ragged row here — which `isTabular` catches and
|
|
35
|
+
* degrades to raw text, the honest outcome.
|
|
36
|
+
*/ function splitLine(line) {
|
|
37
|
+
const fields = [];
|
|
38
|
+
let field = "";
|
|
39
|
+
let quoted = false;
|
|
40
|
+
for(let i = 0; i < line.length; i += 1){
|
|
41
|
+
const char = line[i];
|
|
42
|
+
if (quoted) {
|
|
43
|
+
if (char === '"') {
|
|
44
|
+
if (line[i + 1] === '"') {
|
|
45
|
+
field += '"';
|
|
46
|
+
i += 1;
|
|
47
|
+
} else {
|
|
48
|
+
quoted = false;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
field += char;
|
|
52
|
+
}
|
|
53
|
+
} else if (char === '"') {
|
|
54
|
+
quoted = true;
|
|
55
|
+
} else if (char === ",") {
|
|
56
|
+
fields.push(field);
|
|
57
|
+
field = "";
|
|
58
|
+
} else {
|
|
59
|
+
field += char;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
fields.push(field);
|
|
63
|
+
return fields;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parses CSV text, or returns `null` when it is too large to parse safely.
|
|
67
|
+
*
|
|
68
|
+
* `null` rather than a throw: "too big to preview" is an expected outcome of
|
|
69
|
+
* showing a user their own file, not an exceptional one, and the caller's
|
|
70
|
+
* response is to render a different surface rather than to catch.
|
|
71
|
+
*/ export function parseCsv(text) {
|
|
72
|
+
if (utf8ByteLength(text) > CSV_MAX_PREVIEW_BYTES) return null;
|
|
73
|
+
const lines = text.split(/\r\n|\n|\r/).filter((line)=>line.trim() !== "");
|
|
74
|
+
if (lines.length === 0) return {
|
|
75
|
+
headers: [],
|
|
76
|
+
rows: []
|
|
77
|
+
};
|
|
78
|
+
const [headerLine, ...bodyLines] = lines;
|
|
79
|
+
return {
|
|
80
|
+
headers: splitLine(headerLine ?? ""),
|
|
81
|
+
rows: bodyLines.map(splitLine)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether the parse is rectangular enough to render as a grid.
|
|
86
|
+
*
|
|
87
|
+
* Requires at least two columns and every row to match the header width. A
|
|
88
|
+
* single-column "CSV" is prose that happens to have no commas, and a ragged one
|
|
89
|
+
* has no reliable column alignment — in both cases raw text tells the truth and
|
|
90
|
+
* a table does not.
|
|
91
|
+
*/ export function isTabular(parsed) {
|
|
92
|
+
if (parsed.headers.length < 2) return false;
|
|
93
|
+
return parsed.rows.every((row)=>row.length === parsed.headers.length);
|
|
94
|
+
}
|
|
95
|
+
/** Trims a parse to the caps, keeping the original totals so the UI can say so. */ export function capCsv(parsed) {
|
|
96
|
+
const totalRows = parsed.rows.length;
|
|
97
|
+
const totalColumns = parsed.headers.length;
|
|
98
|
+
const columnsTruncated = totalColumns > CSV_COLUMN_CAP;
|
|
99
|
+
const rowsTruncated = totalRows > CSV_ROW_CAP;
|
|
100
|
+
return {
|
|
101
|
+
headers: columnsTruncated ? parsed.headers.slice(0, CSV_COLUMN_CAP) : parsed.headers,
|
|
102
|
+
rows: (rowsTruncated ? parsed.rows.slice(0, CSV_ROW_CAP) : parsed.rows).map((row)=>columnsTruncated ? row.slice(0, CSV_COLUMN_CAP) : row),
|
|
103
|
+
totalRows,
|
|
104
|
+
totalColumns,
|
|
105
|
+
rowsTruncated,
|
|
106
|
+
columnsTruncated
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure arithmetic behind the dashboard viz pieces: period-over-period deltas,
|
|
3
|
+
* sparkline and mosaic geometry, and zero-filling a sparse daily series. Plain
|
|
4
|
+
* numbers in, plain numbers out — no React, no SVG — so the part of a dashboard
|
|
5
|
+
* that can silently lie is the part you can check by hand.
|
|
6
|
+
*/
|
|
7
|
+
export { formatCompact } from "./chart-geometry.js";
|
|
8
|
+
export type DeltaDirection = "up" | "down" | "flat";
|
|
9
|
+
export type DeltaResult = {
|
|
10
|
+
readonly direction: DeltaDirection;
|
|
11
|
+
/** Percent change vs the previous window, or null when it cannot be computed. */
|
|
12
|
+
readonly pct: number | null;
|
|
13
|
+
readonly delta: number;
|
|
14
|
+
/**
|
|
15
|
+
* False when there is no previous window to compare against. Distinguishes
|
|
16
|
+
* "no change" (comparable, delta 0) from "no comparison available", so a UI
|
|
17
|
+
* never renders a flat indicator that reads as stability.
|
|
18
|
+
*/
|
|
19
|
+
readonly comparable: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Period-over-period change. `comparable` is false when there is no previous
|
|
23
|
+
* window. `pct` is null when the previous value is absent or zero — there is no
|
|
24
|
+
* meaningful baseline to divide by, so callers render the absolute delta rather
|
|
25
|
+
* than a misleading "∞%".
|
|
26
|
+
*/
|
|
27
|
+
export declare function computeDelta(current: number, previous: number | null | undefined): DeltaResult;
|
|
28
|
+
export type SparklineGeometry = {
|
|
29
|
+
/** SVG `<polyline>` `points` string. Empty for an empty series. */
|
|
30
|
+
readonly points: string;
|
|
31
|
+
readonly coords: readonly {
|
|
32
|
+
readonly x: number;
|
|
33
|
+
readonly y: number;
|
|
34
|
+
}[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Maps a value series to polyline coordinates in a `width` × `height` viewBox.
|
|
38
|
+
* The minimum value sits on the bottom edge and the maximum on the top — the
|
|
39
|
+
* shape is the message, and a zero baseline would flatten every real series in
|
|
40
|
+
* a box this small. A flat series renders along the vertical centre and a
|
|
41
|
+
* single point is centred horizontally.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildSparkline(values: readonly number[], width: number, height: number): SparklineGeometry;
|
|
44
|
+
export type MosaicSegment = {
|
|
45
|
+
readonly label: string;
|
|
46
|
+
readonly value: number;
|
|
47
|
+
/** Width as a percentage of the total, 0 when the total is 0. */
|
|
48
|
+
readonly pct: number;
|
|
49
|
+
};
|
|
50
|
+
/** Shares of a whole, as percentage widths for a stacked strip. */
|
|
51
|
+
export declare function buildMosaic(parts: readonly {
|
|
52
|
+
label: string;
|
|
53
|
+
value: number;
|
|
54
|
+
}[]): MosaicSegment[];
|
|
55
|
+
export type FillDailySeriesOptions<T> = {
|
|
56
|
+
/** Reads the row's UTC calendar date, `YYYY-MM-DD`. */
|
|
57
|
+
readonly dateOf: (row: T) => string;
|
|
58
|
+
/** Builds the zero row for a date with no activity. */
|
|
59
|
+
readonly zero: (date: string) => T;
|
|
60
|
+
};
|
|
61
|
+
/** Runaway guard: the longest spine `fillDailySeries` will build. */
|
|
62
|
+
export declare const FILL_DAILY_SERIES_MAX_DAYS = 400;
|
|
63
|
+
/**
|
|
64
|
+
* Expands a sparse daily series (only days with activity) into a continuous,
|
|
65
|
+
* zero-filled spine from `startDate` to `endDate` inclusive (UTC). Without this
|
|
66
|
+
* a sparkline plots gapped days at equal spacing, drawing a false slope across
|
|
67
|
+
* dates where nothing happened. Returns the input unchanged for an unparseable
|
|
68
|
+
* or inverted range, and caps at `FILL_DAILY_SERIES_MAX_DAYS`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function fillDailySeries<T>(series: readonly T[], startDate: string, endDate: string, options: FillDailySeriesOptions<T>): readonly T[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure arithmetic behind the dashboard viz pieces: period-over-period deltas,
|
|
3
|
+
* sparkline and mosaic geometry, and zero-filling a sparse daily series. Plain
|
|
4
|
+
* numbers in, plain numbers out — no React, no SVG — so the part of a dashboard
|
|
5
|
+
* that can silently lie is the part you can check by hand.
|
|
6
|
+
*/ export { formatCompact } from "./chart-geometry.js";
|
|
7
|
+
/**
|
|
8
|
+
* Period-over-period change. `comparable` is false when there is no previous
|
|
9
|
+
* window. `pct` is null when the previous value is absent or zero — there is no
|
|
10
|
+
* meaningful baseline to divide by, so callers render the absolute delta rather
|
|
11
|
+
* than a misleading "∞%".
|
|
12
|
+
*/ export function computeDelta(current, previous) {
|
|
13
|
+
if (previous === null || previous === undefined) {
|
|
14
|
+
return {
|
|
15
|
+
direction: "flat",
|
|
16
|
+
pct: null,
|
|
17
|
+
delta: 0,
|
|
18
|
+
comparable: false
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const delta = current - previous;
|
|
22
|
+
const direction = delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
|
23
|
+
if (previous === 0) {
|
|
24
|
+
return {
|
|
25
|
+
direction,
|
|
26
|
+
pct: null,
|
|
27
|
+
delta,
|
|
28
|
+
comparable: true
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
direction,
|
|
33
|
+
pct: delta / previous * 100,
|
|
34
|
+
delta,
|
|
35
|
+
comparable: true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Maps a value series to polyline coordinates in a `width` × `height` viewBox.
|
|
40
|
+
* The minimum value sits on the bottom edge and the maximum on the top — the
|
|
41
|
+
* shape is the message, and a zero baseline would flatten every real series in
|
|
42
|
+
* a box this small. A flat series renders along the vertical centre and a
|
|
43
|
+
* single point is centred horizontally.
|
|
44
|
+
*/ export function buildSparkline(values, width, height) {
|
|
45
|
+
if (values.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
points: "",
|
|
48
|
+
coords: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const max = Math.max(...values);
|
|
52
|
+
const min = Math.min(...values);
|
|
53
|
+
const span = max - min;
|
|
54
|
+
const stepX = values.length > 1 ? width / (values.length - 1) : 0;
|
|
55
|
+
const coords = values.map((value, index)=>({
|
|
56
|
+
x: values.length > 1 ? index * stepX : width / 2,
|
|
57
|
+
y: span === 0 ? height / 2 : height - (value - min) / span * height
|
|
58
|
+
}));
|
|
59
|
+
const points = coords.map((c)=>`${round(c.x)},${round(c.y)}`).join(" ");
|
|
60
|
+
return {
|
|
61
|
+
points,
|
|
62
|
+
coords
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Shares of a whole, as percentage widths for a stacked strip. */ export function buildMosaic(parts) {
|
|
66
|
+
const total = parts.reduce((sum, part)=>sum + part.value, 0);
|
|
67
|
+
return parts.map((part)=>({
|
|
68
|
+
label: part.label,
|
|
69
|
+
value: part.value,
|
|
70
|
+
pct: total <= 0 ? 0 : part.value / total * 100
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/** Runaway guard: the longest spine `fillDailySeries` will build. */ export const FILL_DAILY_SERIES_MAX_DAYS = 400;
|
|
74
|
+
/**
|
|
75
|
+
* Expands a sparse daily series (only days with activity) into a continuous,
|
|
76
|
+
* zero-filled spine from `startDate` to `endDate` inclusive (UTC). Without this
|
|
77
|
+
* a sparkline plots gapped days at equal spacing, drawing a false slope across
|
|
78
|
+
* dates where nothing happened. Returns the input unchanged for an unparseable
|
|
79
|
+
* or inverted range, and caps at `FILL_DAILY_SERIES_MAX_DAYS`.
|
|
80
|
+
*/ export function fillDailySeries(series, startDate, endDate, options) {
|
|
81
|
+
const byDate = new Map(series.map((row)=>[
|
|
82
|
+
options.dateOf(row),
|
|
83
|
+
row
|
|
84
|
+
]));
|
|
85
|
+
const msPerDay = 86_400_000;
|
|
86
|
+
let cursor = new Date(`${startDate}T00:00:00.000Z`).getTime();
|
|
87
|
+
const end = new Date(`${endDate}T00:00:00.000Z`).getTime();
|
|
88
|
+
if (Number.isNaN(cursor) || Number.isNaN(end) || end < cursor) {
|
|
89
|
+
return series;
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
while(cursor <= end && out.length < FILL_DAILY_SERIES_MAX_DAYS){
|
|
93
|
+
const date = new Date(cursor).toISOString().slice(0, 10);
|
|
94
|
+
out.push(byDate.get(date) ?? options.zero(date));
|
|
95
|
+
cursor += msPerDay;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function round(n) {
|
|
100
|
+
return Math.round(n * 100) / 100;
|
|
101
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model behind the "Now" surface: the things asking for the user's
|
|
3
|
+
* attention, whatever produced them.
|
|
4
|
+
*
|
|
5
|
+
* Two variants, because they behave differently and not because they look
|
|
6
|
+
* different: a `gate` is blocked work that cannot proceed without a decision,
|
|
7
|
+
* and a `mail` is something delivered to be read. A gate is never "read"; a
|
|
8
|
+
* mail has no decision to make. A single optional-field shape would let a
|
|
9
|
+
* caller build a read gate, which is not a thing.
|
|
10
|
+
*
|
|
11
|
+
* `classification` is a free string, not an enum: what counts as a category
|
|
12
|
+
* differs per app, and the label is shown verbatim. Ranking is driven by
|
|
13
|
+
* `priority`, which is fixed, so a new classification never changes ordering.
|
|
14
|
+
*/
|
|
15
|
+
export type NowPriority = "now" | "next" | "later";
|
|
16
|
+
export type NowStatus = "needs-action" | "done";
|
|
17
|
+
type NowItemBase = {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly summary?: string;
|
|
21
|
+
/** Category shown as a badge, e.g. "Approval", "Brief", "Failure". */
|
|
22
|
+
readonly classification: string;
|
|
23
|
+
readonly priority: NowPriority;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the item is still asking for something. On the base and not only on
|
|
26
|
+
* mail: a gate is resolved once decided, exactly as a message is once handled,
|
|
27
|
+
* and a row that had to check `type` before it could show "done" would be
|
|
28
|
+
* wrong the first time a third variant appeared.
|
|
29
|
+
*/
|
|
30
|
+
readonly status: NowStatus;
|
|
31
|
+
/** ISO timestamp. Ties in priority are broken by this, newest first. */
|
|
32
|
+
readonly when: string;
|
|
33
|
+
/** Where the item opens. Without it the card renders as static text. */
|
|
34
|
+
readonly href?: string;
|
|
35
|
+
};
|
|
36
|
+
export type NowItem = (NowItemBase & {
|
|
37
|
+
readonly type: "gate";
|
|
38
|
+
/** The decision being asked for, e.g. "Approve send". */
|
|
39
|
+
readonly action: string;
|
|
40
|
+
}) | (NowItemBase & {
|
|
41
|
+
readonly type: "mail";
|
|
42
|
+
readonly from: string;
|
|
43
|
+
readonly read: boolean;
|
|
44
|
+
});
|
|
45
|
+
/** Short band label for the queue's priority column. */
|
|
46
|
+
export declare const PRIORITY_LABEL: Record<NowPriority, string>;
|
|
47
|
+
/** Human label for the status marker. */
|
|
48
|
+
export declare const STATUS_LABEL: Record<NowStatus, string>;
|
|
49
|
+
/**
|
|
50
|
+
* Priority first, then newest. Sorts a copy — a component handed a frozen or
|
|
51
|
+
* host-owned array must not reorder it in place.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sortNowItems(items: readonly NowItem[]): readonly NowItem[];
|
|
54
|
+
export type NowGroup = {
|
|
55
|
+
readonly priority: NowPriority;
|
|
56
|
+
readonly items: readonly NowItem[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Sorted items split into priority bands. Empty bands are dropped rather than
|
|
60
|
+
* returned empty, so a caller can map straight to headings without checking.
|
|
61
|
+
*/
|
|
62
|
+
export declare function groupNowItemsByPriority(items: readonly NowItem[]): readonly NowGroup[];
|
|
63
|
+
/** The classifications present, in first-appearance order — for a filter row. */
|
|
64
|
+
export declare function nowItemClassifications(items: readonly NowItem[]): readonly string[];
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model behind the "Now" surface: the things asking for the user's
|
|
3
|
+
* attention, whatever produced them.
|
|
4
|
+
*
|
|
5
|
+
* Two variants, because they behave differently and not because they look
|
|
6
|
+
* different: a `gate` is blocked work that cannot proceed without a decision,
|
|
7
|
+
* and a `mail` is something delivered to be read. A gate is never "read"; a
|
|
8
|
+
* mail has no decision to make. A single optional-field shape would let a
|
|
9
|
+
* caller build a read gate, which is not a thing.
|
|
10
|
+
*
|
|
11
|
+
* `classification` is a free string, not an enum: what counts as a category
|
|
12
|
+
* differs per app, and the label is shown verbatim. Ranking is driven by
|
|
13
|
+
* `priority`, which is fixed, so a new classification never changes ordering.
|
|
14
|
+
*/ const PRIORITY_RANK = {
|
|
15
|
+
now: 0,
|
|
16
|
+
next: 1,
|
|
17
|
+
later: 2
|
|
18
|
+
};
|
|
19
|
+
/** Short band label for the queue's priority column. */ export const PRIORITY_LABEL = {
|
|
20
|
+
now: "HIGH",
|
|
21
|
+
next: "MED",
|
|
22
|
+
later: "LOW"
|
|
23
|
+
};
|
|
24
|
+
/** Human label for the status marker. */ export const STATUS_LABEL = {
|
|
25
|
+
"needs-action": "Needs action",
|
|
26
|
+
done: "Done"
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Priority first, then newest. Sorts a copy — a component handed a frozen or
|
|
30
|
+
* host-owned array must not reorder it in place.
|
|
31
|
+
*/ export function sortNowItems(items) {
|
|
32
|
+
return [
|
|
33
|
+
...items
|
|
34
|
+
].sort((a, b)=>{
|
|
35
|
+
const byPriority = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority];
|
|
36
|
+
if (byPriority !== 0) return byPriority;
|
|
37
|
+
return Date.parse(b.when) - Date.parse(a.when);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sorted items split into priority bands. Empty bands are dropped rather than
|
|
42
|
+
* returned empty, so a caller can map straight to headings without checking.
|
|
43
|
+
*/ export function groupNowItemsByPriority(items) {
|
|
44
|
+
const sorted = sortNowItems(items);
|
|
45
|
+
const order = [
|
|
46
|
+
"now",
|
|
47
|
+
"next",
|
|
48
|
+
"later"
|
|
49
|
+
];
|
|
50
|
+
return order.map((priority)=>({
|
|
51
|
+
priority,
|
|
52
|
+
items: sorted.filter((item)=>item.priority === priority)
|
|
53
|
+
})).filter((group)=>group.items.length > 0);
|
|
54
|
+
}
|
|
55
|
+
/** The classifications present, in first-appearance order — for a filter row. */ export function nowItemClassifications(items) {
|
|
56
|
+
return [
|
|
57
|
+
...new Set(sortNowItems(items).map((item)=>item.classification))
|
|
58
|
+
];
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "3 min ago" / "in 2 days", localised, from an ISO timestamp.
|
|
3
|
+
*
|
|
4
|
+
* `now` is a parameter rather than a `Date.now()` call inside so the output is
|
|
5
|
+
* a pure function of its inputs — otherwise every list that renders a timestamp
|
|
6
|
+
* is untestable and re-renders to a different string than the server produced.
|
|
7
|
+
* Pass the server's render time when hydrating.
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatRelativeTime(iso: string, now?: number): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const UNITS = [
|
|
2
|
+
[
|
|
3
|
+
"year",
|
|
4
|
+
365 * 24 * 60 * 60 * 1000
|
|
5
|
+
],
|
|
6
|
+
[
|
|
7
|
+
"month",
|
|
8
|
+
30 * 24 * 60 * 60 * 1000
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
"week",
|
|
12
|
+
7 * 24 * 60 * 60 * 1000
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
"day",
|
|
16
|
+
24 * 60 * 60 * 1000
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"hour",
|
|
20
|
+
60 * 60 * 1000
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
"minute",
|
|
24
|
+
60 * 1000
|
|
25
|
+
]
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* "3 min ago" / "in 2 days", localised, from an ISO timestamp.
|
|
29
|
+
*
|
|
30
|
+
* `now` is a parameter rather than a `Date.now()` call inside so the output is
|
|
31
|
+
* a pure function of its inputs — otherwise every list that renders a timestamp
|
|
32
|
+
* is untestable and re-renders to a different string than the server produced.
|
|
33
|
+
* Pass the server's render time when hydrating.
|
|
34
|
+
*/ export function formatRelativeTime(iso, now = Date.now()) {
|
|
35
|
+
const then = Date.parse(iso);
|
|
36
|
+
if (Number.isNaN(then)) return "";
|
|
37
|
+
const delta = then - now;
|
|
38
|
+
const magnitude = Math.abs(delta);
|
|
39
|
+
const format = new Intl.RelativeTimeFormat(undefined, {
|
|
40
|
+
numeric: "auto",
|
|
41
|
+
style: "narrow"
|
|
42
|
+
});
|
|
43
|
+
for (const [unit, ms] of UNITS){
|
|
44
|
+
if (magnitude >= ms) return format.format(Math.round(delta / ms), unit);
|
|
45
|
+
}
|
|
46
|
+
return format.format(Math.round(delta / 1000), "second");
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Theme mode and preset resolution for ThemeProvider.
|
|
2
|
+
|
|
3
|
+
* Dark mode is a `.dark` class on an ancestor, with an OS-following fallback
|
|
4
|
+
* when neither `.dark` nor `.light` is set (see theme.css — `@custom-variant
|
|
5
|
+
* dark` plus the `prefers-color-scheme` block). `.light` is the explicit
|
|
6
|
+
* opt-out and only works on `documentElement`. This module owns the pure
|
|
7
|
+
* resolution rules; ThemeProvider applies them to the document and persists
|
|
8
|
+
* the choice.
|
|
9
|
+
*/
|
|
10
|
+
export type ThemeMode = "system" | "light" | "dark";
|
|
11
|
+
export type ResolvedThemeMode = "light" | "dark";
|
|
12
|
+
/** Named token overlays shipped as `data-theme` values on the root. */
|
|
13
|
+
export type ThemePreset = "default" | "warm" | "cool";
|
|
14
|
+
export declare const THEME_MODES: readonly ThemeMode[];
|
|
15
|
+
export declare const THEME_PRESETS: readonly ThemePreset[];
|
|
16
|
+
export declare const DEFAULT_THEME_STORAGE_KEY = "corbits-theme";
|
|
17
|
+
export type ThemePreference = {
|
|
18
|
+
readonly mode: ThemeMode;
|
|
19
|
+
readonly preset: ThemePreset;
|
|
20
|
+
};
|
|
21
|
+
export declare function isThemeMode(value: unknown): value is ThemeMode;
|
|
22
|
+
export declare function isThemePreset(value: unknown): value is ThemePreset;
|
|
23
|
+
/** Resolve light/dark after consulting the system preference when mode is system. */
|
|
24
|
+
export declare function resolveThemeMode(mode: ThemeMode, systemPrefersDark: boolean): ResolvedThemeMode;
|
|
25
|
+
export declare function parseThemePreference(raw: string | null, fallback: ThemePreference): ThemePreference;
|
|
26
|
+
export declare function serializeThemePreference(preference: ThemePreference): string;
|
|
27
|
+
/** Cycle system → light → dark → system for a one-control toggle. */
|
|
28
|
+
export declare function nextThemeMode(mode: ThemeMode): ThemeMode;
|
|
29
|
+
/** Apply resolved mode + preset to a root element (usually documentElement). */
|
|
30
|
+
export declare function applyThemeToRoot(root: Element, resolved: ResolvedThemeMode, preset: ThemePreset): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Theme mode and preset resolution for ThemeProvider.
|
|
2
|
+
|
|
3
|
+
* Dark mode is a `.dark` class on an ancestor, with an OS-following fallback
|
|
4
|
+
* when neither `.dark` nor `.light` is set (see theme.css — `@custom-variant
|
|
5
|
+
* dark` plus the `prefers-color-scheme` block). `.light` is the explicit
|
|
6
|
+
* opt-out and only works on `documentElement`. This module owns the pure
|
|
7
|
+
* resolution rules; ThemeProvider applies them to the document and persists
|
|
8
|
+
* the choice.
|
|
9
|
+
*/ export const THEME_MODES = [
|
|
10
|
+
"system",
|
|
11
|
+
"light",
|
|
12
|
+
"dark"
|
|
13
|
+
];
|
|
14
|
+
export const THEME_PRESETS = [
|
|
15
|
+
"default",
|
|
16
|
+
"warm",
|
|
17
|
+
"cool"
|
|
18
|
+
];
|
|
19
|
+
export const DEFAULT_THEME_STORAGE_KEY = "corbits-theme";
|
|
20
|
+
export function isThemeMode(value) {
|
|
21
|
+
return value === "system" || value === "light" || value === "dark";
|
|
22
|
+
}
|
|
23
|
+
export function isThemePreset(value) {
|
|
24
|
+
return value === "default" || value === "warm" || value === "cool";
|
|
25
|
+
}
|
|
26
|
+
/** Resolve light/dark after consulting the system preference when mode is system. */ export function resolveThemeMode(mode, systemPrefersDark) {
|
|
27
|
+
if (mode === "light") return "light";
|
|
28
|
+
if (mode === "dark") return "dark";
|
|
29
|
+
return systemPrefersDark ? "dark" : "light";
|
|
30
|
+
}
|
|
31
|
+
export function parseThemePreference(raw, fallback) {
|
|
32
|
+
if (raw === null || raw.length === 0) return fallback;
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (typeof parsed !== "object" || parsed === null) return fallback;
|
|
36
|
+
const record = parsed;
|
|
37
|
+
const mode = isThemeMode(record.mode) ? record.mode : fallback.mode;
|
|
38
|
+
const preset = isThemePreset(record.preset) ? record.preset : fallback.preset;
|
|
39
|
+
return {
|
|
40
|
+
mode,
|
|
41
|
+
preset
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
// Legacy: a bare mode string from an earlier client.
|
|
45
|
+
if (isThemeMode(raw)) return {
|
|
46
|
+
mode: raw,
|
|
47
|
+
preset: fallback.preset
|
|
48
|
+
};
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function serializeThemePreference(preference) {
|
|
53
|
+
return JSON.stringify({
|
|
54
|
+
mode: preference.mode,
|
|
55
|
+
preset: preference.preset
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Cycle system → light → dark → system for a one-control toggle. */ export function nextThemeMode(mode) {
|
|
59
|
+
if (mode === "system") return "light";
|
|
60
|
+
if (mode === "light") return "dark";
|
|
61
|
+
return "system";
|
|
62
|
+
}
|
|
63
|
+
/** Apply resolved mode + preset to a root element (usually documentElement). */ export function applyThemeToRoot(root, resolved, preset) {
|
|
64
|
+
root.classList.toggle("dark", resolved === "dark");
|
|
65
|
+
// `.light` is the explicit-light escape hatch from the `prefers-color-scheme`
|
|
66
|
+
// media query in theme.css — without it, "light" mode chosen on a dark-OS
|
|
67
|
+
// host would be overridden back to dark tokens by the media query.
|
|
68
|
+
root.classList.toggle("light", resolved === "light");
|
|
69
|
+
if (preset === "default") {
|
|
70
|
+
root.removeAttribute("data-theme");
|
|
71
|
+
} else {
|
|
72
|
+
root.setAttribute("data-theme", preset);
|
|
73
|
+
}
|
|
74
|
+
if (root instanceof HTMLElement) {
|
|
75
|
+
root.style.colorScheme = resolved;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is `url` safe to hand to a live sink — `href`, `src`, `window.open` — as-is?
|
|
3
|
+
*
|
|
4
|
+
* Agent- and artifact-supplied strings are untrusted input: a `javascript:`
|
|
5
|
+
* or `data:` value handed straight to `href` becomes a live script the
|
|
6
|
+
* moment someone clicks it. Checking `.protocol` after `new URL()` parses the
|
|
7
|
+
* string, rather than matching a prefix, is what defeats tricks like embedded
|
|
8
|
+
* tabs/newlines (`java\tscript:`) or mixed case (`JavaScript:`) — the URL
|
|
9
|
+
* parser strips and normalizes both away before the comparison ever runs.
|
|
10
|
+
*
|
|
11
|
+
* Relative and protocol-relative URLs (`/path`, `//host/path`) fail to parse
|
|
12
|
+
* without a base and are rejected rather than resolved against one: every
|
|
13
|
+
* sink this guards links to a third-party resource — a citation, a download,
|
|
14
|
+
* an embed — so there is no in-app page a bare path could sensibly mean, and
|
|
15
|
+
* inventing a base to resolve against would only guess wrong.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isSafeUrl(url: string, allowedProtocols?: readonly string[]): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* `url` back, unchanged, if `isSafeUrl` accepts it — otherwise `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* The shape callers want at a render site: a value they can put straight
|
|
22
|
+
* into `href`/`src` when present, and a clean signal to fall back to plain
|
|
23
|
+
* text when not. Never returns a placeholder like `"#"` — a disabled-looking
|
|
24
|
+
* link that is secretly still a link is its own trap.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toSafeHref(url: string | undefined, allowedProtocols?: readonly string[]): string | undefined;
|
package/dist/lib/url.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const DEFAULT_ALLOWED_PROTOCOLS = [
|
|
2
|
+
"http:",
|
|
3
|
+
"https:"
|
|
4
|
+
];
|
|
5
|
+
/**
|
|
6
|
+
* Is `url` safe to hand to a live sink — `href`, `src`, `window.open` — as-is?
|
|
7
|
+
*
|
|
8
|
+
* Agent- and artifact-supplied strings are untrusted input: a `javascript:`
|
|
9
|
+
* or `data:` value handed straight to `href` becomes a live script the
|
|
10
|
+
* moment someone clicks it. Checking `.protocol` after `new URL()` parses the
|
|
11
|
+
* string, rather than matching a prefix, is what defeats tricks like embedded
|
|
12
|
+
* tabs/newlines (`java\tscript:`) or mixed case (`JavaScript:`) — the URL
|
|
13
|
+
* parser strips and normalizes both away before the comparison ever runs.
|
|
14
|
+
*
|
|
15
|
+
* Relative and protocol-relative URLs (`/path`, `//host/path`) fail to parse
|
|
16
|
+
* without a base and are rejected rather than resolved against one: every
|
|
17
|
+
* sink this guards links to a third-party resource — a citation, a download,
|
|
18
|
+
* an embed — so there is no in-app page a bare path could sensibly mean, and
|
|
19
|
+
* inventing a base to resolve against would only guess wrong.
|
|
20
|
+
*/ export function isSafeUrl(url, allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS) {
|
|
21
|
+
try {
|
|
22
|
+
return allowedProtocols.includes(new URL(url).protocol);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `url` back, unchanged, if `isSafeUrl` accepts it — otherwise `undefined`.
|
|
29
|
+
*
|
|
30
|
+
* The shape callers want at a render site: a value they can put straight
|
|
31
|
+
* into `href`/`src` when present, and a clean signal to fall back to plain
|
|
32
|
+
* text when not. Never returns a placeholder like `"#"` — a disabled-looking
|
|
33
|
+
* link that is secretly still a link is its own trap.
|
|
34
|
+
*/ export function toSafeHref(url, allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS) {
|
|
35
|
+
if (url === undefined) return undefined;
|
|
36
|
+
return isSafeUrl(url, allowedProtocols) ? url : undefined;
|
|
37
|
+
}
|