@fungi.computer/caps 0.3.0 → 0.4.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,4 +1,4 @@
1
- import { type HTMLAttributes } from "react";
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
2
  /** Caller-provided command menu item data. */
3
3
  export type CommandMenuItem = {
4
4
  description?: string;
@@ -7,6 +7,8 @@ export type CommandMenuItem = {
7
7
  label: string;
8
8
  /** Key hint shown before the label, e.g. `z` in a which-key list. */
9
9
  shortcut?: string;
10
+ /** Decorative leading visual, such as a thumbnail, shown before the label. */
11
+ media?: ReactNode;
10
12
  };
11
13
  /** Props accepted by CommandMenu. */
12
14
  export type CommandMenuProps = Omit<HTMLAttributes<HTMLUListElement>, "children" | "onSelect"> & {
@@ -15,6 +17,12 @@ export type CommandMenuProps = Omit<HTMLAttributes<HTMLUListElement>, "children"
15
17
  onSelect: (id: string) => void;
16
18
  /** Caller-owned selected state; distinct from local keyboard focus. */
17
19
  selected?: string;
20
+ /**
21
+ * `end` (default) sets each description at the row's end. `inline` keeps the
22
+ * label at its own width and starts the description right after it,
23
+ * truncated, so long descriptions never push the row's text apart.
24
+ */
25
+ descriptionPlacement?: "end" | "inline";
18
26
  };
19
27
  /** A single-focus listbox with local navigation and caller-owned selection. */
20
28
  export declare const CommandMenu: import("react").ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLUListElement>, "children" | "onSelect"> & {
@@ -23,4 +31,10 @@ export declare const CommandMenu: import("react").ForwardRefExoticComponent<Omit
23
31
  onSelect: (id: string) => void;
24
32
  /** Caller-owned selected state; distinct from local keyboard focus. */
25
33
  selected?: string;
34
+ /**
35
+ * `end` (default) sets each description at the row's end. `inline` keeps the
36
+ * label at its own width and starts the description right after it,
37
+ * truncated, so long descriptions never push the row's text apart.
38
+ */
39
+ descriptionPlacement?: "end" | "inline";
26
40
  } & import("react").RefAttributes<HTMLUListElement>>;
@@ -3,7 +3,7 @@ import { forwardRef, useEffect, useId, useCallback, useImperativeHandle, useRef,
3
3
  import { useGlide } from "../lib/glide.js";
4
4
  import { useSelectableCollection } from "../components/selectable.js";
5
5
  /** A single-focus listbox with local navigation and caller-owned selection. */
6
- export const CommandMenu = forwardRef(function CommandMenu({ className, items, onKeyDown, onSelect, selected, ...props }, ref) {
6
+ export const CommandMenu = forwardRef(function CommandMenu({ className, descriptionPlacement = "end", items, onKeyDown, onSelect, selected, ...props }, ref) {
7
7
  const instanceId = useId();
8
8
  const list = useRef(null);
9
9
  useImperativeHandle(ref, () => list.current);
@@ -66,5 +66,11 @@ export const CommandMenu = forwardRef(function CommandMenu({ className, items, o
66
66
  ].join(" "), "aria-keyshortcuts": item.shortcut, "data-search-option": item.id, id: optionId(item.id), onClick: () => {
67
67
  if (!item.disabled)
68
68
  onSelect(item.id);
69
- }, role: "option", children: [item.shortcut ? (_jsx("kbd", { className: "kbd kbd-sm min-w-6 shrink-0", children: item.shortcut })) : null, _jsx("span", { className: "flex-1 truncate", children: item.label }), item.description ? (_jsx("span", { className: "text-xs text-base-content", children: item.description })) : null] }) }, item.id))) }));
69
+ }, role: "option", children: [item.shortcut ? (_jsx("kbd", { className: "kbd kbd-sm min-w-6 shrink-0", children: item.shortcut })) : null, item.media ? (_jsx("span", { "aria-hidden": "true", className: "flex shrink-0", children: item.media })) : null, _jsx("span", { className: descriptionPlacement === "inline"
70
+ ? "max-w-[50%] shrink-0 truncate"
71
+ : "flex-1 truncate", children: item.label }), item.description ? (_jsx("span", { className: descriptionPlacement === "inline"
72
+ ? "min-w-0 flex-1 truncate text-xs text-base-content"
73
+ : "text-xs text-base-content", title: descriptionPlacement === "inline"
74
+ ? item.description
75
+ : undefined, children: item.description })) : null] }) }, item.id))) }));
70
76
  });
@@ -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 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
  });
package/dist/styles.css CHANGED
@@ -5947,6 +5947,9 @@
5947
5947
  .max-w-\[48rem\] {
5948
5948
  max-width: 48rem;
5949
5949
  }
5950
+ .max-w-\[50\%\] {
5951
+ max-width: 50%;
5952
+ }
5950
5953
  .max-w-\[60rem\] {
5951
5954
  max-width: 60rem;
5952
5955
  }
@@ -6163,9 +6166,6 @@
6163
6166
  margin-block-end: calc(var(--spacing-sm) * calc(1 - var(--tw-space-y-reverse)));
6164
6167
  }
6165
6168
  }
6166
- .self-stretch {
6167
- align-self: stretch;
6168
- }
6169
6169
  .truncate {
6170
6170
  overflow: hidden;
6171
6171
  text-overflow: ellipsis;
@@ -6177,9 +6177,6 @@
6177
6177
  .overflow-hidden {
6178
6178
  overflow: hidden;
6179
6179
  }
6180
- .overflow-visible {
6181
- overflow: visible;
6182
- }
6183
6180
  .overflow-y-auto {
6184
6181
  overflow-y: auto;
6185
6182
  }
@@ -6222,10 +6219,6 @@
6222
6219
  border-bottom-style: var(--tw-border-style);
6223
6220
  border-bottom-width: 1px;
6224
6221
  }
6225
- .border-none {
6226
- --tw-border-style: none;
6227
- border-style: none;
6228
- }
6229
6222
  .badge-outline {
6230
6223
  @layer daisyui.l1.l2 {
6231
6224
  color: var(--badge-color);
@@ -6367,9 +6360,6 @@
6367
6360
  .p-2xl {
6368
6361
  padding: var(--spacing-2xl);
6369
6362
  }
6370
- .p-2xs {
6371
- padding: var(--spacing-2xs);
6372
- }
6373
6363
  .p-4 {
6374
6364
  padding: calc(var(--spacing) * 4);
6375
6365
  }
@@ -8737,6 +8727,20 @@
8737
8727
  white-space: nowrap;
8738
8728
  }
8739
8729
  }
