@fungi.computer/caps 0.1.0 → 0.2.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
@@ -222,9 +222,9 @@ keep their shared seam.
222
222
  ### Class merging
223
223
 
224
224
  `@fungi.computer/caps/lib/cn` merges classes with `cn@0.2.6` and Stipe's
225
- [named spacing scale](https://stipe.shiit.app/concepts/#named-spacing). It
226
- accepts conditional strings, arrays and objects and keeps caller-last Tailwind
227
- overrides, so `cn("text-xs px-xs py-sm", "px-md")` gives
225
+ [named spacing scale](https://docs.fungi.computer/stipe/concepts/#named-spacing).
226
+ It accepts conditional strings, arrays and objects and keeps caller-last
227
+ Tailwind overrides, so `cn("text-xs px-xs py-sm", "px-md")` gives
228
228
  `"text-xs py-sm px-md"`. The helper keeps typography and spacing names apart.
229
229
 
230
230
  ### Forms
@@ -37,8 +37,7 @@ export const Button = forwardRef(function Button({ asChild = false, children, cl
37
37
  const mountedResult = useRef(result).current;
38
38
  const node = useRef(null);
39
39
  useImperativeHandle(ref, () => node.current);
40
- // Text labels compare by value; element labels re-measure each render.
41
- useWidthTween(node, [loading, result, children]);
40
+ useWidthTween(node, loading, result);
42
41
  if (asChild) {
43
42
  return (_jsx(Slot, { ...props, "aria-busy": loading ? true : props["aria-busy"], "aria-disabled": disabled === true || loading ? true : undefined, className: classes, ref: node, children: children }));
44
43
  }
@@ -47,23 +46,45 @@ export const Button = forwardRef(function Button({ asChild = false, children, cl
47
46
  /**
48
47
  * When a button's label changes (loading, a result, new text) its width eases
49
48
  * to the new size instead of jumping, so neighbours never lurch.
49
+ *
50
+ * What counts as "changed" is what the button renders (its text, loading and
51
+ * result), never the identity of its children: a caller that builds a fresh
52
+ * array or element with the same words each render has not changed the label.
53
+ * Widths are layout (CSS) pixels from `offsetWidth`, the unit the animated
54
+ * `width` is in, so a scaled or zoomed ancestor cannot distort the glide. A
55
+ * button that has no box (an ancestor is `display: none`) records no width, so
56
+ * when it is shown it settles at its size instead of growing from nothing.
50
57
  */
51
- function useWidthTween(node, label) {
58
+ function useWidthTween(node, loading, result) {
52
59
  const width = useRef(null);
60
+ const look = useRef(null);
53
61
  const tween = useRef(null);
62
+ // After every commit: the rendered text is only known once it is in the DOM.
54
63
  useLayoutEffect(() => {
55
64
  const element = node.current;
56
65
  if (!element)
57
66
  return;
67
+ const now = JSON.stringify([loading, result ?? null, element.textContent]);
68
+ const changed = now !== look.current;
69
+ look.current = now;
58
70
  const running = tween.current;
59
- const from = running
60
- ? element.getBoundingClientRect().width
61
- : width.current;
62
- running?.cancel();
63
- tween.current = null;
64
- const to = element.getBoundingClientRect().width;
65
- width.current = to;
66
- if (from === null || Math.abs(from - to) < 1 || !canAnimate(element))
71
+ // Nothing new to say: let a glide in progress finish undisturbed.
72
+ if (!changed && running)
73
+ return;
74
+ const from = running ? element.offsetWidth : width.current;
75
+ if (running) {
76
+ tween.current = null;
77
+ running.cancel();
78
+ element.style.removeProperty("overflow");
79
+ element.style.removeProperty("white-space");
80
+ }
81
+ const to = element.offsetWidth;
82
+ width.current = to > 0 ? to : null;
83
+ if (!changed ||
84
+ from === null ||
85
+ to === 0 ||
86
+ Math.abs(from - to) < 1 ||
87
+ !canAnimate(element))
67
88
  return;
68
89
  const motion = readMotion(element);
69
90
  if (motion.reduced)
@@ -82,7 +103,7 @@ function useWidthTween(node, label) {
82
103
  };
83
104
  animation.onfinish = done;
84
105
  animation.oncancel = done;
85
- }, [node, ...label]);
106
+ });
86
107
  }
87
108
  /** How long the done beat holds before settling: long enough to read. */
88
109
  const resultHoldMs = 1100;
@@ -13,6 +13,8 @@ export type WindowCrumb = {
13
13
  export type WindowCommand = {
14
14
  id: string;
15
15
  label: string;
16
+ /** Render a supported glyph alone; label remains the accessible name. */
17
+ icon?: "play";
16
18
  /** Which Caps button a primary command wears; default `outline`. */
17
19
  variant?: "primary" | "outline" | "ghost";
18
20
  /** A menu item that removes or discards something. */
@@ -23,6 +25,8 @@ export type WindowCommand = {
23
25
  export type WindowStatus = {
24
26
  id: string;
25
27
  label: string;
28
+ /** Hide only the visible label, retaining it for assistive technology. */
29
+ presentation?: "bead";
26
30
  tone?: Extract<StatusTone, "neutral" | "info" | "warning" | "success" | "error">;
27
31
  };
28
32
  /** The path as a trail; on a phone only `‹ parent › current` remain. */
@@ -38,7 +42,7 @@ export declare function WindowCommands({ primary, menu, popover, onAction, }: {
38
42
  popover?: HTMLElement | null | undefined;
39
43
  onAction?: ((id: string) => void) | undefined;
40
44
  }): import("react/jsx-runtime").JSX.Element | null;
41
- /** Muted state text with the Caps status bead; hidden on a phone. */
45
+ /** Settled beads reveal their labels on hover/focus; other state stays visible. */
42
46
  export declare function WindowStatuses({ status, }: {
43
47
  status: readonly WindowStatus[];
44
48
  }): import("react/jsx-runtime").JSX.Element | null;
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronDown, Ellipsis } from "lucide-react";
2
+ import { ChevronDown, Ellipsis, Play } from "lucide-react";
3
3
  import { Button } from "./button.js";
4
4
  import { Status } from "./status.js";
5
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
6
6
  import { IconButton } from "./icon-button.js";
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
7
8
  /** The longest path shown whole; longer paths fold the middle into "…". */
8
9
  const CRUMBS_SHOWN = 4;
9
10
  function Separator() {
@@ -16,13 +17,13 @@ function Crumb({ crumb, current, popover, onCrumb, }) {
16
17
  "aria-current": current ? "page" : undefined,
17
18
  };
18
19
  if (crumb.siblings?.length) {
19
- return (_jsxs(DropdownMenu, { modal: false, children: [_jsxs(DropdownMenuTrigger, { ...common, className: "caps-window-crumb shrink", render: _jsx(Button, { size: "sm", variant: "ghost" }), "aria-label": `${crumb.label}, switch`, children: [label, _jsx(ChevronDown, { "aria-hidden": "true", className: "size-3" })] }), _jsx(DropdownMenuContent, { align: "start", container: popover, children: crumb.siblings.map((sibling) => (_jsx(DropdownMenuItem, { onSelect: () => {
20
+ return (_jsxs(DropdownMenu, { modal: false, children: [_jsxs(DropdownMenuTrigger, { ...common, className: "caps-window-crumb shrink", render: _jsx(Button, { size: "xs", variant: "ghost" }), "aria-label": `${crumb.label}, switch`, children: [label, _jsx(ChevronDown, { "aria-hidden": "true", className: "size-3" })] }), _jsx(DropdownMenuContent, { align: "start", container: popover, children: crumb.siblings.map((sibling) => (_jsx(DropdownMenuItem, { onSelect: () => {
20
21
  onCrumb?.(crumb.id, sibling.id);
21
22
  }, children: sibling.label }, sibling.id))) })] }));
22
23
  }
23
24
  if (current || onCrumb === undefined)
24
25
  return (_jsx("span", { ...common, className: "caps-window-crumb", children: label }));
25
- return (_jsx(Button, { ...common, size: "sm", variant: "ghost", className: "caps-window-crumb shrink", onClick: () => {
26
+ return (_jsx(Button, { ...common, size: "xs", variant: "ghost", className: "caps-window-crumb shrink", onClick: () => {
26
27
  onCrumb(crumb.id);
27
28
  }, children: label }));
28
29
  }
@@ -44,7 +45,7 @@ export function WindowCrumbs({ crumbs, popover, onCrumb, }) {
44
45
  for (const crumb of head)
45
46
  add(crumb);
46
47
  if (middle.length > 0)
47
- parts.push(_jsxs("span", { className: "caps-window-crumb-item", "data-caps-crumb-position": "ancestor", children: [_jsx(Separator, {}), _jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { className: "caps-window-crumb", render: _jsx(Button, { size: "sm", variant: "ghost" }), "aria-label": "More of the path", children: _jsx(Ellipsis, { "aria-hidden": "true", className: "size-3" }) }), _jsx(DropdownMenuContent, { align: "start", container: popover, children: middle.map((crumb) => (_jsx(DropdownMenuItem, { onSelect: () => {
48
+ parts.push(_jsxs("span", { className: "caps-window-crumb-item", "data-caps-crumb-position": "ancestor", children: [_jsx(Separator, {}), _jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { className: "caps-window-crumb", render: _jsx(Button, { size: "xs", variant: "ghost" }), "aria-label": "More of the path", children: _jsx(Ellipsis, { "aria-hidden": "true", className: "size-3" }) }), _jsx(DropdownMenuContent, { align: "start", container: popover, children: middle.map((crumb) => (_jsx(DropdownMenuItem, { onSelect: () => {
48
49
  onCrumb?.(crumb.id);
49
50
  }, children: crumb.label }, crumb.id))) })] })] }, "fold"));
50
51
  for (const crumb of tail)
@@ -55,15 +56,21 @@ export function WindowCrumbs({ crumbs, popover, onCrumb, }) {
55
56
  export function WindowCommands({ primary, menu, popover, onAction, }) {
56
57
  if (!primary?.length && !menu?.length)
57
58
  return null;
58
- return (_jsxs("div", { className: "caps-window-commands", role: "group", "aria-label": "Commands", children: [primary?.map((command) => (_jsx(Button, { size: "sm", variant: command.variant ?? "outline", disabled: command.disabled === true, onClick: () => {
59
+ return (_jsxs("div", { className: "caps-window-commands", role: "group", "aria-label": "Commands", children: [primary?.map((command) => command.icon === "play" ? (_jsx(IconButton, { icon: _jsx(Play, {}), "aria-label": command.label, title: command.label, variant: command.variant ?? "ghost", disabled: command.disabled === true, onClick: () => {
60
+ onAction?.(command.id);
61
+ } }, command.id)) : (_jsx(Button, { size: "xs", variant: command.variant ?? "outline", disabled: command.disabled === true, onClick: () => {
59
62
  onAction?.(command.id);
60
63
  }, children: command.label }, command.id))), menu?.length ? (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { render: _jsx(IconButton, { icon: _jsx(Ellipsis, {}), "aria-label": "More" }) }), _jsx(DropdownMenuContent, { align: "end", container: popover, children: menu.map((command) => (_jsx(DropdownMenuItem, { disabled: command.disabled === true, variant: command.tone === "destructive" ? "destructive" : "default", onSelect: () => {
61
64
  onAction?.(command.id);
62
65
  }, children: command.label }, command.id))) })] })) : null] }));
63
66
  }
64
- /** Muted state text with the Caps status bead; hidden on a phone. */
67
+ /** Settled beads reveal their labels on hover/focus; other state stays visible. */
65
68
  export function WindowStatuses({ status, }) {
66
69
  if (status.length === 0)
67
70
  return null;
68
- return (_jsx("div", { className: "caps-window-statuses", role: "group", "aria-label": "State", children: status.map((item) => (_jsx(Status, { glyph: "dot", size: "compact", tone: item.tone ?? "neutral", label: item.label, title: item.label, className: "caps-window-status" }, item.id))) }));
71
+ return (_jsx("div", { className: "caps-window-statuses", role: "group", "aria-label": "State", children: status.map((item) => {
72
+ const beadOnly = item.presentation === "bead";
73
+ const readout = (_jsx(Status, { glyph: "dot", size: "compact", tone: item.tone ?? "neutral", label: beadOnly ? (_jsx("span", { className: "sr-only", children: item.label })) : (item.label), tabIndex: beadOnly ? 0 : undefined, title: beadOnly ? undefined : item.label, className: "caps-window-status" }, item.id));
74
+ return beadOnly ? (_jsxs(Tooltip, { side: "bottom", children: [_jsx(TooltipTrigger, { asChild: true, children: readout }), _jsx(TooltipContent, { children: item.label })] }, item.id)) : (readout);
75
+ }) }));
69
76
  }
package/dist/styles.css CHANGED
@@ -13972,8 +13972,8 @@
13972
13972
  min-height: 44px;
13973
13973
  }
13974
13974
  }
13975
- .caps-window-header:has(> .caps-window-crumbs) {
13976
- min-height: 40px;
13975
+ .caps-window-header > .caps-window-icon {
13976
+ margin-inline: var(--spacing-md) calc(var(--spacing-sm) - var(--spacing-2xs));
13977
13977
  }
13978
13978
  .caps-window-crumbs {
13979
13979
  display: flex;
@@ -13981,7 +13981,7 @@
13981
13981
  min-width: 0;
13982
13982
  flex-shrink: 1;
13983
13983
  font-family: var(--caps-label-font, inherit);
13984
- font-size: var(--text-sm);
13984
+ font-size: 11px;
13985
13985
  font-weight: 500;
13986
13986
  }
13987
13987
  .caps-window-crumb-item[data-caps-crumb-position="current"] {
@@ -14022,7 +14022,7 @@
14022
14022
  gap: var(--spacing-sm);
14023
14023
  flex-shrink: 0;
14024
14024
  padding-inline-end: var(--spacing-sm);
14025
- font-size: var(--text-sm);
14025
+ font-size: 11px;
14026
14026
  color: currentColor;
14027
14027
  @supports (color: color-mix(in lab, red, red)) {
14028
14028
  color: color-mix(in srgb, currentColor 65%, transparent);
@@ -14035,6 +14035,16 @@
14035
14035
  flex-shrink: 0;
14036
14036
  }
14037
14037
  @media (max-width: 640px) {
14038
+ .caps-window-header:has(.caps-window-crumbs) {
14039
+ flex-wrap: wrap;
14040
+ }
14041
+ .caps-window-crumbs {
14042
+ min-width: min-content;
14043
+ flex-shrink: 0;
14044
+ }
14045
+ .caps-window-crumb-item {
14046
+ flex-shrink: 0;
14047
+ }
14038
14048
  .caps-window-statuses {
14039
14049
  gap: var(--spacing-2xs);
14040
14050
  padding-inline-end: var(--spacing-2xs);
@@ -14387,6 +14397,7 @@
14387
14397
  display: block;
14388
14398
  flex: none;
14389
14399
  overflow: visible;
14400
+ pointer-events: none;
14390
14401
  transition: color var(--stipe-motion-normal) ease;
14391
14402
  }
14392
14403
  .caps-status-mark {
@@ -64,6 +64,14 @@ id: string;
64
64
  label: string;
65
65
  ```
66
66
 
67
+ ##### icon?
68
+
69
+ ```ts
70
+ optional icon?: "play";
71
+ ```
72
+
73
+ Render a supported glyph alone; label remains the accessible name.
74
+
67
75
  ##### variant?
68
76
 
69
77
  ```ts
@@ -110,6 +118,14 @@ id: string;
110
118
  label: string;
111
119
  ```
112
120
 
121
+ ##### presentation?
122
+
123
+ ```ts
124
+ optional presentation?: "bead";
125
+ ```
126
+
127
+ Hide only the visible label, retaining it for assistive technology.
128
+
113
129
  ##### tone?
114
130
 
115
131
  ```ts
@@ -171,7 +187,7 @@ Primary commands as buttons, the rest behind a ⋯ menu.
171
187
  function WindowStatuses(__namedParameters): Element | null;
172
188
  ```
173
189
 
174
- Muted state text with the Caps status bead; hidden on a phone.
190
+ Settled beads reveal their labels on hover/focus; other state stays visible.
175
191
 
176
192
  #### Parameters
177
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/caps",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Product-neutral React primitives and blocks for Fungi interfaces",
6
6
  "type": "module",
@@ -371,6 +371,7 @@
371
371
  "import": "./dist/blocks/card-grid.js"
372
372
  },
373
373
  "./fonts.css": "./src/fonts.css",
374
+ "./fonts-sans.css": "./src/fonts-sans.css",
374
375
  "./blocks/section-heading": {
375
376
  "types": "./dist/blocks/section-heading.d.ts",
376
377
  "import": "./dist/blocks/section-heading.js"
@@ -458,6 +459,7 @@
458
459
  "docs/api",
459
460
  "README.md",
460
461
  "src/fonts.css",
462
+ "src/fonts-sans.css",
461
463
  "src/status-tray.css",
462
464
  "src/app-status-bar.css"
463
465
  ],
@@ -468,9 +470,9 @@
468
470
  "effect": "4.0.2"
469
471
  },
470
472
  "devDependencies": {
471
- "@fungi.computer/maosaico": "0.0.1",
472
- "@fungi.computer/sprite": "0.1.0",
473
- "@fungi.computer/sprite-zdog": "0.1.0",
473
+ "@fungi.computer/maosaico": "0.1.0",
474
+ "@fungi.computer/sprite": "0.2.0",
475
+ "@fungi.computer/sprite-zdog": "0.2.0",
474
476
  "@ladle/react": "5.1.1",
475
477
  "@tailwindcss/cli": "4.2.1",
476
478
  "@types/react": "18.3.3",
@@ -487,7 +489,7 @@
487
489
  "typescript": "5.9.3",
488
490
  "vite": "8.2.2",
489
491
  "vitest": "4.1.11",
490
- "@fungi.computer/sigils": "0.1.0",
492
+ "@fungi.computer/sigils": "0.2.0",
491
493
  "postcss-value-parser": "4.2.0",
492
494
  "@tanstack/react-form": "1.33.2",
493
495
  "effect": "4.0.2",
@@ -496,7 +498,7 @@
496
498
  },
497
499
  "dependencies": {
498
500
  "@base-ui/react": "1.9.0",
499
- "@fungi.computer/stipe": "0.0.1",
501
+ "@fungi.computer/stipe": "0.1.0",
500
502
  "@radix-ui/react-slot": "1.3.0",
501
503
  "@tailwindcss/typography": "0.5.19",
502
504
  "@tanstack/highlight": "0.0.9",
@@ -0,0 +1 @@
1
+ @import "@fungi.computer/stipe/fonts-sans.css";
package/src/styles.css CHANGED
@@ -5827,9 +5827,11 @@
5827
5827
  }
5828
5828
  }
5829
5829
  /* The path trail: flat text crumbs on the header face, no new colours.
5830
- A header that carries a path reads at body size, one control high. */
5831
- .caps-window-header:has(> .caps-window-crumbs) {
5832
- min-height: 40px;
5830
+ It keeps the title's size and the header's 32px height, and its icon
5831
+ sits where a plain title's icon sits, so switching views never shifts it. */
5832
+ .caps-window-header > .caps-window-icon {
5833
+ margin-inline: var(--spacing-md)
5834
+ calc(var(--spacing-sm) - var(--spacing-2xs));
5833
5835
  }
5834
5836
  .caps-window-crumbs {
5835
5837
  display: flex;
@@ -5837,7 +5839,7 @@
5837
5839
  min-width: 0;
5838
5840
  flex-shrink: 1;
5839
5841
  font-family: var(--caps-label-font, inherit);
5840
- font-size: var(--text-sm);
5842
+ font-size: 11px;
5841
5843
  font-weight: 500;
5842
5844
  }
5843
5845
  .caps-window-crumb-item[data-caps-crumb-position="current"] {
@@ -5878,7 +5880,7 @@
5878
5880
  gap: var(--spacing-sm);
5879
5881
  flex-shrink: 0;
5880
5882
  padding-inline-end: var(--spacing-sm);
5881
- font-size: var(--text-sm);
5883
+ font-size: 11px;
5882
5884
  color: color-mix(in srgb, currentColor 65%, transparent);
5883
5885
  }
5884
5886
  .caps-window-commands {
@@ -5889,6 +5891,18 @@
5889
5891
  }
5890
5892
  /* A phone keeps `‹ parent › current`; the rest of the trail is a tap away on Back. */
5891
5893
  @media (max-width: 640px) {
5894
+ /* Touch buttons cannot shrink below 44px. Reserve their path's width
5895
+ instead of shrinking its containers underneath neighbouring targets. */
5896
+ .caps-window-header:has(.caps-window-crumbs) {
5897
+ flex-wrap: wrap;
5898
+ }
5899
+ .caps-window-crumbs {
5900
+ min-width: min-content;
5901
+ flex-shrink: 0;
5902
+ }
5903
+ .caps-window-crumb-item {
5904
+ flex-shrink: 0;
5905
+ }
5892
5906
  /* State shrinks to its beads; the words stay for screen readers and a long press. */
5893
5907
  .caps-window-statuses {
5894
5908
  gap: var(--spacing-2xs);
@@ -6321,6 +6335,8 @@
6321
6335
  display: block;
6322
6336
  flex: none;
6323
6337
  overflow: visible;
6338
+ /* Decorative material overflows the bead; only its status wrapper is a target. */
6339
+ pointer-events: none;
6324
6340
  transition: color var(--stipe-motion-normal) ease;
6325
6341
  }
6326
6342