penseat 0.1.0 → 0.3.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/index.mjs CHANGED
@@ -42,7 +42,6 @@ const hasSrc = existsSync(join(cwd, "src"));
42
42
  const base = hasSrc ? join(cwd, "src") : cwd;
43
43
 
44
44
  const componentsDir = join(base, "components", "penseat");
45
- const uiDir = join(componentsDir, "ui");
46
45
  const libDir = join(base, "lib");
47
46
 
48
47
  // ── Check if already installed ──
@@ -56,23 +55,16 @@ if (existsSync(join(componentsDir, "penseat.tsx"))) {
56
55
  console.log(` ${dim("Installing to")} ${cyan(componentsDir.replace(cwd + "/", ""))}`);
57
56
  console.log();
58
57
 
59
- mkdirSync(uiDir, { recursive: true });
58
+ mkdirSync(componentsDir, { recursive: true });
60
59
  mkdirSync(libDir, { recursive: true });
61
60
 
62
61
  const files = [
63
62
  { from: "components/penseat.tsx", to: join(componentsDir, "penseat.tsx") },
64
63
  { from: "components/penseat-bar.tsx", to: join(componentsDir, "penseat-bar.tsx") },
65
64
  { from: "components/drawing-canvas.tsx", to: join(componentsDir, "drawing-canvas.tsx") },
66
- { from: "components/ui/button.tsx", to: join(componentsDir, "ui", "button.tsx") },
67
65
  { from: "lib/capture.ts", to: join(libDir, "capture.ts") },
68
66
  ];
69
67
 
70
- // Only copy utils.ts if it doesn't exist (shadcn projects already have it)
71
- const utilsTarget = join(libDir, "utils.ts");
72
- if (!existsSync(utilsTarget)) {
73
- files.push({ from: "lib/utils.ts", to: utilsTarget });
74
- }
75
-
76
68
  for (const f of files) {
77
69
  copyFileSync(join(TEMPLATES, f.from), f.to);
78
70
  }
@@ -82,11 +74,6 @@ console.log(` ${green("+")} Copied ${files.length} files`);
82
74
  // ── Install dependencies ──
83
75
  const needed = [
84
76
  "html2canvas-pro",
85
- "lucide-react",
86
- "@base-ui/react",
87
- "class-variance-authority",
88
- "clsx",
89
- "tailwind-merge",
90
77
  ];
91
78
 
92
79
  const missing = needed.filter((d) => !deps[d]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "penseat",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Draw on your screen, copy to clipboard. A devtool for annotating and sharing.",
5
5
  "bin": {
6
6
  "penseat": "./index.mjs"
@@ -601,8 +601,10 @@ const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
601
601
  <canvas
602
602
  ref={canvasRef}
603
603
  data-penseat="canvas"
604
- className="absolute top-0 left-0"
605
604
  style={{
605
+ position: "absolute",
606
+ top: 0,
607
+ left: 0,
606
608
  pointerEvents: active ? "auto" : "none",
607
609
  cursor: active
608
610
  ? color === ERASER_COLOR
@@ -616,8 +618,13 @@ const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
616
618
  {active && isEraser && (
617
619
  <div
618
620
  ref={eraserCursorRef}
619
- className="fixed z-[99999] pointer-events-none -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white"
620
621
  style={{
622
+ position: "fixed",
623
+ zIndex: 99999,
624
+ pointerEvents: "none",
625
+ transform: "translate(-50%, -50%)",
626
+ borderRadius: "9999px",
627
+ border: "2px solid #ffffff",
621
628
  width: ERASER_RADIUS * 2,
622
629
  height: ERASER_RADIUS * 2,
623
630
  mixBlendMode: "difference",
@@ -2,8 +2,13 @@
2
2
 
3
3
  import { useState, useCallback, useRef, useEffect, createContext, useContext, type ReactNode } from "react";
4
4
  import { createPortal } from "react-dom";
5
- import { Undo2, Trash2, X, Copy, Check } from "lucide-react";
6
- import { Button } from "./ui/button";
5
+ // Inline SVG icons (from Lucide) zero dependencies
6
+ const iconProps = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
7
+ const Undo2Icon = <svg {...iconProps}><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>;
8
+ const Trash2Icon = <svg {...iconProps}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>;
9
+ const XIcon = <svg {...iconProps} width={20} height={20}><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>;
10
+ const CopyIcon = <svg {...iconProps}><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>;
11
+ const CheckIcon = <svg {...iconProps} strokeWidth={3}><path d="M20 6 9 17l-5-5"/></svg>;
7
12
 
8
13
  const TOOLTIP_DELAY = 360;
9
14
  const TOOLTIP_GAP = 10;
@@ -25,13 +30,26 @@ const CMD_ICON = (
25
30
  function Keycap({ children }: { children: ReactNode }) {
26
31
  const content = children === "command" ? CMD_ICON : children === "shift" ? SHIFT_ICON : children;
27
32
  return (
28
- <kbd className="inline-flex items-center justify-center size-5 rounded bg-zinc-700 border border-zinc-600 text-[13px] font-mono leading-none text-zinc-300">
33
+ <kbd style={{
34
+ display: 'inline-flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ width: '20px',
38
+ height: '20px',
39
+ borderRadius: '4px',
40
+ backgroundColor: '#3f3f46',
41
+ border: '1px solid #52525b',
42
+ fontSize: '13px',
43
+ fontFamily: 'monospace',
44
+ lineHeight: 1,
45
+ color: '#d4d4d8',
46
+ }}>
29
47
  {content}
30
48
  </kbd>
31
49
  );
32
50
  }
33
51
 
34
- function WithTooltip({ label, shortcut, children, pressed }: { label: string; shortcut?: string[]; children: ReactNode; pressed?: boolean }) {
52
+ function WithTooltip({ label, shortcut, children, pressed, forceShow }: { label: string; shortcut?: string[]; children: ReactNode; pressed?: boolean; forceShow?: boolean }) {
35
53
  const below = useContext(TooltipBelowCtx);
36
54
  const [hoverShow, setHoverShow] = useState(false);
37
55
  const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null);
@@ -45,6 +63,11 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
45
63
  }
46
64
  }, []);
47
65
 
66
+ // Force-show: compute position on mount when forceShow is true
67
+ useEffect(() => {
68
+ if (forceShow) computePos();
69
+ }, [forceShow, computePos]);
70
+
48
71
  const onEnter = () => {
49
72
  timerRef.current = setTimeout(() => {
50
73
  computePos();
@@ -61,17 +84,24 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
61
84
  return (
62
85
  <div
63
86
  ref={wrapRef}
64
- className="flex items-center self-stretch transition-transform duration-100"
65
- style={{ transform: pressed ? "scale(0.95)" : undefined }}
87
+ style={{
88
+ display: 'flex',
89
+ alignItems: 'center',
90
+ alignSelf: 'stretch',
91
+ transition: 'transform 100ms',
92
+ transform: pressed ? 'scale(0.95)' : undefined,
93
+ }}
66
94
  onPointerEnter={onEnter}
67
95
  onPointerLeave={onLeave}
68
96
  >
69
97
  {children}
70
- {hoverShow && pos && createPortal(
98
+ {(hoverShow || forceShow) && pos && createPortal(
71
99
  <div
72
100
  data-penseat="tooltip"
73
- className="fixed z-[99999] pointer-events-none"
74
101
  style={{
102
+ position: 'fixed',
103
+ zIndex: 99999,
104
+ pointerEvents: 'none',
75
105
  left: pos.x,
76
106
  top: below ? pos.bottom + TOOLTIP_GAP : pos.top - TOOLTIP_GAP,
77
107
  translate: below ? "-50% 0" : "-50% -100%",
@@ -79,10 +109,20 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
79
109
  animation: below ? "tooltip-in-below 150ms ease-out" : "tooltip-in-above 150ms ease-out",
80
110
  }}
81
111
  >
82
- <div className={`flex items-center gap-2 rounded-lg bg-[#27272A] pl-3 py-1.5 ${shortcut && shortcut.length > 0 ? "pr-1.5" : "pr-3"}`}>
83
- <span className="text-xs font-mono text-zinc-200">{label}</span>
112
+ <div style={{
113
+ display: 'flex',
114
+ alignItems: 'center',
115
+ gap: '8px',
116
+ borderRadius: '8px',
117
+ backgroundColor: '#27272A',
118
+ paddingLeft: '12px',
119
+ paddingTop: '6px',
120
+ paddingBottom: '6px',
121
+ paddingRight: shortcut && shortcut.length > 0 ? '6px' : '12px',
122
+ }}>
123
+ <span style={{ fontSize: '12px', fontFamily: 'monospace', color: '#e4e4e7' }}>{label}</span>
84
124
  {shortcut && shortcut.length > 0 && (
85
- <div className="flex items-center gap-0.5">
125
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
86
126
  {shortcut.map((key, i) => (
87
127
  <Keycap key={i}>{key}</Keycap>
88
128
  ))}
@@ -131,15 +171,27 @@ interface PenseatBarProps {
131
171
  }
132
172
 
133
173
  function EraserDot({ active, onClick }: { active: boolean; onClick: () => void }) {
174
+ const [hovered, setHovered] = useState(false);
134
175
  return (
135
176
  <div
136
177
  onClick={onClick}
137
- className="size-5 rounded-full cursor-pointer transition-transform hover:scale-110 shrink-0 flex items-center justify-center"
178
+ onPointerEnter={() => setHovered(true)}
179
+ onPointerLeave={() => setHovered(false)}
138
180
  style={{
181
+ width: '20px',
182
+ height: '20px',
183
+ borderRadius: '9999px',
184
+ cursor: 'pointer',
185
+ transition: 'transform 150ms',
186
+ transform: hovered ? 'scale(1.1)' : 'scale(1)',
187
+ flexShrink: 0,
188
+ display: 'flex',
189
+ alignItems: 'center',
190
+ justifyContent: 'center',
139
191
  boxShadow: active ? "0 0 0 2px #18181b, 0 0 0 3px #a1a1aa" : "none",
140
192
  }}
141
193
  >
142
- <svg viewBox="0 0 20 20" className="size-5">
194
+ <svg viewBox="0 0 20 20" style={{ width: '20px', height: '20px' }}>
143
195
  <defs>
144
196
  <clipPath id="eraser-clip">
145
197
  <circle cx="10" cy="10" r="8.5" />
@@ -158,7 +210,7 @@ function EraserDot({ active, onClick }: { active: boolean; onClick: () => void }
158
210
 
159
211
  function ColorDots({ colors, active, onChange, pressedId }: { colors: typeof COLORS; active: string; onChange: (c: string) => void; pressedId: string | null }) {
160
212
  return (
161
- <div className="flex items-center gap-2 px-3 shrink-0">
213
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 12px', flexShrink: 0 }}>
162
214
  {colors.map((c, i) => {
163
215
  if (c.name === "Eraser") {
164
216
  return (
@@ -168,41 +220,105 @@ function ColorDots({ colors, active, onChange, pressedId }: { colors: typeof COL
168
220
  );
169
221
  }
170
222
  return (
171
- <WithTooltip key={c.value} label={c.name} shortcut={[String(i + 1)]} pressed={pressedId === `color-${i + 1}`}>
172
- <div
173
- onClick={() => onChange(c.value)}
174
- className="size-5 rounded-full cursor-pointer transition-transform hover:scale-110 shrink-0"
175
- style={{
176
- backgroundColor: c.value,
177
- boxShadow: active === c.value ? `0 0 0 2px #18181b, 0 0 0 3px ${c.value}` : "none",
178
- }}
179
- />
180
- </WithTooltip>
223
+ <ColorDot key={c.value} color={c} index={i} active={active === c.value} onChange={onChange} pressedId={pressedId} />
181
224
  );
182
225
  })}
183
226
  </div>
184
227
  );
185
228
  }
186
229
 
230
+ function ColorDot({ color: c, index: i, active, onChange, pressedId }: { color: typeof COLORS[number]; index: number; active: boolean; onChange: (c: string) => void; pressedId: string | null }) {
231
+ const [hovered, setHovered] = useState(false);
232
+ return (
233
+ <WithTooltip label={c.name} shortcut={[String(i + 1)]} pressed={pressedId === `color-${i + 1}`}>
234
+ <div
235
+ onClick={() => onChange(c.value)}
236
+ onPointerEnter={() => setHovered(true)}
237
+ onPointerLeave={() => setHovered(false)}
238
+ style={{
239
+ width: '20px',
240
+ height: '20px',
241
+ borderRadius: '9999px',
242
+ cursor: 'pointer',
243
+ transition: 'transform 150ms',
244
+ transform: hovered ? 'scale(1.1)' : 'scale(1)',
245
+ flexShrink: 0,
246
+ backgroundColor: c.value,
247
+ boxShadow: active ? `0 0 0 2px #18181b, 0 0 0 3px ${c.value}` : "none",
248
+ }}
249
+ />
250
+ </WithTooltip>
251
+ );
252
+ }
253
+
254
+ function ActionButton({ icon, ariaLabel, onClick, pressed }: { icon: ReactNode; ariaLabel: string; onClick: () => void; pressed: boolean }) {
255
+ const [hovered, setHovered] = useState(false);
256
+ const [active, setActive] = useState(false);
257
+ const isDown = pressed || active;
258
+ return (
259
+ <button
260
+ tabIndex={-1}
261
+ data-penseat-btn
262
+ aria-label={ariaLabel}
263
+ onClick={onClick}
264
+ onPointerEnter={() => setHovered(true)}
265
+ onPointerLeave={() => { setHovered(false); setActive(false); }}
266
+ onPointerDown={() => setActive(true)}
267
+ onPointerUp={() => setActive(false)}
268
+ style={{
269
+ display: 'flex',
270
+ alignItems: 'center',
271
+ justifyContent: 'center',
272
+ width: '32px',
273
+ height: '32px',
274
+ flexShrink: 0,
275
+ cursor: 'pointer',
276
+ transition: 'all 150ms',
277
+ transform: isDown ? 'scale(0.9)' : undefined,
278
+ color: (isDown || hovered) ? '#f4f4f5' : '#a1a1aa',
279
+ backgroundColor: (isDown || hovered) ? '#3f3f46' : 'transparent',
280
+ border: 'none',
281
+ outline: 'none',
282
+ borderRadius: '6px',
283
+ padding: 0,
284
+ }}
285
+ >
286
+ {icon}
287
+ </button>
288
+ );
289
+ }
290
+
187
291
  function Actions({ onUndo, onClear, pressedId }: { onUndo: () => void; onClear: () => void; pressedId: string | null }) {
188
292
  return (
189
- <div className="flex items-center gap-1 px-1 animate-in fade-in duration-200">
293
+ <div style={{
294
+ display: 'flex',
295
+ alignItems: 'center',
296
+ gap: '4px',
297
+ padding: '0 4px',
298
+ opacity: 1,
299
+ }}>
190
300
  <WithTooltip label="Undo" shortcut={["command", "Z"]} pressed={pressedId === "undo"}>
191
- <Button tabIndex={-1} variant="ghost" size="icon" aria-label="Undo" className={`size-8 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 shrink-0 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${pressedId === "undo" ? "bg-zinc-800 text-zinc-100" : ""}`} onClick={onUndo}>
192
- <Undo2 className="size-4" />
193
- </Button>
301
+ <ActionButton
302
+ icon={Undo2Icon}
303
+ ariaLabel="Undo"
304
+ onClick={onUndo}
305
+ pressed={pressedId === "undo"}
306
+ />
194
307
  </WithTooltip>
195
308
  <WithTooltip label="Clear all" shortcut={["X"]} pressed={pressedId === "clear"}>
196
- <Button tabIndex={-1} variant="ghost" size="icon" aria-label="Clear all" className={`size-8 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 shrink-0 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${pressedId === "clear" ? "bg-zinc-800 text-zinc-100" : ""}`} onClick={onClear}>
197
- <Trash2 className="size-4" />
198
- </Button>
309
+ <ActionButton
310
+ icon={Trash2Icon}
311
+ ariaLabel="Clear all"
312
+ onClick={onClear}
313
+ pressed={pressedId === "clear"}
314
+ />
199
315
  </WithTooltip>
200
316
  </div>
201
317
  );
202
318
  }
203
319
 
204
320
  const PEN_ICON = (
205
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="size-5" fill="none">
321
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style={{ width: '20px', height: '20px' }} fill="none">
206
322
  <path fillRule="evenodd" clipRule="evenodd" d="M20.1507 2.76256C19.4673 2.07914 18.3593 2.07915 17.6759 2.76256L16.0006 4.43796L16.0001 4.4375L14.9395 5.49816L18.9091 9.46783C19.202 9.76072 19.202 10.2356 18.9091 10.5285L16.9698 12.4678C16.6769 12.7607 16.6769 13.2356 16.9698 13.5285C17.2627 13.8214 17.7375 13.8214 18.0304 13.5285L19.9698 11.5892C20.8485 10.7105 20.8485 9.28585 19.9698 8.40717L19.5612 7.99862L21.2365 6.32322C21.9199 5.63981 21.9199 4.53177 21.2365 3.84835L20.1507 2.76256ZM17.6159 9.94413L14.0552 6.38347L8.49985 11.9392L12.0605 15.4999L17.6159 9.94413ZM3.83613 16.6032L7.43923 12.9999L10.9999 16.5605L7.39683 20.1639C7.06636 20.4943 6.65711 20.7351 6.20775 20.8635L3.20606 21.7211C2.94416 21.796 2.66229 21.7229 2.46969 21.5303C2.27709 21.3377 2.20405 21.0559 2.27888 20.794L3.1365 17.7923C3.26489 17.3429 3.50567 16.9337 3.83613 16.6032Z" fill="currentColor" />
207
323
  </svg>
208
324
  );
@@ -216,6 +332,7 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
216
332
  pressed?: boolean;
217
333
  rightExpand: boolean;
218
334
  }) {
335
+ const [hovered, setHovered] = useState(false);
219
336
  // Spin toward the toolbar: clockwise when expanding right, counter-clockwise when left
220
337
  const spinDeg = rightExpand ? 90 : -90;
221
338
  return (
@@ -224,18 +341,39 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
224
341
  data-penseat="trigger"
225
342
  onClick={(e) => { (e.currentTarget as HTMLElement).blur(); if (!wasDragRef.current) onToggle(); }}
226
343
  onPointerDown={expanded ? undefined : onPointerDown}
227
- className="relative flex items-center justify-center shrink-0 cursor-pointer transition-colors [&:hover]:bg-zinc-800 rounded-full"
344
+ onPointerEnter={() => setHovered(true)}
345
+ onPointerLeave={() => setHovered(false)}
228
346
  style={{
347
+ position: 'relative',
348
+ display: 'flex',
349
+ alignItems: 'center',
350
+ justifyContent: 'center',
351
+ flexShrink: 0,
352
+ cursor: 'pointer',
353
+ backgroundColor: hovered ? '#3f3f46' : 'transparent',
354
+ borderRadius: '9999px',
355
+ border: 'none',
356
+ outline: 'none',
357
+ padding: 0,
229
358
  width: BAR_HEIGHT - 4,
230
359
  height: BAR_HEIGHT - 4,
231
360
  transform: pressed ? "scale(0.9)" : undefined,
232
- transition: "transform 100ms",
361
+ transition: "transform 100ms, background-color 150ms",
233
362
  }}
234
363
  >
235
364
  {/* Pen icon — visible when collapsed */}
236
365
  <span
237
- className="absolute inset-0 flex items-center justify-center text-white transition-all duration-200 ease-in-out"
238
366
  style={{
367
+ position: 'absolute',
368
+ top: 0,
369
+ left: 0,
370
+ right: 0,
371
+ bottom: 0,
372
+ display: 'flex',
373
+ alignItems: 'center',
374
+ justifyContent: 'center',
375
+ color: '#ffffff',
376
+ transition: 'all 200ms ease-in-out',
239
377
  opacity: expanded ? 0 : 1,
240
378
  transform: expanded ? "scale(0.5)" : "scale(1)",
241
379
  filter: expanded ? "blur(4px)" : "blur(0px)",
@@ -247,8 +385,17 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
247
385
  </span>
248
386
  {/* Close icon — spins in from the toolbar direction */}
249
387
  <span
250
- className="absolute inset-0 flex items-center justify-center text-zinc-400 transition-all duration-300 ease-in-out"
251
388
  style={{
389
+ position: 'absolute',
390
+ top: 0,
391
+ left: 0,
392
+ right: 0,
393
+ bottom: 0,
394
+ display: 'flex',
395
+ alignItems: 'center',
396
+ justifyContent: 'center',
397
+ color: '#a1a1aa',
398
+ transition: 'all 300ms ease-in-out',
252
399
  opacity: expanded ? 1 : 0,
253
400
  transform: expanded ? "scale(1) rotate(0deg)" : `scale(0.5) rotate(${-spinDeg}deg)`,
254
401
  filter: expanded ? "blur(0px)" : "blur(4px)",
@@ -256,44 +403,96 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
256
403
  transitionDelay: expanded ? "0ms" : "60ms",
257
404
  }}
258
405
  >
259
- <X className="size-5" />
406
+ {XIcon}
260
407
  </span>
261
408
  </button>
262
409
  );
263
410
  }
264
411
 
265
412
  function DoneBtn({ onClick, disabled, capturing, copied, pressed, shake }: { onClick: () => void; disabled: boolean; capturing: boolean; copied: boolean; pressed: boolean; shake?: boolean }) {
413
+ const [hovered, setHovered] = useState(false);
414
+ const [active, setActive] = useState(false);
415
+ const isDown = (pressed || active) && !shake;
266
416
  const anim = copied ? "copy-bounce 600ms ease-out" : shake ? "nope-shake 320ms ease-out" : undefined;
267
417
  return (
268
- <div className="flex items-center self-stretch" style={anim ? { animation: anim } : undefined}>
269
- <WithTooltip key={copied ? "copied" : "copy"} label={copied ? "Copied!" : "Copy"} shortcut={copied ? undefined : ["command", "C"]} pressed={pressed}>
270
- <Button
418
+ <div style={{
419
+ display: 'flex',
420
+ alignItems: 'center',
421
+ alignSelf: 'stretch',
422
+ animation: anim,
423
+ }}>
424
+ <WithTooltip label={copied ? "Copied!" : "Copy"} shortcut={copied ? undefined : ["command", "C"]} pressed={pressed} forceShow={copied}>
425
+ <button
271
426
  tabIndex={-1}
272
- variant="ghost"
273
- size="icon"
427
+ data-penseat-btn
274
428
  onClick={onClick}
429
+ onPointerEnter={() => setHovered(true)}
430
+ onPointerLeave={() => { setHovered(false); setActive(false); }}
431
+ onPointerDown={() => setActive(true)}
432
+ onPointerUp={() => setActive(false)}
275
433
  disabled={disabled && !copied}
276
434
  aria-label="Done"
277
- className={`size-8 shrink-0 animate-in fade-in duration-200 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${
278
- copied
279
- ? "bg-blue-500 text-white hover:bg-blue-500 hover:text-white"
280
- : pressed
281
- ? "bg-zinc-800 text-zinc-100"
282
- : "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
283
- } disabled:opacity-50`}
284
- style={{ borderRadius: copied ? 9999 : undefined }}
435
+ style={{
436
+ display: 'flex',
437
+ alignItems: 'center',
438
+ justifyContent: 'center',
439
+ width: '32px',
440
+ height: '32px',
441
+ flexShrink: 0,
442
+ cursor: 'pointer',
443
+ transition: 'all 200ms',
444
+ transform: isDown ? 'scale(0.9)' : undefined,
445
+ border: 'none',
446
+ outline: 'none',
447
+ padding: 0,
448
+ borderRadius: '6px',
449
+ backgroundColor: (isDown || hovered) ? '#3f3f46' : 'transparent',
450
+ color: (isDown || hovered) ? '#f4f4f5' : '#a1a1aa',
451
+ opacity: (disabled && !copied) ? 0.5 : 1,
452
+ }}
285
453
  >
286
454
  {capturing ? (
287
- <span className="size-3 animate-spin rounded-full border-2 border-zinc-400/30 border-t-zinc-400" />
455
+ <span style={{
456
+ width: '12px',
457
+ height: '12px',
458
+ borderRadius: '9999px',
459
+ border: '2px solid rgba(161,161,170,0.3)',
460
+ borderTopColor: '#a1a1aa',
461
+ animation: 'penseat-spin 1s linear infinite',
462
+ }} />
288
463
  ) : (
289
- <span className="relative size-4">
290
- <Copy className="size-4 absolute inset-0 transition-all duration-200" style={{ opacity: copied ? 0 : 1, transform: copied ? "scale(0.5)" : "scale(1)" }} />
291
- <Check className="size-4 absolute inset-0 transition-all duration-200" strokeWidth={3} style={{ opacity: copied ? 1 : 0, transform: copied ? "scale(1)" : "scale(0.5)" }} />
464
+ <span style={{ position: 'relative', width: '16px', height: '16px' }}>
465
+ <span style={{
466
+ position: 'absolute',
467
+ top: 0,
468
+ left: 0,
469
+ right: 0,
470
+ bottom: 0,
471
+ display: 'flex',
472
+ alignItems: 'center',
473
+ justifyContent: 'center',
474
+ transition: 'all 180ms ease-out',
475
+ opacity: copied ? 0 : 1,
476
+ transform: copied ? "scale(0.5)" : "scale(1)",
477
+ }}>{CopyIcon}</span>
478
+ <span style={{
479
+ position: 'absolute',
480
+ top: 0,
481
+ left: 0,
482
+ right: 0,
483
+ bottom: 0,
484
+ display: 'flex',
485
+ alignItems: 'center',
486
+ justifyContent: 'center',
487
+ transition: 'all 180ms ease-out',
488
+ opacity: copied ? 1 : 0,
489
+ transform: copied ? "scale(1)" : "scale(0.5)",
490
+ }}>{CheckIcon}</span>
292
491
  </span>
293
492
  )}
294
- </Button>
493
+ </button>
295
494
  </WithTooltip>
296
- {(shake || copied) && (
495
+ {(shake || copied || capturing) && (
297
496
  <style>{`
298
497
  @keyframes nope-shake {
299
498
  0% { transform: translateX(0); }
@@ -309,6 +508,13 @@ function DoneBtn({ onClick, disabled, capturing, copied, pressed, shake }: { onC
309
508
  70% { transform: translateY(-2px); }
310
509
  100% { transform: translateY(0); }
311
510
  }
511
+ @keyframes penseat-spin {
512
+ from { transform: rotate(0deg); }
513
+ to { transform: rotate(360deg); }
514
+ }
515
+ [data-penseat-btn]:active {
516
+ transform: scale(0.9) !important;
517
+ }
312
518
  `}</style>
313
519
  )}
314
520
  </div>
@@ -616,8 +822,17 @@ export default function PenseatBar({
616
822
  {isDragging && previewTarget && dragPos && (
617
823
  <svg
618
824
  data-penseat="projectile"
619
- className="fixed inset-0 z-[9998] pointer-events-none"
620
- style={{ width: "100%", height: "100%" }}
825
+ style={{
826
+ position: 'fixed',
827
+ top: 0,
828
+ left: 0,
829
+ right: 0,
830
+ bottom: 0,
831
+ zIndex: 9998,
832
+ pointerEvents: 'none',
833
+ width: '100%',
834
+ height: '100%',
835
+ }}
621
836
  >
622
837
  <line
623
838
  x1={dragPos.x}
@@ -643,8 +858,18 @@ export default function PenseatBar({
643
858
  <div
644
859
  data-penseat="bar"
645
860
  onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") e.preventDefault(); }}
646
- className={`fixed z-[9999] flex items-center overflow-hidden rounded-full bg-zinc-800 border-[1.5px] border-white/25 shadow-[0_0_0_0.5px_rgba(255,255,255,0.06),0_1px_3px_rgba(0,0,0,0.15),inset_0_0.5px_0_rgba(255,255,255,0.06)] ${isDragging ? "opacity-80 scale-105" : ""} ${isFlying ? "scale-95" : ""}`}
647
861
  style={{
862
+ position: 'fixed',
863
+ zIndex: 9999,
864
+ display: 'flex',
865
+ alignItems: 'center',
866
+ overflow: 'hidden',
867
+ borderRadius: '9999px',
868
+ backgroundColor: '#27272a',
869
+ border: '1.5px solid rgba(255,255,255,0.25)',
870
+ boxShadow: '0 0 0 0.5px rgba(255,255,255,0.06), 0 1px 3px rgba(0,0,0,0.15), inset 0 0.5px 0 rgba(255,255,255,0.06)',
871
+ opacity: isDragging ? 0.8 : 1,
872
+ transform: isDragging ? 'scale(1.05)' : isFlying ? 'scale(0.95)' : undefined,
648
873
  ...posStyle,
649
874
  height: BAR_HEIGHT,
650
875
  width: expanded ? contentWidth + (BAR_HEIGHT - 4) + 4 : BAR_HEIGHT,
@@ -663,11 +888,18 @@ export default function PenseatBar({
663
888
  {/* Toolbar content — always in DOM, revealed/hidden by width animation */}
664
889
  <div
665
890
  ref={contentRef}
666
- className="flex flex-row items-stretch self-stretch shrink-0 pr-2"
667
- style={{ pointerEvents: expanded ? "auto" : "none" }}
891
+ style={{
892
+ display: 'flex',
893
+ flexDirection: 'row',
894
+ alignItems: 'stretch',
895
+ alignSelf: 'stretch',
896
+ flexShrink: 0,
897
+ paddingRight: '8px',
898
+ pointerEvents: expanded ? 'auto' : 'none',
899
+ }}
668
900
  >
669
901
  <ColorDots colors={COLORS} active={color} onChange={onColorChange} pressedId={pressedId} />
670
- <Actions onUndo={onUndo} onClear={onClear} pressedId={pressedId} />
902
+ <Actions onUndo={onUndo} onClear={onClear} pressedId={pressedId} />
671
903
  <DoneBtn onClick={onDone} disabled={capturing || !!copied} capturing={capturing} copied={!!copied} pressed={pressedId === "copy"} shake={shake} />
672
904
  </div>
673
905
  </div>
@@ -53,7 +53,7 @@ export default function Penseat() {
53
53
  setCopied(true);
54
54
  setTimeout(() => {
55
55
  setCopied(false);
56
- }, 1200);
56
+ }, 2000);
57
57
  }).catch((err) => {
58
58
  console.error("Capture failed:", err);
59
59
  triggerShake();
@@ -1,60 +0,0 @@
1
- "use client"
2
-
3
- import { Button as ButtonPrimitive } from "@base-ui/react/button"
4
- import { cva, type VariantProps } from "class-variance-authority"
5
-
6
- import { cn } from "@/lib/utils"
7
-
8
- const buttonVariants = cva(
9
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
10
- {
11
- variants: {
12
- variant: {
13
- default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
14
- outline:
15
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
16
- secondary:
17
- "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
18
- ghost:
19
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
20
- destructive:
21
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
22
- link: "text-primary underline-offset-4 hover:underline",
23
- },
24
- size: {
25
- default:
26
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
27
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
28
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
29
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
30
- icon: "size-8",
31
- "icon-xs":
32
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
33
- "icon-sm":
34
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
35
- "icon-lg": "size-9",
36
- },
37
- },
38
- defaultVariants: {
39
- variant: "default",
40
- size: "default",
41
- },
42
- }
43
- )
44
-
45
- function Button({
46
- className,
47
- variant = "default",
48
- size = "default",
49
- ...props
50
- }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
51
- return (
52
- <ButtonPrimitive
53
- data-slot="button"
54
- className={cn(buttonVariants({ variant, size, className }))}
55
- {...props}
56
- />
57
- )
58
- }
59
-
60
- export { Button, buttonVariants }
@@ -1,6 +0,0 @@
1
- import { clsx, type ClassValue } from "clsx"
2
- import { twMerge } from "tailwind-merge"
3
-
4
- export function cn(...inputs: ClassValue[]) {
5
- return twMerge(clsx(inputs))
6
- }