8730
+ .caps-app-status-bar .badge {
8731
+ height: auto;
8732
+ min-width: 0;
8733
+ padding: 0;
8734
+ border: 0;
8735
+ background: none;
8736
+ color: var(--stipe-fg-muted);
8737
+ font-size: 0.625rem;
8738
+ font-weight: 600;
8739
+ line-height: 1;
8740
+ }
8741
+ .caps-app-status-bar .badge-warning {
8742
+ color: var(--color-warning);
8743
+ }
8740
8744
  .caps-status-bar-picker-list {
8741
8745
  min-width: min(18rem, calc(100vw - 1.5rem));
8742
8746
  }
@@ -10987,17 +10991,61 @@
10987
10991
  color: var(--color-base-content);
10988
10992
  font-size: 0.875rem;
10989
10993
  }
10990
- .caps-composer > .join {
10991
- border: var(--border) solid var(--color-base-content);
10992
- @supports (color: color-mix(in lab, red, red)) {
10993
- border: var(--border) solid color-mix(in oklab, var(--color-base-content) 12%, var(--color-base-300));
10994
- }
10995
- border-radius: var(--radius-box);
10996
- background: var(--color-base-200);
10997
- box-shadow: var( --stipe-shadow-inset, inset 0 calc(var(--depth) * 1px) calc(var(--depth) * 2px) var(--color-base-content), 0 calc(var(--depth) * 1px) 0 var(--color-base-100) );
10998
- @supports (color: color-mix(in lab, red, red)) {
10999
- box-shadow: var( --stipe-shadow-inset, inset 0 calc(var(--depth) * 1px) calc(var(--depth) * 2px) color-mix( in oklab, var(--color-base-content) calc(var(--depth) * 12%), transparent ), 0 calc(var(--depth) * 1px) 0 color-mix( in oklab, var(--color-base-100) calc(var(--depth) * 60%), transparent ) );
11000
- }
10994
+ .caps-composer {
10995
+ display: flex;
10996
+ min-width: 0;
10997
+ flex-direction: column;
10998
+ gap: var(--spacing-xs);
10999
+ }
11000
+ .caps-composer-attachments {
11001
+ display: flex;
11002
+ flex-wrap: wrap;
11003
+ gap: var(--spacing-xs);
11004
+ }
11005
+ .caps-composer-input {
11006
+ position: relative;
11007
+ min-width: 0;
11008
+ }
11009
+ .caps-composer-input :is(textarea, input), .caps-composer-input :is(textarea, input):focus, .caps-composer-input :is(textarea, input):focus-visible {
11010
+ width: 100%;
11011
+ border: 0;
11012
+ border-radius: 0;
11013
+ background: transparent;
11014
+ box-shadow: none;
11015
+ outline: none;
11016
+ padding-inline: 0;
11017
+ }
11018
+ .caps-composer-bar {
11019
+ display: flex;
11020
+ min-width: 0;
11021
+ align-items: center;
11022
+ gap: var(--spacing-xs);
11023
+ }
11024
+ .caps-composer-toolbar {
11025
+ display: flex;
11026
+ min-width: 0;
11027
+ flex: 1;
11028
+ align-items: center;
11029
+ gap: var(--spacing-xs);
11030
+ }
11031
+ .caps-composer-toolbar > .caps-status-tray {
11032
+ flex: 1;
11033
+ min-width: 0;
11034
+ --caps-status-tray-row-wrap: wrap;
11035
+ --caps-status-tray-chips-display: contents;
11036
+ --caps-status-tray-key-height: calc(var(--size-field) * 8);
11037
+ --caps-status-tray-chip-height: var(--caps-status-tray-key-height);
11038
+ --caps-status-tray-width: 28rem;
11039
+ --caps-keycap-width: auto;
11040
+ --caps-keycap-height: var(--caps-status-tray-key-height);
11041
+ }
11042
+ .caps-composer-toolbar > .caps-status-tray .caps-status-tray-row :is(.caps-keycap-button, .caps-status-tray-chip, .caps-select) {
11043
+ height: var(--caps-status-tray-key-height);
11044
+ min-height: var(--caps-status-tray-key-height);
11045
+ }
11046
+ .caps-composer-toolbar > .caps-status-tray .caps-status-tray-label {
11047
+ display: flex;
11048
+ align-items: center;
11001
11049
  }
11002
11050
  .caps-alert[data-tone="error"] h3 {
11003
11051
  color: var(--color-error);
@@ -12640,6 +12688,13 @@
12640
12688
  padding: var(--spacing-md);
12641
12689
  text-align: center;
12642
12690
  }
12691
+ .caps-send-action {
12692
+ --caps-keycap-width: auto;
12693
+ --caps-keycap-justify: center;
12694
+ --caps-keycap-padding: 0;
12695
+ --caps-keycap-min-width: var(--caps-keycap-height, 2.5rem);
12696
+ flex: none;
12697
+ }
12643
12698
  .caps-send-glyph {
12644
12699
  display: inline-grid;
12645
12700
  place-items: center;
@@ -13584,15 +13639,15 @@
13584
13639
  isolation: var(--caps-button-outline-isolation, auto);
13585
13640
  }
13586
13641
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]) {
13587
- background-color: var(--caps-button-outline-face, revert-layer);
13588
- color: var(--caps-button-outline-ink, revert-layer);
13642
+ background-color: var(--caps-key-face);
13643
+ color: var(--caps-button-outline-ink, var(--color-base-content));
13589
13644
  }
13590
13645
  .caps-button.btn-outline:is(:disabled, [aria-disabled="true"]) {
13591
13646
  background-color: var( --caps-button-outline-disabled-face, var(--color-base-200) );
13592
13647
  }
13593
13648
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]):hover {
13594
- background-color: var( --caps-button-outline-hover-face, var(--caps-button-outline-face, revert-layer) );
13595
- color: var( --caps-button-outline-hover-ink, var(--caps-button-outline-ink, revert-layer) );
13649
+ background-color: var( --caps-button-outline-hover-face, var(--caps-keycap-hover-face, var(--caps-key-face)) );
13650
+ color: var( --caps-button-outline-hover-ink, var(--caps-button-outline-ink, var(--color-base-content)) );
13596
13651
  }
