@parallelworks/ui 0.58.0 → 0.60.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.
Files changed (40) hide show
  1. package/README.md +48 -9
  2. package/dist/ai/agent/diff.d.ts +1 -0
  3. package/dist/ai/agent/diff.js +50 -3
  4. package/dist/ai/components/AttachmentUpload.d.ts +3 -0
  5. package/dist/ai/components/AttachmentUpload.js +51 -43
  6. package/dist/ai/components/agent/AgentMessageParts.js +33 -6
  7. package/dist/ai/strings.d.ts +2 -0
  8. package/dist/ai/strings.js +2 -1
  9. package/dist/components/AppLink.js +8 -3
  10. package/dist/components/Avatar.js +9 -3
  11. package/dist/components/ColorInput.d.ts +3 -1
  12. package/dist/components/ColorInput.js +2 -1
  13. package/dist/components/ExternalLink.d.ts +3 -2
  14. package/dist/components/ExternalLink.js +5 -2
  15. package/dist/components/Field.d.ts +7 -1
  16. package/dist/components/Field.js +83 -56
  17. package/dist/components/HoverCard.js +12 -0
  18. package/dist/components/Input.d.ts +3 -1
  19. package/dist/components/Input.js +2 -1
  20. package/dist/components/Provider.d.ts +8 -1
  21. package/dist/components/Provider.js +9 -3
  22. package/dist/components/StickyBar.d.ts +14 -0
  23. package/dist/components/StickyBar.js +22 -0
  24. package/dist/components/Table.js +3 -3
  25. package/dist/components/Tabs.d.ts +2 -2
  26. package/dist/components/Tabs.js +5 -2
  27. package/dist/components/ThemeEditor.js +10 -0
  28. package/dist/components/stickyTop.d.ts +7 -3
  29. package/dist/components/stickyTop.js +11 -6
  30. package/dist/file-explorer/FileExplorer.js +1 -1
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +2 -1
  33. package/dist/list/ActionToolbar.d.ts +2 -3
  34. package/dist/list/ActionToolbar.js +3 -3
  35. package/dist/list/index.d.ts +1 -1
  36. package/dist/list/index.js +2 -2
  37. package/dist/styles/theme.css +11 -0
  38. package/dist/styles.css +1 -1
  39. package/dist/theme/derive.js +12 -7
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -41,6 +41,12 @@ package navigates and never re-renders anything. Define slot components and
41
41
  string functions outside a component: one made each render is a new value, and
42
42
  a new slot component remounts what it renders.
43
43
 
44
+ Under `UIProvider` the components render no inline `<style>`, so they run
45
+ under a Content Security Policy whose `style-src` is `'self'` alone; the one
46
+ rule a select or scroll area would inject ships in `theme.css`. A host that
47
+ renders on the server passes its policy's `nonce`, which the few components
48
+ that emit a script to place themselves before hydration put on it.
49
+
44
50
  ## Which component?
45
51
 
46
52
  Each need has one component. Reach for it before styling markup by hand.
@@ -60,7 +66,7 @@ Each need has one component. Reach for it before styling markup by hand.
60
66
  | A record's details, such as in a drawer or a card: names and values on their own | `DescriptionList` of `DescriptionListItem`s; `copy` on an ID, `mono` on a path, `variant="card"` to frame them; titled lists in a `DescriptionListGroup` to share one label column |
61
67
  | An error beside a control outside a `Field` | `ErrorText` |
62
68
  | A dense field in a toolbar or a table cell | any field control with `size="sm"`; a width in `className` wins, and `cx` merges classes the same way |
63
- | On or off | `Switch`; `Checkbox` in a list of things to tick, several of which may be on |
69
+ | On or off | `Switch`; `Checkbox` in a list of things to tick, several of which may be on; either in a `Field` with `controlFirst` to read control, label, then its help and description |
64
70
  | One of a few choices, all shown | `RadioGroup` in a form; `SegmentedControl` in a toolbar, or as a field's control in a `Field group` |
65
71
  | Narrowing a list by values, such as statuses: several at once, or one or none | `ToggleGroup` |
66
72
  | Narrowing what is shown by typing | `SearchInput`; `ListSearchControl` on a list page's toolbar |
@@ -72,6 +78,7 @@ Each need has one component. Reach for it before styling markup by hand.
72
78
  | A titled part of a page or form | `Section` |
73
79
  | More detail under a line | `Disclosure` |
