@cosxai/ui 0.11.1 → 0.12.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.1",
3
+ "version": "0.12.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",
@@ -415,18 +415,25 @@ export function ActionBar({
415
415
  alignItems: "center",
416
416
  gap: 4,
417
417
  zIndex: 80,
418
- // Bar chrome stays constant across admin/viewer modes — the
419
- // whole-bar tint + accent border in earlier versions competed
420
- // with the shield toggle for signal and produced awkward hue
421
- // shifts under the item hover overlay (cool grey on tinted
422
- // warm base read as green). Mode state lives entirely on the
423
- // shield now: outline glyph = off, filled glyph = on.
418
+ // Bar background stays constant across modes — the earlier
419
+ // whole-bar tint composited under the item hover overlay
420
+ // (cool grey on tinted warm base) produced an awkward green
421
+ // cast on hover, and competed with the shield toggle for
422
+ // signal. Border colour DOES swap to accent when admin mode
423
+ // is on so the elevated state reads at a glance without a
424
+ // heavy surface change; the shield glyph adds a second cue
425
+ // (outline off → filled on) at the toggle itself.
424
426
  background: "var(--ck-bg-surface)",
425
- border: "1px solid var(--ck-border-subtle)",
427
+ border: `1px solid ${
428
+ adminMode
429
+ ? "var(--ck-accent, #4f46e5)"
430
+ : "var(--ck-border-subtle)"
431
+ }`,
426
432
  borderRadius: 999,
427
433
  boxShadow: "var(--ck-shadow-3)",
428
434
  fontFamily: "var(--ck-font-sans)",
429
435
  color: "var(--ck-text-primary)",
436
+ transition: "border-color 200ms var(--ck-ease, ease)",
430
437
  ...style,
431
438
  }}
432
439
  >
@@ -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,5 @@ 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";