@fungi.computer/caps 0.3.1 → 0.5.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/README.md CHANGED
@@ -72,7 +72,7 @@ export function NoteComposer() {
72
72
  <Composer
73
73
  input={
74
74
  <input
75
- className="input join-item w-full"
75
+ className="input w-full"
76
76
  value={draft}
77
77
  onChange={(event) => setDraft(event.target.value)}
78
78
  aria-label="Note"
@@ -663,9 +663,12 @@ compose, so color belongs in `variant` or an extra daisyUI class.
663
663
 
664
664
  ### Composer with an async submit
665
665
 
666
- `Composer` is a layout block. Its input and action are your controls, and so are
667
- their labels, disabled state, keyboard behaviour and effects. An avatar or other
668
- leading content goes in the `leading` slot as any React node.
666
+ `Composer` is a layout block: one surface with optional `attachments` on top,
667
+ your `input` typed straight onto the host region, and a bottom row of `toolbar`
668
+ controls with the `action` at its end. It draws no box of its own; the host
669
+ region is the field. Every slot is your control, and so are its labels, disabled
670
+ state, keyboard behaviour and effects. A composer's send key is `SendAction`,
671
+ which turns into a stop key while work runs.
669
672
 
670
673
  ```tsx
671
674
  import { useState, type FormEvent } from "react";
@@ -697,7 +700,7 @@ export function PromptComposer({
697
700
  <Composer
698
701
  input={
699
702
  <input
700
- className="input join-item w-full"
703
+ className="input w-full"
701
704
  value={value}
702
705
  onChange={(event) => setValue(event.target.value)}
703
706
  aria-label="Prompt"
@@ -720,7 +723,7 @@ export function PromptComposer({
720
723
  ```
721
724
 
722
725
  `submit` is your effect. It can call an API, enqueue work or do something local.
723
- Caps only joins the controls.
726
+ Caps only lays out the controls.
724
727
 
725
728
  ### Window
726
729
 
@@ -1,23 +1,28 @@
1
1
  import { type HTMLAttributes, type ReactNode } from "react";
2
2
  /** Props accepted by Composer. */
3
3
  export type ComposerProps = HTMLAttributes<HTMLDivElement> & {
4
- /** The caller-owned primary input control. */
4
+ /** The caller-owned field, typed into directly: the surface is the field. */
5
5
  input: ReactNode;
6
- /** The caller-owned action control. Its disabled state and accessible name stay on that control. */
6
+ /** The caller-owned key at the end of the bottom row, such as a SendAction. */
7
7
  action?: ReactNode;
8
- /** Optional caller-owned leading slot, such as an avatar. */
9
- leading?: ReactNode;
10
- /** Classes for the joined row; useful when the host exposes a busy state. */
11
- rowClassName?: string;
8
+ /** Caller-owned controls and readouts along the bottom edge, before the action. */
9
+ toolbar?: ReactNode;
10
+ /** Caller-owned items riding with the draft, shown above the text. */
11
+ attachments?: ReactNode;
12
12
  };
13
- /** Layout and slots only. It never assigns action-control semantics. */
13
+ /**
14
+ * One composer surface: attachments, then the text, then a slim row along the
15
+ * bottom edge holding the toolbar and, at its end, the action. It draws no box
16
+ * of its own: the host region is the field. Layout and slots only; it never
17
+ * assigns action-control semantics.
18
+ */
14
19
  export declare const Composer: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLDivElement> & {
15
- /** The caller-owned primary input control. */
20
+ /** The caller-owned field, typed into directly: the surface is the field. */
16
21
  input: ReactNode;
17
- /** The caller-owned action control. Its disabled state and accessible name stay on that control. */
22
+ /** The caller-owned key at the end of the bottom row, such as a SendAction. */
18
23
  action?: ReactNode;
19
- /** Optional caller-owned leading slot, such as an avatar. */
20
- leading?: ReactNode;
21
- /** Classes for the joined row; useful when the host exposes a busy state. */
22
- rowClassName?: string;
24
+ /** Caller-owned controls and readouts along the bottom edge, before the action. */
25
+ toolbar?: ReactNode;
26
+ /** Caller-owned items riding with the draft, shown above the text. */
27
+ attachments?: ReactNode;
23
28
  } & import("react").RefAttributes<HTMLDivElement>>;
@@ -1,13 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
- /** Layout and slots only. It never assigns action-control semantics. */
4
- export const Composer = forwardRef(function Composer({ action, className, input, leading, rowClassName, ...rootProps }, ref) {
5
- return (_jsx("div", { ...rootProps, ref: ref, className: [
6
- "caps-composer flex min-w-0 flex-1 items-end gap-sm",
7
- className,
8
- ]
9
- .filter(Boolean)
10
- .join(" "), children: _jsxs("div", { className: ["join w-full p-2xs", rowClassName]
11
- .filter(Boolean)
12
- .join(" "), children: [leading, input, action] }) }));
3
+ import { cn } from "../lib/cn.js";
4
+ /**
5
+ * One composer surface: attachments, then the text, then a slim row along the
6
+ * bottom edge holding the toolbar and, at its end, the action. It draws no box
7
+ * of its own: the host region is the field. Layout and slots only; it never
8
+ * assigns action-control semantics.
9
+ */
10
+ export const Composer = forwardRef(function Composer({ action, attachments, className, input, toolbar, ...rootProps }, ref) {
11
+ return (_jsxs("div", { ...rootProps, ref: ref, className: cn("caps-composer", className), children: [attachments === undefined ? null : (_jsx("div", { className: "caps-composer-attachments", children: attachments })), _jsx("div", { className: "caps-composer-input", children: input }), toolbar === undefined && action === undefined ? null : (_jsxs("div", { className: "caps-composer-bar", children: [_jsx("div", { className: "caps-composer-toolbar", children: toolbar }), action] }))] }));
13
12
  });
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useId, useRef, useState, } from "react";
3
3
  import { Button } from "../components/button.js";
4
4
  import { Input } from "../components/input.js";
5
+ import { InputGroup } from "../components/input-group.js";
5
6
  import { cn } from "../lib/cn.js";
6
- import { Composer } from "./composer.js";
7
7
  function words(text) {
8
8
  return text.toLowerCase().match(/[a-z0-9$]+/g) ?? [];
9
9
  }
@@ -71,7 +71,7 @@ export const FaqChat = forwardRef(function FaqChat({ avatar, className, entries,
71
71
  }
72
72
  return (_jsxs("section", { ...rootProps, "aria-labelledby": titleId, className: cn("caps-faq-chat", className), ref: ref, children: [_jsxs("header", { className: "caps-faq-chat-header", children: [avatar, _jsxs("div", { className: "caps-faq-chat-heading", children: [_jsx("p", { className: "caps-faq-chat-title", id: titleId, children: title }), note ? _jsx("p", { className: "caps-faq-chat-note", children: note }) : null] })] }), _jsxs("div", { "aria-live": "polite", className: "caps-faq-chat-log", ref: logRef, role: "log", children: [_jsx("div", { className: "caps-faq-chat-agent", children: greeting }), turns.map((turn) => turn.speaker === "user" ? (_jsx("p", { className: "caps-faq-chat-user", children: turn.text }, turn.key)) : (_jsxs("div", { className: "caps-faq-chat-agent", children: [turn.answer, turn.actions ? (_jsx("div", { className: "caps-faq-chat-actions", children: turn.actions })) : null] }, turn.key)))] }), _jsx("ul", { "aria-label": "Suggested questions", className: "caps-faq-chat-suggestions", children: entries.map((entry) => (_jsx("li", { children: _jsx(Button, { onClick: () => {
73
73
  ask(entry.question, entry);
74
- }, size: "xs", type: "button", variant: "outline", children: entry.question }) }, entry.id))) }), _jsx("form", { className: "caps-faq-chat-form", onSubmit: submit, children: _jsx(Composer, { action: _jsx(Button, { className: "join-item", type: "submit", children: submitLabel }), input: _jsx(Input, { "aria-label": inputLabel, className: "join-item", onChange: (event) => {
75
- setDraft(event.target.value);
76
- }, placeholder: placeholder, value: draft }) }) })] }));
74
+ }, size: "xs", type: "button", variant: "outline", children: entry.question }) }, entry.id))) }), _jsx("form", { className: "caps-faq-chat-form", onSubmit: submit, children: _jsxs(InputGroup, { children: [_jsx(Input, { "aria-label": inputLabel, onChange: (event) => {
75
+ setDraft(event.target.value);
76
+ }, placeholder: placeholder, value: draft }), _jsx(Button, { type: "submit", children: submitLabel })] }) })] }));
77
77
  });
