cantip 0.1.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,227 @@
1
+ import { useNavigate, useLocation } from "@remix-run/react";
2
+ import { MoreVertical, X } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import { DropdownMenu, DropdownMenuItem } from "~/components/ui/dropdown-menu";
6
+ import { useTabs, normTabPath } from "~/lib/tabs";
7
+ import { useKeyboardShortcuts, type Shortcut } from "~/lib/useKeyboardShortcuts";
8
+ import { cn } from "~/lib/utils";
9
+
10
+ /**
11
+ * Drives a custom overlay scrollbar over a horizontally-scrolling element whose
12
+ * native scrollbar is hidden (`.scrollbar-none`). Returns a ref to attach to the
13
+ * scroll container, the thumb geometry (as 0–1 fractions of the track), and a
14
+ * flag for whether the content overflows at all.
15
+ *
16
+ * The thumb is positioned/sized from `scrollLeft / scrollWidth / clientWidth`,
17
+ * recomputed on scroll, resize, and whenever the tab set changes.
18
+ */
19
+ function useOverlayScrollbar(deps: unknown[]) {
20
+ const ref = useRef<HTMLDivElement>(null);
21
+ const [thumb, setThumb] = useState({ width: 0, left: 0 });
22
+
23
+ const measure = useCallback(() => {
24
+ const el = ref.current;
25
+ if (!el) return;
26
+ const { scrollWidth, clientWidth, scrollLeft } = el;
27
+ if (scrollWidth <= clientWidth) {
28
+ setThumb({ width: 0, left: 0 });
29
+ return;
30
+ }
31
+ setThumb({
32
+ width: clientWidth / scrollWidth,
33
+ left: scrollLeft / scrollWidth,
34
+ });
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ measure();
39
+ const el = ref.current;
40
+ if (!el) return;
41
+ const ro = new ResizeObserver(measure);
42
+ ro.observe(el);
43
+ return () => ro.disconnect();
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, deps);
46
+
47
+ // Translate vertical wheel ticks into horizontal scroll, the way VS Code does.
48
+ const onWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
49
+ const el = ref.current;
50
+ if (!el || el.scrollWidth <= el.clientWidth) return;
51
+ // Only hijack a predominantly-vertical wheel; let trackpad horizontal
52
+ // gestures (deltaX) scroll natively.
53
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
54
+ el.scrollLeft += e.deltaY;
55
+ }
56
+ }, []);
57
+
58
+ return {
59
+ ref,
60
+ thumb,
61
+ onScroll: measure,
62
+ onWheel,
63
+ hasOverflow: thumb.width > 0,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Editor-style tab strip shown **only above the content area** (right of the
69
+ * sidebar). Renders nothing when no tabs are open. The active tab is whichever
70
+ * one matches the current URL. Clicking a tab navigates to it; the × button
71
+ * closes it — closing the active tab activates a neighbor (right, else left).
72
+ * A vertical-dots button at the far end (always visible, outside the scroll
73
+ * region) closes all tabs at once.
74
+ */
75
+ export default function TabBar() {
76
+ const { tabs, closeTab, closeAll } = useTabs();
77
+ const navigate = useNavigate();
78
+ const location = useLocation();
79
+ const { ref, thumb, onScroll, onWheel, hasOverflow } = useOverlayScrollbar([
80
+ tabs.length,
81
+ ]);
82
+
83
+ const cur = normTabPath(location.pathname);
84
+
85
+ // Close a tab by path: drop it, and if it was the active one, move to a
86
+ // neighbor (prefer the right, else the left). Shared by the × button and the
87
+ // `w` shortcut. Defined before the empty-tabs early return so the hook below
88
+ // it always runs (hooks can't follow a conditional return).
89
+ const closePath = useCallback(
90
+ (path: string) => {
91
+ const norm = normTabPath(path);
92
+ const idx = tabs.findIndex((t) => normTabPath(t.path) === norm);
93
+ const wasActive = norm === cur;
94
+ closeTab(path);
95
+ if (wasActive && tabs.length > 1) {
96
+ const neighbor = tabs[idx + 1] ?? tabs[idx - 1];
97
+ if (neighbor) navigate(neighbor.path);
98
+ }
99
+ },
100
+ [tabs, cur, closeTab, navigate]
101
+ );
102
+
103
+ // `w` (outside text fields) closes the current tab — bare key, so no clash
104
+ // with the browser's reserved Ctrl/Cmd+W. No-op when no tab matches the URL.
105
+ const tabShortcuts = useMemo<Shortcut[]>(
106
+ () => [
107
+ {
108
+ keys: "w",
109
+ label: "Закрыть текущую вкладку",
110
+ group: "Вкладки",
111
+ run: () => {
112
+ if (tabs.some((t) => normTabPath(t.path) === cur)) closePath(cur);
113
+ },
114
+ },
115
+ ],
116
+ [tabs, cur, closePath]
117
+ );
118
+ useKeyboardShortcuts(tabShortcuts);
119
+
120
+ if (tabs.length === 0) return null;
121
+
122
+ const close = (e: React.MouseEvent, path: string) => {
123
+ e.stopPropagation();
124
+ e.preventDefault();
125
+ closePath(path);
126
+ };
127
+
128
+ // Stickiness is handled by the grid cell wrapper in root.tsx (sticky top-11),
129
+ // since a sticky grid item can travel the whole grid height while this inner
130
+ // strip's own parent box is only as tall as the strip.
131
+ return (
132
+ <div
133
+ data-tab-strip
134
+ className="flex h-9 items-stretch border-b bg-background max-md:hidden"
135
+ >
136
+ {/* Scrollable tab list wrapper. `group/strip relative` is the positioning
137
+ context for the overlay scrollbar, which sits as a non-scrolling
138
+ sibling pinned to the bottom edge. */}
139
+ <div className="group/strip relative flex min-w-0 flex-1 items-stretch">
140
+ {/* The scroll region itself. The native scrollbar is hidden
141
+ (`scrollbar-none`) so it reserves no vertical space and never
142
+ shifts the tab text. Vertical wheel ticks scroll it horizontally
143
+ (VS Code style) via onWheel. */}
144
+ <div
145
+ ref={ref}
146
+ onScroll={onScroll}
147
+ onWheel={onWheel}
148
+ className="flex min-w-0 flex-1 items-stretch overflow-x-auto scrollbar-none"
149
+ >
150
+ {tabs.map((tab) => {
151
+ const isActive = normTabPath(tab.path) === cur;
152
+ return (
153
+ <div
154
+ key={tab.path}
155
+ title={tab.title}
156
+ onClick={() => navigate(tab.path)}
157
+ className={cn(
158
+ "group flex max-w-[12rem] shrink-0 cursor-pointer items-center gap-1.5 border-r pl-3 pr-1.5 text-sm select-none",
159
+ "transition-colors",
160
+ isActive
161
+ ? "bg-sidebar-accent text-foreground"
162
+ : "bg-background text-muted-foreground hover:bg-sidebar-accent/60"
163
+ )}
164
+ >
165
+ <span className="min-w-0 truncate">{tab.title}</span>
166
+ <button
167
+ type="button"
168
+ aria-label={`Закрыть ${tab.title}`}
169
+ // The `w` shortcut closes the *active* tab, so only hint it there.
170
+ title={isActive ? "Закрыть (W)" : undefined}
171
+ onClick={(e) => close(e, tab.path)}
172
+ className={cn(
173
+ "flex size-5 shrink-0 items-center justify-center rounded hover:bg-foreground/10",
174
+ isActive
175
+ ? "opacity-100"
176
+ : "opacity-0 group-hover:opacity-100"
177
+ )}
178
+ >
179
+ <X className="size-3.5" />
180
+ </button>
181
+ </div>
182
+ );
183
+ })}
184
+ </div>
185
+ {/* Overlay scrollbar. Non-scrolling sibling pinned to the bottom edge
186
+ of the strip; only rendered when the tabs overflow. The thumb's
187
+ width/offset are fractions of the track, derived from the scroll
188
+ geometry. It's invisible until the strip is hovered, then fades in
189
+ — so it costs no layout space and stays out of the way. */}
190
+ {hasOverflow && (
191
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5">
192
+ <div
193
+ className={cn(
194
+ "h-full rounded-full bg-border opacity-0 transition-opacity",
195
+ "group-hover/strip:opacity-100"
196
+ )}
197
+ style={{
198
+ width: `${thumb.width * 100}%`,
199
+ marginLeft: `${thumb.left * 100}%`,
200
+ }}
201
+ />
202
+ </div>
203
+ )}
204
+ </div>
205
+ {/* Actions menu: pinned at the end, outside the scroll region, so the
206
+ trigger stays visible no matter how far the tab list scrolls. The
207
+ vertical-dots button opens a dropdown with "close all tabs". */}
208
+ <DropdownMenu
209
+ align="end"
210
+ label="Действия с вкладками"
211
+ className={({ open }) =>
212
+ cn(
213
+ "flex h-full w-9 shrink-0 items-center justify-center border-l text-muted-foreground",
214
+ "transition-colors hover:bg-sidebar-accent/60 hover:text-foreground",
215
+ open && "bg-sidebar-accent text-foreground"
216
+ )
217
+ }
218
+ trigger={() => <MoreVertical className="size-4" />}
219
+ >
220
+ <DropdownMenuItem onSelect={closeAll}>
221
+ <X className="size-4 shrink-0 text-muted-foreground" />
222
+ <span>Закрыть все вкладки ({tabs.length})</span>
223
+ </DropdownMenuItem>
224
+ </DropdownMenu>
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useStickyBox } from 'react-sticky-box'
3
+ import type { Heading } from '~/lib/content.server'
4
+ import { t } from '~/lib/site'
5
+ import { cn } from '~/lib/utils'
6
+
7
+ // Headings shown in every TOC: section (h2) and subsection (h3) only.
8
+ export function tocHeadings(headings: Heading[]) {
9
+ return headings.filter((h) => h.depth >= 2 && h.depth <= 3)
10
+ }
11
+
12
+ // Scroll-spy: returns the slug of the heading the reader is currently at.
13
+ //
14
+ // We observe each heading element and keep the set that's intersecting the
15
+ // "active band" — the top 20% of the viewport (rootMargin shrinks the bottom
16
+ // to -80%). The active heading is the LAST visible one in document order, so
17
+ // it stays highlighted while reading its section, then advances as the next
18
+ // heading crosses into the band. The 44px top inset matches the sticky TopBar
19
+ // so a heading scrolled flush under the bar still counts as "at the top".
20
+ export function useActiveHeading(shown: Heading[]) {
21
+ const [activeSlug, setActiveSlug] = useState<string | null>(null)
22
+
23
+ useEffect(() => {
24
+ if (shown.length === 0) return
25
+
26
+ const elements = shown
27
+ .map((h) => document.getElementById(h.slug))
28
+ .filter((el): el is HTMLElement => el !== null)
29
+ if (elements.length === 0) return
30
+
31
+ // Track visibility per slug so we can pick the last visible heading even
32
+ // across multiple observer callbacks.
33
+ const visible = new Set<string>()
34
+
35
+ const observer = new IntersectionObserver(
36
+ (entries) => {
37
+ for (const entry of entries) {
38
+ if (entry.isIntersecting) visible.add(entry.target.id)
39
+ else visible.delete(entry.target.id)
40
+ }
41
+ // Last visible heading in document order wins.
42
+ const slugs = shown.map((h) => h.slug)
43
+ const last = slugs.filter((s) => visible.has(s)).at(-1)
44
+ if (last) {
45
+ setActiveSlug(last)
46
+ } else {
47
+ // Nothing in the band (e.g. mid-section with a tall block):
48
+ // keep the last heading scrolled past the top of the band.
49
+ const passed = elements.filter((el) => el.getBoundingClientRect().top < 44)
50
+ const lastPassed = passed.at(-1)
51
+ if (lastPassed) setActiveSlug(lastPassed.id)
52
+ }
53
+ },
54
+ { rootMargin: '-44px 0px -80% 0px', threshold: 0 },
55
+ )
56
+
57
+ for (const el of elements) observer.observe(el)
58
+ return () => observer.disconnect()
59
+ }, [shown])
60
+
61
+ return activeSlug
62
+ }
63
+
64
+ // Shared link list rendered by the desktop sidebar TOC and the mobile TOC modal.
65
+ export function TocLinks({ shown }: { shown: Heading[] }) {
66
+ const activeSlug = useActiveHeading(shown)
67
+
68
+ if (shown.length === 0) {
69
+ return <p className="m-0 text-muted-foreground">Нет содержания</p>
70
+ }
71
+ return (
72
+ <ul className="m-0 list-none p-0">
73
+ {shown.map((h) => {
74
+ const isActive = h.slug === activeSlug
75
+ return (
76
+ <li key={h.slug} className={cn('m-0', h.depth === 3 && 'pl-3')}>
77
+ <a
78
+ href={`#${h.slug}`}
79
+ aria-current={isActive ? 'location' : undefined}
80
+ className={cn(
81
+ 'block rounded px-2 py-1 no-underline hover:text-foreground',
82
+ isActive
83
+ ? 'bg-accent font-medium text-foreground'
84
+ : 'text-muted-foreground',
85
+ )}
86
+ >
87
+ {h.text}
88
+ </a>
89
+ </li>
90
+ )
91
+ })}
92
+ </ul>
93
+ )
94
+ }
95
+
96
+ export default function Toc({ headings }: { headings: Heading[] }) {
97
+ const shown = tocHeadings(headings)
98
+
99
+ // "Smart sticky" via react-sticky-box: a short TOC pins below the TopBar and
100
+ // stays; a TOC taller than the viewport follows the page as you scroll and
101
+ // pins at whichever edge you scrolled away from (bottom on the way down, top
102
+ // on the way back up) so every item is reachable without an inner scrollbar.
103
+ // Plain CSS `position: sticky` can only pin one edge, which clipped tall TOCs.
104
+ //
105
+ // offsetTop is the 44px TopBar height (the bar is `sticky top-0 h-11`). The tab
106
+ // strip lives in the content column (col 2), NOT over this TOC column (col 3),
107
+ // so it never overlaps the TOC and the offset stays 44px whether tabs are open.
108
+ const stickyRef = useStickyBox({ offsetTop: 44, offsetBottom: 0 })
109
+
110
+ // The <aside> is the full-height TRACK: it spans both grid rows so its height
111
+ // equals the scroll area the sticky inner node travels within. The inner <div>
112
+ // (stickyRef) is what react-sticky-box positions.
113
+ //
114
+ // `!row-start-1` is important-flagged on purpose: the parent Outlet wrapper in
115
+ // root.tsx applies `[&>*]:row-start-2` to every child, and that descendant
116
+ // selector outranks a plain `xl:row-start-1` here — so without `!` the TOC
117
+ // would start in row 2 (below the tab strip) instead of spanning from row 1.
118
+ return (
119
+ <aside
120
+ id="toc"
121
+ className="hidden border-l xl:col-start-3 xl:row-span-2 xl:!row-start-1 xl:block"
122
+ >
123
+ <div ref={stickyRef} className="px-5 pb-6 pt-2 text-[0.8125rem]">
124
+ <h2 className="mb-2 mt-0 text-lg font-semibold text-foreground">{t('onThisPage')}</h2>
125
+ <TocLinks shown={shown} />
126
+ </div>
127
+ </aside>
128
+ )
129
+ }
@@ -0,0 +1,74 @@
1
+ import { Keyboard } from 'lucide-react'
2
+
3
+ import { ThemeToggle } from '~/components/theme-toggle'
4
+ import { Search } from '~/components/Search'
5
+ import ProjectSwitcher from '~/components/ProjectSwitcher'
6
+ import { openShortcutsHelp } from '~/components/ShortcutsHelp'
7
+ import { Button } from '~/components/ui/button'
8
+ import { cn } from '~/lib/utils'
9
+ import { site, t } from '~/lib/site'
10
+
11
+ interface Props {
12
+ /** Active project id, or null when no project is selected (e.g. `/`). */
13
+ projectId: string | null
14
+ }
15
+
16
+ /**
17
+ * Slim top bar: logo + project switcher on the left, centered search, theme
18
+ * toggle on the right. Full-width and sticky at the top of the page: stays
19
+ * pinned while content scrolls beneath it, and the sidebar starts below it
20
+ * (so the bar overlaps neither the content nor the menu).
21
+ * Desktop only — mobile has its own bottom bar with the switcher and toggle.
22
+ */
23
+ export default function TopBar({ projectId }: Props) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ 'sticky top-0 z-50 flex h-11 items-center justify-between px-3 max-md:hidden',
28
+ 'border-b bg-background',
29
+ )}
30
+ >
31
+ <div className="flex shrink-0 items-center gap-3">
32
+ <a href="/" className="flex shrink-0 items-center">
33
+ {/* Theme is class-based (<html class="dark">), driven by the in-app toggle —
34
+ NOT prefers-color-scheme — so swap logos on the `dark` class, not the OS
35
+ setting. The *-dark.svg has white text (for dark bg); *-light.svg has
36
+ black text (for light bg). Inline height: app.css has an unlayered
37
+ `img { height: auto }` rule that otherwise beats Tailwind's h-* utility. */}
38
+ <img
39
+ src={site.logo.light}
40
+ alt={site.title}
41
+ style={{ height: 20, width: 'auto' }}
42
+ className="block dark:hidden"
43
+ />
44
+ <img
45
+ src={site.logo.dark}
46
+ alt={site.title}
47
+ style={{ height: 20, width: 'auto' }}
48
+ className="hidden dark:block"
49
+ />
50
+ </a>
51
+ {/* Project selector sits just right of the logo. */}
52
+ <ProjectSwitcher activeId={projectId} />
53
+ </div>
54
+ {/* Centered search: absolutely positioned so it stays centered in the bar
55
+ regardless of the logo / toggle widths on either side. */}
56
+ <div className="absolute left-1/2 -translate-x-1/2">
57
+ <Search className="w-full max-w-md justify-start sm:w-80 md:w-96" />
58
+ </div>
59
+ <div className="flex shrink-0 items-center">
60
+ {/* Opens the `?` cheatsheet — same overlay the `?` key toggles. */}
61
+ <Button
62
+ variant="ghost"
63
+ size="icon"
64
+ onClick={openShortcutsHelp}
65
+ aria-label={t('shortcuts')}
66
+ title={`${t('shortcuts')} (?)`}
67
+ >
68
+ <Keyboard />
69
+ </Button>
70
+ <ThemeToggle />
71
+ </div>
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Moon, Sun } from 'lucide-react'
3
+
4
+ import { Button } from '~/components/ui/button'
5
+ import { site, t } from '~/lib/site'
6
+
7
+ const STORAGE_KEY = 'theme'
8
+
9
+ /** Whether the site defaults to dark when the user hasn't picked a theme. */
10
+ const DEFAULTS_DARK = site.defaultTheme !== 'light'
11
+
12
+ /**
13
+ * Inline script injected into <head> (before paint) so the correct theme class
14
+ * is on <html> before first render — avoids a flash of the wrong theme. Uses the
15
+ * configured `site.defaultTheme` when nothing is stored.
16
+ */
17
+ export const themeInitScript = `(function(){try{var t=localStorage.getItem('${STORAGE_KEY}');var d=t?t==='dark':${DEFAULTS_DARK};document.documentElement.classList.toggle('dark',d);}catch(e){document.documentElement.classList.${DEFAULTS_DARK ? 'add' : 'remove'}('dark');}})();`
18
+
19
+ function getInitialIsDark(): boolean {
20
+ if (typeof document === 'undefined') return DEFAULTS_DARK
21
+ return document.documentElement.classList.contains('dark')
22
+ }
23
+
24
+ export function ThemeToggle({ className }: { className?: string }) {
25
+ // Render a stable icon during SSR/first paint; sync to the real DOM state
26
+ // after mount so the button reflects whatever the init script applied.
27
+ const [isDark, setIsDark] = useState(DEFAULTS_DARK)
28
+ const [mounted, setMounted] = useState(false)
29
+
30
+ useEffect(() => {
31
+ setMounted(true)
32
+ setIsDark(getInitialIsDark())
33
+ }, [])
34
+
35
+ function toggle() {
36
+ const next = !isDark
37
+ setIsDark(next)
38
+ // Suppress CSS transitions for the duration of the theme swap. Several
39
+ // elements (search input, project switcher, home cards, buttons) carry a
40
+ // `transition-colors`/`transition-all` for their hover states, which would
41
+ // otherwise also animate the color change on theme toggle — making them lag
42
+ // visibly behind the sidebar/content that have no transition. We add a
43
+ // global override that zeroes transitions, flip the class, force a reflow,
44
+ // then remove the override on the next frame so hovers animate normally again.
45
+ const root = document.documentElement
46
+ root.classList.add('theme-switching')
47
+ root.classList.toggle('dark', next)
48
+ // Force a synchronous style flush so the no-transition state is committed
49
+ // with the new colors before transitions are restored.
50
+ void root.offsetHeight
51
+ requestAnimationFrame(() => root.classList.remove('theme-switching'))
52
+ try {
53
+ localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light')
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+
59
+ return (
60
+ <Button
61
+ variant="ghost"
62
+ size="icon"
63
+ className={className}
64
+ onClick={toggle}
65
+ aria-label={t('toggleTheme')}
66
+ title={t('toggleTheme')}
67
+ >
68
+ {mounted && !isDark ? <Sun /> : <Moon />}
69
+ </Button>
70
+ )
71
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+
5
+ import { cn } from '~/lib/utils'
6
+
7
+ const buttonVariants = cva(
8
+ "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] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
13
+ destructive:
14
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
15
+ outline:
16
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
17
+ secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
18
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
19
+ link: 'text-primary underline-offset-4 hover:underline',
20
+ },
21
+ size: {
22
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
23
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
24
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
25
+ icon: 'size-9',
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ variant: 'default',
30
+ size: 'default',
31
+ },
32
+ },
33
+ )
34
+
35
+ function Button({
36
+ className,
37
+ variant,
38
+ size,
39
+ asChild = false,
40
+ ...props
41
+ }: React.ComponentProps<'button'> &
42
+ VariantProps<typeof buttonVariants> & {
43
+ asChild?: boolean
44
+ }) {
45
+ const Comp = asChild ? Slot : 'button'
46
+
47
+ return (
48
+ <Comp
49
+ data-slot="button"
50
+ className={cn(buttonVariants({ variant, size, className }))}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ export { Button, buttonVariants }
@@ -0,0 +1,55 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '~/lib/utils'
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<'div'>) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
32
+ return (
33
+ <div
34
+ data-slot="card-title"
35
+ className={cn('leading-none font-semibold', className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
42
+ return (
43
+ <div
44
+ data-slot="card-description"
45
+ className={cn('text-muted-foreground text-sm', className)}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
52
+ return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
53
+ }
54
+
55
+ export { Card, CardHeader, CardTitle, CardDescription, CardContent }