From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/color-picker.tsx | 13 +- apps/web/src/components/ui/context-menu.tsx | 14 +- apps/web/src/components/ui/dialog.tsx | 16 +- apps/web/src/components/ui/dropdown-menu.tsx | 23 ++- apps/web/src/components/ui/font-picker.tsx | 107 +++------- apps/web/src/components/ui/input-with-back.tsx | 85 -------- apps/web/src/components/ui/input.tsx | 7 +- apps/web/src/components/ui/number-field.tsx | 223 ++++++++++++++++----- apps/web/src/components/ui/popover.tsx | 20 +- apps/web/src/components/ui/select.tsx | 16 +- apps/web/src/components/ui/sheet.tsx | 17 +- apps/web/src/components/ui/sonner.tsx | 2 +- apps/web/src/components/ui/tabs.tsx | 92 ++++++--- apps/web/src/components/ui/tooltip.tsx | 4 +- .../src/components/ui/use-overlay-open-change.ts | 78 +++++++ 16 files changed, 457 insertions(+), 262 deletions(-) delete mode 100644 apps/web/src/components/ui/input-with-back.tsx create mode 100644 apps/web/src/components/ui/use-overlay-open-change.ts (limited to 'apps/web/src/components/ui') diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index aa52e228..3dff9417 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,7 +23,7 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2", - sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm", + sm: "h-7 p-1 px-2.5 text-sm rounded-sm", lg: "h-10 p-5 px-6", icon: "size-7 rounded-sm", text: "p-0", diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index fbf9bbd7..6d911a75 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -270,6 +270,7 @@ const ColorPicker = forwardRef( backgroundColor: "#fff", }; + return (
( className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative" type="button" > - + (
( -
); @@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { className="shrink-0" style={{ width: entry.w, - height: SPRITE_ROW_HEIGHT, + height: ROW_HEIGHT, backgroundColor: "currentColor", WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, @@ -283,6 +225,7 @@ function FontRow({ const fontName = filteredFonts[index]; const entry = atlas.fonts[fontName]; const isSelected = fontName === selectedFont; + const isSystemFont = SYSTEM_FONTS.has(fontName); return ( ); diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx deleted file mode 100644 index 1c22fd2e..00000000 --- a/apps/web/src/components/ui/input-with-back.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowLeft, Search } from "lucide-react"; -import { motion } from "motion/react"; -import { useState, useEffect } from "react"; - -interface InputWithBackProps { - isExpanded: boolean; - setIsExpanded: (isExpanded: boolean) => void; - placeholder?: string; - value?: string; - onChange?: (value: string) => void; - disableAnimation?: boolean; -} - -export function InputWithBack({ - isExpanded, - setIsExpanded, - placeholder = "Search anything", - value, - onChange, - disableAnimation = false, -}: InputWithBackProps) { - const [containerRef, setContainerRef] = useState(null); - const [buttonOffset, setButtonOffset] = useState(-60); - - const smoothTransition = { - duration: disableAnimation ? 0 : 0.35, - ease: [0.25, 0.1, 0.25, 1] as const, - }; - - useEffect(() => { - if (containerRef) { - const rect = containerRef.getBoundingClientRect(); - setButtonOffset(-rect.left - 48); - } - }, [containerRef]); - - return ( -
- setIsExpanded(!isExpanded)} - > - - -
- - - onChange?.(e.target.value)} - /> - -
-
- ); -} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 762cc675..1dce2f24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react"; import { useState } from "react"; const inputVariants = cva( - "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: "selection:bg-primary selection:text-primary-foreground", + default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20", + outline: "bg-background", destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", }, size: { default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", xs: "h-7 px-3 text-xs file:h-6 file:text-xs", - sm: "h-8 px-3 text-sm file:h-6 file:text-xs", + sm: "h-7 px-3 text-sm file:h-6 file:text-xs", lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", }, }, diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index 48297dc0..18d71591 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,12 +1,15 @@ "use client"; import { cn } from "@/utils/ui"; -import { useRef, useState, type ComponentProps } from "react"; +import { clamp } from "@/utils/math"; +import { useRef, useState, useLayoutEffect, type ComponentProps } from "react"; import { useFocusLock } from "@/hooks/use-focus-lock"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; +const SUFFIX_GAP_PX = 6; + const DRAG_SENSITIVITIES = { default: 1, slow: 0.5, @@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = { type DragSensitivity = "default" | "slow"; +type ScrubRange = { + from: number; + to: number; + pixelsPerUnit: number; +}; + +type ScrubClamp = { + min?: number; + max?: number; +}; + +function clampScrubValue({ + value, + min, + max, +}: { + value: number; + min?: number; + max?: number; +}): number { + if (min != null && max != null) return clamp({ value, min, max }); + if (min != null) return Math.max(min, value); + if (max != null) return Math.min(max, value); + return value; +} + +function getActiveRange({ + value, + direction, + ranges, +}: { + value: number; + direction: number; + ranges: readonly ScrubRange[]; +}): ScrubRange | undefined { + return ranges.find((range) => + direction > 0 + ? value >= range.from && value < range.to + : value > range.from && value <= range.to, + ); +} + +function scrubAcrossRanges({ + startValue, + pixelDelta, + ranges, + min, + max, +}: { + startValue: number; + pixelDelta: number; + ranges: readonly ScrubRange[]; + min?: number; + max?: number; +}): number { + let currentValue = clampScrubValue({ value: startValue, min, max }); + let remainingPixels = pixelDelta; + + while (remainingPixels !== 0) { + const direction = Math.sign(remainingPixels); + + const range = getActiveRange({ value: currentValue, direction, ranges }); + if (!range) break; + + const boundary = direction > 0 ? range.to : range.from; + const pixelsToBoundary = + Math.abs(boundary - currentValue) * range.pixelsPerUnit; + + if (Math.abs(remainingPixels) <= pixelsToBoundary) { + currentValue += remainingPixels / range.pixelsPerUnit; + break; + } + + currentValue = boundary; + remainingPixels -= direction * pixelsToBoundary; + } + + return clampScrubValue({ value: currentValue, min, max }); +} + interface NumberFieldProps extends Omit, "size" | "type"> { icon?: React.ReactNode; + suffix?: string; + suffixClassName?: string; dragSensitivity?: DragSensitivity; + scrubRanges?: readonly ScrubRange[]; + scrubClamp?: ScrubClamp; onScrub?: (value: number) => void; onScrubEnd?: () => void; allowExpressions?: boolean; @@ -28,8 +115,12 @@ interface NumberFieldProps function NumberField({ className, icon, + suffix, + suffixClassName, disabled, dragSensitivity = "default", + scrubRanges, + scrubClamp, onScrub, onScrubEnd, value, @@ -45,9 +136,18 @@ function NumberField({ }: NumberFieldProps & { ref?: React.Ref }) { const iconRef = useRef(null); const inputRef = useRef(null); + const ghostRef = useRef(null); const startValueRef = useRef(0); const cumulativeDeltaRef = useRef(0); const [isInputFocused, setIsInputFocused] = useState(false); + const [suffixLeft, setSuffixLeft] = useState(0); + + useLayoutEffect(() => { + if (!suffix || !ghostRef.current || !inputRef.current) return; + const paddingLeft = + parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; + setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); + }, [value, suffix]); const { containerRef: wrapperRef } = useFocusLock({ isActive: isInputFocused, @@ -71,12 +171,16 @@ function NumberField({ return; } cumulativeDeltaRef.current += moveEvent.movementX; - const sensitivity = - typeof dragSensitivity === "number" - ? dragSensitivity - : DRAG_SENSITIVITIES[dragSensitivity]; - const newValue = - startValueRef.current + cumulativeDeltaRef.current * sensitivity; + const newValue = scrubRanges + ? scrubAcrossRanges({ + startValue: startValueRef.current, + pixelDelta: cumulativeDeltaRef.current, + ranges: scrubRanges, + min: scrubClamp?.min, + max: scrubClamp?.max, + }) + : startValueRef.current + + cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; onScrub(newValue); }; @@ -93,11 +197,48 @@ function NumberField({ const canScrub = Boolean(icon && onScrub); + const inputNode = ( + { + const inputElement = event.currentTarget; + const shouldPreventNativeCaretPlacement = + event.button === 0 && document.activeElement !== inputElement; + if (shouldPreventNativeCaretPlacement) { + event.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + onMouseDown?.(event); + }} + onFocus={(event) => { + setIsInputFocused(true); + event.currentTarget.select(); + onFocus?.(event); + }} + onKeyDown={(event) => { + const shouldBlurInput = event.key === "Enter" || event.key === "Escape"; + if (shouldBlurInput) event.currentTarget.blur(); + onKeyDown?.(event); + }} + onBlur={(event) => { + setIsInputFocused(false); + onBlur?.(event); + }} + {...props} + /> + ); + return (
event.preventDefault() : undefined} onPointerDown={canScrub ? handleIconPointerDown : undefined} > {icon} )} - { - const inputElement = event.currentTarget; - const shouldPreventNativeCaretPlacement = - event.button === 0 && document.activeElement !== inputElement; - if (shouldPreventNativeCaretPlacement) { - event.preventDefault(); - inputElement.focus(); - inputElement.select(); - } - onMouseDown?.(event); - }} - onFocus={(event) => { - setIsInputFocused(true); - event.currentTarget.select(); - onFocus?.(event); - }} - onKeyDown={(event) => { - const shouldBlurInput = - event.key === "Enter" || event.key === "Escape"; - if (shouldBlurInput) event.currentTarget.blur(); - onKeyDown?.(event); - }} - onBlur={(event) => { - setIsInputFocused(false); - onBlur?.(event); - }} - {...props} - /> + > + {inputNode} + {suffix && ( + <> + {/* Ghost mirrors value text to measure width for suffix positioning */} + + + {suffix} + + + )} + {onReset && !isDefault && (
)} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 8fd511a3..cf480c8c 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -3,8 +3,26 @@ import * as React from "react"; import { Popover as PopoverPrimitive } from "radix-ui"; import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Popover = PopoverPrimitive.Root; +function Popover({ + open, + onOpenChange, + ...props +}: React.ComponentProps) { + const handleOpenChange = useOverlayOpenChange({ + source: "popover", + open, + onOpenChange, + }); + return ( + + ); +} const PopoverTrigger = PopoverPrimitive.Trigger; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 1b57ea4d..b7092b87 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -7,8 +7,22 @@ import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/utils/ui"; import { HugeiconsIcon } from "@hugeicons/react"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Select = SelectPrimitive.Root; +function Select({ + open, + onOpenChange, + ...props +}: React.ComponentProps) { + const handleOpenChange = useOverlayOpenChange({ + source: "select", + open, + onOpenChange, + }); + return ( + + ); +} const SelectGroup = SelectPrimitive.Group; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 3155787d..d56cd0ac 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -4,10 +4,23 @@ import * as React from "react"; import { Dialog as SheetPrimitive } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"; import { X } from "lucide-react"; - import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; -const Sheet = SheetPrimitive.Root; +function Sheet({ + open, + onOpenChange, + ...props +}: React.ComponentProps) { + const handleOpenChange = useOverlayOpenChange({ + source: "sheet", + open, + onOpenChange, + }); + return ( + + ); +} const SheetTrigger = SheetPrimitive.Trigger; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 4bae2fad..f0c8197a 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => { ("default"); + +const Tabs = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: TabsVariant; + } +>(({ variant = "default", ...props }, ref) => ( + + + +)); +Tabs.displayName = TabsPrimitive.Root.displayName; const TabsList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + + ); +}); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + + ); +}); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const variant = React.useContext(TabsVariantContext); + return ( + + ); +}); TabsContent.displayName = TabsPrimitive.Content.displayName; export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 067042e6..2e7b53d4 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; const tooltipVariants = cva( - "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-visible rounded-sm text-sm shadow-md", { variants: { variant: { @@ -22,7 +22,7 @@ const tooltipVariants = cva( important: "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]", promotions: - "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", + "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", personal: "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]", updates: diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts new file mode 100644 index 00000000..165efc79 --- /dev/null +++ b/apps/web/src/components/ui/use-overlay-open-change.ts @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; + +export function useOverlayOpenChange({ + source, + open, + onOpenChange, +}: { + source: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { openOverlay, closeOverlay } = useKeybindingsStore(); + const isTrackedRef = useRef(false); + const isControlled = typeof open === "boolean"; + const overlayId = useId(); + + useEffect(() => { + if (!isControlled) return; + + if (open && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + return; + } + + if (!open && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]); + + useEffect(() => { + return () => { + if (!isTrackedRef.current) return; + // #region agent log + fetch( + "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Debug-Session-Id": "3997d9", + }, + body: JSON.stringify({ + sessionId: "3997d9", + runId: "post-fix", + hypothesisId: "H2", + location: "use-overlay-open-change.ts:cleanup", + message: "Overlay closed during unmount cleanup", + data: { source, overlayId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + closeOverlay(overlayId, source); + isTrackedRef.current = false; + }; + }, [closeOverlay, overlayId, source]); + + return useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + if (nextOpen && !isTrackedRef.current) { + openOverlay(overlayId, source); + isTrackedRef.current = true; + } else if (!nextOpen && isTrackedRef.current) { + closeOverlay(overlayId, source); + isTrackedRef.current = false; + } + } + + onOpenChange?.(nextOpen); + }, + [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source], + ); +} -- cgit v1.3.1