13597
13652
  .caps-button.btn-outline::before {
13598
13653
  content: var(--caps-button-outline-inset-content, none);
@@ -13605,21 +13660,36 @@
13605
13660
  .caps-button.btn-outline:hover::before {
13606
13661
  background: var(--color-base-content);
13607
13662
  }
13608
- .caps-button.btn-outline {
13609
- --btn-border: var(--color-base-content);
13663
+ .caps-keycap-button, .caps-button-filled, .caps-button.btn-outline {
13664
+ --caps-key-lip: calc(var(--depth) * 1px);
13665
+ --caps-key-rest-shadow: inset 0 1px 0
13666
+ oklch(100% 0 0 / calc(var(--depth) * var(--caps-key-top-edge, 0.14))),
13667
+ 0 var(--caps-key-lip) 0
13668
+ var(--caps-key-face);
13610
13669
  @supports (color: color-mix(in lab, red, red)) {
13611
- --btn-border: color-mix(
13612
- in oklab,
13613
- var(--color-base-content) 24%,
13614
- transparent
13615
- );
13670
+ --caps-key-rest-shadow: inset 0 1px 0
13671
+ oklch(100% 0 0 / calc(var(--depth) * var(--caps-key-top-edge, 0.14))),
13672
+ 0 var(--caps-key-lip) 0
13673
+ color-mix(
13674
+ in oklab,
13675
+ var(--caps-key-face),
13676
+ var(--caps-key-lip-ink, var(--color-base-content)) 14%
13677
+ );
13616
13678
  }
13679
+ --caps-key-pressed-shadow: inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)), 0 0 0 transparent;
13680
+ }
13681
+ .caps-button.btn-outline {
13682
+ --btn-border: var(--stipe-linework);
13683
+ --caps-key-face: var(--caps-button-outline-face, var(--color-base-200));
13617
13684
  font-weight: var(--caps-button-weight, 500);
13618
- box-shadow: var(--caps-button-outline-drop, var(--caps-button-drop, none));
13685
+ box-shadow: var( --caps-button-outline-drop, var(--caps-button-drop, var(--caps-key-rest-shadow)) );
13686
+ transition-property: background-color, border-color, box-shadow, translate;
13687
+ transition-duration: var(--stipe-motion-fast);
13688
+ transition-timing-function: ease-out;
13619
13689
  }
13620
13690
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]):active {
13621
- translate: 0 var(--caps-button-press, var(--stipe-press-depth));
13622
- box-shadow: var( --caps-button-outline-drop, var(--caps-button-drop-active, none) );
13691
+ translate: 0 var(--caps-button-press, var(--caps-key-lip));
13692
+ box-shadow: var( --caps-button-outline-drop, var(--caps-button-drop-active, var(--caps-key-pressed-shadow)) );
13623
13693
  }
13624
13694
  .caps-button.btn-ghost {
13625
13695
  font-weight: 500;
@@ -13629,6 +13699,9 @@
13629
13699
  }
13630
13700
  .caps-button-filled {
13631
13701
  --btn-bg: var(--btn-color);
13702
+ --caps-key-face: var(--btn-color);
13703
+ --caps-key-lip-ink: black;
13704
+ --caps-key-top-edge: 0.06;
13632
13705
  --caps-button-filled-edge: var(
13633
13706
  --caps-button-border-color,
13634
13707
  var(--btn-bg)
@@ -13638,7 +13711,7 @@
13638
13711
  --caps-button-border-color,
13639
13712
  color-mix(
13640
13713
  in oklab,
13641
- color-mix(in oklab, var(--btn-bg), black calc(var(--depth) * 8%)),
13714
+ color-mix(in oklab, var(--btn-bg), black calc(var(--depth) * 6%)),
13642
13715
  var(--stipe-linework)
13643
13716
  calc((1 - var(--depth)) * var(--caps-button-linework, 100%))
13644
13717
  )
@@ -13646,7 +13719,7 @@
13646
13719
  }
13647
13720
  --caps-button-filled-drop: var(
13648
13721
  --caps-button-drop,
13649
- inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18))
13722
+ var(--caps-key-rest-shadow)
13650
13723
  );
13651
13724
  background-color: var(--btn-bg);
13652
13725
  color: var(--btn-fg);
@@ -13689,9 +13762,9 @@
13689
13762
  @supports (color: color-mix(in lab, red, red)) {
13690
13763
  --btn-bg: color-mix(in oklab, var(--btn-color) 92%, black);
13691
13764
  }
13692
- translate: 0 var(--caps-button-press, var(--stipe-press-depth));
13765
+ translate: 0 var(--caps-button-press, var(--caps-key-lip));
13693
13766
  scale: var(--stipe-press-scale);
13694
- box-shadow: var( --caps-button-drop-active, inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)) );
13767
+ box-shadow: var(--caps-button-drop-active, var(--caps-key-pressed-shadow));
13695
13768
  transition-duration: var(--stipe-motion-squish);
13696
13769
  }
13697
13770
  .caps-button.caps-button-menubar {
@@ -14672,7 +14745,7 @@
14672
14745
  scale: none;
14673
14746
  }
14674
14747
  .caps-button-filled:not(:disabled):not([aria-disabled="true"]):active {
14675
- translate: var(--caps-button-press-reduced, none);
14748
+ translate: var(--caps-button-press-reduced, 0 var(--caps-key-lip));
14676
14749
  }
14677
14750
  .caps-gooey-motion, .caps-gooey-motion::before, .caps-switch, .caps-radio, .caps-checkbox, .caps-checkbox-track, .caps-input, .caps-status-bead {
14678
14751
  animation: none !important;
@@ -15857,7 +15930,7 @@
15857
15930
  }
15858
15931
  }
15859
15932
  @media (prefers-reduced-motion: reduce) {
15860
- .caps-keycap, .caps-keycap-button {
15933
+ .caps-keycap, .caps-keycap-button, .caps-button.btn-outline {
15861
15934
  transition: none;
15862
15935
  }
15863
15936
  }
@@ -15928,7 +16001,21 @@
15928
16001
  height: 0.625rem;
15929
16002
  }