@@ -1,4 +1,4 @@
1
- import { type HTMLAttributes, type ReactNode } from "react";
1
+ import { type ComponentPropsWithRef, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { type StatusGlyph, type StatusMotion, type StatusTone } from "../components/status.js";
3
3
  /**
4
4
  * One footer readout and the small tray it opens. Semantic data only: the
@@ -43,6 +43,12 @@ export type StatusTrayProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
43
43
  leading?: ReactNode;
44
44
  /** Trailing controls after the chips (Send, Expand, …). */
45
45
  trailing?: ReactNode;
46
+ /**
47
+ * Compose a native button trigger, forwarding these attributes, children
48
+ * and ref. StatusTray still owns disclosure, dismissal and return focus.
49
+ * Omit to retain the default chip and its theme-owned finish.
50
+ */
51
+ renderTrigger?: ((props: ComponentPropsWithRef<"button">) => ReactNode) | undefined;
46
52
  /**
47
53
  * Structural seat. `housing` sits on the plastic below a screen; `screen`
48
54
  * sits inside a Display as flat, screen-native text (the wiki's screen
@@ -59,7 +65,7 @@ export type StatusTrayProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
59
65
  hostInset?: number;
60
66
  };
61
67
  /** A footer row of readout chips; one opens its tray above the row at a time. */
62
- export declare function StatusTray({ items, openId, onOpenChange, leading, trailing, placement, align, hostInset, className, ...props }: StatusTrayProps): import("react/jsx-runtime").JSX.Element;
68
+ export declare function StatusTray({ items, openId, onOpenChange, leading, trailing, renderTrigger, placement, align, hostInset, className, ...props }: StatusTrayProps): import("react/jsx-runtime").JSX.Element;
63
69
  /** A group of tray rows. Precede it with a DataListHeading to label it. */
64
70
  export declare function StatusTrayList({ children, className, ...props }: HTMLAttributes<HTMLDivElement>): import("react/jsx-runtime").JSX.Element;
65
71
  /**
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
3
  import { Fragment, useEffect, useId, useRef, } from "react";
4
4
  import { IconButton } from "../components/icon-button.js";
@@ -6,7 +6,7 @@ import { Popover, PopoverAnchor } from "../components/popover.js";
6
6
  import { Status, } from "../components/status.js";
7
7
  import { cn } from "../lib/cn.js";
8
8
  /** A footer row of readout chips; one opens its tray above the row at a time. */
9
- export function StatusTray({ items, openId, onOpenChange, leading, trailing, placement = "housing", align = "end", hostInset, className, ...props }) {
9
+ export function StatusTray({ items, openId, onOpenChange, leading, trailing, renderTrigger, placement = "housing", align = "end", hostInset, className, ...props }) {
10
10
  const scope = useId();
11
11
  const root = useRef(null);
12
12
  const panel = useRef(null);
@@ -50,14 +50,30 @@ export function StatusTray({ items, openId, onOpenChange, leading, trailing, pla
50
50
  document.removeEventListener("pointerdown", onPointer);
51
51
  };
52
52
  }, [activeId, onOpenChange]);
53
- return (_jsxs(PopoverAnchor, { ...props, ref: root, className: cn("caps-status-tray", className), "data-placement": placement, "data-align": align, children: [open ? (_jsxs(Popover, { open: true, ref: panel, tabIndex: -1, side: "top", id: `${scope}-${open.id}`, role: "dialog", "aria-labelledby": `${scope}-title`, className: "caps-status-tray-panel", "data-status-tray-panel": open.id, children: [_jsxs("div", { className: "caps-status-tray-head", children: [open.tone ? (_jsx(Status, { size: "compact", tone: open.tone, glyph: open.glyph ?? "dot" })) : null, _jsx("span", { id: `${scope}-title`, className: "caps-status-tray-title", children: open.title ?? open.label }), open.actions, _jsx(IconButton, { "aria-label": "Close tray", icon: _jsx(X, { "aria-hidden": "true" }), onClick: close })] }), _jsx("div", { className: "caps-status-tray-body", children: open.content })] })) : null, placement === "host" ? null : (_jsxs("div", { className: "caps-status-tray-row", children: [leading, _jsx("div", { className: "caps-status-tray-chips", children: items.map((item) => (_jsxs(Fragment, { children: [_jsxs("button", { ref: (node) => {
54
- if (node)
55
- triggers.current.set(item.id, node);
56
- else
57
- triggers.current.delete(item.id);
58
- }, type: "button", className: "caps-status-tray-chip", "aria-expanded": activeId === item.id, "aria-controls": activeId === item.id ? `${scope}-${item.id}` : undefined, "aria-label": item.accessibleLabel, disabled: item.disabled, title: item.disabled ? item.accessibleLabel : undefined, "aria-haspopup": "dialog", "data-status-tray-chip": item.id, onClick: () => {
59
- onOpenChange(openId === item.id ? null : item.id);
60
- }, children: [item.tone ? (_jsx(Status, { size: "compact", glyph: item.glyph ?? "dot", motion: item.motion ?? "none", tone: item.tone })) : null, _jsx("span", { className: "caps-status-tray-label", children: item.label }), item.count !== undefined && item.count > 0 ? (_jsx("span", { className: "caps-status-tray-count", children: item.count })) : null] }), item.after] }, item.id))) }), trailing] }))] }));
53
+ return (_jsxs(PopoverAnchor, { ...props, ref: root, className: cn("caps-status-tray", className), "data-placement": placement, "data-align": align, children: [open ? (_jsxs(Popover, { open: true, ref: panel, tabIndex: -1, side: "top", id: `${scope}-${open.id}`, role: "dialog", "aria-labelledby": `${scope}-title`, className: "caps-status-tray-panel", "data-status-tray-panel": open.id, children: [_jsxs("div", { className: "caps-status-tray-head", children: [open.tone ? (_jsx(Status, { size: "compact", tone: open.tone, glyph: open.glyph ?? "dot" })) : null, _jsx("span", { id: `${scope}-title`, className: "caps-status-tray-title", children: open.title ?? open.label }), open.actions, _jsx(IconButton, { "aria-label": "Close tray", icon: _jsx(X, { "aria-hidden": "true" }), onClick: close })] }), _jsx("div", { className: "caps-status-tray-body", children: open.content })] })) : null, placement === "host" ? null : (_jsxs("div", { className: "caps-status-tray-row", children: [leading, _jsx("div", { className: "caps-status-tray-chips", children: items.map((item) => {
54
+ const trigger = {
55
+ ref: (node) => {
56
+ if (node)
57
+ triggers.current.set(item.id, node);
58
+ else
59
+ triggers.current.delete(item.id);
60
+ },
61
+ type: "button",
62
+ className: renderTrigger ? undefined : "caps-status-tray-chip",
63
+ "aria-expanded": activeId === item.id,
64
+ "aria-controls": activeId === item.id ? `${scope}-${item.id}` : undefined,
65
+ "aria-label": item.accessibleLabel,
66
+ disabled: item.disabled,
67
+ title: item.disabled ? item.accessibleLabel : undefined,
68
+ "aria-haspopup": "dialog",
69
+ "data-status-tray-chip": item.id,
70
+ onClick: () => {
71
+ onOpenChange(openId === item.id ? null : item.id);
72
+ },
73
+ children: (_jsxs(_Fragment, { children: [item.tone ? (_jsx(Status, { size: "compact", glyph: item.glyph ?? "dot", motion: item.motion ?? "none", tone: item.tone })) : null, _jsx("span", { className: "caps-status-tray-label", children: item.label }), item.count !== undefined && item.count > 0 ? (_jsx("span", { className: "caps-status-tray-count", children: item.count })) : null] })),
74
+ };
75
+ return (_jsxs(Fragment, { children: [renderTrigger ? (renderTrigger(trigger)) : (_jsx("button", { ...trigger })), item.after] }, item.id));
76
+ }) }), trailing] }))] }));
61
77
  }
62
78
  /** A group of tray rows. Precede it with a DataListHeading to label it. */
63
79
  export function StatusTrayList({ children, className, ...props }) {
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Menu as BaseMenu } from "@base-ui/react/menu";
3
3
  import * as React from "react";
4
4
  import { createContext, forwardRef, useContext, useCallback, useImperativeHandle, useRef, } from "react";
@@ -79,12 +79,12 @@ export const DropdownMenuItem = forwardRef(function DropdownMenuItem({ className
79
79
  }, ref: ref }));
80
80
  });
81
81
  /** A checkable item in a DropdownMenu. */
82
- export const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem({ className, onClick, onSelect, ...props }, ref) {
83
- return (_jsx(BaseMenu.CheckboxItem, { ...props, className: (state) => cn(itemClasses, resolveClassName(className, state)), onClick: (event) => {
82
+ export const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem({ children, className, onClick, onSelect, ...props }, ref) {
83
+ return (_jsxs(BaseMenu.CheckboxItem, { ...props, className: (state) => cn(itemClasses, resolveClassName(className, state)), onClick: (event) => {
84
84
  onClick?.(event);
85
85
  if (!event.defaultPrevented)
86
86
  onSelect?.(event);
87
- }, ref: ref }));
87
+ }, ref: ref, children: [_jsx("span", { className: "min-w-0 flex-1", children: children }), _jsx(BaseMenu.CheckboxItemIndicator, { "aria-hidden": "true", className: "shrink-0", children: "\u2713" })] }));
88
88
  });
89
89
  /** A visual separator in a DropdownMenu. */
90
90
  export const DropdownMenuSeparator = forwardRef(function DropdownMenuSeparator({ className, ...props }, ref) {
@@ -21,6 +21,8 @@ export declare function FormFooter({ children, divider, error, layout, status, .
21
21
  export type FormProps = {
22
22
  /** Disables every control while a submission is in flight. */
23
23
  busy?: boolean;
24
+ /** Opt-in label/control grid; the default field rhythm is unchanged. */
25
+ layout?: "columns" | undefined;
24
26
  } & FormHTMLAttributes<HTMLFormElement>;
25
27
  /**
26
28
  * A form with Caps' field rhythm. While `busy`, its fieldset disables every
@@ -29,11 +31,15 @@ export type FormProps = {
29
31
  export declare const Form: import("react").ForwardRefExoticComponent<{
30
32
  /** Disables every control while a submission is in flight. */
31
33
  busy?: boolean;
34
+ /** Opt-in label/control grid; the default field rhythm is unchanged. */
35
+ layout?: "columns" | undefined;
32
36
  } & FormHTMLAttributes<HTMLFormElement> & import("react").RefAttributes<HTMLFormElement>>;
33
37
  /** Props accepted by FormSection. */
34
38
  export type FormSectionProps = {
35
39
  title: ReactNode;
36
40
  description?: ReactNode;
41
+ /** Opt-in label/control grid when used without a Form parent. */
42
+ layout?: "columns" | undefined;
37
43
  /** Heading level inside the page outline. */
38
44
  level?: 2 | 3 | 4;
39
45
  children: ReactNode;
@@ -42,6 +48,8 @@ export type FormSectionProps = {
42
48
  export declare const FormSection: import("react").ForwardRefExoticComponent<{
43
49
  title: ReactNode;
44
50
  description?: ReactNode;
51
+ /** Opt-in label/control grid when used without a Form parent. */
52
+ layout?: "columns" | undefined;
45
53
  /** Heading level inside the page outline. */
46
54
  level?: 2 | 3 | 4;
47
55
  children: ReactNode;
@@ -49,7 +57,11 @@ export declare const FormSection: import("react").ForwardRefExoticComponent<{
49
57
  /** Props accepted by FieldSet. */
50
58
  export type FieldSetProps = {
51
59
  legend: ReactNode;
60
+ /** Full legend on hover when a columns form clips it to one line. */
61
+ legendTitle?: string;
52
62
  description?: ReactNode;
63
+ /** Opt-in label/control grid when used without a Form parent. */
64
+ layout?: "columns" | undefined;
53
65
  /** The group's announced error, such as "Choose at least one right". */
54
66
  error?: ReactNode;
55
67
  children: ReactNode;
@@ -60,7 +72,11 @@ export type FieldSetProps = {
60
72
  */
61
73
  export declare const FieldSet: import("react").ForwardRefExoticComponent<{
62
74
  legend: ReactNode;
75
+ /** Full legend on hover when a columns form clips it to one line. */
76
+ legendTitle?: string;
63
77
  description?: ReactNode;
78
+ /** Opt-in label/control grid when used without a Form parent. */
79
+ layout?: "columns" | undefined;
64
80
  /** The group's announced error, such as "Choose at least one right". */
65
81
  error?: ReactNode;
66
82
  children: ReactNode;
@@ -76,6 +92,8 @@ export type FieldProps = {
76
92
  */
77
93
  icon?: ReactNode;
78
94
  label?: ReactNode;
95
+ /** Full label on hover when a columns form clips it to one line. */
96
+ labelTitle?: string;
79
97
  /**
80
98
  * `vertical` stacks label, control and error; `horizontal` puts the control
81
99
  * opposite its copy (settings rows); `inline` puts a checkbox or switch
@@ -102,6 +120,8 @@ export declare const Field: import("react").ForwardRefExoticComponent<{
102
120
  */
103
121
  icon?: ReactNode;
104
122
  label?: ReactNode;
123
+ /** Full label on hover when a columns form clips it to one line. */
124
+ labelTitle?: string;
105
125
  /**
106
126
  * `vertical` stacks label, control and error; `horizontal` puts the control
107
127
  * opposite its copy (settings rows); `inline` puts a checkbox or switch
@@ -25,21 +25,21 @@ export function FormFooter({ children, divider = true, error, layout = "end", st
25
25
  * A form with Caps' field rhythm. While `busy`, its fieldset disables every
26
26
  * control and the form reports `aria-busy`; validation stays with the caller.
27
27
  */
28
- export const Form = forwardRef(function Form({ busy = false, children, className, ...props }, ref) {
29
- return (_jsx("form", { ...props, "aria-busy": busy || undefined, className: cn("caps-form", className), ref: ref, children: _jsx("fieldset", { className: "caps-form-body", disabled: busy, children: children }) }));
28
+ export const Form = forwardRef(function Form({ busy = false, children, className, layout, ...props }, ref) {
29
+ return (_jsx("form", { ...props, "aria-busy": busy || undefined, className: cn("caps-form", className), "data-layout": layout, ref: ref, children: _jsx("fieldset", { className: "caps-form-body", disabled: busy, children: children }) }));
30
30
  });
31
31
  const sectionHeadings = { 2: "h2", 3: "h3", 4: "h4" };
32
32
  /** A titled group of fields; consecutive sections share one quiet rule. */
33
- export const FormSection = forwardRef(function FormSection({ children, className, description, level = 3, title, ...props }, ref) {
33
+ export const FormSection = forwardRef(function FormSection({ children, className, description, layout, level = 3, title, ...props }, ref) {
34
34
  const id = useId();
35
35
  const Heading = sectionHeadings[level];
36
- return (_jsxs("section", { ...props, "aria-labelledby": `${id}-title`, className: cn("caps-form-section", className), ref: ref, children: [_jsxs("header", { className: "caps-form-section-copy", children: [_jsx(Heading, { className: "caps-form-section-title", id: `${id}-title`, children: title }), description ? (_jsx("p", { className: "caps-form-section-description", children: description })) : null] }), _jsx("div", { className: "caps-form-section-fields", children: children })] }));
36
+ return (_jsxs("section", { ...props, "aria-labelledby": `${id}-title`, className: cn("caps-form-section", className), "data-layout": layout, ref: ref, children: [_jsxs("header", { className: "caps-form-section-copy", children: [_jsx(Heading, { className: "caps-form-section-title", id: `${id}-title`, children: title }), description ? (_jsx("p", { className: "caps-form-section-description", children: description })) : null] }), _jsx("div", { className: "caps-form-section-fields", children: children })] }));
37
37
  });
38
38
  /**
39
39
  * A legend-named group of related choices, such as a list of inline
40
40
  * checkbox Fields. `disabled` disables every control inside.
41
41
  */
42
- export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, legend, ...props }, ref) {
42
+ export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, layout, legend, legendTitle, ...props }, ref) {
43
43
  const id = useId();
44
44
  const describedBy = [
45
45
  description ? `${id}-description` : props["aria-describedby"],
@@ -47,7 +47,7 @@ export const FieldSet = forwardRef(function FieldSet({ children, className, desc
47
47
  ]
48
48
  .filter(Boolean)
49
49
  .join(" ") || undefined;
50
- return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), ref: ref, children: [_jsx("legend", { className: "caps-fieldset-legend", children: legend }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
50
+ return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), "data-layout": layout, ref: ref, children: [_jsx("legend", { className: "caps-fieldset-legend", title: legendTitle, children: legend }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
51
51
  });
52
52
  /** The labelled control inside an InputGroup: its first non-addon element. */
53
53
  function groupControl(group) {
@@ -60,7 +60,7 @@ function groupControl(group) {
60
60
  return controls[0] ?? null;
61
61
  }
62
62
  /** A labelled form-field layout that renders caller-provided control and supporting content. */
63
- export const Field = forwardRef(function Field({ children, className, description, error, icon, label, optional, orientation = "vertical", readout = false, required, ...props }, ref) {
63
+ export const Field = forwardRef(function Field({ children, className, description, error, icon, label, labelTitle, optional, orientation = "vertical", readout = false, required, ...props }, ref) {
64
64
  const generatedId = useId();
65
65
  const child = isValidElement(children) ? children : null;
66
66
  // An InputGroup is layout: the labelled control is the input inside it.
@@ -97,7 +97,7 @@ export const Field = forwardRef(function Field({ children, className, descriptio
97
97
  : part))
98
98
  : cloneElement(child, clonedProps)
99
99
  : children;
100
- const labelContent = label ? (_jsx(Label, { htmlFor: readout ? undefined : controlId,
100
+ const labelContent = label ? (_jsx(Label, { htmlFor: readout ? undefined : controlId, title: labelTitle,
101
101
  // A choice's own label reads as body text; the group's legend carries weight.
102
102
  className: orientation === "inline" ? "font-normal" : undefined, ...(required === undefined ? {} : { required }), ...(optional === undefined ? {} : { optional }), children: label })) : null;
103
103
  const copy = label || description ? (_jsxs("div", { className: "caps-field-copy", children: [labelContent, description ? (_jsx("p", { className: "caps-field-description", id: descriptionId, children: description })) : null] })) : null;
@@ -1,4 +1,4 @@
1
- import { type ButtonProps } from "./button.js";
1
+ import { type KeycapButtonProps } from "./keycap.js";
2
2
  /** Where a composer's send action is: nothing to send, ready, or working. */
3
3
  export type SendActionState = "empty" | "ready" | "busy";
4
4
  /** Props accepted by SendAction. */
@@ -12,13 +12,14 @@ export type SendActionProps = {
12
12
  stopLabel?: string;
13
13
  /** Glyph size in pixels. */
14
14
  iconSize?: number;
15
- } & Omit<ButtonProps, "children" | "size" | "loading" | "aria-label">;
15
+ } & Omit<KeycapButtonProps, "label" | "icon" | "readout" | "shortcut" | "pressed" | "expanded" | "aria-label">;
16
16
  /**
17
- * A composer's send key. Empty, it rests disabled; given something to send it
18
- * wakes with a small pop; sending, its arrow launches away and a working
19
- * indicator (or, with `onStop`, a stop square) takes its place; when the work
20
- * ends the arrow drops back in. All timing comes from the theme's motion
21
- * tokens, and reduced motion keeps short fades only.
17
+ * A composer's one send key, on the Caps keycap. Empty, it rests disabled;
18
+ * given something to send it wakes with a small pop; sending, its arrow
19
+ * launches away and a working indicator (or, with `onStop`, a stop square)
20
+ * takes its place in the same key; when the work ends the arrow drops back
21
+ * in. It never grows a second button. All timing comes from the theme's
22
+ * motion tokens, and reduced motion keeps short fades only.
22
23
  */
23
24
  export declare const SendAction: import("react").ForwardRefExoticComponent<{
24
25
  state: SendActionState;
@@ -30,4 +31,4 @@ export declare const SendAction: import("react").ForwardRefExoticComponent<{
30
31
  stopLabel?: string;
31
32
  /** Glyph size in pixels. */
32
33
  iconSize?: number;
33
- } & Omit<ButtonProps, "aria-label" | "children" | "size" | "loading"> & import("react").RefAttributes<HTMLButtonElement>>;
34
+ } & Omit<KeycapButtonProps, "aria-label" | "label" | "icon" | "readout" | "expanded" | "pressed" | "shortcut"> & import("react").RefAttributes<HTMLButtonElement>>;
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ArrowUp, Square } from "lucide-react";
3
3
  import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, } from "react";
4
4
  import { canAnimate, motionEasing, readMotion, } from "../lib/motion.js";
5
- import { Button } from "./button.js";
5
+ import { KeycapButton } from "./keycap.js";
6
6
  import { Loader } from "./loader.js";
7
+ import { cn } from "../lib/cn.js";
7
8
  function lift(face, motion) {
8
9
  // The arrow launches upward and away: the prompt has gone.
9
10
  return face.animate(motion.reduced
@@ -57,13 +58,14 @@ function charge(button, motion) {
57
58
  });
58
59
  }
59
60
  /**
60
- * A composer's send key. Empty, it rests disabled; given something to send it
61
- * wakes with a small pop; sending, its arrow launches away and a working
62
- * indicator (or, with `onStop`, a stop square) takes its place; when the work
63
- * ends the arrow drops back in. All timing comes from the theme's motion
64
- * tokens, and reduced motion keeps short fades only.
61
+ * A composer's one send key, on the Caps keycap. Empty, it rests disabled;
62
+ * given something to send it wakes with a small pop; sending, its arrow
63
+ * launches away and a working indicator (or, with `onStop`, a stop square)
64
+ * takes its place in the same key; when the work ends the arrow drops back
65
+ * in. It never grows a second button. All timing comes from the theme's
66
+ * motion tokens, and reduced motion keeps short fades only.
65
67
  */
66
- export const SendAction = forwardRef(function SendAction({ state, onStop, stopLabel = "Stop", iconSize = 20, onClick, disabled, "aria-label": label, ...props }, ref) {
68
+ export const SendAction = forwardRef(function SendAction({ state, onStop, stopLabel = "Stop", iconSize = 20, onClick, disabled, className, tone = "primary", "aria-label": label, ...props }, ref) {
67
69
  const node = useRef(null);
68
70
  useImperativeHandle(ref, () => node.current);
69
71
  const previous = useRef(state);
@@ -92,5 +94,5 @@ export const SendAction = forwardRef(function SendAction({ state, onStop, stopLa
92
94
  }, [state]);
93
95
  const busy = state === "busy";
94
96
  const stoppable = busy && onStop !== undefined;
95
- return (_jsx(Button, { ...props, ref: node, "aria-busy": busy ? true : undefined, "aria-label": stoppable ? stopLabel : label, "data-send-state": state, disabled: disabled === true || state === "empty" || (busy && !stoppable), onClick: stoppable ? onStop : onClick, size: "icon", children: _jsxs("span", { className: "caps-send-glyph", "data-state": state, children: [_jsx("span", { "data-glyph": "arrow", children: _jsx(ArrowUp, { "aria-hidden": "true", size: iconSize }) }), _jsx("span", { "data-glyph": "busy", children: onStop === undefined ? (_jsx(Loader, { decorative: true, size: "sm" })) : (_jsx(Square, { "aria-hidden": "true", fill: "currentColor", size: Math.round(iconSize * 0.7) })) })] }) }));
97
+ return (_jsx(KeycapButton, { ...props, ref: node, className: cn("caps-send-action", className), tone: tone, "aria-busy": busy ? true : undefined, "aria-label": stoppable ? stopLabel : label, "data-send-state": state, disabled: disabled === true || state === "empty" || (busy && !stoppable), onClick: stoppable ? onStop : onClick, label: null, icon: _jsxs("span", { className: "caps-send-glyph", "data-state": state, children: [_jsx("span", { "data-glyph": "arrow", children: _jsx(ArrowUp, { "aria-hidden": "true", size: iconSize }) }), _jsx("span", { "data-glyph": "busy", children: onStop === undefined ? (_jsx(Loader, { decorative: true, size: "sm" })) : (_jsx(Square, { "aria-hidden": "true", fill: "currentColor", size: Math.round(iconSize * 0.7) })) })] }) }));
96
98
  });
@@ -3,6 +3,8 @@ import type { SchemaFormModel } from "./schema-form.js";
3
3
  /** Props accepted by FormFields. */
4
4
  export type FormFieldsProps<V extends FormValues> = {
5
5
  model: SchemaFormModel<V>;
6
+ /** Opt-in label/control grid for generated sections and groups. */
7
+ layout?: "columns" | undefined;
6
8
  /** Render only these fields, in this order, without section headings. */
7
9
  names?: readonly (keyof V & string)[];
8
10
  /** Disable every field, such as while an uncertain request is retried. */
@@ -12,4 +14,4 @@ export type FormFieldsProps<V extends FormValues> = {
12
14
  * Render a schema form's fields from its schema: every section in order, or
13
15
  * only `names` (for placing fields in Screen cells or custom layouts).
14
16
  */
15
- export declare function FormFields<V extends FormValues>({ model, names, disabled, }: FormFieldsProps<V>): (import("react/jsx-runtime").JSX.Element | null)[];
17
+ export declare function FormFields<V extends FormValues>({ model, names, disabled, layout, }: FormFieldsProps<V>): (import("react/jsx-runtime").JSX.Element | null)[];
@@ -159,7 +159,7 @@ const groupKinds = new Set([
159
159
  "icon-checkboxes",
160
160
  ]);
161
161
  /** Choice groups: a legend names the group; each option names its input. */
162
- function SpecGroup({ spec, field, locked, error, }) {
162
+ function SpecGroup({ spec, field, locked, error, layout, }) {
163
163
  const disabled = locked || spec.readOnly === true;
164
164
  const legend = spec.label;
165
165
  if (spec.kind === "checkboxes" || spec.kind === "icon-checkboxes") {
@@ -169,7 +169,7 @@ function SpecGroup({ spec, field, locked, error, }) {
169
169
  ? [...selected, value]
170
170
  : selected.filter((current) => current !== value));
171
171
  };
172
- return (_jsx(FieldSet, { legend: legend, description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
172
+ return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
173
173
  ? {}
174
174
  : { caption: option.caption }), icon: option.icon, disabled: option.disabled, checked: selected.includes(option.value), onBlur: field.handleBlur, onChange: (event) => {
175
175
  toggle(option.value, event.currentTarget.checked);
@@ -178,7 +178,7 @@ function SpecGroup({ spec, field, locked, error, }) {
178
178
  } }) }, option.value)))) }));
179
179
  }
180
180
  const cards = spec.options.some((option) => option.description !== undefined || option.meta !== undefined);
181
- return (_jsx(FieldSet, { legend: legend, description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
181
+ return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
182
182
  field.handleChange(next);
183
183
  field.handleBlur();
184
184
  },
@@ -206,45 +206,52 @@ function RenderedControl({ spec, field, locked, error, render, ...control }) {
206
206
  "aria-invalid": error !== undefined,
207
207
  });
208
208
  }
209
- function SchemaField({ form, spec, locked, }) {
209
+ function SchemaField({ form, spec, locked, layout, }) {
210
210
  const field = useField({ form, name: spec.name });
211
211
  const submitted = useSelector(form.store, (state) => state.submissionAttempts > 0);
212
212
  const error = field.state.meta.isBlurred || submitted
213
213
  ? fieldError(field.state.meta)
214
214
  : undefined;
215
215
  if (spec.render === undefined && groupKinds.has(spec.kind))
216
- return (_jsx(SpecGroup, { spec: spec, field: field, locked: locked, error: error }));
217
- const orientation = spec.kind === "checkbox"
218
- ? "inline"
219
- : (spec.layout ?? (spec.kind === "switch" ? "row" : "stacked")) === "row"
216
+ return (_jsx(SpecGroup, { spec: spec, field: field, locked: locked, error: error, layout: layout }));
217
+ // A columns form chooses one rhythm; per-field layout hints only govern
218
+ // the unchanged default presentation.
219
+ const orientation = layout === "columns"
220
+ ? spec.kind === "switch" || spec.kind === "checkbox"
220
221
  ? "horizontal"
221
- : "vertical";
222
- return (_jsx(Field, { label: spec.label, description: spec.help, icon: spec.icon, orientation: orientation, error: error, ...(spec.required === undefined ? {} : { required: spec.required }), ...(spec.optional === undefined ? {} : { optional: spec.optional }), children: spec.render === undefined ? (_jsx(SpecControl, { spec: spec, field: field, locked: locked })) : (_jsx(RenderedControl, { spec: spec, field: field, locked: locked, error: error, render: spec.render })) }));
222
+ : "vertical"
223
+ : spec.kind === "checkbox"
224
+ ? "inline"
225
+ : (spec.layout ?? (spec.kind === "switch" ? "row" : "stacked")) ===
226
+ "row"
227
+ ? "horizontal"
228
+ : "vertical";
229
+ return (_jsx(Field, { label: spec.label, ...(layout === "columns" ? { labelTitle: spec.label } : {}), description: spec.help, icon: spec.icon, orientation: orientation, error: error, ...(spec.required === undefined ? {} : { required: spec.required }), ...(spec.optional === undefined ? {} : { optional: spec.optional }), children: spec.render === undefined ? (_jsx(SpecControl, { spec: spec, field: field, locked: locked })) : (_jsx(RenderedControl, { spec: spec, field: field, locked: locked, error: error, render: spec.render })) }));
223
230
  }
224
231
  const readValues = Schema.decodeUnknownOption(Schema.Record(Schema.String, inputSchema));
225
- function Visible({ form, spec, locked, }) {
232
+ function Visible({ form, spec, locked, layout, }) {
226
233
  const when = spec.when;
227
234
  const shown = useSelector(form.store, (state) => when === undefined
228
235
  ? true
229
236
  : when(Option.getOrElse(readValues(state.values), () => ({}))));
230
237
  if (!shown)
231
238
  return null;
232
- return _jsx(SchemaField, { form: form, spec: spec, locked: locked });
239
+ return (_jsx(SchemaField, { form: form, spec: spec, locked: locked, layout: layout }));
233
240
  }
234
- function SectionFields({ form, section, locked, }) {
235
- const fields = section.fields.map((spec) => (_jsx(Visible, { form: form, spec: spec, locked: locked }, spec.name)));
236
- return section.title === undefined ? (fields) : (_jsx(FormSection, { title: section.title, description: section.description, children: fields }));
241
+ function SectionFields({ form, section, locked, layout, }) {
242
+ const fields = section.fields.map((spec) => (_jsx(Visible, { form: form, spec: spec, locked: locked, layout: layout }, spec.name)));
243
+ return section.title === undefined ? (fields) : (_jsx(FormSection, { title: section.title, description: section.description, layout: layout, children: fields }));
237
244
  }
238
245
  /**
239
246
  * Render a schema form's fields from its schema: every section in order, or
240
247
  * only `names` (for placing fields in Screen cells or custom layouts).
241
248
  */
242
- export function FormFields({ model, names, disabled = false, }) {
249
+ export function FormFields({ model, names, disabled = false, layout, }) {
243
250
  const form = model.form;
244
251
  if (names !== undefined)
245
252
  return names.map((name) => {
246
253
  const spec = model.description.fields.find((field) => field.name === name);
247
- return spec === undefined ? null : (_jsx(Visible, { form: form, spec: spec, locked: disabled }, name));
254
+ return spec === undefined ? null : (_jsx(Visible, { form: form, spec: spec, locked: disabled, layout: layout }, name));
248
255
  });
249
- return model.description.sections.map((section) => (_jsx(SectionFields, { form: form, section: section, locked: disabled }, section.id ?? "")));
256
+ return model.description.sections.map((section) => (_jsx(SectionFields, { form: form, section: section, locked: disabled, layout: layout }, section.id ?? "")));
250
257
  }
@@ -1,2 +1,8 @@
1
- /** Format USD with cents, preserving sub-cent precision without displaying non-zero amounts as zero. */
2
- export declare function formatMoney(amount: number): string;
1
+ /**
2
+ * Format USD to cents, marking non-zero sub-cent amounts rather than rounding
3
+ * them to zero. Callers needing the supported five-decimal precision opt in
4
+ * with `precision: "exact"`.
5
+ */
6
+ export declare function formatMoney(amount: number, options?: Readonly<{
7
+ precision: "exact";
8
+ }>): string;
@@ -4,19 +4,22 @@ const regularUsd = new Intl.NumberFormat("en-US", {
4
4
  minimumFractionDigits: 2,
5
5
  maximumFractionDigits: 2,
6
6
  });
7
- const subcentUsd = new Intl.NumberFormat("en-US", {
7
+ const exactUsd = new Intl.NumberFormat("en-US", {
8
8
  style: "currency",
9
9
  currency: "USD",
10
10
  minimumFractionDigits: 2,
11
11
  maximumFractionDigits: 5,
12
12
  });
13
- /** Format USD with cents, preserving sub-cent precision without displaying non-zero amounts as zero. */
14
- export function formatMoney(amount) {
13
+ /**
14
+ * Format USD to cents, marking non-zero sub-cent amounts rather than rounding
15
+ * them to zero. Callers needing the supported five-decimal precision opt in
16
+ * with `precision: "exact"`.
17
+ */
18
+ export function formatMoney(amount, options) {
15
19
  if (!Number.isFinite(amount))
16
20
  throw new RangeError("Money amount must be finite");
17
- if (amount !== 0 && Math.abs(amount) < 0.00001)
21
+ const subcent = amount !== 0 && Math.abs(amount) < 0.01;
22
+ if (subcent && (options?.precision !== "exact" || Math.abs(amount) < 0.00001))
18
23
  return amount < 0 ? ">-$0.01" : "<$0.01";
19
- return amount !== 0 && Math.abs(amount) < 0.01
20
- ? subcentUsd.format(amount)
21
- : regularUsd.format(amount);
24
+ return subcent ? exactUsd.format(amount) : regularUsd.format(amount);
22
25
  }