feat: typography overhaul, custom scrollbars, import/export, settings UI

Includes changes from prior sessions: Epilogue + Space Mono fonts,
OverlayScrollbars integration, markdown editor fixes, settings dialog,
import/export buttons, and various UI refinements.
This commit is contained in:
Your Name
2026-02-16 14:56:36 +02:00
parent 6bbf4c973b
commit bb1b6312ba
9 changed files with 343 additions and 205 deletions

View File

@@ -7,7 +7,7 @@
<title>OpenPylon</title> <title>OpenPylon</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Epilogue:ital,wght@0,100..900;1,100..900&family=Instrument+Serif&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>

View File

@@ -39,20 +39,30 @@ export default function App() {
}); });
}, [init]); }, [init]);
// Save window state + flush board saves before the app window closes // Flush board saves before the app window closes
useEffect(() => { useEffect(() => {
function handleBeforeUnload() { function handleBeforeUnload() {
useBoardStore.getState().closeBoard(); useBoardStore.getState().closeBoard();
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, []);
// Save window state synchronously-ish (fire and forget) // Save window state on resize/move (debounced) so it persists without blocking close
useEffect(() => {
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
Promise.all([ let timeout: ReturnType<typeof setTimeout> | null = null;
async function saveWindowState() {
const [size, position, maximized] = await Promise.all([
appWindow.outerSize(), appWindow.outerSize(),
appWindow.outerPosition(), appWindow.outerPosition(),
appWindow.isMaximized(), appWindow.isMaximized(),
]).then(([size, position, maximized]) => { ]);
const settings = useAppStore.getState().settings; const settings = useAppStore.getState().settings;
saveSettings({ await saveSettings({
...settings, ...settings,
windowState: { windowState: {
x: position.x, x: position.x,
@@ -62,11 +72,20 @@ export default function App() {
maximized, maximized,
}, },
}); });
});
} }
window.addEventListener("beforeunload", handleBeforeUnload);
function debouncedSave() {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(saveWindowState, 500);
}
const unlistenResize = appWindow.onResized(debouncedSave);
const unlistenMove = appWindow.onMoved(debouncedSave);
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload); if (timeout) clearTimeout(timeout);
unlistenResize.then((fn) => fn());
unlistenMove.then((fn) => fn());
}; };
}, []); }, []);

View File