15930
16003
  .caps-keycap-button {
15931
- --caps-keycap-lip: calc(var(--depth) * 1px);
16004
+ --caps-key-face: var(--caps-keycap-face, var(--color-base-200));
16005
+ --caps-keycap-rest-shadow: inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18)),
16006
+ 0 var(--caps-key-lip) 0
16007
+ var(--caps-key-face),
16008
+ 0 var(--caps-key-lip) calc(var(--depth) * 2px)
16009
+ oklch(0% 0 0 / calc(var(--depth) * 0.15));
16010
+ @supports (color: color-mix(in lab, red, red)) {
16011
+ --caps-keycap-rest-shadow: inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18)),
16012
+ 0 var(--caps-key-lip) 0
16013
+ color-mix(in oklab, var(--caps-key-face), var(--color-base-content) 14%),
16014
+ 0 var(--caps-key-lip) calc(var(--depth) * 2px)
16015
+ oklch(0% 0 0 / calc(var(--depth) * 0.15));
16016
+ }
16017
+ --caps-keycap-pressed-shadow: inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)),
16018
+ 0 0 0 transparent, 0 0 0 transparent;
15932
16019
  display: flex;
15933
16020
  width: var(--caps-keycap-width, 100%);
15934
16021
  min-width: var(--caps-keycap-min-width, 0);
@@ -15937,7 +16024,10 @@
15937
16024
  justify-content: var(--caps-keycap-justify, space-between);
15938
16025
  gap: var(--spacing-sm);
15939
16026
  padding: var(--caps-keycap-padding, var(--spacing-xs) var(--spacing-sm));
15940
- border: var( --caps-keycap-context-border, var(--border) solid var(--caps-keycap-edge, var(--stipe-linework)) );
16027
+ border: var( --caps-keycap-context-border, var(--border) solid var( --caps-keycap-edge, var(--stipe-linework) ) );
16028
+ @supports (color: color-mix(in lab, red, red)) {
16029
+ border: var( --caps-keycap-context-border, var(--border) solid var( --caps-keycap-edge, color-mix(in oklab, var(--stipe-linework) 80%, var(--caps-key-face)) ) );
16030
+ }
15941
16031
  font-size: var(--caps-keycap-font-size, inherit);
15942
16032
  font-weight: var( --caps-keycap-context-weight, var(--caps-keycap-font-weight, inherit) );
15943
16033
  border-radius: var(--radius-field);
@@ -15946,12 +16036,28 @@
15946
16036
  var(--caps-keycap-face, var(--color-base-200))
15947
16037
  );
15948
16038
  background-color: var(--caps-keycap-painted-face);
15949
- --caps-keycap-default-image: none;
15950
- background-image: var( --caps-keycap-context-image, var(--caps-keycap-default-image) );
15951
- box-shadow: var( --caps-keycap-context-shadow, inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.14)), 0 var(--caps-keycap-lip) 0 var(--caps-keycap-face, var(--color-base-200)) );
16039
+ --caps-keycap-default-image: linear-gradient(
16040
+ to bottom,
16041
+ var(--caps-keycap-painted-face),
16042
+ var(--caps-keycap-painted-face)
16043
+ );
15952
16044
  @supports (color: color-mix(in lab, red, red)) {
15953
- box-shadow: var( --caps-keycap-context-shadow, inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.14)), 0 var(--caps-keycap-lip) 0 color-mix( in oklab, var(--caps-keycap-face, var(--color-base-200)), var(--color-base-content) 14% ) );
16045
+ --caps-keycap-default-image: linear-gradient(
16046
+ to bottom,
16047
+ color-mix(
16048
+ in oklab,
16049
+ var(--caps-keycap-painted-face),
16050
+ white calc(var(--depth) * 3%)
16051
+ ),
16052
+ color-mix(
16053
+ in oklab,
16054
+ var(--caps-keycap-painted-face),
16055
+ black calc(var(--depth) * 3%)
16056
+ )
16057
+ );
15954
16058
  }
16059
+ background-image: var( --caps-keycap-context-image, var(--caps-keycap-default-image) );
16060
+ box-shadow: var( --caps-keycap-context-shadow, var(--caps-keycap-rest-shadow) );
15955
16061
  color: var(--caps-keycap-ink, var(--color-base-content));
15956
16062
  text-align: left;
15957
16063
  cursor: pointer;
@@ -15960,8 +16066,8 @@
15960
16066
  transition-timing-function: ease-out;
15961
16067
  }
15962
16068
  .caps-keycap-button:active {
15963
- translate: 0 var(--caps-keycap-context-travel, var(--caps-keycap-lip));
15964
- box-shadow: var( --caps-keycap-active-shadow, inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)), 0 0 0 transparent );
16069
+ translate: 0 var(--caps-keycap-context-travel, var(--caps-key-lip));
16070
+ box-shadow: var( --caps-keycap-active-shadow, var(--caps-keycap-pressed-shadow) );
15965
16071
  }
15966
16072
  .caps-keycap-button:focus-visible {
15967
16073
  outline: 2px solid var(--caps-keycap-focus-ink, var(--color-base-content));
@@ -16008,6 +16114,28 @@
16008
16114
  --caps-keycap-ink: var(--color-neutral-content);
16009
16115
  --caps-keycap-edge: var(--color-neutral);
16010
16116
  }
