@cosxai/ui 0.11.2 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -0,0 +1,139 @@
1
+ import { forwardRef, useCallback, useRef, useState, type HTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ // CopyField — read-only value field with an embedded Copy button.
5
+ //
6
+ // One bordered frame (same 36px field chrome as Input-with-addon):
7
+ // the value renders mono + ellipsized on the left, the Copy button
8
+ // sits INSIDE the frame flush right on a muted slab (mirrors Input's
9
+ // suffix addon). Clicking copies to the clipboard and flips the
10
+ // label to "Copied ✓" for a beat; clicking the value text selects it
11
+ // (manual-copy fallback for non-secure origins where the Clipboard
12
+ // API is unavailable).
13
+ //
14
+ // Use for share links, signing URLs, tokens, API keys — anywhere the
15
+ // user's next action is overwhelmingly "copy this". Compose with:
16
+ // dialog bodies (share link rows), sidebar token reveals, settings
17
+ // pages (API-key display).
18
+ //
19
+ // Forwards ref to the copy <button>. Spreads `...rest` on the frame
20
+ // so consumers can pass data-* / aria-* / style / className.
21
+
22
+ export interface CopyFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "onCopy"> {
23
+ // The string shown + copied. Rendered mono, single-line, ellipsized.
24
+ value: string;
25
+ // Copy-button label; flips to `copiedLabel` after a click.
26
+ copyLabel?: ReactNode;
27
+ copiedLabel?: ReactNode;
28
+ // How long the copied state lingers before flipping back (ms).
29
+ copiedForMs?: number;
30
+ // Notified after a successful clipboard write. Clipboard failures
31
+ // are silent by design — the value stays selectable in the field.
32
+ onCopied?: (value: string) => void;
33
+ }
34
+
35
+ export const CopyField = forwardRef<HTMLButtonElement, CopyFieldProps>(function CopyField(
36
+ {
37
+ value,
38
+ copyLabel = "Copy",
39
+ copiedLabel = "Copied ✓",
40
+ copiedForMs = 1500,
41
+ onCopied,
42
+ className,
43
+ style,
44
+ ...rest
45
+ },
46
+ ref,
47
+ ) {
48
+ const [copied, setCopied] = useState(false);
49
+ const valueRef = useRef<HTMLElement | null>(null);
50
+
51
+ const handleCopy = useCallback(() => {
52
+ void navigator.clipboard
53
+ ?.writeText(value)
54
+ .then(() => onCopied?.(value))
55
+ .catch(() => {
56
+ // Clipboard rejected (permissions / non-secure origin). The
57
+ // value stays visible + selectable in the field — no error UI.
58
+ });
59
+ setCopied(true);
60
+ window.setTimeout(() => setCopied(false), copiedForMs);
61
+ }, [value, copiedForMs, onCopied]);
62
+
63
+ // Click-to-select on the value — the manual-copy fallback.
64
+ const handleSelectAll = useCallback(() => {
65
+ const node = valueRef.current;
66
+ if (!node) return;
67
+ const range = document.createRange();
68
+ range.selectNodeContents(node);
69
+ const sel = window.getSelection();
70
+ sel?.removeAllRanges();
71
+ sel?.addRange(range);
72
+ }, []);
73
+
74
+ return (
75
+ <div
76
+ {...rest}
77
+ className={cn("ck-copy-field", className)}
78
+ style={{
79
+ display: "flex",
80
+ alignItems: "stretch",
81
+ height: 36,
82
+ background: "var(--ck-bg-surface)",
83
+ border: "1px solid var(--ck-border-strong)",
84
+ borderRadius: "var(--ck-radius-sm)",
85
+ overflow: "hidden",
86
+ minWidth: 0,
87
+ ...(style ?? {}),
88
+ }}
89
+ >
90
+ <code
91
+ ref={valueRef}
92
+ title={value}
93
+ onClick={handleSelectAll}
94
+ style={{
95
+ flex: 1,
96
+ minWidth: 0,
97
+ alignSelf: "center",
98
+ padding: "0 12px",
99
+ font: "400 12px/1.5 var(--ck-font-mono, ui-monospace, monospace)",
100
+ color: "var(--ck-text-primary)",
101
+ whiteSpace: "nowrap",
102
+ overflow: "hidden",
103
+ textOverflow: "ellipsis",
104
+ cursor: "text",
105
+ }}
106
+ >
107
+ {value}
108
+ </code>
109
+ <button
110
+ ref={ref}
111
+ type="button"
112
+ onClick={handleCopy}
113
+ disabled={copied}
114
+ className="ck-copy-field-btn"
115
+ style={{
116
+ flex: "0 0 auto",
117
+ display: "inline-flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ padding: "0 14px",
121
+ // Reserve the wider copied-label footprint so the value
122
+ // column doesn't shift mid-flash.
123
+ minWidth: 84,
124
+ border: "none",
125
+ borderLeft: "1px solid var(--ck-border-strong)",
126
+ background: "var(--ck-bg-muted)",
127
+ color: copied ? "var(--ck-text-secondary)" : "var(--ck-text-primary)",
128
+ font: "500 11px/1 var(--ck-font-sans)",
129
+ letterSpacing: "0.04em",
130
+ textTransform: "uppercase",
131
+ cursor: copied ? "default" : "pointer",
132
+ transition: "color var(--ck-dur-fast) var(--ck-ease)",
133
+ }}
134
+ >
135
+ {copied ? copiedLabel : copyLabel}
136
+ </button>
137
+ </div>
138
+ );
139
+ });
@@ -0,0 +1,415 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type CSSProperties,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ /**
15
+ * SignaturePad — legally-binding e-signature capture.
16
+ *
17
+ * Two modes:
18
+ * - `typed` — signer types their name; canvas paints it in the
19
+ * Caveat cursive script + emits PNG dataURL.
20
+ * - `drawn` — signer draws with mouse / trackpad / touch; captured
21
+ * as a smoothed stroke path + rasterised to PNG.
22
+ *
23
+ * Both modes produce the same output shape: `image/png` dataURL via
24
+ * `canvas.toDataURL('image/png')`. Downstream consumer (metaroom
25
+ * SignDocumentPage) uploads that dataURL to mesh's signing endpoint;
26
+ * mesh stores as `signature_png` on the signing session + bakes into
27
+ * the certified PDF client-side.
28
+ *
29
+ * Compose with:
30
+ * - SigningCommitBar (footer state machine that owns the submit
31
+ * transition — SignaturePad is presentation only)
32
+ * - FieldWalker (DOM sequencer that focuses this pad when the
33
+ * current field is a signature block)
34
+ *
35
+ * Visual: see docs/components/signature-pad.
36
+ *
37
+ * Design decisions:
38
+ * - Controlled component. Parent owns `value` + `onChange`. This
39
+ * lets SigningCommitBar preserve captured state across phase
40
+ * transitions (fill → consent → review → back to fill).
41
+ * - `mode` toggle is controlled by parent too — no built-in tabs.
42
+ * Callers usually render a small pill switcher above the pad.
43
+ * - Font loading uses `document.fonts.ready` before rendering typed
44
+ * mode. Without the await, the first typed capture paints in the
45
+ * browser fallback (usually Brush Script or serif italic).
46
+ * - Drawn mode uses `touch-action: none` + `pointerdown`
47
+ * preventDefault to stop iOS Safari from scrolling instead of
48
+ * drawing.
49
+ * - `imperativeHandle` exposes `clear()` for the parent's Undo
50
+ * button.
51
+ */
52
+ export interface SignaturePadProps {
53
+ /** Capture mode. Parent owns the toggle. */
54
+ mode: "typed" | "drawn";
55
+ /**
56
+ * Current captured PNG dataURL, or null when nothing captured.
57
+ * Controlled by the parent so state survives the parent's phase
58
+ * transitions.
59
+ */
60
+ value: string | null;
61
+ /**
62
+ * Fires whenever the pad captures a new dataURL (typed input
63
+ * changed, drawn stroke completed, cleared). null on clear.
64
+ */
65
+ onChange: (dataURL: string | null) => void;
66
+ /**
67
+ * Typed-mode input. Parent may bind this to the signer's display
68
+ * name for a stable signature. Ignored in drawn mode.
69
+ */
70
+ typedName?: string;
71
+ /**
72
+ * Typed-mode text change — fires ONLY in typed mode, on every
73
+ * keystroke. Parent updates its state; the pad reruns the typed
74
+ * bake on the next paint. Ignored in drawn mode.
75
+ */
76
+ onTypedNameChange?: (next: string) => void;
77
+ /** Canvas pixel width. Defaults to 480. */
78
+ width?: number;
79
+ /** Canvas pixel height. Defaults to 120. */
80
+ height?: number;
81
+ /**
82
+ * Font family used in typed mode. Defaults to `"Caveat", "Segoe
83
+ * Script", cursive` — Caveat is the most common signature font
84
+ * available across the Google Fonts CDN + Adobe Fonts.
85
+ */
86
+ typedFontFamily?: string;
87
+ /** Stroke color in drawn mode. Defaults to `#0b1220` (near-black). */
88
+ strokeColor?: string;
89
+ /** Stroke width in drawn mode. Defaults to 2. */
90
+ strokeWidth?: number;
91
+ /**
92
+ * Consumer-set className on the outer wrapper. Component ships its
93
+ * own inline styles; className is for layout only (grid parent
94
+ * spacing etc).
95
+ */
96
+ className?: string;
97
+ }
98
+
99
+ /** Imperative handle for parent-side controls (Undo, Clear buttons). */
100
+ export interface SignaturePadHandle {
101
+ /** Wipe the canvas + emit null via onChange. */
102
+ clear: () => void;
103
+ }
104
+
105
+ const DEFAULT_WIDTH = 480;
106
+ const DEFAULT_HEIGHT = 120;
107
+ const DEFAULT_TYPED_FONT = '"Caveat", "Segoe Script", cursive';
108
+
109
+ export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(
110
+ function SignaturePad(
111
+ {
112
+ mode,
113
+ value,
114
+ onChange,
115
+ typedName = "",
116
+ onTypedNameChange,
117
+ width = DEFAULT_WIDTH,
118
+ height = DEFAULT_HEIGHT,
119
+ typedFontFamily = DEFAULT_TYPED_FONT,
120
+ strokeColor = "#0b1220",
121
+ strokeWidth = 2,
122
+ className,
123
+ }: SignaturePadProps,
124
+ ref,
125
+ ): ReactNode {
126
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
127
+ // Track whether the currently-painted canvas is "dirty" (has
128
+ // strokes / typed pixels) so clear() knows whether to emit null.
129
+ const dirtyRef = useRef<boolean>(value !== null);
130
+
131
+ // Drawn mode point buffer — used for smoothed line via quadratic
132
+ // interpolation. Reset on stroke end.
133
+ const drawStateRef = useRef<{
134
+ drawing: boolean;
135
+ lastX: number;
136
+ lastY: number;
137
+ }>({ drawing: false, lastX: 0, lastY: 0 });
138
+
139
+ // Font-load await for typed mode. Setting to `true` triggers a
140
+ // re-render + re-paint of the current typedName. First mount in
141
+ // typed mode WITHOUT this await paints in the browser fallback
142
+ // font — Ben's spec called this out as a bug we must not ship.
143
+ const [fontReady, setFontReady] = useState<boolean>(() => {
144
+ if (typeof document === "undefined") return true;
145
+ // Best-effort: document.fonts may be absent in older browsers.
146
+ // Fall through as ready so we don't block the pad forever.
147
+ const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
148
+ return !fonts;
149
+ });
150
+ useEffect(() => {
151
+ if (fontReady) return;
152
+ if (typeof document === "undefined") return;
153
+ const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
154
+ if (!fonts) {
155
+ setFontReady(true);
156
+ return;
157
+ }
158
+ let cancelled = false;
159
+ void fonts.ready.then(() => {
160
+ if (cancelled) return;
161
+ setFontReady(true);
162
+ });
163
+ return () => {
164
+ cancelled = true;
165
+ };
166
+ }, [fontReady]);
167
+
168
+ // Repaint on typedName / mode / fontReady change. Drawn mode
169
+ // doesn't repaint imperatively — strokes are appended as the user
170
+ // draws.
171
+ useEffect(() => {
172
+ if (mode !== "typed") return;
173
+ const canvas = canvasRef.current;
174
+ if (!canvas) return;
175
+ const ctx = canvas.getContext("2d");
176
+ if (!ctx) return;
177
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
178
+ if (!typedName.trim()) {
179
+ dirtyRef.current = false;
180
+ onChange(null);
181
+ return;
182
+ }
183
+ if (!fontReady) return;
184
+ // Paint the name centred vertically, left-aligned horizontally.
185
+ // Auto-fit font size to width: start at height * 0.6 and shrink
186
+ // until measureText fits within `width - 24` (12px padding each
187
+ // side).
188
+ let fontSize = Math.round(height * 0.7);
189
+ ctx.textBaseline = "middle";
190
+ ctx.fillStyle = strokeColor;
191
+ const availableWidth = width - 24;
192
+ while (fontSize > 10) {
193
+ ctx.font = `${fontSize}px ${typedFontFamily}`;
194
+ if (ctx.measureText(typedName).width <= availableWidth) break;
195
+ fontSize -= 2;
196
+ }
197
+ ctx.fillText(typedName, 12, height / 2);
198
+ const dataURL = canvas.toDataURL("image/png");
199
+ dirtyRef.current = true;
200
+ onChange(dataURL);
201
+ // onChange is stable-by-contract from parent (React best
202
+ // practice); avoid feeding it into deps to prevent infinite
203
+ // loop.
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ }, [
206
+ mode,
207
+ typedName,
208
+ fontReady,
209
+ width,
210
+ height,
211
+ typedFontFamily,
212
+ strokeColor,
213
+ ]);
214
+
215
+ // Restore a previously-captured value into the canvas on remount.
216
+ // Typed mode uses the effect above; drawn mode needs to explicitly
217
+ // paint the dataURL back onto the canvas so subsequent strokes
218
+ // continue on top of it (rather than starting from blank).
219
+ useEffect(() => {
220
+ if (mode !== "drawn") return;
221
+ if (!value) return;
222
+ const canvas = canvasRef.current;
223
+ if (!canvas) return;
224
+ const ctx = canvas.getContext("2d");
225
+ if (!ctx) return;
226
+ const img = new Image();
227
+ img.onload = () => {
228
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
229
+ ctx.drawImage(img, 0, 0);
230
+ dirtyRef.current = true;
231
+ };
232
+ img.src = value;
233
+ // eslint-disable-next-line react-hooks/exhaustive-deps
234
+ }, [mode]);
235
+
236
+ // Mode switch — clear the canvas so a stale typed baseline
237
+ // doesn't hang around behind a drawn signature (or vice versa).
238
+ useEffect(() => {
239
+ const canvas = canvasRef.current;
240
+ if (!canvas) return;
241
+ const ctx = canvas.getContext("2d");
242
+ if (!ctx) return;
243
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
244
+ dirtyRef.current = false;
245
+ onChange(null);
246
+ // Only fire when mode itself changes.
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, [mode]);
249
+
250
+ const clear = useCallback(() => {
251
+ const canvas = canvasRef.current;
252
+ if (canvas) {
253
+ const ctx = canvas.getContext("2d");
254
+ ctx?.clearRect(0, 0, canvas.width, canvas.height);
255
+ }
256
+ dirtyRef.current = false;
257
+ onChange(null);
258
+ if (mode === "typed") {
259
+ onTypedNameChange?.("");
260
+ }
261
+ }, [mode, onChange, onTypedNameChange]);
262
+
263
+ useImperativeHandle(ref, () => ({ clear }), [clear]);
264
+
265
+ // Drawn mode — pointer event handlers.
266
+ const onPointerDown = useCallback(
267
+ (e: ReactPointerEvent<HTMLCanvasElement>) => {
268
+ if (mode !== "drawn") return;
269
+ // touch-action: none on the canvas isn't enough on iOS
270
+ // Safari — need preventDefault too, otherwise the browser
271
+ // scrolls the page instead of drawing.
272
+ e.preventDefault();
273
+ const canvas = canvasRef.current;
274
+ if (!canvas) return;
275
+ const rect = canvas.getBoundingClientRect();
276
+ // Coordinate space: pointer client coords → canvas backing
277
+ // store coords. `rect` is CSS pixels; canvas.width is device
278
+ // pixels (or logical pixels when no DPR handling). Scale so
279
+ // strokes land at the visual point.
280
+ const scaleX = canvas.width / rect.width;
281
+ const scaleY = canvas.height / rect.height;
282
+ const x = (e.clientX - rect.left) * scaleX;
283
+ const y = (e.clientY - rect.top) * scaleY;
284
+ drawStateRef.current.drawing = true;
285
+ drawStateRef.current.lastX = x;
286
+ drawStateRef.current.lastY = y;
287
+ canvas.setPointerCapture(e.pointerId);
288
+ },
289
+ [mode],
290
+ );
291
+
292
+ const onPointerMove = useCallback(
293
+ (e: ReactPointerEvent<HTMLCanvasElement>) => {
294
+ if (mode !== "drawn") return;
295
+ if (!drawStateRef.current.drawing) return;
296
+ const canvas = canvasRef.current;
297
+ if (!canvas) return;
298
+ const ctx = canvas.getContext("2d");
299
+ if (!ctx) return;
300
+ const rect = canvas.getBoundingClientRect();
301
+ const scaleX = canvas.width / rect.width;
302
+ const scaleY = canvas.height / rect.height;
303
+ const x = (e.clientX - rect.left) * scaleX;
304
+ const y = (e.clientY - rect.top) * scaleY;
305
+ ctx.strokeStyle = strokeColor;
306
+ ctx.lineWidth = strokeWidth;
307
+ ctx.lineCap = "round";
308
+ ctx.lineJoin = "round";
309
+ ctx.beginPath();
310
+ ctx.moveTo(drawStateRef.current.lastX, drawStateRef.current.lastY);
311
+ // Simple line-to; a smoother quadratic curve is possible via
312
+ // buffering the last N points, but the default line-to
313
+ // already reads well at 2px stroke on human-scale pads.
314
+ ctx.lineTo(x, y);
315
+ ctx.stroke();
316
+ drawStateRef.current.lastX = x;
317
+ drawStateRef.current.lastY = y;
318
+ dirtyRef.current = true;
319
+ },
320
+ [mode, strokeColor, strokeWidth],
321
+ );
322
+
323
+ const onPointerUp = useCallback(
324
+ (e: ReactPointerEvent<HTMLCanvasElement>) => {
325
+ if (mode !== "drawn") return;
326
+ drawStateRef.current.drawing = false;
327
+ const canvas = canvasRef.current;
328
+ if (!canvas) return;
329
+ try {
330
+ canvas.releasePointerCapture(e.pointerId);
331
+ } catch {
332
+ // Some browsers throw when the pointer isn't captured
333
+ // (spurious pointerup); safe to ignore.
334
+ }
335
+ if (!dirtyRef.current) return;
336
+ const dataURL = canvas.toDataURL("image/png");
337
+ onChange(dataURL);
338
+ },
339
+ [mode, onChange],
340
+ );
341
+
342
+ // Wrapper + canvas styles. Inline (following the ck-* CSS var
343
+ // conventions) so consumers get a functional pad without needing
344
+ // to import a CSS file.
345
+ const wrapStyle: CSSProperties = useMemo(
346
+ () => ({
347
+ display: "flex",
348
+ flexDirection: "column",
349
+ gap: 8,
350
+ width: "fit-content",
351
+ }),
352
+ [],
353
+ );
354
+ const canvasStyle: CSSProperties = useMemo(
355
+ () => ({
356
+ width,
357
+ height,
358
+ border: "1px solid var(--ck-border-subtle, #d4d4d8)",
359
+ borderRadius: "var(--ck-radius-sm, 6px)",
360
+ background: "var(--ck-bg-surface, #ffffff)",
361
+ // touch-action: none is the load-bearing bit for iOS Safari
362
+ // drawn mode — otherwise the page scrolls instead of drawing.
363
+ touchAction: "none",
364
+ cursor: mode === "drawn" ? "crosshair" : "default",
365
+ display: "block",
366
+ }),
367
+ [mode, width, height],
368
+ );
369
+ const typedInputStyle: CSSProperties = useMemo(
370
+ () => ({
371
+ padding: "6px 8px",
372
+ border: "1px solid var(--ck-border-subtle, #d4d4d8)",
373
+ borderRadius: "var(--ck-radius-sm, 6px)",
374
+ background: "var(--ck-bg-surface, #ffffff)",
375
+ color: "var(--ck-text-primary, #0b1220)",
376
+ fontSize: 14,
377
+ fontFamily: "inherit",
378
+ width,
379
+ }),
380
+ [width],
381
+ );
382
+
383
+ return (
384
+ <div className={className} style={wrapStyle}>
385
+ {mode === "typed" ? (
386
+ <input
387
+ type="text"
388
+ value={typedName}
389
+ placeholder="Type your name"
390
+ onChange={(e) => onTypedNameChange?.(e.target.value)}
391
+ style={typedInputStyle}
392
+ aria-label="Type your signature"
393
+ />
394
+ ) : null}
395
+ <canvas
396
+ ref={canvasRef}
397
+ width={width}
398
+ height={height}
399
+ style={canvasStyle}
400
+ role="img"
401
+ aria-label={
402
+ mode === "typed"
403
+ ? "Typed signature preview"
404
+ : "Draw your signature"
405
+ }
406
+ onPointerDown={onPointerDown}
407
+ onPointerMove={onPointerMove}
408
+ onPointerUp={onPointerUp}
409
+ onPointerCancel={onPointerUp}
410
+ onPointerLeave={onPointerUp}
411
+ />
412
+ </div>
413
+ );
414
+ },
415
+ );
@@ -28,3 +28,7 @@ export { PageHeader } from "./PageHeader";
28
28
  export type { PageHeaderProps } from "./PageHeader";
29
29
  export { MentionCombobox, findActiveMentionStart, extractQuery } from "./MentionCombobox";
30
30
  export type { MentionComboboxProps, MentionComboboxHandle } from "./MentionCombobox";
31
+ export { SignaturePad } from "./SignaturePad";
32
+ export type { SignaturePadProps, SignaturePadHandle } from "./SignaturePad";
33
+ export { CopyField } from "./CopyField";
34
+ export type { CopyFieldProps } from "./CopyField";