74
80
  | Views of one thing | `Tabs` |
81
+ | Chrome that stays in view as the page scrolls | `sticky` on `ActionToolbar` or `Tabs`; `StickyBar` around controls of your own. They stack under the page header by themselves (see [Host chrome](#host-chrome)) |
75
82
  | A list of actions | `ActionMenu`; `useContextMenu` for a right-click or a row's menu |
76
83
  | A panel of controls anchored to a trigger | `Popover` |
77
84
  | A hint for a control | `Tooltip`; a `Field`'s `help` for more about a field, which its control reads and which opens as Tab reaches it; `InfoTip` for help about anything else |
@@ -353,9 +360,20 @@ leaves unset follows the `color-scheme` it sets:
353
360
  ```css
354
361
  /* In a Tailwind v4 build: */
355
362
  @import '@parallelworks/ui/theme.css';
356
- @source '../node_modules/@parallelworks/ui/src/**/*.{ts,tsx}';
363
+ @source '../node_modules/@parallelworks/ui/dist/components';
364
+ @source '../node_modules/@parallelworks/ui/dist/list';
357
365
  ```
358
366
 
367
+ The package publishes only `dist`, built one JavaScript module per source
368
+ module under the same folders as its subpaths (`dist/components`, `dist/list`,
369
+ `dist/form`, `dist/ai` and so on), so `@source` points at those modules. List
370
+ the folders of the subpaths the host imports: each one adds the utilities of
371
+ every component in it. Scanning all of `dist` works too, but also reads the
372
+ editor's bundled workers in `dist/assets`. A host that wants only a few
373
+ components can name their modules one by one, such as
374
+ `dist/components/Avatar.js`, as long as it also names the modules they style
375
+ through, such as `dist/components/floating.js` for a popup's panel.
376
+
359
377
  Consumers without a Tailwind build can import the compiled standalone
360
378
  stylesheet instead:
361
379
 
@@ -582,13 +600,34 @@ document.documentElement.style.setProperty('--app-inset-top', '56px')
582
600
  document.documentElement.style.setProperty('--app-inset-start', '220px')
583
601
  ```
584
602
 
585
- A table's sticky header row (`Table stickyHeader`, which list tables use) needs
586
- no variable. It stops just below whatever its scrolling box already pins at the
587
- top, such as a page header, a toolbar and tabs: it finds each element there with
588
- `position: sticky` and a `top` whose parent also holds the table, so it stays
589
- pinned while the table passes, and follows it as it wraps to a new height or
590
- comes and goes. In a box that scrolls on its own, such as a fixed-height panel
591
- or `ListPage`'s rows, it pins at the box's top.
603
+ Sticky chrome stacks by itself, with no offsets to set. A page's pinned pieces,
604
+ `ActionToolbar sticky`, `Tabs sticky`, a `StickyBar` around controls of the
605
+ page's own, and a table's header row (`Table stickyHeader`, which list tables
606
+ use), each stop just below whatever their scrolling box already pins at the top.
607
+ Each finds the elements there with `position: sticky` and a `top` whose parent
608
+ also holds it, so they stay pinned while it passes, and follows them as they
609
+ wrap to a new height or come and go, before the page paints. In a box that
610
+ scrolls on its own, such as a fixed-height panel or `ListPage`'s rows, they pin
611
+ at the box's top.
612
+
613
+ The stack starts from the host's page header. Put it inside the page's
614
+ scroller, ahead of the page, with `position: sticky` and a `top`, such as
615
+ `sticky top-0`: as a child of the scroller or of a wrapper the page also sits
616
+ in. A header above the scroller, outside it, needs nothing: the page's chrome
617
+ pins at the scroller's top, just under it.
618
+
619
+ ```tsx
620
+ <div className="overflow-auto">
621
+ <header className="sticky top-0 z-(--layer-header)">…</header>
622
+ <main>
623
+ <StickyBar className="px-4 py-1.5">
624
+ <ActionToolbar groups={actions} />
625
+ </StickyBar>
626
+ <Tabs label="Cluster" sticky items={tabs} />
627
+ <Table stickyHeader>…</Table>
628
+ </main>
629
+ </div>
630
+ ```
592
631
 
593
632
  Everything the package lays over the page stacks on one scale, set on the
594
633
  document root by `base.css`. Give the host's own fixed elements a place on it,
@@ -10,6 +10,7 @@ export interface EditToolArgs {
10
10
  }
11
11
  export declare function isEditDiffTool(name: string): boolean;
12
12
  export declare function parseEditArgs(argsJSON: string): EditToolArgs | null;
13
+ export declare const MAX_DIFF_CELLS = 1000000;
13
14
  export declare function wordEmphasisRanges(oldLine: string, newLine: string): Array<[number, number]> | null;
14
15
  export declare function lineDelta(oldLines: string[], newLines: string[]): [number, number];
15
16
  export interface DiffRow {
@@ -19,7 +19,51 @@ function splitLines(s) {
19
19
  const trimmed = s.endsWith("\n") ? s.slice(0, -1) : s;
20
20
  return trimmed === "" ? [] : trimmed.split("\n");
21
21
  }
22
+ var MAX_DIFF_CELLS = 1e6;
22
23
  function getOpCodes(a, b) {
24
+ if (a.length * b.length <= 1e6) return lcsOpCodes(a, b);
25
+ let start = 0;
26
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
27
+ let endA = a.length;
28
+ let endB = b.length;
29
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
30
+ endA--;
31
+ endB--;
32
+ }
33
+ const midA = a.slice(start, endA);
34
+ const midB = b.slice(start, endB);
35
+ const middle = midA.length * midB.length <= 1e6 ? lcsOpCodes(midA, midB) : [{
36
+ tag: "r",
37
+ i1: 0,
38
+ i2: midA.length,
39
+ j1: 0,
40
+ j2: midB.length
41
+ }];
42
+ const ops = [];
43
+ if (start > 0) ops.push({
44
+ tag: "e",
45
+ i1: 0,
46
+ i2: start,
47
+ j1: 0,
48
+ j2: start
49
+ });
50
+ for (const op of middle) ops.push({
51
+ tag: op.tag,
52
+ i1: op.i1 + start,
53
+ i2: op.i2 + start,
54
+ j1: op.j1 + start,
55
+ j2: op.j2 + start
56
+ });
57
+ if (endA < a.length) ops.push({
58
+ tag: "e",
59
+ i1: endA,
60
+ i2: a.length,
61
+ j1: endB,
62
+ j2: b.length
63
+ });
64
+ return ops;
65
+ }
66
+ function lcsOpCodes(a, b) {
23
67
  const n = a.length;
24
68
  const m = b.length;
25
69
  const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
@@ -106,9 +150,12 @@ function wordEmphasisRanges(oldLine, newLine) {
106
150
  return ranges.length > 0 ? ranges : null;
107
151
  }
108
152
  function lineDelta(oldLines, newLines) {
153
+ return countLines(getOpCodes(oldLines, newLines));
154
+ }
155
+ function countLines(ops) {
109
156
  let added = 0;
110
157
  let removed = 0;
111
- for (const op of getOpCodes(oldLines, newLines)) {
158
+ for (const op of ops) {
112
159
  if (op.tag === "d" || op.tag === "r") removed += op.i2 - op.i1;
113
160
  if (op.tag === "i" || op.tag === "r") added += op.j2 - op.j1;
114
161
  }
@@ -117,8 +164,8 @@ function lineDelta(oldLines, newLines) {
117
164
  function computeEditDiff(oldString, newString, startLine = 0) {
118
165
  const oldLines = splitLines(oldString);
119
166
  const newLines = splitLines(newString);
120
- const [added, removed] = lineDelta(oldLines, newLines);
121
167
  const ops = getOpCodes(oldLines, newLines);
168
+ const [added, removed] = countLines(ops);
122
169
  const numbered = startLine > 0;
123
170
  const rows = [];
124
171
  let truncated = false;
@@ -213,4 +260,4 @@ function editSummary(toolName, argsJSON, t = defaultToolStrings) {
213
260
  return "";
214
261
  }
215
262
  //#endregion
216
- export { MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, lineDelta, parseEditArgs, wordEmphasisRanges };
263
+ export { MAX_DIFF_CELLS, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, lineDelta, parseEditArgs, wordEmphasisRanges };
@@ -16,6 +16,9 @@ export interface Attachment {
16
16
  interface AttachmentUploadProps {
17
17
  conversationId?: string | undefined;
18
18
  attachments: Attachment[];
19
+ /** The list with a change. Keep its entries as given: an upload's result
20
+ * replaces its pending entry by identity, on the list as it is when the
21
+ * upload finishes. */
19
22
  onAttachmentsChange: (attachments: Attachment[]) => void;
20
23
  disabled?: boolean;
21
24
  maxFiles?: number;
@@ -7,7 +7,7 @@ import { useChatConfig } from "../core/config.js";
7
7
  import { useChat } from "../core/ChatProvider.js";
8
8
  import { ATTACHMENTS_INFINITE_KEY } from "./attachmentKeys.js";
9
9
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
- import { useCallback, useImperativeHandle, useRef, useState } from "react";
10
+ import { useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
11
11
  import { mutate } from "swr";
12
12
  //#region src/ai/components/AttachmentUpload.tsx
13
13
  var DEFAULT_MAX_FILE_SIZE = 26214400;
@@ -24,18 +24,23 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
24
24
  const { common } = useStrings();
25
25
  const [isDragging, setIsDragging] = useState(false);
26
26
  const fileInputRef = useRef(null);
27
- const validateFile = useCallback((file) => {
28
- if (file.size > maxFileSize) return t.fileTooLarge(formatBytes(maxFileSize));
29
- if (attachments.length >= maxFiles) return t.maxFilesAllowed(maxFiles);
30
- return null;
31
- }, [
32
- attachments.length,
33
- maxFileSize,
34
- maxFiles,
35
- t
36
- ]);
27
+ const latest = useRef(attachments);
28
+ useLayoutEffect(() => {
29
+ latest.current = attachments;
30
+ }, [attachments]);
31
+ const commit = useCallback((next) => {
32
+ latest.current = next;
33
+ onAttachmentsChange(next);
34
+ }, [onAttachmentsChange]);
37
35
  const uploadFile = useCallback(async (file) => {
38
- if (!adapter.attachments) return null;
36
+ const failed = (error) => ({
37
+ file,
38
+ filename: file.name,
39
+ mimeType: file.type,
40
+ size: file.size,
41
+ error
42
+ });
43
+ if (!adapter.attachments) return failed(t.uploadUnavailable);
39
44
  try {
40
45
  const uploaded = await adapter.attachments.upload(file, conversationId);
41
46
  await mutate(ATTACHMENTS_INFINITE_KEY);
@@ -47,44 +52,47 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
47
52
  uploaded: true
48
53
  };
49
54
  } catch (error) {
50
- return {
51
- file,
52
- filename: file.name,
53
- mimeType: file.type,
54
- size: file.size,
55
- error: error instanceof Error ? error.message : String(error)
56
- };
55
+ return failed(error instanceof Error ? error.message : String(error));
57
56
  }
58
- }, [adapter, conversationId]);
57
+ }, [
58
+ adapter,
59
+ conversationId,
60
+ t
61
+ ]);
59
62
  const handleFiles = useCallback(async (files) => {
60
- const fileArray = Array.from(files);
61
- const newAttachments = [];
62
- for (const file of fileArray) {
63
- const error = validateFile(file);
64
- if (error) {
65
- toast.error(error);
63
+ const pending = [];
64
+ let room = maxFiles - latest.current.length;
65
+ let overLimit = false;
66
+ for (const file of Array.from(files)) {
67
+ if (file.size > maxFileSize) {
68
+ toast.error(t.fileTooLarge(formatBytes(maxFileSize)));
66
69
  continue;
67
70
  }
68
- const pendingAttachment = {
71
+ if (room <= 0) {
72
+ overLimit = true;
73
+ continue;
74
+ }
75
+ room--;
76
+ pending.push({
69
77
  file,
70
78
  filename: file.name,
71
79
  mimeType: file.type,
72
80
  size: file.size,
73
81
  uploading: true
74
- };
75
- newAttachments.push(pendingAttachment);
82
+ });
76
83
  }
77
- onAttachmentsChange([...attachments, ...newAttachments]);
78
- const uploaded = await Promise.all(newAttachments.map(async (a) => {
79
- if (a.file) return uploadFile(a.file);
80
- return a;
81
- }));
82
- onAttachmentsChange(attachments.concat(uploaded.filter((a) => a !== null)));
84
+ if (overLimit) toast.error(t.maxFilesAllowed(maxFiles));
85
+ if (pending.length === 0) return;
86
+ commit([...latest.current, ...pending]);
87
+ const results = await Promise.all(pending.map((a) => uploadFile(a.file)));
88
+ const resultOf = new Map(pending.map((a, i) => [a, results[i]]));
89
+ commit(latest.current.map((a) => resultOf.get(a) ?? a));
83
90
  }, [
84
- attachments,
85
- onAttachmentsChange,
86
- uploadFile,
87
- validateFile
91
+ commit,
92
+ maxFileSize,
93
+ maxFiles,
94
+ t,
95
+ uploadFile
88
96
  ]);
89
97
  const handleDragOver = useCallback((e) => {
90
98
  e.preventDefault();
@@ -103,9 +111,9 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
103
111
  if (e.target.files && e.target.files.length > 0) handleFiles(e.target.files);
104
112
  e.target.value = "";
105
113
  }, [handleFiles]);
106
- const handleRemove = useCallback((index) => {
107
- onAttachmentsChange(attachments.filter((_, i) => i !== index));
108
- }, [attachments, onAttachmentsChange]);
114
+ const handleRemove = useCallback((attachment) => {
115
+ commit(latest.current.filter((a) => a !== attachment));
116
+ }, [commit]);
109
117
  useImperativeHandle(ref, () => ({ addFiles: handleFiles }), [handleFiles]);
110
118
  const handleClick = () => {
111
119
  fileInputRef.current?.click();
@@ -148,7 +156,7 @@ function AttachmentUpload({ conversationId, attachments, onAttachmentsChange, di
148
156
  }),
149
157
  /* @__PURE__ */ jsx("button", {
150
158
  type: "button",
151
- onClick: () => handleRemove(index),
159
+ onClick: () => handleRemove(attachment),
152
160
  className: "p-0.5 rounded hover:bg-(--theme-error-muted)",
153
161
  title: common.remove,
154
162
  disabled,
@@ -92,10 +92,30 @@ function DiffRows({ rows, lang }) {
92
92
  function EditDiffBlock({ part }) {
93
93
  const { strings } = useChatConfig();
94
94
  const t = strings.agentTranscript;
95
- const a = parseEditArgs(part.args);
96
- if (!a) return null;
97
- const outcome = editSummary(part.name, part.args, strings.tools);
98
- const model = part.name === "EditFile" ? computeEditDiff(a.old_string ?? "", a.new_string ?? "", part.startLine) : computeWriteRows(a.content ?? "");
95
+ const diff = useMemo(() => {
96
+ const a = parseEditArgs(part.args);
97
+ if (!a) return null;
98
+ if (part.name === "EditFile") {
99
+ const model = computeEditDiff(a.old_string ?? "", a.new_string ?? "", part.startLine);
100
+ return {
101
+ a,
102
+ model,
103
+ outcome: strings.tools.edited(model.added, model.removed)
104
+ };
105
+ }
106
+ return {
107
+ a,
108
+ model: computeWriteRows(a.content ?? ""),
109
+ outcome: editSummary(part.name, part.args, strings.tools)
110
+ };
111
+ }, [
112
+ part.name,
113
+ part.args,
114
+ part.startLine,
115
+ strings.tools
116
+ ]);
117
+ if (!diff) return null;
118
+ const { a, model, outcome } = diff;
99
119
  return /* @__PURE__ */ jsxs("div", {
100
120
  className: "mt-1.5 space-y-1",
101
121
  children: [
@@ -210,10 +230,17 @@ function ToolCallBlock({ part }) {
210
230
  const header = toolHeaderFor(part.name);
211
231
  const argSummary = summarizeToolArgs(header.arg, part.args);
212
232
  const isDiff = isEditDiffTool(part.name);
213
- const summary = part.status === "running" ? t.running : toolSummary(part.name, part.args, part.result ?? "", part.status === "error", {
233
+ const summary = useMemo(() => part.status === "running" ? t.running : toolSummary(part.name, part.args, part.result ?? "", part.status === "error", {
214
234
  editSummary,
215
235
  strings: strings.tools
216
- });
236
+ }), [
237
+ part.status,
238
+ part.name,
239
+ part.args,
240
+ part.result,
241
+ t.running,
242
+ strings.tools
243
+ ]);
217
244
  return /* @__PURE__ */ jsxs("div", {
218
245
  className: "py-1.5 text-sm",
219
246
  "data-testid": "tool-call-part",
@@ -139,6 +139,8 @@ export interface ChatStrings {
139
139
  addFiles: string;
140
140
  maxSizePerFile: (size: string) => string;
141
141
  uploading: string;
142
+ /** On a file added where the chat has nowhere to upload it. */
143
+ uploadUnavailable: string;
142
144
  };
143
145
  attachmentManager: {
144
146
  title: string;
@@ -174,7 +174,8 @@ var defaultChatStrings = {
174
174
  releaseToUpload: "Release to upload",
175
175
  addFiles: "Add files (drag or click)",
176
176
  maxSizePerFile: (size) => `Max ${size} per file`,
177
- uploading: "Uploading…"
177
+ uploading: "Uploading…",
178
+ uploadUnavailable: "Attachments can’t be uploaded here"
178
179
  },
179
180
  attachmentManager: {
180
181
  title: "Attachments",
@@ -3,9 +3,14 @@ import { useLink, useStrings } from "./Provider.js";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/components/AppLink.tsx
5
5
  function leavesApp(url) {
6
- if (!/^([a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return false;
7
- if (typeof window === "undefined") return true;
8
- return new URL(url, window.location.href).origin !== window.location.origin;
6
+ if (typeof window === "undefined") return /^([a-z][a-z\d+.-]*:|[/\\]{2})/i.test(url.replace(/[\t\n\r]/g, "").trimStart());
7
+ const { origin, protocol } = window.location;
8
+ try {
9
+ const resolved = new URL(url, window.location.href);
10
+ return resolved.origin !== origin || resolved.protocol !== protocol;
11
+ } catch {
12
+ return true;
13
+ }
9
14
  }
10
15
  /** True for a click the browser would open in place: the primary button with
11
16
  * no modifier. A link's own onClick work, such as closing a drawer or clearing
@@ -18,11 +18,17 @@ var STATUS_CLASSES = {
18
18
  busy: "bg-(--theme-error)",
19
19
  away: "bg-(--theme-warning)"
20
20
  };
21
+ var graphemes = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : void 0;
22
+ function firstCharacter(text) {
23
+ if (!text) return;
24
+ if (graphemes) return graphemes.segment(text)[Symbol.iterator]().next().value?.segment;
25
+ return Array.from(text)[0];
26
+ }
21
27
  /** Up to two initials: the first and last word of a name split on spaces and dots. */
22
28
  function avatarInitials(name) {
23
29
  const parts = (name ?? "").split(/[\s.]+/).filter(Boolean);
24
- const first = parts[0]?.charAt(0);
25
- const last = parts.at(-1)?.charAt(0);
30
+ const first = firstCharacter(parts[0]);
31
+ const last = firstCharacter(parts.at(-1));
26
32
  if (parts.length >= 2 && first && last) return (first + last).toUpperCase();
27
33
  return first?.toUpperCase() || "?";
28
34
  }
@@ -48,7 +54,7 @@ function Avatar$1({ src, name, colorKey, size = "md", status, className }) {
48
54
  }, src), /* @__PURE__ */ jsx(Avatar.Fallback, {
49
55
  "aria-hidden": "true",
50
56
  className: cx("flex size-full items-center justify-center", colorBy ? categoryClasses(colorBy) : "bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)"),
51
- children: size === "xs" ? avatarInitials(name).charAt(0) : avatarInitials(name)
57
+ children: size === "xs" ? firstCharacter(avatarInitials(name)) : avatarInitials(name)
52
58
  })]
53
59
  }), status && /* @__PURE__ */ jsx("span", {
54
60
  "data-testid": "online-indicator",
@@ -9,6 +9,8 @@ export interface ColorInputProps {
9
9
  label?: ReactNode;
10
10
  /** Beside the label and outside it, so it stays out of the field's name. */
11
11
  hint?: ReactNode;
12
+ /** Buttons at the far end of the label's row that act on the color, as Field's `actions`. */
13
+ actions?: ReactNode;
12
14
  /** Help under the field. */
13
15
  description?: ReactNode;
14
16
  error?: ReactNode;
@@ -33,4 +35,4 @@ export interface ColorInputProps {
33
35
  * in. A half-typed color stays in the field, marked invalid, until it parses; leaving the field
34
36
  * puts back the last color that did.
35
37
  */
36
- export declare function ColorInput({ value, onValueChange, label, hint, description, error, size, id, name, disabled, required, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, }: ColorInputProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
38
+ export declare function ColorInput({ value, onValueChange, label, hint, actions, description, error, size, id, name, disabled, required, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, }: ColorInputProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
@@ -14,7 +14,7 @@ var swatchClassName = cx("shrink-0 cursor-pointer p-0.5", "[&::-webkit-color-swa
14
14
  * in. A half-typed color stays in the field, marked invalid, until it parses; leaving the field
15
15
  * puts back the last color that did.
16
16
  */
17
- function ColorInput({ value, onValueChange, label, hint, description, error, size = "md", id, name, disabled, required, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, className }) {
17
+ function ColorInput({ value, onValueChange, label, hint, actions, description, error, size = "md", id, name, disabled, required, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, className }) {
18
18
  const t = useStrings().colorInput;
19
19
  const [draft, setDraft] = useState(null);
20
20
  const errorId = useId();
@@ -25,6 +25,7 @@ function ColorInput({ value, onValueChange, label, hint, description, error, siz
25
25
  return framed({
26
26
  label,
27
27
  hint,
28
+ actions,
28
29
  description,
29
30
  error: error ?? (unparsed ? t.invalid : void 0),
30
31
  size
@@ -4,5 +4,6 @@ export type ExternalLinkProps = Omit<ComponentPropsWithRef<'a'>, 'target' | 'rel
4
4
  icon?: boolean;
5
5
  };
6
6
  /** A link that opens in a new tab, says so to screen readers, and gives the
7
- * page it opens no hold on this one. */
8
- export declare function ExternalLink({ children, icon, className, ...props }: ExternalLinkProps): import("react").JSX.Element;
7
+ * page it opens no hold on this one. Its href passes `safeUrl`, as every
8
+ * component's does, and one that doesn't is dropped. */
9
+ export declare function ExternalLink({ href, children, icon, className, ...props }: ExternalLinkProps): import("react").JSX.Element;
@@ -1,14 +1,17 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { ExternalLinkIcon } from "../icons.js";
3
+ import { safeUrl } from "../safeUrl.js";
3
4
  import { useStrings } from "./Provider.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/components/ExternalLink.tsx
6
7
  /** A link that opens in a new tab, says so to screen readers, and gives the
7
- * page it opens no hold on this one. */
8
- function ExternalLink({ children, icon = false, className, ...props }) {
8
+ * page it opens no hold on this one. Its href passes `safeUrl`, as every
9
+ * component's does, and one that doesn't is dropped. */
10
+ function ExternalLink({ href, children, icon = false, className, ...props }) {
9
11
  const { common } = useStrings();
10
12
  return /* @__PURE__ */ jsxs("a", {
11
13
  ...props,
14
+ href: safeUrl(href),
12
15
  target: "_blank",
13
16
  rel: "noopener noreferrer",
14
17
  className: cx(icon && "inline-flex items-center gap-1", className),
@@ -59,6 +59,12 @@ export interface FieldProps {
59
59
  size?: FieldBoxSize | undefined;
60
60
  /** A set of controls, such as radios or checkboxes, that a legend names instead of a label. */
61
61
  group?: boolean | undefined;
62
+ /** Puts a checkbox, a switch or a lone radio before its label, as such a control reads: the
63
+ * control, then its label and ?, with the description and error under the label. A click on
64
+ * the label sets the control. Inline, or in a row of fields, it sits in the control column,
65
+ * its first line on the line of the labels beside it. Not for a `group`, whose options are
66
+ * each a `FieldOption`. */
67
+ controlFirst?: boolean | undefined;
62
68
  /** The control's id; one is made up when left out. */
63
69
  id?: string | undefined;
64
70
  className?: string | undefined;
@@ -77,7 +83,7 @@ export declare function ErrorText({ id, children }: {
77
83
  }): import("react").JSX.Element | null;
78
84
  /** A control with its label, help and error, tied to it for assistive technology. One frame
79
85
  * for every field, in a form or standing alone. */
80
- export declare function Field({ label, hint, help, actions, description, error, invalid, required, disabled, layout, compact, size, group, id: ownId, className, children, }: FieldProps): import("react").JSX.Element;
86
+ export declare function Field({ label, hint, help, actions, description, error, invalid, required, disabled, layout, compact, size, group, controlFirst, id: ownId, className, children, }: FieldProps): import("react").JSX.Element;
81
87
  /** One checkbox or radio and its label inside a Field, such as an option of a group. A bare
82
88
  * `<label>` there would take the Field's label as its own, so every option read as the field. */
83
89
  export declare function FieldOption({ disabled, className, children, }: {