16117
+ .caps-keycap-button.caps-send-action[data-tone="primary"] {
16118
+ --caps-keycap-face: var(--color-primary);
16119
+ --caps-keycap-ink: var(--color-primary-content);
16120
+ --caps-keycap-edge: var(--color-primary);
16121
+ @supports (color: color-mix(in lab, red, red)) {
16122
+ --caps-keycap-edge: color-mix(in oklab, var(--color-primary) 82%, black);
16123
+ }
16124
+ --caps-keycap-hover-face: var(--color-primary);
16125
+ @supports (color: color-mix(in lab, red, red)) {
16126
+ --caps-keycap-hover-face: color-mix(
16127
+ in oklab,
16128
+ var(--color-primary) 92%,
16129
+ black
16130
+ );
16131
+ }
16132
+ }
16133
+ .caps-keycap-button.caps-send-action[data-send-state="empty"] {
16134
+ --caps-keycap-face: var(--color-base-200);
16135
+ --caps-keycap-ink: var(--color-base-content);
16136
+ --caps-keycap-edge: var(--stipe-linework);
16137
+ --caps-keycap-hover-face: var(--color-base-200);
16138
+ }
16011
16139
  .caps-keycap-button[data-tone="destructive"]:is(:hover, :focus-visible) {
16012
16140
  --caps-keycap-ink: var(--color-error);
16013
16141
  --caps-keycap-edge: var(--color-error);
@@ -52,6 +52,14 @@ optional shortcut?: string;
52
52
 
53
53
  Key hint shown before the label, e.g. `z` in a which-key list.
54
54
 
55
+ ##### media?
56
+
57
+ ```ts
58
+ optional media?: ReactNode;
59
+ ```
60
+
61
+ Decorative leading visual, such as a thumbnail, shown before the label.
62
+
55
63
  ---
56
64
 
57
65
  ### CommandMenuProps
@@ -68,11 +76,12 @@ Props accepted by CommandMenu.
68
76
 
69
77
  #### Type Declaration
70
78
 
71
- | Name | Type | Description |
72
- | ------------ | ------------------------------------------------ | ---------------------------------------------------------------- |
73
- | `items` | readonly [`CommandMenuItem`](#commandmenuitem)[] | - |
74
- | `onSelect()` | (`id`) => `void` | Called after a non-disabled item is selected. |
75
- | `selected?` | `string` | Caller-owned selected state; distinct from local keyboard focus. |
79
+ | Name | Type | Description |
80
+ | ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `items` | readonly [`CommandMenuItem`](#commandmenuitem)[] | - |
82
+ | `onSelect()` | (`id`) => `void` | Called after a non-disabled item is selected. |
83
+ | `selected?` | `string` | Caller-owned selected state; distinct from local keyboard focus. |
84
+ | `descriptionPlacement?` | `"end"` \| `"inline"` | `end` (default) sets each description at the row's end. `inline` keeps the label at its own width and starts the description right after it, truncated, so long descriptions never push the row's text apart. |
76
85
 
77
86
  ## Variables
78
87
 
@@ -20,12 +20,12 @@ Props accepted by Composer.
20
20
 
21
21
  #### Type Declaration
22
22
 
23
- | Name | Type | Description |
24
- | --------------- | ----------- | --------------------------------------------------------------------------------------------- |
25
- | `input` | `ReactNode` | The caller-owned primary input control. |
26
- | `action?` | `ReactNode` | The caller-owned action control. Its disabled state and accessible name stay on that control. |
27
- | `leading?` | `ReactNode` | Optional caller-owned leading slot, such as an avatar. |
28
- | `rowClassName?` | `string` | Classes for the joined row; useful when the host exposes a busy state. |
23
+ | Name | Type | Description |
24
+ | -------------- | ----------- | ---------------------------------------------------------------------------- |
25
+ | `input` | `ReactNode` | The caller-owned field, typed into directly: the surface is the field. |
26
+ | `action?` | `ReactNode` | The caller-owned key at the end of the bottom row, such as a SendAction. |
27
+ | `toolbar?` | `ReactNode` | Caller-owned controls and readouts along the bottom edge, before the action. |
28
+ | `attachments?` | `ReactNode` | Caller-owned items riding with the draft, shown above the text. |
29
29
 
30
30
  ## Variables
31
31
 
@@ -37,4 +37,7 @@ const Composer: ForwardRefExoticComponent<
37
37
  >;
38
38
  ```
39
39
 
40
- Layout and slots only. It never assigns action-control semantics.
40
+ One composer surface: attachments, then the text, then a slim row along the
41
+ bottom edge holding the toolbar and, at its end, the action. It draws no box of
42
+ its own: the host region is the field. Layout and slots only; it never assigns
43
+ action-control semantics.
@@ -24,7 +24,16 @@ Where a composer's send action is: nothing to send, ready, or working.
24
24
 
25
25
  ```ts
26
26
  type SendActionProps = object &
27
- Omit<ButtonProps, "children" | "size" | "loading" | "aria-label">;
27
+ Omit<
28
+ KeycapButtonProps,
29
+ | "label"
30
+ | "icon"
31
+ | "readout"
32
+ | "shortcut"
33
+ | "pressed"
34
+ | "expanded"
35
+ | "aria-label"
36
+ >;
28
37
  ```
29
38
 
30
39
  Props accepted by SendAction.
@@ -46,13 +55,23 @@ Props accepted by SendAction.
46
55
  ```ts
47
56
  const SendAction: ForwardRefExoticComponent<
48
57
  object &
49
- Omit<ButtonProps, "aria-label" | "children" | "size" | "loading"> &
58
+ Omit<
59
+ KeycapButtonProps,
60
+ | "aria-label"
61
+ | "label"
62
+ | "icon"
63
+ | "readout"
64
+ | "expanded"
65
+ | "pressed"
66
+ | "shortcut"
67
+ > &
50
68
  RefAttributes<HTMLButtonElement>
51
69
  >;
52
70
  ```
53
71
 
54
- A composer's send key. Empty, it rests disabled; given something to send it
55
- wakes with a small pop; sending, its arrow launches away and a working indicator
56
- (or, with `onStop`, a stop square) takes its place; when the work ends the arrow
57
- drops back in. All timing comes from the theme's motion tokens, and reduced
72
+ A composer's one send key, on the Caps keycap. Empty, it rests disabled; given
73
+ something to send it wakes with a small pop; sending, its arrow launches away
74
+ and a working indicator (or, with `onStop`, a stop square) takes its place in
75
+ the same key; when the work ends the arrow drops back in. It never grows a
76
+ second button. All timing comes from the theme's motion tokens, and reduced
58
77
  motion keeps short fades only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/caps",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Product-neutral React primitives and blocks for Fungi interfaces",
6
6
  "type": "module",
@@ -134,6 +134,27 @@
134
134
  }
135
135
  }
136
136
 
137
+ /*
138
+ * A count in a bar key is a quiet readout beside its label: plain muted
139
+ * numerals below the label's cap height, never a filled pill as tall as
140
+ * the key. Every key's CountBadge draws this way.
141
+ */
142
+ .caps-app-status-bar .badge {
143
+ height: auto;
144
+ min-width: 0;
145
+ padding: 0;
146
+ border: 0;
147
+ background: none;
148
+ color: var(--stipe-fg-muted);
149
+ font-size: 0.625rem;
150
+ font-weight: 600;
151
+ line-height: 1;
152
+ }
153
+
154
+ .caps-app-status-bar .badge-warning {
155
+ color: var(--color-warning);
156
+ }
157
+
137
158
  .caps-status-bar-picker-list {
138
159
  min-width: min(18rem, calc(100vw - 1.5rem));
139
160
  }
package/src/styles.css CHANGED
@@ -2422,26 +2422,73 @@
2422
2422
  color: var(--color-base-content);
2423
2423
  font-size: 0.875rem;
2424
2424
  }
2425
- .caps-composer > .join {
2426
- border: var(--border) solid
2427
- color-mix(in oklab, var(--color-base-content) 12%, var(--color-base-300));
2428
- border-radius: var(--radius-box);
2429
- background: var(--color-base-200);
2430
- box-shadow: var(
2431
- --stipe-shadow-inset,
2432
- inset 0 calc(var(--depth) * 1px) calc(var(--depth) * 2px)
2433
- color-mix(
2434
- in oklab,
2435
- var(--color-base-content) calc(var(--depth) * 12%),
2436
- transparent
2437
- ),
2438
- 0 calc(var(--depth) * 1px) 0
2439
- color-mix(
2440
- in oklab,
2441
- var(--color-base-100) calc(var(--depth) * 60%),
2442
- transparent
2443
- )
2444
- );
2425
+ /* The composer is a surface, not a box inside a panel: the host region is
2426
+ the field, and the text sits directly on the host's face. A slim row along
2427
+ the bottom edge carries the toolbar and, at its end, the action key. */
2428
+ .caps-composer {
2429
+ display: flex;
2430
+ min-width: 0;
2431
+ flex-direction: column;
2432
+ gap: var(--spacing-xs);
2433
+ }
2434
+ .caps-composer-attachments {
2435
+ display: flex;
2436
+ flex-wrap: wrap;
2437
+ gap: var(--spacing-xs);
2438
+ }
2439
+ .caps-composer-input {
2440
+ position: relative;
2441
+ min-width: 0;
2442
+ }
2443
+ .caps-composer-input :is(textarea, input),
2444
+ .caps-composer-input :is(textarea, input):focus,
2445
+ .caps-composer-input :is(textarea, input):focus-visible {
2446
+ width: 100%;
2447
+ border: 0;
2448
+ border-radius: 0;
2449
+ background: transparent;
2450
+ box-shadow: none;
2451
+ outline: none;
2452
+ padding-inline: 0;
2453
+ }
2454
+ .caps-composer-bar {
2455
+ display: flex;
2456
+ min-width: 0;
2457
+ align-items: center;
2458
+ gap: var(--spacing-xs);
2459
+ }
2460
+ .caps-composer-toolbar {
2461
+ display: flex;
2462
+ min-width: 0;
2463
+ flex: 1;
2464
+ align-items: center;
2465
+ gap: var(--spacing-xs);
2466
+ }
2467
+ /* A StatusTray seated as the composer's toolbar: one wrapping row of keys,
2468
+ selects and chips on the theme's field-size grid, the same in every App. */
2469
+ .caps-composer-toolbar > .caps-status-tray {
2470
+ flex: 1;
2471
+ min-width: 0;
2472
+ --caps-status-tray-row-wrap: wrap;
2473
+ --caps-status-tray-chips-display: contents;
2474
+ --caps-status-tray-key-height: calc(var(--size-field) * 8);
2475
+ --caps-status-tray-chip-height: var(--caps-status-tray-key-height);
2476
+ --caps-status-tray-width: 28rem;
2477
+ --caps-keycap-width: auto;
2478
+ --caps-keycap-height: var(--caps-status-tray-key-height);
2479
+ }
2480
+ .caps-composer-toolbar
2481
+ > .caps-status-tray
2482
+ .caps-status-tray-row
2483
+ :is(.caps-keycap-button, .caps-status-tray-chip, .caps-select) {
2484
+ height: var(--caps-status-tray-key-height);
2485
+ min-height: var(--caps-status-tray-key-height);
2486
+ }
2487
+ /* A chip label is a flex row, so an inline label box adds no text strut
2488
+ and chip text shares the keys' baseline. */
2489
+ .caps-composer-toolbar > .caps-status-tray .caps-status-tray-label {
2490
+ display: flex;
2491
+ align-items: center;
2445
2492
  }
2446
2493
  .caps-alert[data-tone="error"] h3 {
2447
2494
  color: var(--color-error);
@@ -4221,6 +4268,14 @@
4221
4268
  text-align: center;
4222
4269
  }
4223
4270
  /* SendAction: the arrow and the working glyph share one cell. */
4271
+ /* The send key: one square keycap whose glyph changes, never a second key. */
4272
+ .caps-send-action {
4273
+ --caps-keycap-width: auto;
4274
+ --caps-keycap-justify: center;
4275
+ --caps-keycap-padding: 0;
4276
+ --caps-keycap-min-width: var(--caps-keycap-height, 2.5rem);
4277
+ flex: none;
4278
+ }
4224
4279
  .caps-send-glyph {
4225
4280
  display: inline-grid;
4226
4281
  place-items: center;
@@ -5333,8 +5388,8 @@
5333
5388
  isolation: var(--caps-button-outline-isolation, auto);
5334
5389
  }
5335
5390
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]) {
5336
- background-color: var(--caps-button-outline-face, revert-layer);
5337
- color: var(--caps-button-outline-ink, revert-layer);
5391
+ background-color: var(--caps-key-face);
5392
+ color: var(--caps-button-outline-ink, var(--color-base-content));
5338
5393
  }
5339
5394
  .caps-button.btn-outline:is(:disabled, [aria-disabled="true"]) {
5340
5395
  background-color: var(
@@ -5345,11 +5400,11 @@
5345
5400
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]):hover {
5346
5401
  background-color: var(
5347
5402
  --caps-button-outline-hover-face,
5348
- var(--caps-button-outline-face, revert-layer)
5403
+ var(--caps-keycap-hover-face, var(--caps-key-face))
5349
5404
  );
5350
5405
  color: var(
5351
5406
  --caps-button-outline-hover-ink,
5352
- var(--caps-button-outline-ink, revert-layer)
5407
+ var(--caps-button-outline-ink, var(--color-base-content))
5353
5408
  );
5354
5409
  }
5355
5410
  /* A cut outline uses an inset face; an ordinary outline needs no extra face. */
@@ -5365,24 +5420,48 @@
5365
5420
  background: var(--color-base-content);
5366
5421
  }
5367
5422
 
5368
- /* An outline button is a hairline frame with a medium label: no drop in
5369
- any state, unless a theme's finish asks for one. */
5423
+ /* The Caps key material: a flat face on a 1px lip shaded toward the ink,
5424
+ a faint top edge, and a press that settles the face onto its lip. Every
5425
+ Button reads it; KeycapButton reads its face and lip and raises its own
5426
+ finish on them. Everything scales with --depth, so flat themes keep a
5427
+ flat linework key, and a theme's --caps-button-drop/-press still win. */
5428
+ .caps-keycap-button,
5429
+ .caps-button-filled,
5370
5430
  .caps-button.btn-outline {
5371
- --btn-border: color-mix(
5372
- in oklab,
5373
- var(--color-base-content) 24%,
5374
- transparent
5375
- );
5431
+ --caps-key-lip: calc(var(--depth) * 1px);
5432
+ --caps-key-rest-shadow:
5433
+ inset 0 1px 0
5434
+ oklch(100% 0 0 / calc(var(--depth) * var(--caps-key-top-edge, 0.14))),
5435
+ 0 var(--caps-key-lip) 0
5436
+ color-mix(
5437
+ in oklab,
5438
+ var(--caps-key-face),
5439
+ var(--caps-key-lip-ink, var(--color-base-content)) 14%
5440
+ );
5441
+ --caps-key-pressed-shadow:
5442
+ inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)), 0 0 0 transparent;
5443
+ }
5444
+
5445
+ /* An outline button is the neutral key: a base-200 face on linework. */
5446
+ .caps-button.btn-outline {
5447
+ --btn-border: var(--stipe-linework);
5448
+ --caps-key-face: var(--caps-button-outline-face, var(--color-base-200));
5376
5449
 
5377
5450
  font-weight: var(--caps-button-weight, 500);
5378
- box-shadow: var(--caps-button-outline-drop, var(--caps-button-drop, none));
5451
+ box-shadow: var(
5452
+ --caps-button-outline-drop,
5453
+ var(--caps-button-drop, var(--caps-key-rest-shadow))
5454
+ );
5455
+ transition-property: background-color, border-color, box-shadow, translate;
5456
+ transition-duration: var(--stipe-motion-fast);
5457
+ transition-timing-function: ease-out;
5379
5458
  }
5380
5459
 
5381
5460
  .caps-button.btn-outline:not(:disabled):not([aria-disabled="true"]):active {
5382
- translate: 0 var(--caps-button-press, var(--stipe-press-depth));
5461
+ translate: 0 var(--caps-button-press, var(--caps-key-lip));
5383
5462
  box-shadow: var(
5384
5463
  --caps-button-outline-drop,
5385
- var(--caps-button-drop-active, none)
5464
+ var(--caps-button-drop-active, var(--caps-key-pressed-shadow))
5386
5465
  );
5387
5466
  }
5388
5467
 
@@ -5394,25 +5473,28 @@
5394
5473
  --radius-field: var(--radius-button);
5395
5474
  }
5396
5475
 
5397
- /* A filled button is a flat key: one solid fill, a hairline edge a step
5398
- darker than the fill, and at most a faint 1px top highlight. Flat themes
5399
- edge it in linework instead; a theme sets --caps-button-linework: 0% to
5400
- edge it in its own fill. Press feel comes from the theme's
5401
- --stipe-press-depth and --stipe-press-scale. */
5476
+ /* A filled button is the same key in its role colour: one solid fill with
5477
+ no lighter ring, an edge a hair darker than the fill, a quiet lip shaded
5478
+ darker still, and only a very faint top edge. Flat themes edge it in
5479
+ linework instead; a theme sets --caps-button-linework: 0% to edge it in
5480
+ its own fill. */
5402
5481
  .caps-button-filled {
5403
5482
  --btn-bg: var(--btn-color);
5483
+ --caps-key-face: var(--btn-color);
5484
+ --caps-key-lip-ink: black;
5485
+ --caps-key-top-edge: 0.06;
5404
5486
  --caps-button-filled-edge: var(
5405
5487
  --caps-button-border-color,
5406
5488
  color-mix(
5407
5489
  in oklab,
5408
- color-mix(in oklab, var(--btn-bg), black calc(var(--depth) * 8%)),
5490
+ color-mix(in oklab, var(--btn-bg), black calc(var(--depth) * 6%)),
5409
5491
  var(--stipe-linework)
5410
5492
  calc((1 - var(--depth)) * var(--caps-button-linework, 100%))
5411
5493
  )
5412
5494
  );
5413
5495
  --caps-button-filled-drop: var(
5414
5496
  --caps-button-drop,
5415
- inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18))
5497
+ var(--caps-key-rest-shadow)
5416
5498
  );
5417
5499
 
5418
5500
  background-color: var(--btn-bg);
@@ -5470,12 +5552,9 @@
5470
5552
  ) {
5471
5553
  --btn-bg: color-mix(in oklab, var(--btn-color) 92%, black);
5472
5554
 
5473
- translate: 0 var(--caps-button-press, var(--stipe-press-depth));
5555
+ translate: 0 var(--caps-button-press, var(--caps-key-lip));
5474
5556
  scale: var(--stipe-press-scale);
5475
- box-shadow: var(
5476
- --caps-button-drop-active,
5477
- inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08))
5478
- );
5557
+ box-shadow: var(--caps-button-drop-active, var(--caps-key-pressed-shadow));
5479
5558
  transition-duration: var(--stipe-motion-squish);
5480
5559
  }
5481
5560
 
@@ -6686,8 +6765,9 @@
6686
6765
  scale: none;
6687
6766
  }
6688
6767
 
6768
+ /* The press is a state, not motion: a key still settles onto its lip. */
6689
6769
  .caps-button-filled:not(:disabled):not([aria-disabled="true"]):active {
6690
- translate: var(--caps-button-press-reduced, none);
6770
+ translate: var(--caps-button-press-reduced, 0 var(--caps-key-lip));
6691
6771
  }
6692
6772
 
6693
6773
  .caps-gooey-motion,
@@ -7990,7 +8070,8 @@
7990
8070
 
7991
8071
  @media (prefers-reduced-motion: reduce) {
7992
8072
  .caps-keycap,
7993
- .caps-keycap-button {
8073
+ .caps-keycap-button,
8074
+ .caps-button.btn-outline {
7994
8075
  transition: none;
7995
8076
  }
7996
8077
  }
@@ -8071,11 +8152,23 @@
8071
8152
  .caps-game-meter .progress {
8072
8153
  height: 0.625rem;
8073
8154
  }
8074
- /* A key: a flat face on a 1px lip, with a faint top highlight. The lip and
8075
- press travel scale with --depth, so flat themes keep a flat linework key.
8076
- A host or theme picks the face through --caps-keycap-face/-ink/-edge. */
8155
+ /* A key: today's flat key on a 1px lip, a touch richer. The face carries a
8156
+ faint ±3% grade, the top edge is 0.18, a small soft drop sits under the
8157
+ lip, and the linework edge is softened 20% toward the face. It reads its
8158
+ face and lip from the Caps key material; everything scales with --depth,
8159
+ so flat themes keep a flat linework key. A host or theme picks the face
8160
+ through --caps-keycap-face/-ink/-edge. */
8077
8161
  .caps-keycap-button {
8078
- --caps-keycap-lip: calc(var(--depth) * 1px);
8162
+ --caps-key-face: var(--caps-keycap-face, var(--color-base-200));
8163
+ --caps-keycap-rest-shadow:
8164
+ inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18)),
8165
+ 0 var(--caps-key-lip) 0
8166
+ color-mix(in oklab, var(--caps-key-face), var(--color-base-content) 14%),
8167
+ 0 var(--caps-key-lip) calc(var(--depth) * 2px)
8168
+ oklch(0% 0 0 / calc(var(--depth) * 0.15));
8169
+ --caps-keycap-pressed-shadow:
8170
+ inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)),
8171
+ 0 0 0 transparent, 0 0 0 transparent;
8079
8172
  display: flex;
8080
8173
  width: var(--caps-keycap-width, 100%);
8081
8174
  min-width: var(--caps-keycap-min-width, 0);
@@ -8086,7 +8179,11 @@
8086
8179
  padding: var(--caps-keycap-padding, var(--spacing-xs) var(--spacing-sm));
8087
8180
  border: var(
8088
8181
  --caps-keycap-context-border,
8089
- var(--border) solid var(--caps-keycap-edge, var(--stipe-linework))
8182
+ var(--border) solid
8183
+ var(
8184
+ --caps-keycap-edge,
8185
+ color-mix(in oklab, var(--stipe-linework) 80%, var(--caps-key-face))
8186
+ )
8090
8187
  );
8091
8188
  font-size: var(--caps-keycap-font-size, inherit);
8092
8189
  font-weight: var(
@@ -8099,20 +8196,26 @@
8099
8196
  var(--caps-keycap-face, var(--color-base-200))
8100
8197
  );
8101
8198
  background-color: var(--caps-keycap-painted-face);
8102
- --caps-keycap-default-image: none;
8199
+ --caps-keycap-default-image: linear-gradient(
8200
+ to bottom,
8201
+ color-mix(
8202
+ in oklab,
8203
+ var(--caps-keycap-painted-face),
8204
+ white calc(var(--depth) * 3%)
8205
+ ),
8206
+ color-mix(
8207
+ in oklab,
8208
+ var(--caps-keycap-painted-face),
8209
+ black calc(var(--depth) * 3%)
8210
+ )
8211
+ );
8103
8212
  background-image: var(
8104
8213
  --caps-keycap-context-image,
8105
8214
  var(--caps-keycap-default-image)
8106
8215
  );
8107
8216
  box-shadow: var(
8108
8217
  --caps-keycap-context-shadow,
8109
- inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.14)),
8110
- 0 var(--caps-keycap-lip) 0
8111
- color-mix(
8112
- in oklab,
8113
- var(--caps-keycap-face, var(--color-base-200)),
8114
- var(--color-base-content) 14%
8115
- )
8218
+ var(--caps-keycap-rest-shadow)
8116
8219
  );
8117
8220
  color: var(--caps-keycap-ink, var(--color-base-content));
8118
8221
  text-align: left;
@@ -8123,11 +8226,10 @@
8123
8226
  transition-timing-function: ease-out;
8124
8227
  }
8125
8228
  .caps-keycap-button:active {
8126
- translate: 0 var(--caps-keycap-context-travel, var(--caps-keycap-lip));
8229
+ translate: 0 var(--caps-keycap-context-travel, var(--caps-key-lip));
8127
8230
  box-shadow: var(
8128
8231
  --caps-keycap-active-shadow,
8129
- inset 0 1px 0 oklch(0% 0 0 / calc(var(--depth) * 0.08)),
8130
- 0 0 0 transparent
8232
+ var(--caps-keycap-pressed-shadow)
8131
8233
  );
8132
8234
  }
8133
8235
  .caps-keycap-button:focus-visible {
@@ -8164,6 +8266,25 @@
8164
8266
  --caps-keycap-ink: var(--color-neutral-content);
8165
8267
  --caps-keycap-edge: var(--color-neutral);
8166
8268
  }
8269
+ /* The send key is a filled key in its role colour, like a filled Button:
8270
+ a solid face and a hairline edge one step darker, no light rim. With
8271
+ nothing to send it rests as a calm neutral key. */
8272
+ .caps-keycap-button.caps-send-action[data-tone="primary"] {
8273
+ --caps-keycap-face: var(--color-primary);
8274
+ --caps-keycap-ink: var(--color-primary-content);
8275
+ --caps-keycap-edge: color-mix(in oklab, var(--color-primary) 82%, black);
8276
+ --caps-keycap-hover-face: color-mix(
8277
+ in oklab,
8278
+ var(--color-primary) 92%,
8279
+ black
8280
+ );
8281
+ }
8282
+ .caps-keycap-button.caps-send-action[data-send-state="empty"] {
8283
+ --caps-keycap-face: var(--color-base-200);
8284
+ --caps-keycap-ink: var(--color-base-content);
8285
+ --caps-keycap-edge: var(--stipe-linework);
8286
+ --caps-keycap-hover-face: var(--color-base-200);
8287
+ }
8167
8288
  /* Destructive keys stay quiet until the pointer or focus is on them. */
8168
8289
  .caps-keycap-button[data-tone="destructive"]:is(:hover, :focus-visible) {
8169
8290
  --caps-keycap-ink: var(--color-error);