@open-slide/core 0.0.11 → 0.0.13

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 (89) hide show
  1. package/dist/{build-DHiRlpjn.js → build-DC3FTpWO.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-Cuw0mC5h.js} +592 -63
  4. package/dist/design-BUML7uvZ.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-BuWsdYvn.js} +2 -1
  6. package/dist/index.d.ts +55 -4
  7. package/dist/index.js +110 -1
  8. package/dist/{preview-UikovHEt.js → preview-CIcG-lP3.js} +2 -1
  9. package/dist/sync-3oqN1WyK.js +139 -0
  10. package/dist/sync-B4eLo2H6.js +3 -0
  11. package/dist/vite/index.d.ts +1 -1
  12. package/dist/vite/index.js +2 -1
  13. package/package.json +2 -1
  14. package/skills/apply-comments/SKILL.md +83 -0
  15. package/skills/create-slide/SKILL.md +81 -0
  16. package/skills/create-theme/SKILL.md +194 -0
  17. package/skills/slide-authoring/SKILL.md +288 -0
  18. package/src/app/{App.tsx → app.tsx} +8 -6
  19. package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
  20. package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
  21. package/src/app/components/history-provider.tsx +120 -0
  22. package/src/app/components/image-placeholder.tsx +121 -0
  23. package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
  24. package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
  25. package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
  26. package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
  27. package/src/app/components/inspector/save-bar.tsx +47 -0
  28. package/src/app/components/panel/panel-fields.tsx +60 -0
  29. package/src/app/components/panel/panel-shell.tsx +78 -0
  30. package/src/app/components/panel/save-card.tsx +139 -0
  31. package/src/app/components/pdf-progress-toast.tsx +25 -0
  32. package/src/app/components/player.tsx +341 -0
  33. package/src/app/components/present/blackout-overlay.tsx +18 -0
  34. package/src/app/components/present/control-bar.tsx +204 -0
  35. package/src/app/components/present/help-overlay.tsx +56 -0
  36. package/src/app/components/present/jump-input.tsx +74 -0
  37. package/src/app/components/present/laser-pointer.tsx +40 -0
  38. package/src/app/components/present/overview-grid.tsx +184 -0
  39. package/src/app/components/present/progress-bar.tsx +26 -0
  40. package/src/app/components/present/use-idle.ts +44 -0
  41. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  42. package/src/app/components/present/use-presenter-channel.ts +71 -0
  43. package/src/app/components/present/use-touch-swipe.ts +63 -0
  44. package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
  45. package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
  46. package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
  47. package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
  48. package/src/app/components/style-panel/design-provider.tsx +139 -0
  49. package/src/app/components/style-panel/style-panel.tsx +326 -0
  50. package/src/app/components/style-panel/use-design.ts +112 -0
  51. package/src/app/components/theme-toggle.tsx +57 -0
  52. package/src/app/components/thumbnail-rail.tsx +151 -0
  53. package/src/app/components/ui/button.tsx +51 -19
  54. package/src/app/components/ui/card.tsx +1 -1
  55. package/src/app/components/ui/dialog.tsx +25 -9
  56. package/src/app/components/ui/dropdown-menu.tsx +29 -12
  57. package/src/app/components/ui/input.tsx +13 -9
  58. package/src/app/components/ui/popover.tsx +5 -2
  59. package/src/app/components/ui/progress.tsx +2 -2
  60. package/src/app/components/ui/select.tsx +11 -5
  61. package/src/app/components/ui/separator.tsx +1 -1
  62. package/src/app/components/ui/slider.tsx +4 -4
  63. package/src/app/components/ui/sonner.tsx +11 -1
  64. package/src/app/components/ui/tabs.tsx +6 -6
  65. package/src/app/components/ui/textarea.tsx +11 -7
  66. package/src/app/components/ui/toggle-group.tsx +2 -2
  67. package/src/app/components/ui/toggle.tsx +6 -6
  68. package/src/app/components/ui/tooltip.tsx +5 -2
  69. package/src/app/lib/design.ts +64 -0
  70. package/src/app/lib/export-html.ts +10 -1
  71. package/src/app/lib/export-pdf.ts +7 -0
  72. package/src/app/lib/folders.ts +1 -1
  73. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  74. package/src/app/lib/sdk.ts +5 -0
  75. package/src/app/lib/slides.ts +1 -1
  76. package/src/app/lib/utils.ts +1 -1
  77. package/src/app/main.tsx +5 -2
  78. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  79. package/src/app/routes/presenter.tsx +400 -0
  80. package/src/app/routes/slide.tsx +519 -0
  81. package/src/app/styles.css +338 -67
  82. package/src/app/components/PdfProgressToast.tsx +0 -23
  83. package/src/app/components/Player.tsx +0 -100
  84. package/src/app/components/ThumbnailRail.tsx +0 -68
  85. package/src/app/components/inspector/SaveBar.tsx +0 -77
  86. package/src/app/routes/Slide.tsx +0 -478
  87. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  88. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  89. /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ type Pos = { x: number; y: number } | null;
