@gigo-ui/components 1.0.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.
Files changed (96) hide show
  1. package/README.md +42 -0
  2. package/dist/assets/favicon.svg +1 -0
  3. package/dist/components/chaos/CatchSubmit.svelte +144 -0
  4. package/dist/components/chaos/CatchSubmit.svelte.d.ts +11 -0
  5. package/dist/components/chaos/ChaosButton.svelte +132 -0
  6. package/dist/components/chaos/ChaosButton.svelte.d.ts +4 -0
  7. package/dist/components/chaos/ChaosForm.svelte +206 -0
  8. package/dist/components/chaos/ChaosForm.svelte.d.ts +10 -0
  9. package/dist/components/chaos/ColorPickerWrong.svelte +141 -0
  10. package/dist/components/chaos/ColorPickerWrong.svelte.d.ts +11 -0
  11. package/dist/components/chaos/DropdownCalc.svelte +195 -0
  12. package/dist/components/chaos/DropdownCalc.svelte.d.ts +8 -0
  13. package/dist/components/chaos/GhostCard.svelte +157 -0
  14. package/dist/components/chaos/GhostCard.svelte.d.ts +13 -0
  15. package/dist/components/chaos/GravityInput.svelte +161 -0
  16. package/dist/components/chaos/GravityInput.svelte.d.ts +13 -0
  17. package/dist/components/chaos/PasswordPeekhole.svelte +141 -0
  18. package/dist/components/chaos/PasswordPeekhole.svelte.d.ts +11 -0
  19. package/dist/components/chaos/ProgressDoom.svelte +139 -0
  20. package/dist/components/chaos/ProgressDoom.svelte.d.ts +12 -0
  21. package/dist/components/chaos/RotaryDial.svelte +221 -0
  22. package/dist/components/chaos/RotaryDial.svelte.d.ts +10 -0
  23. package/dist/components/chaos/SliderPhone.svelte +92 -0
  24. package/dist/components/chaos/SliderPhone.svelte.d.ts +10 -0
  25. package/dist/components/chaos/TermsSidescroll.svelte +121 -0
  26. package/dist/components/chaos/TermsSidescroll.svelte.d.ts +12 -0
  27. package/dist/components/chaos/VolumeSlider.svelte +116 -0
  28. package/dist/components/chaos/VolumeSlider.svelte.d.ts +14 -0
  29. package/dist/components/ui/Button.svelte +162 -0
  30. package/dist/components/ui/Button.svelte.d.ts +4 -0
  31. package/dist/components/ui/Card.svelte +141 -0
  32. package/dist/components/ui/Card.svelte.d.ts +4 -0
  33. package/dist/components/ui/Carousel.svelte +164 -0
  34. package/dist/components/ui/Carousel.svelte.d.ts +4 -0
  35. package/dist/components/ui/Form.svelte +178 -0
  36. package/dist/components/ui/Form.svelte.d.ts +4 -0
  37. package/dist/components/ui/Input.svelte +135 -0
  38. package/dist/components/ui/Input.svelte.d.ts +4 -0
  39. package/dist/components/ui/Modal.svelte +173 -0
  40. package/dist/components/ui/Modal.svelte.d.ts +4 -0
  41. package/dist/components/ui/Navigation.svelte +91 -0
  42. package/dist/components/ui/Navigation.svelte.d.ts +4 -0
  43. package/dist/docs/categories.d.ts +13 -0
  44. package/dist/docs/categories.js +17 -0
  45. package/dist/docs/component-data.d.ts +6 -0
  46. package/dist/docs/component-data.js +49 -0
  47. package/dist/docs/components/badui/catch-submit.d.ts +2 -0
  48. package/dist/docs/components/badui/catch-submit.js +49 -0
  49. package/dist/docs/components/badui/color-picker-wrong.d.ts +2 -0
  50. package/dist/docs/components/badui/color-picker-wrong.js +40 -0
  51. package/dist/docs/components/badui/dropdown-calc.d.ts +2 -0
  52. package/dist/docs/components/badui/dropdown-calc.js +28 -0
  53. package/dist/docs/components/badui/ghost-card.d.ts +2 -0
  54. package/dist/docs/components/badui/ghost-card.js +54 -0
  55. package/dist/docs/components/badui/gravity-input.d.ts +2 -0
  56. package/dist/docs/components/badui/gravity-input.js +64 -0
  57. package/dist/docs/components/badui/password-peekhole.d.ts +2 -0
  58. package/dist/docs/components/badui/password-peekhole.js +51 -0
  59. package/dist/docs/components/badui/progress-doom.d.ts +2 -0
  60. package/dist/docs/components/badui/progress-doom.js +48 -0
  61. package/dist/docs/components/badui/rotary-dial.d.ts +2 -0
  62. package/dist/docs/components/badui/rotary-dial.js +40 -0
  63. package/dist/docs/components/badui/slider-phone.d.ts +2 -0
  64. package/dist/docs/components/badui/slider-phone.js +40 -0
  65. package/dist/docs/components/badui/terms-sidescroll.d.ts +2 -0
  66. package/dist/docs/components/badui/terms-sidescroll.js +46 -0
  67. package/dist/docs/components/badui/volume-slider.d.ts +2 -0
  68. package/dist/docs/components/badui/volume-slider.js +52 -0
  69. package/dist/docs/components/chaos/chaos-button.d.ts +2 -0
  70. package/dist/docs/components/chaos/chaos-button.js +48 -0
  71. package/dist/docs/components/chaos/chaos-form.d.ts +2 -0
  72. package/dist/docs/components/chaos/chaos-form.js +68 -0
  73. package/dist/docs/components/standard/button.d.ts +2 -0
  74. package/dist/docs/components/standard/button.js +66 -0
  75. package/dist/docs/components/standard/card.d.ts +2 -0
  76. package/dist/docs/components/standard/card.js +55 -0
  77. package/dist/docs/components/standard/carousel.d.ts +2 -0
  78. package/dist/docs/components/standard/carousel.js +63 -0
  79. package/dist/docs/components/standard/form.d.ts +2 -0
  80. package/dist/docs/components/standard/form.js +57 -0
  81. package/dist/docs/components/standard/input.d.ts +2 -0
  82. package/dist/docs/components/standard/input.js +65 -0
  83. package/dist/docs/components/standard/modal.d.ts +2 -0
  84. package/dist/docs/components/standard/modal.js +63 -0
  85. package/dist/docs/components/standard/navigation.d.ts +2 -0
  86. package/dist/docs/components/standard/navigation.js +51 -0
  87. package/dist/docs/types.d.ts +16 -0
  88. package/dist/docs/types.js +1 -0
  89. package/dist/index.d.ts +22 -0
  90. package/dist/index.js +21 -0
  91. package/dist/styles/globals.css +569 -0
  92. package/dist/types/index.d.ts +177 -0
  93. package/dist/types/index.js +1 -0
  94. package/dist/utils/cn.d.ts +9 -0
  95. package/dist/utils/cn.js +90 -0
  96. package/package.json +61 -0
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ placeholder?: string;
4
+ spawnInterval?: number;
5
+ maxFalling?: number;
6
+ characters?: string;
7
+ arenaHeight?: number;
8
+ class?: string;
9
+ [key: string]: unknown;
10
+ };
11
+ declare const GravityInput: import("svelte").Component<$$ComponentProps, {}, "value">;
12
+ type GravityInput = ReturnType<typeof GravityInput>;
13
+ export default GravityInput;
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../utils/cn.js';
3
+
4
+ let {
5
+ value = $bindable(''),
6
+ placeholder = 'Type your password...',
7
+ holeSize = 40,
8
+ accentColor = '#e040fb',
9
+ class: className,
10
+ ...restProps
11
+ }: {
12
+ value?: string;
13
+ placeholder?: string;
14
+ holeSize?: number;
15
+ accentColor?: string;
16
+ class?: string;
17
+ [key: string]: unknown;
18
+ } = $props();
19
+
20
+ let holeX = $state(60);
21
+ let holeY = $state(20);
22
+ let isDragging = $state(false);
23
+ let dragOffset = $state({ x: 0, y: 0 });
24
+ let containerEl: HTMLDivElement | undefined = $state();
25
+ let inputEl: HTMLInputElement | undefined = $state();
26
+ let isFocused = $state(false);
27
+
28
+ let holeCenterX = $derived(holeX + holeSize / 2);
29
+ let holeCenterY = $derived(holeY + holeSize / 2);
30
+
31
+ function onHolePointerDown(e: PointerEvent) {
32
+ isDragging = true;
33
+ const rect = (e.target as HTMLElement).getBoundingClientRect();
34
+ dragOffset = {
35
+ x: e.clientX - rect.left - holeSize / 2,
36
+ y: e.clientY - rect.top - holeSize / 2
37
+ };
38
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
39
+ }
40
+
41
+ function onPointerMove(e: PointerEvent) {
42
+ if (!isDragging || !containerEl) return;
43
+ const container = containerEl.getBoundingClientRect();
44
+ holeX = Math.max(0, Math.min(e.clientX - container.left - dragOffset.x - holeSize / 2,
45
+ container.width - holeSize));
46
+ holeY = Math.max(0, Math.min(e.clientY - container.top - dragOffset.y - holeSize / 2,
47
+ container.height - holeSize));
48
+ }
49
+
50
+ function onPointerUp() {
51
+ isDragging = false;
52
+ }
53
+
54
+ function focusInput() {
55
+ inputEl?.focus();
56
+ }
57
+ </script>
58
+
59
+ <div class={cn('space-y-3 max-w-sm', className)} {...restProps}>
60
+ <!-- Password field container -->
61
+ <div
62
+ bind:this={containerEl}
63
+ class="relative rounded-xl border overflow-hidden cursor-text"
64
+ role="textbox"
65
+ tabindex="-1"
66
+ style:border-color={isFocused ? accentColor : 'var(--border)'}
67
+ style:box-shadow={isFocused ? `0 0 0 2px ${accentColor}26` : 'none'}
68
+ onclick={focusInput}
69
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') focusInput(); }}
70
+ onpointermove={onPointerMove}
71
+ onpointerup={onPointerUp}
72
+ >
73
+ <!-- Real input -->
74
+ <input
75
+ bind:this={inputEl}
76
+ type="text"
77
+ bind:value
78
+ {placeholder}
79
+ onfocus={() => isFocused = true}
80
+ onblur={() => isFocused = false}
81
+ class="w-full px-4 py-3 bg-(--card) font-mono text-base text-transparent caret-transparent outline-none selection:bg-transparent"
82
+ style="color: transparent;"
83
+ autocomplete="off"
84
+ spellcheck="false"
85
+ />
86
+
87
+ <!-- Visible text layer underneath the overlay -->
88
+ <div
89
+ class="absolute inset-0 flex items-center px-4 pointer-events-none font-mono text-base"
90
+ >
91
+ <span class="text-(--foreground)">
92
+ {#if value}
93
+ {value}
94
+ {:else}
95
+ <span class="text-(--muted-foreground)">{placeholder}</span>
96
+ {/if}
97
+ </span>
98
+ </div>
99
+
100
+ <!-- Overlay with circular hole cut out via CSS mask -->
101
+ <div
102
+ class="absolute inset-0 pointer-events-none bg-(--card)"
103
+ style:-webkit-mask-image="radial-gradient(circle {holeSize / 2}px at {holeCenterX}px {holeCenterY}px, transparent {holeSize / 2}px, black {holeSize / 2}px)"
104
+ style:mask-image="radial-gradient(circle {holeSize / 2}px at {holeCenterX}px {holeCenterY}px, transparent {holeSize / 2}px, black {holeSize / 2}px)"
105
+ >
106
+ </div>
107
+
108
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
109
+ <!-- Peephole handle (draggable) -->
110
+ <div
111
+ role="slider"
112
+ aria-label="Peephole position"
113
+ aria-valuenow={Math.round(holeX)}
114
+ tabindex="0"
115
+ class="absolute rounded-full border-2 cursor-grab active:cursor-grabbing z-10"
116
+ style:width="{holeSize}px"
117
+ style:height="{holeSize}px"
118
+ style:left="{holeX}px"
119
+ style:top="{holeY}px"
120
+ style:border-color={isDragging ? accentColor : `${accentColor}66`}
121
+ style:box-shadow={isDragging ? `0 0 16px ${accentColor}66` : `0 0 8px ${accentColor}33`}
122
+ style:background="transparent"
123
+ onpointerdown={onHolePointerDown}
124
+ >
125
+ <div class="absolute -top-1 -left-1 w-2 h-2 border-t-2 border-l-2 rounded-tl" style:border-color={accentColor}></div>
126
+ <div class="absolute -top-1 -right-1 w-2 h-2 border-t-2 border-r-2 rounded-tr" style:border-color={accentColor}></div>
127
+ <div class="absolute -bottom-1 -left-1 w-2 h-2 border-b-2 border-l-2 rounded-bl" style:border-color={accentColor}></div>
128
+ <div class="absolute -bottom-1 -right-1 w-2 h-2 border-b-2 border-r-2 rounded-br" style:border-color={accentColor}></div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Character count -->
133
+ <div class="flex items-center justify-between text-xs font-mono text-(--muted-foreground)">
134
+ <span>{value.length} chars (probably)</span>
135
+ <span class="text-gigo-magenta/60">⬡ drag peephole to peek</span>
136
+ </div>
137
+
138
+ <p class="text-[10px] font-mono text-(--muted-foreground) text-center">
139
+ Your password is hidden. Drag the peephole to see what you typed. Security! 🔒
140
+ </p>
141
+ </div>
@@ -0,0 +1,11 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ placeholder?: string;
4
+ holeSize?: number;
5
+ accentColor?: string;
6
+ class?: string;
7
+ [key: string]: unknown;
8
+ };
9
+ declare const PasswordPeekhole: import("svelte").Component<$$ComponentProps, {}, "value">;
10
+ type PasswordPeekhole = ReturnType<typeof PasswordPeekhole>;
11
+ export default PasswordPeekhole;
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../utils/cn.js';
3
+
4
+ let {
5
+ targetMs = 5000,
6
+ goBackwards = true,
7
+ stall = true,
8
+ lieAboutCompletion = true,
9
+ onComplete,
10
+ class: className,
11
+ ...restProps
12
+ }: {
13
+ targetMs?: number;
14
+ goBackwards?: boolean;
15
+ stall?: boolean;
16
+ lieAboutCompletion?: boolean;
17
+ onComplete?: () => void;
18
+ class?: string;
19
+ [key: string]: unknown;
20
+ } = $props();
21
+
22
+ let progress = $state(0);
23
+ let displayProgress = $state(0);
24
+ let statusText = $state('Loading...');
25
+ let isComplete = $state(false);
26
+ let fakePercent = $state(0);
27
+ let barColor = $state('#e040fb');
28
+
29
+ const STATUS_MESSAGES = [
30
+ 'Loading...', 'Almost there...', 'Just a moment...', 'Reticulating splines...',
31
+ 'Downloading more RAM...', 'Consulting the oracle...', 'Please wait...',
32
+ 'Undoing progress...', 'Going backwards...', 'Starting over...',
33
+ '99% complete (lie)', 'Any second now...', 'Oops, lost some progress...',
34
+ 'Compiling excuses...', 'Buffering your patience...',
35
+ ];
36
+
37
+ $effect(() => {
38
+ let startTime = performance.now();
39
+ let running = true;
40
+
41
+ function tick() {
42
+ if (!running) return;
43
+ const elapsed = performance.now() - startTime;
44
+ let rawProgress = Math.min((elapsed / targetMs) * 100, 100);
45
+
46
+ if (goBackwards && rawProgress > 30 && rawProgress < 70 && Math.random() > 0.95) {
47
+ startTime += 800;
48
+ rawProgress = Math.max(0, rawProgress - 15);
49
+ }
50
+
51
+ if (stall && rawProgress > 50 && rawProgress < 80 && Math.random() > 0.9) {
52
+ startTime += 500;
53
+ }
54
+
55
+ progress = rawProgress;
56
+ displayProgress = Math.max(0, Math.min(100, rawProgress + (Math.random() - 0.5) * 8));
57
+
58
+ if (lieAboutCompletion) {
59
+ if (rawProgress < 20) fakePercent = rawProgress * 3;
60
+ else if (rawProgress < 80) fakePercent = 60 + Math.random() * 35;
61
+ else fakePercent = 95 + Math.random() * 4.9;
62
+ } else {
63
+ fakePercent = displayProgress;
64
+ }
65
+
66
+ const msgIdx = Math.floor((rawProgress / 100) * STATUS_MESSAGES.length);
67
+ statusText = STATUS_MESSAGES[Math.min(msgIdx, STATUS_MESSAGES.length - 1)];
68
+
69
+ if (rawProgress < 30) barColor = '#e040fb';
70
+ else if (rawProgress < 60) barColor = '#18ffff';
71
+ else if (rawProgress < 90) barColor = '#76ff03';
72
+ else barColor = '#ff1744';
73
+
74
+ if (rawProgress >= 100 && !isComplete) {
75
+ isComplete = true;
76
+ statusText = lieAboutCompletion ? 'Done! (was it though?)' : 'Complete!';
77
+ onComplete?.();
78
+ }
79
+
80
+ if (!isComplete) requestAnimationFrame(tick);
81
+ }
82
+
83
+ requestAnimationFrame(tick);
84
+ return () => { running = false; };
85
+ });
86
+
87
+ function restart() {
88
+ progress = 0;
89
+ displayProgress = 0;
90
+ fakePercent = 0;
91
+ isComplete = false;
92
+ statusText = 'Loading...';
93
+ }
94
+ </script>
95
+
96
+ <div class={cn('space-y-3 max-w-md', className)} {...restProps}>
97
+ <div class="flex items-center justify-between">
98
+ <span class="text-xs font-mono text-(--muted-foreground)">{statusText}</span>
99
+ <span class="text-xs font-mono font-bold" style:color={barColor}>
100
+ {Math.round(fakePercent)}%
101
+ </span>
102
+ </div>
103
+
104
+ <div class="relative h-4 w-full overflow-hidden rounded-full border border-(--border) bg-(--secondary)">
105
+ <div
106
+ class="absolute inset-y-0 left-0 rounded-full transition-all duration-300"
107
+ style:width="{Math.max(0, Math.min(100, displayProgress))}%"
108
+ style:background="linear-gradient(90deg, {barColor}, {barColor}99)"
109
+ style:box-shadow="0 0 12px {barColor}66"
110
+ ></div>
111
+ {#if displayProgress > 10}
112
+ <div
113
+ class="absolute inset-y-0 left-0 rounded-full opacity-30"
114
+ style:width="{Math.max(0, Math.min(100, displayProgress))}%"
115
+ style:background="linear-gradient(90deg, transparent, white 50%, transparent)"
116
+ style:animation="gigo-shimmer 1.5s linear infinite"
117
+ ></div>
118
+ {/if}
119
+ </div>
120
+
121
+ <div class="flex items-center justify-between text-[10px] font-mono text-(--muted-foreground)">
122
+ <span>Real: {Math.round(progress)}%</span>
123
+ <span>Shown: {Math.round(displayProgress)}%</span>
124
+ <span>Claimed: {Math.round(fakePercent)}%</span>
125
+ </div>
126
+
127
+ {#if isComplete}
128
+ <button
129
+ class="w-full rounded-xl border border-gigo-magenta/30 bg-gigo-magenta/10 px-4 py-2 text-sm font-mono text-gigo-magenta hover:bg-gigo-magenta/20 transition-colors cursor-pointer"
130
+ onclick={restart}
131
+ >
132
+ Restart the suffering
133
+ </button>
134
+ {/if}
135
+
136
+ <p class="text-[10px] font-mono text-(--muted-foreground) text-center">
137
+ Three different progress values. None of them are honest.
138
+ </p>
139
+ </div>
@@ -0,0 +1,12 @@
1
+ type $$ComponentProps = {
2
+ targetMs?: number;
3
+ goBackwards?: boolean;
4
+ stall?: boolean;
5
+ lieAboutCompletion?: boolean;
6
+ onComplete?: () => void;
7
+ class?: string;
8
+ [key: string]: unknown;
9
+ };
10
+ declare const ProgressDoom: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type ProgressDoom = ReturnType<typeof ProgressDoom>;
12
+ export default ProgressDoom;
@@ -0,0 +1,221 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../utils/cn.js';
3
+
4
+ let {
5
+ value = $bindable(''),
6
+ maxDigits = 10,
7
+ dialSize = 260,
8
+ class: className,
9
+ ...restProps
10
+ }: {
11
+ value?: string;
12
+ maxDigits?: number;
13
+ dialSize?: number;
14
+ class?: string;
15
+ [key: string]: unknown;
16
+ } = $props();
17
+
18
+ let rotation = $state(0);
19
+ let isDragging = $state(false);
20
+ let dialEl: SVGSVGElement | undefined = $state();
21
+ let startAngle = $state(0);
22
+ let startRotation = $state(0);
23
+ let selectedDigit = $state<number | null>(null);
24
+ let isAnimatingReturn = $state(false);
25
+
26
+ let CENTER = $derived(dialSize / 2);
27
+ let HOLE_RADIUS = $derived(dialSize * 18 / 260);
28
+ let RING_RADIUS = $derived(dialSize * 95 / 260);
29
+ const STOPPER_ANGLE = 30;
30
+
31
+ // Digit positions (0-9) arranged clockwise starting from bottom-right
32
+ // Real rotary phones: 1 at ~330°, going clockwise to 0 at ~210°
33
+ const digitAngles = [
34
+ 240, // 0
35
+ 330, // 1
36
+ 310, // 2
37
+ 290, // 3
38
+ 270, // 4
39
+ 250, // 5
40
+ 230, // 6
41
+ 210, // 7
42
+ 190, // 8
43
+ 170, // 9
44
+ ];
45
+
46
+ function getDigitPos(angle: number) {
47
+ const rad = ((angle - 90) * Math.PI) / 180;
48
+ return {
49
+ x: CENTER + RING_RADIUS * Math.cos(rad),
50
+ y: CENTER + RING_RADIUS * Math.sin(rad)
51
+ };
52
+ }
53
+
54
+ function getAngleFromCenter(clientX: number, clientY: number) {
55
+ if (!dialEl) return 0;
56
+ const rect = dialEl.getBoundingClientRect();
57
+ const cx = rect.left + rect.width / 2;
58
+ const cy = rect.top + rect.height / 2;
59
+ return Math.atan2(clientY - cy, clientX - cx) * (180 / Math.PI) + 90;
60
+ }
61
+
62
+ function onPointerDown(e: PointerEvent, digitIndex: number) {
63
+ if (isAnimatingReturn) return;
64
+ if (value.length >= maxDigits) return;
65
+ isDragging = true;
66
+ selectedDigit = digitIndex;
67
+ startAngle = getAngleFromCenter(e.clientX, e.clientY);
68
+ startRotation = rotation;
69
+ (e.target as HTMLElement)?.setPointerCapture?.(e.pointerId);
70
+ }
71
+
72
+ function onPointerMove(e: PointerEvent) {
73
+ if (!isDragging || selectedDigit === null) return;
74
+ const currentAngle = getAngleFromCenter(e.clientX, e.clientY);
75
+ let delta = currentAngle - startAngle;
76
+
77
+ // Only allow clockwise rotation (positive delta)
78
+ if (delta < -180) delta += 360;
79
+ if (delta > 180) delta -= 360;
80
+
81
+ // Clamp to clockwise only
82
+ const newRotation = Math.max(0, Math.min(startRotation + delta, 360 - digitAngles[selectedDigit] + STOPPER_ANGLE));
83
+ rotation = Math.max(0, newRotation);
84
+ }
85
+
86
+ function onPointerUp() {
87
+ if (!isDragging || selectedDigit === null) return;
88
+ isDragging = false;
89
+
90
+ // Check if rotated far enough
91
+ const requiredRotation = 360 - digitAngles[selectedDigit];
92
+ if (rotation >= requiredRotation * 0.75) {
93
+ // Digit entered!
94
+ value += String(selectedDigit);
95
+ }
96
+
97
+ // Animate return
98
+ isAnimatingReturn = true;
99
+ const returnStart = rotation;
100
+ const duration = 400 + rotation * 2; // longer return for bigger rotations
101
+ const startTime = performance.now();
102
+
103
+ function animateReturn(now: number) {
104
+ const elapsed = now - startTime;
105
+ const progress = Math.min(elapsed / duration, 1);
106
+ // Ease out
107
+ const eased = 1 - Math.pow(1 - progress, 3);
108
+ rotation = returnStart * (1 - eased);
109
+
110
+ if (progress < 1) {
111
+ requestAnimationFrame(animateReturn);
112
+ } else {
113
+ rotation = 0;
114
+ isAnimatingReturn = false;
115
+ selectedDigit = null;
116
+ }
117
+ }
118
+ requestAnimationFrame(animateReturn);
119
+ }
120
+
121
+ function removeLastDigit() {
122
+ value = value.slice(0, -1);
123
+ }
124
+ </script>
125
+
126
+ <div class={cn('flex flex-col items-center gap-4', className)} {...restProps}>
127
+ <!-- Value display -->
128
+ <div class="flex items-center gap-2 rounded-xl border border-(--border) bg-(--secondary) px-4 py-2 min-w-50">
129
+ <span class="font-mono text-lg text-(--foreground) flex-1 text-center tracking-[0.3em]">
130
+ {#if value}
131
+ {value}
132
+ {:else}
133
+ <span class="text-(--muted-foreground) tracking-normal text-sm">Dial a number...</span>
134
+ {/if}
135
+ </span>
136
+ {#if value}
137
+ <button
138
+ class="text-xs text-(--muted-foreground) hover:text-gigo-red transition-colors"
139
+ onclick={removeLastDigit}
140
+ >&#x232b;</button>
141
+ {/if}
142
+ </div>
143
+
144
+ <!-- Rotary dial -->
145
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
146
+ <svg
147
+ bind:this={dialEl}
148
+ width={dialSize}
149
+ height={dialSize}
150
+ viewBox="0 0 {dialSize} {dialSize}"
151
+ class="select-none touch-none"
152
+ role="group"
153
+ aria-label="Rotary dial"
154
+ onpointermove={onPointerMove}
155
+ onpointerup={onPointerUp}
156
+ onpointerleave={onPointerUp}
157
+ >
158
+ <!-- Outer ring -->
159
+ <circle cx={CENTER} cy={CENTER} r={dialSize / 2 - 4} fill="none" stroke="var(--border)" stroke-width="2" />
160
+
161
+ <circle cx={CENTER} cy={CENTER} r={dialSize / 2 - 8} fill="var(--card)" />
162
+
163
+ <!-- Center circle -->
164
+ <circle cx={CENTER} cy={CENTER} r="35" fill="var(--secondary)" stroke="var(--border)" stroke-width="1" />
165
+
166
+ <!-- Center label -->
167
+ <text x={CENTER} y={CENTER + 4} text-anchor="middle" font-size="10" font-family="monospace" fill="var(--muted-foreground)">GIGO</text>
168
+
169
+ <!-- Stopper -->
170
+ {#if true}
171
+ {@const stopPos = getDigitPos(STOPPER_ANGLE + 340)}
172
+ <rect
173
+ x={stopPos.x - 4}
174
+ y={stopPos.y - 12}
175
+ width="8"
176
+ height="24"
177
+ rx="3"
178
+ fill="var(--muted-foreground)"
179
+ opacity="0.3"
180
+ />
181
+ {/if}
182
+
183
+ <!-- Rotating group -->
184
+ <g transform="rotate({rotation}, {CENTER}, {CENTER})">
185
+ {#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as digit}
186
+ {@const pos = getDigitPos(digitAngles[digit])}
187
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
188
+ <circle
189
+ cx={pos.x}
190
+ cy={pos.y}
191
+ r={HOLE_RADIUS}
192
+ fill={selectedDigit === digit ? 'rgba(224, 64, 251, 0.15)' : 'var(--secondary)'}
193
+ stroke={selectedDigit === digit ? '#e040fb' : 'var(--border)'}
194
+ stroke-width={selectedDigit === digit ? 2 : 1}
195
+ class="cursor-pointer"
196
+ role="button"
197
+ tabindex="0"
198
+ aria-label="Digit {digit}"
199
+ style="filter: {selectedDigit === digit ? 'drop-shadow(0 0 6px rgba(224, 64, 251, 0.4))' : 'none'}"
200
+ onpointerdown={(e) => onPointerDown(e, digit)}
201
+ />
202
+ <text
203
+ x={pos.x}
204
+ y={pos.y + 5}
205
+ text-anchor="middle"
206
+ font-size="16"
207
+ font-weight="bold"
208
+ font-family="monospace"
209
+ fill={selectedDigit === digit ? '#e040fb' : 'var(--foreground)'}
210
+ class="pointer-events-none select-none"
211
+ >
212
+ {digit}
213
+ </text>
214
+ {/each}
215
+ </g>
216
+ </svg>
217
+
218
+ <p class="text-[10px] font-mono text-(--muted-foreground) text-center max-w-65">
219
+ Drag a digit clockwise to the stopper, then release. Just like grandma used to do.
220
+ </p>
221
+ </div>
@@ -0,0 +1,10 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ maxDigits?: number;
4
+ dialSize?: number;
5
+ class?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ declare const RotaryDial: import("svelte").Component<$$ComponentProps, {}, "value">;
9
+ type RotaryDial = ReturnType<typeof RotaryDial>;
10
+ export default RotaryDial;
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../utils/cn.js';
3
+
4
+ let {
5
+ value = $bindable(''),
6
+ digitCount = 10,
7
+ formatPattern = '(XXX) XXX-XXXX',
8
+ class: className,
9
+ ...restProps
10
+ }: {
11
+ value?: string;
12
+ digitCount?: number;
13
+ formatPattern?: string;
14
+ class?: string;
15
+ [key: string]: unknown;
16
+ } = $props();
17
+
18
+ let sliderValues = $state<number[]>([]);
19
+
20
+ $effect(() => {
21
+ if (sliderValues.length !== digitCount) {
22
+ sliderValues = Array(digitCount).fill(0);
23
+ }
24
+ });
25
+
26
+ // Sync value from sliders
27
+ $effect(() => {
28
+ value = sliderValues.map(v => Math.round(v)).join('');
29
+ });
30
+
31
+ function formatPhoneDisplay(raw: string): string {
32
+ const digits = raw.padEnd(digitCount, '_');
33
+ let result = '';
34
+ let di = 0;
35
+ for (const ch of formatPattern) {
36
+ if (ch === 'X') {
37
+ result += digits[di] ?? '_';
38
+ di++;
39
+ } else {
40
+ result += ch;
41
+ }
42
+ }
43
+ if (di < digits.length) {
44
+ result += digits.slice(di);
45
+ }
46
+ return result;
47
+ }
48
+
49
+ function setSliderValue(index: number, newVal: number) {
50
+ sliderValues = sliderValues.map((v, i) => i === index ? newVal : v);
51
+ }
52
+ </script>
53
+
54
+ <div class={cn('space-y-4 max-w-md', className)} {...restProps}>
55
+ <!-- Phone display -->
56
+ <div class="rounded-xl border border-(--border) bg-(--secondary) px-4 py-3 text-center">
57
+ <span class="font-mono text-xl tracking-[0.15em] text-(--foreground)">
58
+ {formatPhoneDisplay(value)}
59
+ </span>
60
+ </div>
61
+
62
+ <!-- Sliders grid -->
63
+ <div class="grid gap-2">
64
+ {#each sliderValues as sv, i}
65
+ <div class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--card) px-3 py-2">
66
+ <span class="text-[10px] font-mono text-(--muted-foreground) min-w-14">
67
+ Digit {i + 1}
68
+ </span>
69
+ <input
70
+ type="range"
71
+ min="0"
72
+ max="9"
73
+ step="1"
74
+ value={sv}
75
+ oninput={(e) => setSliderValue(i, Number((e.target as HTMLInputElement).value))}
76
+ class="flex-1 h-2 rounded-full appearance-none cursor-pointer
77
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-gigo-magenta [&::-webkit-slider-thumb]:shadow-[0_0_8px_rgba(224,64,251,0.5)] [&::-webkit-slider-thumb]:cursor-pointer
78
+ [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-gigo-magenta [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:cursor-pointer"
79
+ style="background: linear-gradient(to right, rgba(224,64,251,0.3) {sv * 11.11}%, var(--secondary) {sv * 11.11}%)"
80
+ />
81
+ <span class="text-lg font-mono font-bold min-w-5 text-center"
82
+ style:color={sv > 0 ? '#e040fb' : 'var(--muted-foreground)'}>
83
+ {Math.round(sv)}
84
+ </span>
85
+ </div>
86
+ {/each}
87
+ </div>
88
+
89
+ <p class="text-[10px] font-mono text-(--muted-foreground) text-center">
90
+ Slide each digit individually. The future of phone number entry is here.
91
+ </p>
92
+ </div>
@@ -0,0 +1,10 @@
1
+ type $$ComponentProps = {
2
+ value?: string;
3
+ digitCount?: number;
4
+ formatPattern?: string;
5
+ class?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ declare const SliderPhone: import("svelte").Component<$$ComponentProps, {}, "value">;
9
+ type SliderPhone = ReturnType<typeof SliderPhone>;
10
+ export default SliderPhone;