@playfast/reform-better-hub-ui-primitives 0.0.5 → 0.2.1

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
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- # `@playfast/better-hub-ui-primitives`
3
+ # `@playfast/reform-better-hub-ui-primitives`
4
4
 
5
5
  **React UI primitives — buttons, badges, dialogs, inputs, popovers, a command palette, and friends — adapted from better-hub by Better Auth.**
6
6
 
@@ -36,7 +36,7 @@ Docs: [playbook/api.doc.md](./playbook/api.doc.md).
36
36
  ## Install
37
37
 
38
38
  ```sh
39
- bun add @playfast/better-hub-ui-primitives
39
+ bun add @playfast/reform-better-hub-ui-primitives
40
40
  # peers: react, react-dom (^19)
41
41
  ```
42
42
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-better-hub-ui-primitives",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.5",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "description": "React UI primitives adapted from better-hub by Better Auth (https://github.com/better-auth/better-hub). Full credit to the Better Auth team.",
7
7
  "keywords": [
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "exports": {
31
31
  "./package.json": "./package.json",
32
- ".": "./src/index.tsx",
32
+ ".": "./src/index.ts",
33
33
  "./styles.css": "./src/foundation/styles.css",
34
34
  "./*": "./src/*.tsx"
35
35
  },
@@ -3,7 +3,11 @@
3
3
 
4
4
  import type * as React from "react";
5
5
 
6
- export function AgentIcon({ className }: { className?: string }): React.JSX.Element {
6
+ interface AgentIconPropsExternalApi {
7
+ className?: string;
8
+ }
9
+
10
+ export function AgentIcon({ className }: AgentIconPropsExternalApi): React.JSX.Element {
7
11
  return (
8
12
  <svg
9
13
  xmlns="http://www.w3.org/2000/svg"
@@ -7,13 +7,15 @@ import { Slot } from "radix-ui";
7
7
 
8
8
  import { cn } from "../foundation/cn";
9
9
 
10
- const badgeVariants: (
10
+ type BadgeVariantsExternalApi = (
11
11
  props?:
12
12
  | ({
13
- variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | null | undefined;
13
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | undefined;
14
14
  } & ClassProp)
15
15
  | undefined,
16
- ) => string = cva(
16
+ ) => string;
17
+
18
+ const badgeVariants: BadgeVariantsExternalApi = cva(
17
19
  "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
18
20
  {
19
21
  variants: {
@@ -33,12 +35,15 @@ const badgeVariants: (
33
35
  },
34
36
  );
35
37
 
38
+ type BadgePropsExternalApi = React.ComponentProps<"span"> &
39
+ VariantProps<typeof badgeVariants> & { asChild?: boolean };
40
+
36
41
  function Badge({
37
42
  className,
38
43
  variant = "default",
39
44
  asChild = false,
40
45
  ...props
41
- }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }): React.JSX.Element {
46
+ }: BadgePropsExternalApi): React.JSX.Element {
42
47
  const Comp = asChild ? Slot.Root : "span";
43
48
 
44
49
  return (
@@ -7,7 +7,7 @@ import type { ClassProp } from "class-variance-authority/types";
7
7
  import type * as React from "react";
8
8
  import { cn } from "../foundation/cn";
9
9
 
10
- const buttonVariants: (
10
+ type ButtonVariantsExternalApi = (
11
11
  props?:
12
12
  | ({
13
13
  variant?:
@@ -17,12 +17,13 @@ const buttonVariants: (
17
17
  | "secondary"
18
18
  | "ghost"
19
19
  | "link"
20
- | null
21
20
  | undefined;
22
- size?: "default" | "sm" | "lg" | "icon" | null | undefined;
21
+ size?: "default" | "sm" | "lg" | "icon" | undefined;
23
22
  } & ClassProp)
24
23
  | undefined,
25
- ) => string = cva(
24
+ ) => string;
25
+
26
+ const buttonVariants: ButtonVariantsExternalApi = cva(
26
27
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
27
28
  {
28
29
  variants: {
@@ -49,16 +50,18 @@ const buttonVariants: (
49
50
  },
50
51
  );
51
52
 
53
+ type ButtonPropsExternalApi = React.ComponentProps<"button"> &
54
+ VariantProps<typeof buttonVariants> & {
55
+ asChild?: boolean;
56
+ };
57
+
52
58
  function Button({
53
59
  className,
54
60
  variant,
55
61
  size,
56
62
  asChild = false,
57
63
  ...props
58
- }: React.ComponentProps<"button"> &
59
- VariantProps<typeof buttonVariants> & {
60
- asChild?: boolean;
61
- }): React.JSX.Element {
64
+ }: ButtonPropsExternalApi): React.JSX.Element {
62
65
  const Comp = asChild ? Slot : "button";
63
66
  return (
64
67
  <Comp
@@ -49,6 +49,11 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props):
49
49
  )
50
50
  }
51
51
 
52
+ type ComboboxInputPropsExternalApi = ComboboxPrimitive.Input.Props & {
53
+ showTrigger?: boolean
54
+ showClear?: boolean
55
+ }
56
+
52
57
  function ComboboxInput({
53
58
  className,
54
59
  children,
@@ -56,10 +61,7 @@ function ComboboxInput({
56
61
  showTrigger = true,
57
62
  showClear = false,
58
63
  ...props
59
- }: ComboboxPrimitive.Input.Props & {
60
- showTrigger?: boolean
61
- showClear?: boolean
62
- }): React.JSX.Element {
64
+ }: ComboboxInputPropsExternalApi): React.JSX.Element {
63
65
  return (
64
66
  <InputGroup className={cn('w-auto', className)}>
65
67
  <ComboboxPrimitive.Input
@@ -225,14 +227,16 @@ function ComboboxChips({
225
227
  )
226
228
  }
227
229
 
230
+ type ComboboxChipPropsExternalApi = ComboboxPrimitive.Chip.Props & {
231
+ showRemove?: boolean
232
+ }
233
+
228
234
  function ComboboxChip({
229
235
  className,
230
236
  children,
231
237
  showRemove = true,
232
238
  ...props
233
- }: ComboboxPrimitive.Chip.Props & {
234
- showRemove?: boolean
235
- }): React.JSX.Element {
239
+ }: ComboboxChipPropsExternalApi): React.JSX.Element {
236
240
  return (
237
241
  <ComboboxPrimitive.Chip
238
242
  data-slot="combobox-chip"
@@ -266,8 +270,10 @@ function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive
266
270
  )
267
271
  }
268
272
 
269
- function useComboboxAnchor(): React.RefObject<HTMLDivElement | null> {
270
- return React.useRef<HTMLDivElement | null>(null)
273
+ type ComboboxAnchorElementExternalApi = HTMLDivElement | null
274
+
275
+ function useComboboxAnchor(): React.RefObject<ComboboxAnchorElementExternalApi> {
276
+ return React.useRef<ComboboxAnchorElementExternalApi>(null)
271
277
  }
272
278
 
273
279
  export {
@@ -26,6 +26,13 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
26
26
  );
27
27
  }
28
28
 
29
+ type CommandDialogPropsExternalApi = React.ComponentProps<typeof Dialog> & {
30
+ title?: string;
31
+ description?: string;
32
+ className?: string;
33
+ showCloseButton?: boolean;
34
+ };
35
+
29
36
  function CommandDialog({
30
37
  title = "Command Palette",
31
38
  description = "Search for a command to run...",
@@ -33,12 +40,7 @@ function CommandDialog({
33
40
  className,
34
41
  showCloseButton = true,
35
42
  ...props
36
- }: React.ComponentProps<typeof Dialog> & {
37
- title?: string;
38
- description?: string;
39
- className?: string;
40
- showCloseButton?: boolean;
41
- }): React.JSX.Element {
43
+ }: CommandDialogPropsExternalApi): React.JSX.Element {
42
44
  return (
43
45
  <Dialog {...props}>
44
46
  <DialogContent
@@ -38,14 +38,16 @@ function DialogOverlay({
38
38
  );
39
39
  }
40
40
 
41
+ type DialogContentPropsExternalApi = React.ComponentProps<typeof DialogPrimitive.Content> & {
42
+ showCloseButton?: boolean;
43
+ };
44
+
41
45
  function DialogContent({
42
46
  className,
43
47
  children,
44
48
  showCloseButton = true,
45
49
  ...props
46
- }: React.ComponentProps<typeof DialogPrimitive.Content> & {
47
- showCloseButton?: boolean;
48
- }): React.JSX.Element {
50
+ }: DialogContentPropsExternalApi): React.JSX.Element {
49
51
  return (
50
52
  <DialogPortal>
51
53
  <DialogOverlay />
@@ -46,13 +46,15 @@ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMen
46
46
  return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
47
47
  }
48
48
 
49
+ type DropdownMenuItemPropsExternalApi = React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
50
+ inset?: boolean;
51
+ };
52
+
49
53
  function DropdownMenuItem({
50
54
  className,
51
55
  inset,
52
56
  ...props
53
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
54
- inset?: boolean;
55
- }): React.JSX.Element {
57
+ }: DropdownMenuItemPropsExternalApi): React.JSX.Element {
56
58
  return (
57
59
  <DropdownMenuPrimitive.Item
58
60
  data-slot="dropdown-menu-item"
@@ -90,13 +92,15 @@ function DropdownMenuCheckboxItem({
90
92
  );
91
93
  }
92
94
 
95
+ type DropdownMenuLabelPropsExternalApi = React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
96
+ inset?: boolean;
97
+ };
98
+
93
99
  function DropdownMenuLabel({
94
100
  className,
95
101
  inset,
96
102
  ...props
97
- }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
98
- inset?: boolean;
99
- }): React.JSX.Element {
103
+ }: DropdownMenuLabelPropsExternalApi): React.JSX.Element {
100
104
  return (
101
105
  <DropdownMenuPrimitive.Label
102
106
  data-slot="dropdown-menu-label"
@@ -4,13 +4,16 @@
4
4
  import type * as React from "react";
5
5
  import { useMemo } from "react";
6
6
 
7
- function createRng(seed: number) {
8
- let s = seed;
9
- return () => {
10
- s = (s * 16807) % 2147483647;
11
- return (s - 1) / 2147483646;
12
- };
13
- }
7
+ const RNG_MULTIPLIER = 16807;
8
+ const RNG_MODULUS = 2147483647;
9
+ const RNG_DIVISOR = 2147483646;
10
+ const RNG_SEED = 42;
11
+ const WEEKS = 52;
12
+ const DAYS_PER_WEEK = 7;
13
+ const CELL = 15;
14
+ const CELL_RECT_SIZE = 12;
15
+ const CELL_ANIM_WEEK_DELAY = 0.04;
16
+ const CELL_ANIM_DAY_DELAY = 0.015;
14
17
 
15
18
  const LEVELS = [
16
19
  "rgba(255,255,255,0.02)",
@@ -20,22 +23,81 @@ const LEVELS = [
20
23
  "rgba(255,255,255,0.40)",
21
24
  ];
22
25
 
23
- const CELL = 15;
26
+ const LEVEL_THRESHOLDS = [0.3, 0.5, 0.7, 0.85];
27
+
28
+ const COMMIT_GROUPS = [
29
+ {
30
+ prefix: "m",
31
+ cx: 200,
32
+ radius: 4,
33
+ fill: "rgba(255,255,255,0.25)",
34
+ ys: [80, 150, 230, 300, 400, 480, 550, 620, 690, 770, 850],
35
+ animBase: 0.5,
36
+ animStep: 0.15,
37
+ },
38
+ {
39
+ prefix: "f1",
40
+ cx: 280,
41
+ radius: 3.5,
42
+ fill: "rgba(255,255,255,0.20)",
43
+ ys: [230, 280, 330],
44
+ animBase: 1.0,
45
+ animStep: 0.2,
46
+ },
47
+ {
48
+ prefix: "f2",
49
+ cx: 130,
50
+ radius: 3.5,
51
+ fill: "rgba(255,255,255,0.18)",
52
+ ys: [360, 420, 470],
53
+ animBase: 1.5,
54
+ animStep: 0.2,
55
+ },
56
+ {
57
+ prefix: "hf",
58
+ cx: 260,
59
+ radius: 3,
60
+ fill: "rgba(255,255,255,0.15)",
61
+ ys: [600, 630, 660],
62
+ animBase: 2.0,
63
+ animStep: 0.2,
64
+ },
65
+ {
66
+ prefix: "f3",
67
+ cx: 110,
68
+ radius: 3,
69
+ fill: "rgba(255,255,255,0.12)",
70
+ ys: [680, 730, 780],
71
+ animBase: 2.4,
72
+ animStep: 0.2,
73
+ },
74
+ ];
75
+
76
+ const nextSeed = (seed: number) => (seed * RNG_MULTIPLIER) % RNG_MODULUS;
77
+ const seedToUnit = (seed: number) => (seed - 1) / RNG_DIVISOR;
78
+ const levelForValue = (sample: number) =>
79
+ LEVEL_THRESHOLDS.filter((threshold) => sample >= threshold).length;
80
+
81
+ type Cell = { week: number; day: number; level: number };
24
82
 
25
83
  export function GitHubBackground(): React.JSX.Element {
26
- const grid = useMemo(() => {
27
- const rng = createRng(42);
28
- const cells: { w: number; d: number; level: number }[] = [];
29
- for (let w = 0; w < 52; w++) {
30
- for (let d = 0; d < 7; d++) {
31
- const r = rng();
32
- const level =
33
- r < 0.3 ? 0 : r < 0.5 ? 1 : r < 0.7 ? 2 : r < 0.85 ? 3 : 4;
34
- cells.push({ w, d, level });
35
- }
36
- }
37
- return cells;
38
- }, []);
84
+ const grid = useMemo<Cell[]>(
85
+ () =>
86
+ Array.from({ length: WEEKS * DAYS_PER_WEEK }).reduce<{
87
+ seed: number;
88
+ cells: Cell[];
89
+ }>(
90
+ (acc, _slot, flatIndex) => {
91
+ const seed = nextSeed(acc.seed);
92
+ const level = levelForValue(seedToUnit(seed));
93
+ const week = Math.floor(flatIndex / DAYS_PER_WEEK);
94
+ const day = flatIndex % DAYS_PER_WEEK;
95
+ return { seed, cells: [...acc.cells, { week, day, level }] };
96
+ },
97
+ { seed: RNG_SEED, cells: [] },
98
+ ).cells,
99
+ [],
100
+ );
39
101
 
40
102
  return (
41
103
  <div
@@ -63,19 +125,19 @@ export function GitHubBackground(): React.JSX.Element {
63
125
  }}
64
126
  >
65
127
  <svg
66
- width={52 * CELL}
67
- height={7 * CELL}
68
- viewBox={`0 0 ${52 * CELL} ${7 * CELL}`}
128
+ width={WEEKS * CELL}
129
+ height={DAYS_PER_WEEK * CELL}
130
+ viewBox={`0 0 ${WEEKS * CELL} ${DAYS_PER_WEEK * CELL}`}
69
131
  >
70
- {grid.map(({ w, d, level }, i) => (
132
+ {grid.map((cell, index) => (
71
133
  <rect
72
- key={i}
73
- x={w * CELL}
74
- y={d * CELL}
75
- width={12}
76
- height={12}
134
+ key={index}
135
+ x={cell.week * CELL}
136
+ y={cell.day * CELL}
137
+ width={CELL_RECT_SIZE}
138
+ height={CELL_RECT_SIZE}
77
139
  rx={2}
78
- fill={LEVELS[level]}
140
+ fill={LEVELS[cell.level]}
79
141
  opacity={0}
80
142
  >
81
143
  <animate
@@ -83,7 +145,7 @@ export function GitHubBackground(): React.JSX.Element {
83
145
  from="0"
84
146
  to="1"
85
147
  dur="0.5s"
86
- begin={`${w * 0.04 + d * 0.015}s`}
148
+ begin={`${cell.week * CELL_ANIM_WEEK_DELAY + cell.day * CELL_ANIM_DAY_DELAY}s`}
87
149
  fill="freeze"
88
150
  />
89
151
  </rect>
@@ -155,15 +217,15 @@ export function GitHubBackground(): React.JSX.Element {
155
217
  style={{ animationDelay: "2.2s" }}
156
218
  />
157
219
 
158
- {/* Commit nodes — main branch */}
159
- {[80, 150, 230, 300, 400, 480, 550, 620, 690, 770, 850].map(
160
- (y, i) => (
220
+ {/* Commit nodes */}
221
+ {COMMIT_GROUPS.map((group) =>
222
+ group.ys.map((nodeY, index) => (
161
223
  <circle
162
- key={`m${i}`}
163
- cx={200}
164
- cy={y}
165
- r={4}
166
- fill="rgba(255,255,255,0.25)"
224
+ key={`${group.prefix}${index}`}
225
+ cx={group.cx}
226
+ cy={nodeY}
227
+ r={group.radius}
228
+ fill={group.fill}
167
229
  opacity={0}
168
230
  >
169
231
  <animate
@@ -171,96 +233,12 @@ export function GitHubBackground(): React.JSX.Element {
171
233
  from="0"
172
234
  to="1"
173
235
  dur="0.3s"
174
- begin={`${0.5 + i * 0.15}s`}
236
+ begin={`${group.animBase + index * group.animStep}s`}
175
237
  fill="freeze"
176
238
  />
177
239
  </circle>
178
- ),
240
+ )),
179
241
  )}
180
-
181
- {/* Commit nodes — feature 1 */}
182
- {[230, 280, 330].map((y, i) => (
183
- <circle
184
- key={`f1${i}`}
185
- cx={280}
186
- cy={y}
187
- r={3.5}
188
- fill="rgba(255,255,255,0.20)"
189
- opacity={0}
190
- >
191
- <animate
192
- attributeName="opacity"
193
- from="0"
194
- to="1"
195
- dur="0.3s"
196
- begin={`${1.0 + i * 0.2}s`}
197
- fill="freeze"
198
- />
199
- </circle>
200
- ))}
201
-
202
- {/* Commit nodes — feature 2 */}
203
- {[360, 420, 470].map((y, i) => (
204
- <circle
205
- key={`f2${i}`}
206
- cx={130}
207
- cy={y}
208
- r={3.5}
209
- fill="rgba(255,255,255,0.18)"
210
- opacity={0}
211
- >
212
- <animate
213
- attributeName="opacity"
214
- from="0"
215
- to="1"
216
- dur="0.3s"
217
- begin={`${1.5 + i * 0.2}s`}
218
- fill="freeze"
219
- />
220
- </circle>
221
- ))}
222
-
223
- {/* Commit nodes — hotfix */}
224
- {[600, 630, 660].map((y, i) => (
225
- <circle
226
- key={`hf${i}`}
227
- cx={260}
228
- cy={y}
229
- r={3}
230
- fill="rgba(255,255,255,0.15)"
231
- opacity={0}
232
- >
233
- <animate
234
- attributeName="opacity"
235
- from="0"
236
- to="1"
237
- dur="0.3s"
238
- begin={`${2.0 + i * 0.2}s`}
239
- fill="freeze"
240
- />
241
- </circle>
242
- ))}
243
-
244
- {/* Commit nodes — feature 3 */}
245
- {[680, 730, 780].map((y, i) => (
246
- <circle
247
- key={`f3${i}`}
248
- cx={110}
249
- cy={y}
250
- r={3}
251
- fill="rgba(255,255,255,0.12)"
252
- opacity={0}
253
- >
254
- <animate
255
- attributeName="opacity"
256
- from="0"
257
- to="1"
258
- dur="0.3s"
259
- begin={`${2.4 + i * 0.2}s`}
260
- fill="freeze"
261
- />
262
- </circle>
263
- ))}
264
242
  </svg>
265
243
  </div>
266
244
 
@@ -143,6 +143,15 @@ void main() {
143
143
  gl_FragColor = vec4(vec3(col), 1.0);
144
144
  }`;
145
145
 
146
+ const RENDER_SCALE = 0.5;
147
+ const MS_PER_SECOND = 1000;
148
+ const MOUSE_LERP = 0.08;
149
+ const MOUSE_LERP_IDLE = 0.02;
150
+ const CLICK_DECAY = 0.96;
151
+ const FULLSCREEN_VERTEX_COUNT = 6;
152
+
153
+ type ShaderSpec = { readonly type: number; readonly source: string };
154
+
146
155
  export function HalftoneBackground(): React.JSX.Element {
147
156
  const canvasRef = useRef<HTMLCanvasElement>(null);
148
157
  const wrapperRef = useRef<HTMLDivElement>(null);
@@ -154,66 +163,76 @@ export function HalftoneBackground(): React.JSX.Element {
154
163
  useEffect(() => {
155
164
  const canvas = canvasRef.current;
156
165
  const wrapper = wrapperRef.current;
157
- if (!canvas || !wrapper) return;
166
+ if (!canvas || !wrapper) {
167
+ return;
168
+ }
158
169
 
159
- const gl = canvas.getContext("webgl", {
170
+ const glContext = canvas.getContext("webgl", {
160
171
  alpha: false,
161
172
  antialias: false,
162
173
  preserveDrawingBuffer: false,
163
174
  });
164
- if (!gl) return;
175
+ if (!glContext) {
176
+ return;
177
+ }
165
178
 
166
- function createShader(type: number, source: string) {
167
- const shader = gl!.createShader(type)!;
168
- gl!.shaderSource(shader, source);
169
- gl!.compileShader(shader);
179
+ const createShader = ({ type, source }: ShaderSpec) => {
180
+ const shader = glContext!.createShader(type)!;
181
+ glContext!.shaderSource(shader, source);
182
+ glContext!.compileShader(shader);
170
183
  return shader;
171
- }
184
+ };
172
185
 
173
- const vs = createShader(gl.VERTEX_SHADER, VERTEX_SHADER);
174
- const fs = createShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
186
+ const vertexShader = createShader({
187
+ type: glContext.VERTEX_SHADER,
188
+ source: VERTEX_SHADER,
189
+ });
190
+ const fragmentShader = createShader({
191
+ type: glContext.FRAGMENT_SHADER,
192
+ source: FRAGMENT_SHADER,
193
+ });
175
194
 
176
- const program = gl.createProgram()!;
177
- gl.attachShader(program, vs);
178
- gl.attachShader(program, fs);
179
- gl.linkProgram(program);
180
- gl.useProgram(program);
195
+ const program = glContext.createProgram()!;
196
+ glContext.attachShader(program, vertexShader);
197
+ glContext.attachShader(program, fragmentShader);
198
+ glContext.linkProgram(program);
199
+ glContext.useProgram(program);
181
200
 
182
- const buffer = gl.createBuffer();
183
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
184
- gl.bufferData(
185
- gl.ARRAY_BUFFER,
201
+ const buffer = glContext.createBuffer();
202
+ glContext.bindBuffer(glContext.ARRAY_BUFFER, buffer);
203
+ glContext.bufferData(
204
+ glContext.ARRAY_BUFFER,
186
205
  new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
187
- gl.STATIC_DRAW,
206
+ glContext.STATIC_DRAW,
188
207
  );
189
208
 
190
- const aPosition = gl.getAttribLocation(program, "a_position");
191
- gl.enableVertexAttribArray(aPosition);
192
- gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
209
+ const aPosition = glContext.getAttribLocation(program, "a_position");
210
+ glContext.enableVertexAttribArray(aPosition);
211
+ glContext.vertexAttribPointer(aPosition, 2, glContext.FLOAT, false, 0, 0);
193
212
 
194
- const uTime = gl.getUniformLocation(program, "u_time");
195
- const uResolution = gl.getUniformLocation(program, "u_resolution");
196
- const uMouse = gl.getUniformLocation(program, "u_mouse");
197
- const uClick = gl.getUniformLocation(program, "u_click");
198
- const uClickPos = gl.getUniformLocation(program, "u_clickPos");
213
+ const uTime = glContext.getUniformLocation(program, "u_time");
214
+ const uResolution = glContext.getUniformLocation(program, "u_resolution");
215
+ const uMouse = glContext.getUniformLocation(program, "u_mouse");
216
+ const uClick = glContext.getUniformLocation(program, "u_click");
217
+ const uClickPos = glContext.getUniformLocation(program, "u_clickPos");
199
218
 
200
219
  const startTime = performance.now();
201
220
 
202
221
  const resize = () => {
203
222
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
204
- const scale = 0.5 * dpr;
223
+ const scale = RENDER_SCALE * dpr;
205
224
  canvas.width = canvas.offsetWidth * scale;
206
225
  canvas.height = canvas.offsetHeight * scale;
207
- gl.viewport(0, 0, canvas.width, canvas.height);
226
+ glContext.viewport(0, 0, canvas.width, canvas.height);
208
227
  };
209
228
 
210
229
  resize();
211
230
  window.addEventListener("resize", resize);
212
231
 
213
- const onMouseMove = (e: MouseEvent) => {
232
+ const onMouseMove = (event: MouseEvent) => {
214
233
  const rect = wrapper.getBoundingClientRect();
215
- mouseRef.current.x = (e.clientX - rect.left) / rect.width;
216
- mouseRef.current.y = 1.0 - (e.clientY - rect.top) / rect.height;
234
+ mouseRef.current.x = (event.clientX - rect.left) / rect.width;
235
+ mouseRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
217
236
  };
218
237
 
219
238
  const onMouseLeave = () => {
@@ -221,10 +240,10 @@ export function HalftoneBackground(): React.JSX.Element {
221
240
  mouseRef.current.y = -1;
222
241
  };
223
242
 
224
- const onClick = (e: MouseEvent) => {
243
+ const onClick = (event: MouseEvent) => {
225
244
  const rect = wrapper.getBoundingClientRect();
226
- clickRef.current.x = (e.clientX - rect.left) / rect.width;
227
- clickRef.current.y = 1.0 - (e.clientY - rect.top) / rect.height;
245
+ clickRef.current.x = (event.clientX - rect.left) / rect.width;
246
+ clickRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
228
247
  clickRef.current.strength = 1.0;
229
248
  };
230
249
 
@@ -233,27 +252,26 @@ export function HalftoneBackground(): React.JSX.Element {
233
252
  wrapper.addEventListener("click", onClick);
234
253
 
235
254
  const draw = () => {
236
- const elapsed = (performance.now() - startTime) / 1000;
255
+ const elapsed = (performance.now() - startTime) / MS_PER_SECOND;
237
256
 
238
- const lerp = 0.08;
239
257
  const target = mouseRef.current;
240
258
  const smooth = smoothMouseRef.current;
241
259
  if (target.x < 0) {
242
- smooth.x += (target.x - smooth.x) * 0.02;
243
- smooth.y += (target.y - smooth.y) * 0.02;
260
+ smooth.x += (target.x - smooth.x) * MOUSE_LERP_IDLE;
261
+ smooth.y += (target.y - smooth.y) * MOUSE_LERP_IDLE;
244
262
  } else {
245
- smooth.x += (target.x - smooth.x) * lerp;
246
- smooth.y += (target.y - smooth.y) * lerp;
263
+ smooth.x += (target.x - smooth.x) * MOUSE_LERP;
264
+ smooth.y += (target.y - smooth.y) * MOUSE_LERP;
247
265
  }
248
266
 
249
- clickRef.current.strength *= 0.96;
267
+ clickRef.current.strength *= CLICK_DECAY;
250
268
 
251
- gl.uniform1f(uTime, elapsed);
252
- gl.uniform2f(uResolution, canvas.width, canvas.height);
253
- gl.uniform2f(uMouse, smooth.x, smooth.y);
254
- gl.uniform1f(uClick, clickRef.current.strength);
255
- gl.uniform2f(uClickPos, clickRef.current.x, clickRef.current.y);
256
- gl.drawArrays(gl.TRIANGLES, 0, 6);
269
+ glContext.uniform1f(uTime, elapsed);
270
+ glContext.uniform2f(uResolution, canvas.width, canvas.height);
271
+ glContext.uniform2f(uMouse, smooth.x, smooth.y);
272
+ glContext.uniform1f(uClick, clickRef.current.strength);
273
+ glContext.uniform2f(uClickPos, clickRef.current.x, clickRef.current.y);
274
+ glContext.drawArrays(glContext.TRIANGLES, 0, FULLSCREEN_VERTEX_COUNT);
257
275
  frameRef.current = requestAnimationFrame(draw);
258
276
  };
259
277
 
@@ -265,10 +283,10 @@ export function HalftoneBackground(): React.JSX.Element {
265
283
  window.removeEventListener("mousemove", onMouseMove);
266
284
  wrapper.removeEventListener("mouseleave", onMouseLeave);
267
285
  wrapper.removeEventListener("click", onClick);
268
- gl.deleteProgram(program);
269
- gl.deleteShader(vs);
270
- gl.deleteShader(fs);
271
- gl.deleteBuffer(buffer);
286
+ glContext.deleteProgram(program);
287
+ glContext.deleteShader(vertexShader);
288
+ glContext.deleteShader(fragmentShader);
289
+ glContext.deleteBuffer(buffer);
272
290
  };
273
291
  }, []);
274
292
 
@@ -39,13 +39,15 @@ function InputGroup({ className, ...props }: ComponentProps<"div">): React.JSX.E
39
39
  );
40
40
  }
41
41
 
42
- const inputGroupAddonVariants: (
42
+ type InputGroupAddonVariantsExternalApi = (
43
43
  props?:
44
44
  | ({
45
- align?: "inline-start" | "inline-end" | "block-start" | "block-end" | null | undefined;
45
+ align?: "inline-start" | "inline-end" | "block-start" | "block-end" | undefined;
46
46
  } & ClassProp)
47
47
  | undefined,
48
- ) => string = cva(
48
+ ) => string;
49
+
50
+ const inputGroupAddonVariants: InputGroupAddonVariantsExternalApi = cva(
49
51
  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
50
52
  {
51
53
  variants: {
@@ -77,24 +79,29 @@ function InputGroupAddon({
77
79
  data-slot="input-group-addon"
78
80
  data-align={align}
79
81
  className={cn(inputGroupAddonVariants({ align }), className)}
80
- onClick={(e) => {
81
- if ((e.target as HTMLElement).closest("button")) {
82
+ onClick={(event) => {
83
+ const target = event.target;
84
+ if (target instanceof HTMLElement && target.closest("button")) {
82
85
  return;
83
86
  }
84
- e.currentTarget.parentElement?.querySelector("input")?.focus();
87
+ event.currentTarget.parentElement?.querySelector("input")?.focus();
85
88
  }}
86
89
  {...props}
87
90
  />
88
91
  );
89
92
  }
90
93
 
91
- const inputGroupButtonVariants: (
94
+ type InputGroupButtonVariantsExternalApi = (
92
95
  props?:
93
96
  | ({
94
- size?: "xs" | "sm" | "icon-xs" | "icon-sm" | null | undefined;
97
+ size?: "xs" | "sm" | "icon-xs" | "icon-sm" | undefined;
95
98
  } & ClassProp)
96
99
  | undefined,
97
- ) => string = cva("flex items-center gap-2 text-sm shadow-none", {
100
+ ) => string;
101
+
102
+ const inputGroupButtonVariants: InputGroupButtonVariantsExternalApi = cva(
103
+ "flex items-center gap-2 text-sm shadow-none",
104
+ {
98
105
  variants: {
99
106
  size: {
100
107
  xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
@@ -1,20 +1,38 @@
1
1
  // Adapted from better-hub by Better Auth — https://github.com/better-auth/better-hub
2
2
  // (apps/web/src/components/ui/live-duration.tsx). Full credit to the Better Auth team.
3
3
 
4
+ import { DateTime, Option } from "effect";
4
5
  import type * as React from "react";
5
6
  import { useState, useEffect } from "react";
6
7
 
8
+ const MS_PER_SECOND = 1000;
9
+ const SECONDS_PER_HOUR = 3600;
10
+ const SECONDS_PER_MINUTE = 60;
11
+ const TICK_INTERVAL_MS = 1000;
12
+
13
+ interface FormatDurationInputExternalApi {
14
+ startedAt: string | null;
15
+ completedAt: string | null;
16
+ }
17
+
7
18
  // Inlined from "@/lib/utils" (formatDuration) — only this helper is used here.
8
- function formatDuration(startedAt: string | null, completedAt: string | null): string {
9
- if (!startedAt) return "";
10
- const start = new Date(startedAt);
11
- const end = completedAt ? new Date(completedAt) : new Date();
12
- const totalSeconds = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 1000));
13
- const hours = Math.floor(totalSeconds / 3600);
14
- const minutes = Math.floor((totalSeconds % 3600) / 60);
15
- const seconds = totalSeconds % 60;
16
- if (hours > 0) return `${hours}h ${minutes}m`;
17
- if (minutes > 0) return `${minutes}m ${seconds}s`;
19
+ function formatDuration({ startedAt, completedAt }: FormatDurationInputExternalApi): string {
20
+ if (!startedAt) {
21
+ return "";
22
+ }
23
+ const start = DateTime.unsafeMake(startedAt);
24
+ const end = completedAt ? DateTime.unsafeMake(completedAt) : DateTime.unsafeNow();
25
+ const elapsedMs = DateTime.toEpochMillis(end) - DateTime.toEpochMillis(start);
26
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / MS_PER_SECOND));
27
+ const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
28
+ const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
29
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
30
+ if (hours > 0) {
31
+ return `${hours}h ${minutes}m`;
32
+ }
33
+ if (minutes > 0) {
34
+ return `${minutes}m ${seconds}s`;
35
+ }
18
36
  return `${seconds}s`;
19
37
  }
20
38
 
@@ -22,45 +40,50 @@ function formatDuration(startedAt: string | null, completedAt: string | null): s
22
40
  // Shared tick for LiveDuration components. One interval drives all subscribers
23
41
  // instead of each component running its own 1s interval.
24
42
  const callbacks = new Set<() => void>();
25
- const tickState: { intervalId: ReturnType<typeof setInterval> | null } = {
26
- intervalId: null,
43
+ const tickState: { intervalId: Option.Option<ReturnType<typeof setInterval>> } = {
44
+ intervalId: Option.none(),
27
45
  };
28
46
 
29
47
  function subscribeLiveTick(callback: () => void): () => void {
30
48
  callbacks.add(callback);
31
- if (tickState.intervalId === null) {
32
- tickState.intervalId = setInterval(() => {
49
+ if (Option.isNone(tickState.intervalId)) {
50
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- browser-timer seam: shared render ticker for React, no Effect runtime here
51
+ tickState.intervalId = Option.some(setInterval(() => {
33
52
  callbacks.forEach((cb) => cb());
34
- }, 1000);
53
+ }, TICK_INTERVAL_MS));
35
54
  }
36
55
  return () => {
37
56
  callbacks.delete(callback);
38
- if (callbacks.size === 0 && tickState.intervalId !== null) {
39
- clearInterval(tickState.intervalId);
40
- tickState.intervalId = null;
57
+ if (callbacks.size === 0 && Option.isSome(tickState.intervalId)) {
58
+ clearInterval(tickState.intervalId.value);
59
+ tickState.intervalId = Option.none();
41
60
  }
42
61
  };
43
62
  }
44
63
 
45
- interface LiveDurationProps {
64
+ interface LiveDurationPropsExternalApi {
46
65
  startedAt: string | null;
47
66
  completedAt?: string | null;
48
67
  className?: string;
49
68
  }
50
69
 
51
- export function LiveDuration({ startedAt, completedAt, className }: LiveDurationProps): React.ReactNode {
70
+ export function LiveDuration({ startedAt, completedAt, className }: LiveDurationPropsExternalApi): React.ReactNode {
52
71
  // tick forces re-renders when subscribeLiveTick fires; value is unused in render
53
72
  const [, setTick] = useState(0);
54
73
 
55
74
  useEffect(() => {
56
- if (!startedAt || completedAt != null) return;
75
+ if (!startedAt || completedAt != null) {
76
+ return;
77
+ }
57
78
  setTick(0);
58
- return subscribeLiveTick(() => setTick((t) => t + 1));
79
+ return subscribeLiveTick(() => setTick((prev) => prev + 1));
59
80
  }, [startedAt, completedAt]);
60
81
 
61
- if (!startedAt) return null;
82
+ if (!startedAt) {
83
+ return null;
84
+ }
62
85
 
63
- const formatted = formatDuration(startedAt, completedAt ?? null);
86
+ const formatted = formatDuration({ startedAt, completedAt: completedAt ?? null });
64
87
 
65
88
  return (
66
89
  <span className={className} suppressHydrationWarning>
@@ -3,7 +3,11 @@
3
3
  import type * as React from "react";
4
4
  import { cn } from '../foundation/cn';
5
5
 
6
- export function Logo({ className }: { className?: string }): React.JSX.Element {
6
+ interface LogoPropsExternalApi {
7
+ className?: string;
8
+ }
9
+
10
+ export function Logo({ className }: LogoPropsExternalApi): React.JSX.Element {
7
11
  return (
8
12
  <span className={cn("font-mono text-sm font-medium tracking-tight", className)}>
9
13
  BETTER-HUB.
@@ -11,7 +15,7 @@ export function Logo({ className }: { className?: string }): React.JSX.Element {
11
15
  );
12
16
  }
13
17
 
14
- export function LogoMark({ className }: { className?: string }): React.JSX.Element {
18
+ export function LogoMark({ className }: LogoPropsExternalApi): React.JSX.Element {
15
19
  return (
16
20
  <span className={cn("font-mono text-sm font-bold tracking-tight", className)}>
17
21
  b.
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "react";
6
6
  import type { MouseEvent as ReactMouseEvent } from "react";
7
7
  import { cn } from "../foundation/cn";
8
8
 
9
- interface ResizeHandleProps {
9
+ interface ResizeHandlePropsExternalApi {
10
10
  /** Called continuously during drag with the pointer position on the resize axis
11
11
  * (clientX for `axis: "x"`, clientY for `axis: "y"`). */
12
12
  onResize: (clientPos: number) => void;
@@ -29,13 +29,13 @@ export function ResizeHandle({
29
29
  onDoubleClick,
30
30
  axis = "x",
31
31
  className,
32
- }: ResizeHandleProps): React.JSX.Element {
32
+ }: ResizeHandlePropsExternalApi): React.JSX.Element {
33
33
  const [isDragging, setIsDragging] = useState(false);
34
34
  const vertical = axis === "y";
35
35
 
36
36
  const handleMouseDown = useCallback(
37
- (e: ReactMouseEvent) => {
38
- e.preventDefault();
37
+ (event: ReactMouseEvent) => {
38
+ event.preventDefault();
39
39
  setIsDragging(true);
40
40
  onDragStart?.();
41
41
  },
@@ -43,10 +43,12 @@ export function ResizeHandle({
43
43
  );
44
44
 
45
45
  useEffect(() => {
46
- if (!isDragging) return;
46
+ if (!isDragging) {
47
+ return;
48
+ }
47
49
 
48
- const handleMouseMove = (e: MouseEvent) => {
49
- onResize(vertical ? e.clientY : e.clientX);
50
+ const handleMouseMove = (event: MouseEvent) => {
51
+ onResize(vertical ? event.clientY : event.clientX);
50
52
  };
51
53
 
52
54
  const handleMouseUp = () => {
@@ -38,7 +38,7 @@ function SheetOverlay({
38
38
  );
39
39
  }
40
40
 
41
- interface SheetContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
41
+ interface SheetContentPropsExternalApi extends React.ComponentProps<typeof DialogPrimitive.Content> {
42
42
  side?: "left" | "right" | "top" | "bottom";
43
43
  showCloseButton?: boolean;
44
44
  }
@@ -49,7 +49,7 @@ function SheetContent({
49
49
  side = "right",
50
50
  showCloseButton = true,
51
51
  ...props
52
- }: SheetContentProps): React.JSX.Element {
52
+ }: SheetContentPropsExternalApi): React.JSX.Element {
53
53
  const sideStyles = {
54
54
  right: "inset-y-0 right-0 h-full w-3/4 sm:max-w-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
55
55
  left: "inset-y-0 left-0 h-full w-3/4 sm:max-w-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
@@ -1,53 +1,77 @@
1
1
  // Adapted from better-hub by Better Auth — https://github.com/better-auth/better-hub
2
2
  // (apps/web/src/components/ui/time-ago.tsx). Full credit to the Better Auth team.
3
3
 
4
+ import { DateTime } from "effect";
4
5
  import type * as React from "react";
5
6
  import { useState, useEffect } from 'react';
6
7
 
8
+ const MS_PER_SECOND = 1000;
9
+ const SECONDS_PER_MINUTE = 60;
10
+ const SECONDS_PER_HOUR = 3600;
11
+ const SECONDS_PER_DAY = 86400;
12
+ const SECONDS_PER_WEEK = 604800;
13
+ const SECONDS_PER_MONTH = 2592000;
14
+ const MONTHS_PER_YEAR = 12;
15
+ const REFRESH_INTERVAL_MS = 60_000;
16
+
7
17
  // Inlined from better-hub's "@/lib/utils" (timeAgo) — only the piece used here.
8
18
  function timeAgo(date: string | Date): string {
9
- const now = new Date();
10
- const then = new Date(date);
11
- const seconds = Math.floor((now.getTime() - then.getTime()) / 1000);
19
+ const now = DateTime.unsafeNow();
20
+ const then = DateTime.unsafeMake(date);
21
+ const seconds = Math.floor((DateTime.toEpochMillis(now) - DateTime.toEpochMillis(then)) / MS_PER_SECOND);
12
22
 
13
- if (seconds < 60) return 'just now';
14
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
15
- if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
16
- if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
17
- if (seconds < 2592000) return `${Math.floor(seconds / 604800)}w ago`;
23
+ if (seconds < SECONDS_PER_MINUTE) {
24
+ return 'just now';
25
+ }
26
+ if (seconds < SECONDS_PER_HOUR) {
27
+ return `${Math.floor(seconds / SECONDS_PER_MINUTE)}m ago`;
28
+ }
29
+ if (seconds < SECONDS_PER_DAY) {
30
+ return `${Math.floor(seconds / SECONDS_PER_HOUR)}h ago`;
31
+ }
32
+ if (seconds < SECONDS_PER_WEEK) {
33
+ return `${Math.floor(seconds / SECONDS_PER_DAY)}d ago`;
34
+ }
35
+ if (seconds < SECONDS_PER_MONTH) {
36
+ return `${Math.floor(seconds / SECONDS_PER_WEEK)}w ago`;
37
+ }
18
38
 
19
- const months = Math.floor(seconds / 2592000);
20
- if (months < 12) return `${months}mo ago`;
39
+ const months = Math.floor(seconds / SECONDS_PER_MONTH);
40
+ if (months < MONTHS_PER_YEAR) {
41
+ return `${months}mo ago`;
42
+ }
21
43
 
22
- const sameYear = then.getFullYear() === now.getFullYear();
44
+ const thenDate = DateTime.toDate(then);
45
+ const sameYear = thenDate.getFullYear() === DateTime.toDate(now).getFullYear();
23
46
  if (sameYear) {
24
- return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
47
+ return thenDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
25
48
  }
26
- return then.toLocaleDateString('en-US', {
49
+ return thenDate.toLocaleDateString('en-US', {
27
50
  month: 'short',
28
51
  day: 'numeric',
29
52
  year: 'numeric',
30
53
  });
31
54
  }
32
55
 
33
- interface TimeAgoProps {
56
+ interface TimeAgoPropsExternalApi {
34
57
  date: string | Date;
35
58
  className?: string;
36
59
  }
37
60
 
38
- export function TimeAgo({ date, className }: TimeAgoProps): React.JSX.Element {
61
+ export function TimeAgo({ date, className }: TimeAgoPropsExternalApi): React.JSX.Element {
39
62
  const [text, setText] = useState(() => timeAgo(date));
40
63
 
41
64
  useEffect(() => {
42
65
  setText(timeAgo(date));
43
- const interval = setInterval(() => setText(timeAgo(date)), 60_000);
66
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- browser-timer seam: periodic relative-time refresh for React, no Effect runtime here
67
+ const interval = setInterval(() => setText(timeAgo(date)), REFRESH_INTERVAL_MS);
44
68
  return () => clearInterval(interval);
45
69
  }, [date]);
46
70
 
47
71
  return (
48
72
  <time
49
73
  dateTime={typeof date === 'string' ? date : date.toISOString()}
50
- title={new Date(date).toLocaleString()}
74
+ title={DateTime.toDate(DateTime.unsafeMake(date)).toLocaleString()}
51
75
  className={className}
52
76
  suppressHydrationWarning
53
77
  >
@@ -5,4 +5,5 @@ import { twMerge } from 'tailwind-merge'
5
5
  * Merge class names with Tailwind-aware de-duplication.
6
6
  * Adapted from better-hub (apps/web/src/lib/utils.ts). See ../../README.md.
7
7
  */
8
+ // oxlint-disable-next-line reform-rules/min-var-name -- `cn` is the canonical shadcn/Tailwind class-merge helper, a public export consumed across the monorepo
8
9
  export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
File without changes