4
+
5
+ /**
6
+ * Soft red dot that follows the cursor when the laser tool is active.
7
+ * Hides the system cursor on the player root via a `cursor-none` class
8
+ * applied by the parent.
9
+ */
10
+ export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
11
+ const [pos, setPos] = useState<Pos>(null);
12
+
13
+ useEffect(() => {
14
+ if (!enabled) {
15
+ setPos(null);
16
+ return;
17
+ }
18
+ const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
19
+ window.addEventListener('mousemove', onMove, { passive: true });
20
+ return () => window.removeEventListener('mousemove', onMove);
21
+ }, [enabled]);
22
+
23
+ if (!enabled || !pos) return null;
24
+ return (
25
+ <div
26
+ aria-hidden
27
+ className="pointer-events-none fixed z-[60]"
28
+ style={{
29
+ left: pos.x,
30
+ top: pos.y,
31
+ width: 18,
32
+ height: 18,
33
+ transform: 'translate(-50%, -50%)',
34
+ borderRadius: '50%',
35
+ background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
36
+ boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
37
+ }}
38
+ />
39
+ );
40
+ }
@@ -0,0 +1,184 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { cn } from '@/lib/utils';
3
+ import type { DesignSystem } from '../../lib/design';
4
+ import type { Page } from '../../lib/sdk';
5
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../../lib/sdk';
6
+ import { SlideCanvas } from '../slide-canvas';
7
+
8
+ const THUMB_W = 320;
9
+ const THUMB_H = (THUMB_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
10
+
11
+ type Props = {
12
+ pages: Page[];
13
+ design?: DesignSystem;
14
+ open: boolean;
15
+ current: number;
16
+ onClose: () => void;
17
+ onSelect: (index: number) => void;
18
+ };
19
+
20
+ /**
21
+ * Full-screen grid of slide thumbnails. Reuses SlideCanvas at fixed scale
22
+ * so each preview is rendered with the slide's design tokens but with
23
+ * motion frozen. Arrow keys move focus; Enter/click jumps and closes.
24
+ */
25
+ export function PresentOverviewGrid({
26
+ pages,
27
+ design,
28
+ open,
29
+ current,
30
+ onClose,
31
+ onSelect,
32
+ }: Props) {
33
+ const [focused, setFocused] = useState(current);
34
+ const gridRef = useRef<HTMLDivElement>(null);
35
+ const focusedRef = useRef<HTMLButtonElement | null>(null);
36
+
37
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
38
+ useEffect(() => {
39
+ if (open) setFocused(current);
40
+ }, [open]);
41
+
42
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `focused` swaps which button holds the ref; we must re-run to focus the new node
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ focusedRef.current?.focus();
46
+ focusedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
47
+ }, [focused, open]);
48
+
49
+ useEffect(() => {
50
+ if (!open) return;
51
+ const onKey = (e: KeyboardEvent) => {
52
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
53
+ const cols = computeCols(gridRef.current);
54
+ if (e.key === 'ArrowRight') {
55
+ e.preventDefault();
56
+ e.stopPropagation();
57
+ setFocused((i) => Math.min(pages.length - 1, i + 1));
58
+ } else if (e.key === 'ArrowLeft') {
59
+ e.preventDefault();
60
+ e.stopPropagation();
61
+ setFocused((i) => Math.max(0, i - 1));
62
+ } else if (e.key === 'ArrowDown') {
63
+ e.preventDefault();
64
+ e.stopPropagation();
65
+ setFocused((i) => Math.min(pages.length - 1, i + cols));
66
+ } else if (e.key === 'ArrowUp') {
67
+ e.preventDefault();
68
+ e.stopPropagation();
69
+ setFocused((i) => Math.max(0, i - cols));
70
+ } else if (e.key === 'Home') {
71
+ e.preventDefault();
72
+ e.stopPropagation();
73
+ setFocused(0);
74
+ } else if (e.key === 'End') {
75
+ e.preventDefault();
76
+ e.stopPropagation();
77
+ setFocused(pages.length - 1);
78
+ } else if (e.key === 'Enter') {
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ onSelect(focused);
82
+ onClose();
83
+ } else if (e.key === 'Escape') {
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ onClose();
87
+ }
88
+ };
89
+ window.addEventListener('keydown', onKey, true);
90
+ return () => window.removeEventListener('keydown', onKey, true);
91
+ }, [open, pages.length, focused, onClose, onSelect]);
92
+
93
+ if (!open) return null;
94
+ return (
95
+ <div
96
+ role="dialog"
97
+ aria-modal="true"
98
+ aria-label="Slide overview"
99
+ className="absolute inset-0 z-50 flex flex-col bg-black/95 backdrop-blur-sm"
100
+ >
101
+ <div className="flex shrink-0 items-baseline justify-between px-8 pt-6 pb-3">
102
+ <span className="eyebrow text-white/55">Overview</span>
103
+ <span className="font-mono text-[11px] text-white/55 tabular-nums">
104
+ {(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
105
+ </span>
106
+ </div>
107
+ <div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
108
+ <div
109
+ className="grid gap-5"
110
+ style={{
111
+ gridTemplateColumns: `repeat(auto-fill, minmax(${THUMB_W}px, 1fr))`,
112
+ }}
113
+ >
114
+ {pages.map((PageComp, i) => {
115
+ const isFocused = i === focused;
116
+ const isCurrent = i === current;
117
+ return (
118
+ <button
119
+ // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
120
+ key={i}
121
+ ref={isFocused ? focusedRef : undefined}
122
+ type="button"
123
+ onClick={() => {
124
+ onSelect(i);
125
+ onClose();
126
+ }}
127
+ onMouseEnter={() => setFocused(i)}
128
+ aria-label={`Go to slide ${i + 1}`}
129
+ aria-current={isCurrent ? 'true' : undefined}
130
+ className={cn(
131
+ 'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
132
+ isFocused ? 'bg-white/10' : 'hover:bg-white/5',
133
+ )}
134
+ >
135
+ <div
136
+ className={cn(
137
+ 'relative w-full overflow-hidden rounded-[4px] bg-black ring-1 ring-white/10 transition-shadow',
138
+ isFocused && 'ring-2 ring-[var(--brand,#ef4444)]',
139
+ )}
140
+ style={{ height: THUMB_H }}
141
+ >
142
+ <SlideCanvas
143
+ scale={THUMB_W / CANVAS_WIDTH}
144
+ center={false}
145
+ flat
146
+ freezeMotion
147
+ design={design}
148
+ >
149
+ <PageComp />
150
+ </SlideCanvas>
151
+ {isCurrent && (
152
+ <span
153
+ aria-hidden
154
+ className="pointer-events-none absolute top-1.5 right-1.5 rounded-[3px] bg-[var(--brand,#ef4444)] px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.06em] uppercase text-white"
155
+ >
156
+ Now
157
+ </span>
158
+ )}
159
+ </div>
160
+ <span
161
+ className={cn(
162
+ 'font-mono text-[10.5px] tracking-[0.08em] tabular-nums uppercase',
163
+ isFocused || isCurrent ? 'text-white/85' : 'text-white/45',
164
+ )}
165
+ >
166
+ {(i + 1).toString().padStart(2, '0')}
167
+ </span>
168
+ </button>
169
+ );
170
+ })}
171
+ </div>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function computeCols(grid: HTMLDivElement | null) {
178
+ if (!grid) return 4;
179
+ const inner = grid.firstElementChild as HTMLElement | null;
180
+ if (!inner) return 4;
181
+ const cs = getComputedStyle(inner);
182
+ const cols = cs.gridTemplateColumns.split(' ').filter(Boolean).length;
183
+ return Math.max(1, cols);
184
+ }
@@ -0,0 +1,26 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ type Props = {
4
+ index: number;
5
+ total: number;
6
+ visible: boolean;
7
+ };
8
+
9
+ export function PresentProgressBar({ index, total, visible }: Props) {
10
+ const pct = total > 0 ? ((index + 1) / total) * 100 : 0;
11
+ return (
12
+ <div
13
+ aria-hidden
14
+ className={cn(
15
+ 'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] bg-white/8',
16
+ 'motion-safe:transition-opacity motion-safe:duration-200',
17
+ visible ? 'opacity-100' : 'opacity-0',
18
+ )}
19
+ >
20
+ <div
21
+ className="h-full bg-[var(--brand,#ef4444)] transition-[width] duration-200 ease-out"
22
+ style={{ width: `${pct}%` }}
23
+ />
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Reports whether the user has been idle (no pointer / key / touch input)
5
+ * for at least `delayMs`. Resets on any input event. The hook starts in
6
+ * the non-idle state so freshly-mounted UI is visible while the user
7
+ * orients themselves.
8
+ *
9
+ * Pass `enabled = false` to short-circuit (useful when the player is
10
+ * paused on an overlay and we don't want to hide chrome behind it).
11
+ */
12
+ export function useIdle(delayMs: number, enabled = true) {
13
+ const [idle, setIdle] = useState(false);
14
+
15
+ useEffect(() => {
16
+ if (!enabled) {
17
+ setIdle(false);
18
+ return;
19
+ }
20
+ let timer: ReturnType<typeof setTimeout> | null = null;
21
+ const reset = () => {
22
+ setIdle(false);
23
+ if (timer) clearTimeout(timer);
24
+ timer = setTimeout(() => setIdle(true), delayMs);
25
+ };
26
+ reset();
27
+ const opts = { passive: true } as const;
28
+ window.addEventListener('mousemove', reset, opts);
29
+ window.addEventListener('mousedown', reset, opts);
30
+ window.addEventListener('keydown', reset);
31
+ window.addEventListener('touchstart', reset, opts);
32
+ window.addEventListener('wheel', reset, opts);
33
+ return () => {
34
+ if (timer) clearTimeout(timer);
35
+ window.removeEventListener('mousemove', reset);
36
+ window.removeEventListener('mousedown', reset);
37
+ window.removeEventListener('keydown', reset);
38
+ window.removeEventListener('touchstart', reset);
39
+ window.removeEventListener('wheel', reset);
40
+ };
41
+ }, [delayMs, enabled]);
42
+
43
+ return idle;
44
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Returns true while the mouse pointer sits within `thresholdPx` of the
5
+ * viewport's bottom edge. Pure pointer-position tracking — keyboard input
6
+ * does not affect the result, so arrow-key navigation won't reveal the
7
+ * control chrome.
8
+ *
9
+ * Pass `enabled = false` to short-circuit (e.g. when an overlay owns
10
+ * visibility) and reset to false.
11
+ */
12
+ export function usePointerNearBottom(thresholdPx: number, enabled = true) {
13
+ const [near, setNear] = useState(false);
14
+
15
+ useEffect(() => {
16
+ if (!enabled) {
17
+ setNear(false);
18
+ return;
19
+ }
20
+ const update = (clientY: number) => {
21
+ setNear(clientY >= window.innerHeight - thresholdPx);
22
+ };
23
+ const onMove = (e: MouseEvent) => update(e.clientY);
24
+ const onLeave = () => setNear(false);
25
+ window.addEventListener('mousemove', onMove, { passive: true });
26
+ document.addEventListener('mouseleave', onLeave);
27
+ return () => {
28
+ window.removeEventListener('mousemove', onMove);
29
+ document.removeEventListener('mouseleave', onLeave);
30
+ };
31
+ }, [thresholdPx, enabled]);
32
+
33
+ return near;
34
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+
3
+ export type PresenterState = {
4
+ index: number;
5
+ pageCount: number;
6
+ blackout: 'black' | 'white' | null;
7
+ startedAt: number; // epoch ms when present mode began
8
+ };
9
+
10
+ export type PresenterCommand =
11
+ | { type: 'state'; state: PresenterState }
12
+ | { type: 'goto'; index: number }
13
+ | { type: 'next' }
14
+ | { type: 'prev' }
15
+ | { type: 'request-state' }
16
+ | { type: 'restart-timer' }
17
+ | { type: 'toggle-blackout'; mode: 'black' | 'white' };
18
+
19
+ type Handler = (msg: PresenterCommand) => void;
20
+
21
+ const SUPPORTED = typeof window !== 'undefined' && typeof BroadcastChannel !== 'undefined';
22
+
23
+ /**
24
+ * BroadcastChannel wrapper used by both the projection window (Player) and
25
+ * the Presenter View. The channel is keyed by slideId so multiple decks
26
+ * open in different tabs do not cross-talk. Falls back to no-op when the
27
+ * API is missing (older browsers, SSR).
28
+ *
29
+ * The channel is owned by the effect (not useMemo) so React 18 StrictMode's
30
+ * double-invoke creates a fresh channel on the second mount instead of
31
+ * leaving a closed one behind that throws on the next `send()`.
32
+ */
33
+ export function usePresenterChannel(slideId: string, onMessage?: Handler) {
34
+ const onMessageRef = useRef(onMessage);
35
+ onMessageRef.current = onMessage;
36
+
37
+ const channelRef = useRef<BroadcastChannel | null>(null);
38
+ const [available, setAvailable] = useState(false);
39
+
40
+ useEffect(() => {
41
+ if (!SUPPORTED) return;
42
+ const channel = new BroadcastChannel(`open-slide:presenter:${slideId}`);
43
+ channelRef.current = channel;
44
+ setAvailable(true);
45
+ const handler = (e: MessageEvent<PresenterCommand>) => {
46
+ onMessageRef.current?.(e.data);
47
+ };
48
+ channel.addEventListener('message', handler);
49
+ return () => {
50
+ channel.removeEventListener('message', handler);
51
+ channel.close();
52
+ if (channelRef.current === channel) channelRef.current = null;
53
+ setAvailable(false);
54
+ };
55
+ }, [slideId]);
56
+
57
+ return useMemo(
58
+ () => ({
59
+ send(msg: PresenterCommand) {
60
+ try {
61
+ channelRef.current?.postMessage(msg);
62
+ } catch {
63
+ // Channel may have been closed between the availability check
64
+ // and the send (e.g. StrictMode unmount mid-flush). Treat as no-op.
65
+ }
66
+ },
67
+ available,
68
+ }),
69
+ [available],
70
+ );
71
+ }
@@ -0,0 +1,63 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+
3
+ const MIN_SWIPE_PX = 50;
4
+ const MAX_SWIPE_MS = 600;
5
+
6
+ type Options<T extends HTMLElement> = {
7
+ ref: RefObject<T | null>;
8
+ enabled?: boolean;
9
+ onPrev: () => void;
10
+ onNext: () => void;
11
+ };
12
+
13
+ /**
14
+ * Single-finger horizontal swipe → prev/next. Vertical-dominant gestures
15
+ * are left alone so scroll-y on tablets keeps working. The handler only
16
+ * binds when `enabled`, so overlay layers can suppress it.
17
+ */
18
+ export function useTouchSwipe<T extends HTMLElement>({
19
+ ref,
20
+ enabled = true,
21
+ onPrev,
22
+ onNext,
23
+ }: Options<T>) {
24
+ const start = useRef<{ x: number; y: number; t: number } | null>(null);
25
+
26
+ useEffect(() => {
27
+ const el = ref.current;
28
+ if (!el || !enabled) return;
29
+
30
+ const onStart = (e: TouchEvent) => {
31
+ if (e.touches.length !== 1) {
32
+ start.current = null;
33
+ return;
34
+ }
35
+ const t = e.touches[0];
36
+ start.current = { x: t.clientX, y: t.clientY, t: performance.now() };
37
+ };
38
+ const onEnd = (e: TouchEvent) => {
39
+ const s = start.current;
40
+ start.current = null;
41
+ if (!s) return;
42
+ const t = e.changedTouches[0];
43
+ if (!t) return;
44
+ const dx = t.clientX - s.x;
45
+ const dy = t.clientY - s.y;
46
+ if (performance.now() - s.t > MAX_SWIPE_MS) return;
47
+ if (Math.abs(dx) < MIN_SWIPE_PX) return;
48
+ if (Math.abs(dx) <= Math.abs(dy)) return;
49
+ if (dx < 0) onNext();
50
+ else onPrev();
51
+ };
52
+
53
+ el.addEventListener('touchstart', onStart, { passive: true });
54
+ el.addEventListener('touchend', onEnd);
55
+ el.addEventListener('touchcancel', () => {
56
+ start.current = null;
57
+ });
58
+ return () => {
59
+ el.removeEventListener('touchstart', onStart);
60
+ el.removeEventListener('touchend', onEnd);
61
+ };
62
+ }, [ref, enabled, onPrev, onNext]);
63
+ }
@@ -1,24 +1,43 @@
1
1
  import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
2
- import { useState } from 'react';
3
- import type { Folder, FolderIcon } from '@/lib/sdk';
4
- import { cn } from '@/lib/utils';
5
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
2
+ import { useEffect, useRef, useState } from 'react';
6
3
  import {
7
4
  DropdownMenu,
8
5
  DropdownMenuContent,
9
6
  DropdownMenuItem,
10
7
  DropdownMenuTrigger,
11
8
  } from '@/components/ui/dropdown-menu';
12
- import { IconPicker } from './IconPicker';
9
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
10
+ import type { Folder, FolderIcon } from '@/lib/sdk';
11
+ import { cn } from '@/lib/utils';
12
+ import { IconPicker } from './icon-picker';
13
13
 
14
14
  export const SLIDE_DND_MIME = 'application/x-slide-id';
15
15
 
16
+ function useSlideDragActive() {
17
+ const [active, setActive] = useState(false);
18
+ useEffect(() => {
19
+ const onStart = (e: DragEvent) => {
20
+ if (e.dataTransfer?.types?.includes(SLIDE_DND_MIME)) setActive(true);
21
+ };
22
+ const onEnd = () => setActive(false);
23
+ document.addEventListener('dragstart', onStart);
24
+ document.addEventListener('dragend', onEnd);
25
+ document.addEventListener('drop', onEnd);
26
+ return () => {
27
+ document.removeEventListener('dragstart', onStart);
28
+ document.removeEventListener('dragend', onEnd);
29
+ document.removeEventListener('drop', onEnd);
30
+ };
31
+ }, []);
32
+ return active;
33
+ }
34
+
16
35
  export function FolderIconChip({ icon, className }: { icon: FolderIcon; className?: string }) {
17
36
  if (icon.type === 'emoji') {
18
37
  return (
19
38
  <span
20
39
  className={cn(
21
- 'inline-flex size-5 items-center justify-center text-base leading-none',
40
+ 'inline-flex size-5 items-center justify-center text-[15px] leading-none',
22
41
  className,
23
42
  )}
24
43
  >
@@ -28,7 +47,10 @@ export function FolderIconChip({ icon, className }: { icon: FolderIcon; classNam
28
47
  }
29
48
  return (
30
49
  <span
31
- className={cn('inline-block size-4 rounded-[4px] ring-1 ring-black/10', className)}
50
+ className={cn(
51
+ 'inline-block size-3 rounded-[3px] ring-1 ring-foreground/15 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)]',
52
+ className,
53
+ )}
32
54
  style={{ background: icon.value }}
33
55
  />
34
56
  );
@@ -61,18 +83,29 @@ export function FolderItem({
61
83
  }) {
62
84
  const [renaming, setRenaming] = useState(false);
63
85
  const [dragOver, setDragOver] = useState(false);
86
+ const dragDepth = useRef(0);
64
87
  const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
88
+ const slideDragActive = useSlideDragActive();
65
89
 
90
+ const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
91
+ const handleDragEnter = (e: React.DragEvent) => {
92
+ if (!isSlideDrag(e)) return;
93
+ dragDepth.current += 1;
94
+ if (dragDepth.current === 1) setDragOver(true);
95
+ };
66
96
  const handleDragOver = (e: React.DragEvent) => {
67
- if (e.dataTransfer.types.includes(SLIDE_DND_MIME)) {
68
- e.preventDefault();
69
- e.dataTransfer.dropEffect = 'move';
70
- setDragOver(true);
71
- }
97
+ if (!isSlideDrag(e)) return;
98
+ e.preventDefault();
99
+ e.dataTransfer.dropEffect = 'move';
100
+ };
101
+ const handleDragLeave = (e: React.DragEvent) => {
102
+ if (!isSlideDrag(e)) return;
103
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
104
+ if (dragDepth.current === 0) setDragOver(false);
72
105
  };
73
- const handleDragLeave = () => setDragOver(false);
74
106
  const handleDrop = (e: React.DragEvent) => {
75
107
  const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
108
+ dragDepth.current = 0;
76
109
  setDragOver(false);
77
110
  if (!slideId) return;
78
111
  e.preventDefault();
@@ -94,15 +127,22 @@ export function FolderItem({
94
127
  // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
95
128
  <div
96
129
  className={cn(
97
- 'group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
98
- selected ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/60',
99
- dragOver && 'ring-2 ring-primary ring-offset-1 ring-offset-background',
130
+ 'group relative flex items-center gap-2.5 rounded-[5px] px-2 py-[5px] text-[12.5px] transition-colors',
131
+ // Editorial selected state: subtle warm tint + a thin vermillion
132
+ // ink-mark on the leading edge. Avoids the heavy "filled pill" look.
133
+ selected
134
+ ? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
135
+ : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
136
+ slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
137
+ dragOver &&
138
+ 'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
100
139
  )}
140
+ onDragEnter={handleDragEnter}
101
141
  onDragOver={handleDragOver}
102
142
  onDragLeave={handleDragLeave}
103
143
  onDrop={handleDrop}
104
144
  >
105
- {row.kind === 'folder' ? (
145
+ {row.kind === 'folder' && import.meta.env.DEV ? (
106
146
  <Popover>
107
147
  <PopoverTrigger asChild>
108
148
  <button
@@ -137,7 +177,7 @@ export function FolderItem({
137
177
  }
138
178
  }}
139
179
  maxLength={40}
140
- className="min-w-0 flex-1 rounded-sm bg-background px-1 text-sm outline-none ring-1 ring-ring/40"
180
+ className="min-w-0 flex-1 rounded-[3px] bg-card px-1 text-[12.5px] outline-none ring-1 ring-foreground/20"
141
181
  />
142
182
  ) : (
143
183
  <button type="button" onClick={onSelect} className="min-w-0 flex-1 truncate text-left">
@@ -145,22 +185,17 @@ export function FolderItem({
145
185
  </button>
146
186
  )}
147
187
 
148
- <span
149
- className={cn(
150
- 'shrink-0 text-xs tabular-nums text-muted-foreground',
151
- count === 0 && 'opacity-0 group-hover:opacity-100',
152
- )}
153
- >
154
- {count}
188
+ <span className={cn('folio shrink-0', count === 0 && 'opacity-0 group-hover:opacity-100')}>
189
+ {count.toString().padStart(2, '0')}
155
190
  </span>
156
191
 
157
- {row.kind === 'folder' && (
192
+ {row.kind === 'folder' && import.meta.env.DEV && (
158
193
  <DropdownMenu>
159
194
  <DropdownMenuTrigger asChild>
160
195
  <button
161
196
  type="button"
162
197
  onClick={(e) => e.stopPropagation()}
163
- className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-muted-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
198
+ className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
164
199
  aria-label="Folder actions"
165
200
  >
166
201
  <MoreHorizontal className="mx-auto size-3.5" />
@@ -1,16 +1,19 @@
1
1
  import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
2
- import type { FolderIcon } from '@/lib/sdk';
3
2
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3
+ import type { FolderIcon } from '@/lib/sdk';
4
4
 
5
+ // Editorial palette — restrained warm/earth tones, no shadcn defaults
6
+ // (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
7
+ // vermillion brand accent without shouting over it.
5
8
  export const PRESET_COLORS = [
6
- '#8b5cf6',
7
- '#6366f1',
8
- '#3b82f6',
9
- '#10b981',
10
- '#f59e0b',
11
- '#ef4444',
12
- '#ec4899',
13
- '#64748b',
9
+ '#c0392b', // vermillion
10
+ '#b8743e', // ochre
11
+ '#6f7a3a', // olive
12
+ '#2f6a4f', // forest
13
+ '#3a5a7c', // ink blue
14
+ '#6b4675', // plum
15
+ '#a3543b', // terracotta
16
+ '#3a3a3a', // graphite
14
17
  ];
15
18
 
16
19
  export function IconPicker({
@@ -47,7 +50,7 @@ export function IconPicker({
47
50
  key={c}
48
51
  type="button"
49
52
  onClick={() => onChange({ type: 'color', value: c })}
50
- className="size-6 rounded-md ring-1 ring-black/10 transition-transform hover:scale-110"
53
+ className="size-6 rounded-[4px] ring-1 ring-foreground/10 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)] transition-transform hover:scale-110"
51
54
  style={{ background: c }}
52
55
  aria-label={c}
53
56
  />