@@ -1,16 +1,63 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Plus } from "lucide-react"; import { Plus, ArrowUpDown } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
rectSortingStrategy,
} from "@dnd-kit/sortable";
import { staggerContainer, scaleIn, springs } from "@/lib/motion"; import { staggerContainer, scaleIn, springs } from "@/lib/motion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { BoardCard } from "@/components/boards/BoardCard"; import { BoardCard } from "@/components/boards/BoardCard";
import { BoardCardOverlay } from "@/components/boards/BoardCardOverlay";
import { NewBoardDialog } from "@/components/boards/NewBoardDialog"; import { NewBoardDialog } from "@/components/boards/NewBoardDialog";
import { ImportExportButtons } from "@/components/import-export/ImportExportButtons"; import { ImportButton } from "@/components/import-export/ImportExportButtons";
import type { BoardSortOrder } from "@/types/settings";
const SORT_LABELS: Record<BoardSortOrder, string> = {
manual: "Manual",
title: "Name",
updated: "Last modified",
created: "Date created",
};
export function BoardList() { export function BoardList() {
const boards = useAppStore((s) => s.boards); const boards = useAppStore((s) => s.boards);
const sortOrder = useAppStore((s) => s.settings.boardSortOrder);
const setSortOrder = useAppStore((s) => s.setBoardSortOrder);
const setBoardManualOrder = useAppStore((s) => s.setBoardManualOrder);
const getSortedBoards = useAppStore((s) => s.getSortedBoards);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [activeBoardId, setActiveBoardId] = useState<string | null>(null);
const sortedBoards = getSortedBoards();
const isManual = sortOrder === "manual";
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor)
);
// Listen for custom event to open new board dialog from command palette // Listen for custom event to open new board dialog from command palette
useEffect(() => { useEffect(() => {
@@ -23,6 +70,33 @@ export function BoardList() {
}; };
}, []); }, []);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveBoardId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveBoardId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const currentOrder = useAppStore.getState().getSortedBoards().map((b) => b.id);
const oldIndex = currentOrder.indexOf(active.id as string);
const newIndex = currentOrder.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = [...currentOrder];
newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, active.id as string);
setBoardManualOrder(newOrder);
},
[setBoardManualOrder]
);
const handleDragCancel = useCallback(() => {
setActiveBoardId(null);
}, []);
if (boards.length === 0) { if (boards.length === 0) {
return ( return (
<> <>
@@ -47,7 +121,7 @@ export function BoardList() {
<Plus className="size-4" /> <Plus className="size-4" />
Create Board Create Board
</Button> </Button>
<ImportExportButtons /> <ImportButton />
</div> </div>
</motion.div> </motion.div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
@@ -55,16 +129,51 @@ export function BoardList() {
); );
} }
const activeBoard = activeBoardId
? sortedBoards.find((b) => b.id === activeBoardId)
: null;
return ( return (
<> <>
<div className="h-full overflow-y-auto p-6"> <OverlayScrollbarsComponent
className="h-full"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true } }}
defer
>
<div className="p-6">
{/* Heading row */} {/* Heading row */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"> <h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
Your Boards Your Boards
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ImportExportButtons /> {/* Sort dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ArrowUpDown className="size-3.5" />
<span className="font-mono text-xs">{SORT_LABELS[sortOrder]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(v) => setSortOrder(v as BoardSortOrder)}
>
{(Object.keys(SORT_LABELS) as BoardSortOrder[]).map((key) => (
<DropdownMenuRadioItem key={key} value={key}>
{SORT_LABELS[key]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ImportButton />
<Button size="sm" onClick={() => setDialogOpen(true)}> <Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
New New
@@ -73,17 +182,35 @@ export function BoardList() {
</div> </div>
{/* Board grid */} {/* Board grid */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={sortedBoards.map((b) => b.id)}
strategy={rectSortingStrategy}
>
<motion.div <motion.div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={staggerContainer(0.05)} variants={staggerContainer(0.05)}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
> >
{boards.map((board) => ( {sortedBoards.map((board) => (
<BoardCard key={board.id} board={board} /> <BoardCard key={board.id} board={board} sortable={isManual} />
))} ))}
</motion.div> </motion.div>
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeBoard ? <BoardCardOverlay board={activeBoard} /> : null}
</DragOverlay>
</DndContext>
</div> </div>
</OverlayScrollbarsComponent>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</> </>

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Plus, Check } from "lucide-react"; import { Plus, Check } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
@@ -81,7 +82,12 @@ export function LabelPicker({
{/* Existing labels */} {/* Existing labels */}
{boardLabels.length > 0 && ( {boardLabels.length > 0 && (
<div className="flex max-h-40 flex-col gap-1 overflow-y-auto"> <OverlayScrollbarsComponent
className="max-h-40"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<div className="flex flex-col gap-1">
{boardLabels.map((label) => { {boardLabels.map((label) => {
const isSelected = cardLabelIds.includes(label.id); const isSelected = cardLabelIds.includes(label.id);
return ( return (
@@ -104,6 +110,7 @@ export function LabelPicker({
); );
})} })}
</div> </div>
</OverlayScrollbarsComponent>
)} )}
{/* Create new label */} {/* Create new label */}

View File

@@ -1,9 +1,14 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
const OS_OPTIONS = {
scrollbars: { theme: "os-theme-pylon" as const, autoHide: "scroll" as const, autoHideDelay: 600, clickScroll: true },
};
interface MarkdownEditorProps { interface MarkdownEditorProps {
cardId: string; cardId: string;
value: string; value: string;
@@ -21,10 +26,13 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
setDraft(value); setDraft(value);
}, [value]); }, [value]);
// Auto-focus textarea when switching to edit mode // Auto-focus and auto-size textarea when switching to edit mode
useEffect(() => { useEffect(() => {
if (mode === "edit" && textareaRef.current) { if (mode === "edit" && textareaRef.current) {
textareaRef.current.focus(); const el = textareaRef.current;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
el.focus();
} }
}, [mode]); }, [mode]);
@@ -41,6 +49,10 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
const text = e.target.value; const text = e.target.value;
setDraft(text); setDraft(text);
// Auto-size textarea to fit content (parent OverlayScrollbars handles overflow)
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
// Debounced auto-save // Debounced auto-save
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
@@ -90,17 +102,25 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
{/* Editor / Preview */} {/* Editor / Preview */}
{mode === "edit" ? ( {mode === "edit" ? (
<OverlayScrollbarsComponent
className="max-h-[160px] rounded-md border border-pylon-text-secondary/20 bg-pylon-surface focus-within:border-pylon-accent focus-within:ring-1 focus-within:ring-pylon-accent"
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
defer
>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={draft} value={draft}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
placeholder="Add a description... (Markdown supported)" placeholder="Add a description... (Markdown supported)"
className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md border border-pylon-text-secondary/20 bg-pylon-surface px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:border-pylon-accent focus:ring-1 focus:ring-pylon-accent" className="min-h-[100px] w-full resize-none overflow-hidden bg-transparent px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
/> />
</OverlayScrollbarsComponent>
) : ( ) : (
<div <OverlayScrollbarsComponent
className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20" className="min-h-[100px] max-h-[160px] cursor-pointer rounded-md border border-transparent px-1 py-1 transition-colors hover:border-pylon-text-secondary/20"
options={{ ...OS_OPTIONS, overflow: { x: "hidden" as const } }}
defer
onClick={() => setMode("edit")} onClick={() => setMode("edit")}
> >
{draft ? ( {draft ? (
@@ -114,7 +134,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
Click to add a description... Click to add a description...
</p> </p>
)} )}
</div> </OverlayScrollbarsComponent>
)} )}
</div> </div>
); );

View File

@@ -1,60 +1,23 @@
import { useRef } from "react"; import { useRef } from "react";
import { Download, Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { useToastStore } from "@/stores/toast-store"; import { useToastStore } from "@/stores/toast-store";
import { saveBoard } from "@/lib/storage"; import { saveBoard } from "@/lib/storage";
import { import {
exportBoardAsJson,
exportBoardAsCsv,
importBoardFromJson, importBoardFromJson,
importFromTrelloJson, importFromTrelloJson,
} from "@/lib/import-export"; } from "@/lib/import-export";
function downloadBlob(content: string, filename: string, mimeType: string) { export function ImportButton() {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function ImportExportButtons() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
const board = useBoardStore((s) => s.board);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard); const openBoard = useBoardStore((s) => s.openBoard);
function handleExportJson() {
if (!board) return;
const json = exportBoardAsJson(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json");
addToast("Board exported as JSON", "success");
}
function handleExportCsv() {
if (!board) return;
const csv = exportBoardAsCsv(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv");
addToast("Board exported as CSV", "success");
}
function handleImportClick() { function handleImportClick() {
fileInputRef.current?.click(); fileInputRef.current?.click();
} }
@@ -94,8 +57,7 @@ export function ImportExportButtons() {
} }
return ( return (
<div className="flex gap-2"> <>
{/* Import button */}
<Button variant="outline" size="sm" onClick={handleImportClick}> <Button variant="outline" size="sm" onClick={handleImportClick}>
<Upload className="size-4" /> <Upload className="size-4" />
Import Import
@@ -107,24 +69,6 @@ export function ImportExportButtons() {
onChange={handleFileSelected} onChange={handleFileSelected}
className="hidden" className="hidden"
/> />
</>
{/* Export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={!board}>
<Download className="size-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExportJson}>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportCsv}>
Export as CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { springs, scaleIn, microInteraction } from "@/lib/motion"; import { springs, microInteraction } from "@/lib/motion";
import { import {
Sun, Moon, Monitor, RotateCcw, Sun, Moon, Monitor, RotateCcw,
} from "lucide-react"; } from "lucide-react";
@@ -94,9 +94,33 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const setDensity = useAppStore((s) => s.setDensity); const setDensity = useAppStore((s) => s.setDensity);
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth); const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
const roRef = useRef<ResizeObserver | null>(null);
const [height, setHeight] = useState<number | "auto">("auto");
// Callback ref: sets up ResizeObserver when dialog content mounts in portal
const contentRef = useCallback((node: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (node) {
const measure = () => setHeight(node.getBoundingClientRect().height);
measure();
roRef.current = new ResizeObserver(measure);
roRef.current.observe(node);
}
}, []);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-lg"> <DialogContent className="bg-pylon-surface sm:max-w-lg overflow-hidden p-0">
<motion.div
animate={{ height: typeof height === "number" && height > 0 ? height : "auto" }}
initial={false}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="overflow-hidden"
>
<div ref={contentRef} className="flex flex-col gap-4 p-6">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-heading text-pylon-text"> <DialogTitle className="font-heading text-pylon-text">
Settings Settings
@@ -121,16 +145,15 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
))} ))}
</div> </div>
{/* Tab content */} {/* Tab content — entire dialog height animates between tabs */}
<AnimatePresence mode="wait"> <AnimatePresence mode="popLayout" initial={false}>
<motion.div <motion.div
key={tab} key={tab}
className="flex flex-col gap-5 pt-1" className="flex flex-col gap-5"
variants={scaleIn} initial={{ opacity: 0 }}
initial="hidden" animate={{ opacity: 1 }}
animate="visible" exit={{ opacity: 0 }}
exit="exit" transition={{ duration: 0.15 }}
transition={springs.snappy}
> >
{tab === "appearance" && ( {tab === "appearance" && (
<> <>
@@ -294,6 +317,8 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
)} )}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div>
</motion.div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -51,8 +52,8 @@
--color-pylon-text-secondary: var(--pylon-text-secondary); --color-pylon-text-secondary: var(--pylon-text-secondary);
--color-pylon-danger: var(--pylon-danger); --color-pylon-danger: var(--pylon-danger);
--font-heading: "Instrument Serif", Georgia, serif; --font-heading: "Instrument Serif", Georgia, serif;
--font-body: "Satoshi", system-ui, -apple-system, sans-serif; --font-body: "Epilogue", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", "Fira Code", monospace; --font-mono: "Space Mono", "Courier New", monospace;
} }
:root { :root {
@@ -149,41 +150,15 @@
scrollbar-color: oklch(80% 0 0 / 15%) transparent; scrollbar-color: oklch(80% 0 0 / 15%) transparent;
} }
/* Custom scrollbar for Chromium/WebKit (Tauri WebView) */ /* Hide native scrollbars — OverlayScrollbars renders custom ones */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 0;
height: 8px; height: 0;
} display: none;
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(50% 0 0 / 20%);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(50% 0 0 / 35%);
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(80% 0 0 / 15%);
border: 2px solid transparent;
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(80% 0 0 / 30%);
border: 2px solid transparent;
background-clip: content-box;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Satoshi", system-ui, -apple-system, sans-serif; font-family: "Epilogue", system-ui, -apple-system, sans-serif;
} }
:focus-visible { :focus-visible {
outline: 2px solid var(--pylon-accent); outline: 2px solid var(--pylon-accent);
@@ -191,6 +166,23 @@
} }
} }
/* OverlayScrollbars custom theme */
.os-theme-pylon {
--os-handle-bg: oklch(50% 0 0 / 22%);
--os-handle-bg-hover: oklch(50% 0 0 / 40%);
--os-handle-bg-active: oklch(50% 0 0 / 55%);
--os-size: 8px;
--os-handle-border-radius: 9999px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-handle-min-size: 30px;
}
.dark .os-theme-pylon {
--os-handle-bg: oklch(80% 0 0 / 18%);
--os-handle-bg-hover: oklch(80% 0 0 / 35%);
--os-handle-bg-active: oklch(80% 0 0 / 50%);
}
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
:root { :root {
--pylon-text: oklch(10% 0.02 50); --pylon-text: oklch(10% 0.02 50);

View File

@@ -1,8 +1,12 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { OverlayScrollbars, ClickScrollPlugin } from "overlayscrollbars";
import App from "./App"; import App from "./App";
import "overlayscrollbars/overlayscrollbars.css";
import "./index.css"; import "./index.css";
OverlayScrollbars.plugin(ClickScrollPlugin);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />