From ad7f900cfe66056591a8eb5181a529e49c179767 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Sun, 31 May 2026 15:31:45 +0200 Subject: [PATCH] Update --- website/app/globals.css | 81 ++- website/app/page.tsx | 1028 +++++++++++++++++++++++++------------ website/profiles/index.ts | 227 ++++++++ 3 files changed, 996 insertions(+), 340 deletions(-) create mode 100644 website/profiles/index.ts diff --git a/website/app/globals.css b/website/app/globals.css index 3fab681..8086f8b 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -94,13 +94,41 @@ button:disabled { } .github-link, -.menu { +.menu, +.export-pill { backdrop-filter: blur(24px); background: rgba(10, 10, 14, 0.42); border: 1px solid var(--stroke); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24); } +.export-pill { + align-items: center; + border-radius: 999px; + bottom: 24px; + color: var(--ink); + cursor: pointer; + display: inline-flex; + font-size: 0.92rem; + font-weight: 600; + gap: 8px; + height: 42px; + right: 380px; + padding: 0 18px 0 14px; + position: fixed; + z-index: 10; +} + +.export-pill:hover { + background: rgba(255, 255, 255, 0.1); +} + +.export-pill svg { + fill: currentColor; + height: 16px; + width: 16px; +} + .github-link { align-items: center; border-radius: 999px; @@ -267,6 +295,17 @@ button:disabled { font-weight: 700; } +.menu-brand-count { + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + color: rgba(255, 255, 255, 0.78); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + font-weight: 700; + margin-left: auto; + padding: 2px 8px; +} + .menu-row { align-items: center; background: transparent; @@ -322,6 +361,11 @@ button:disabled { .menu-chevron { font-size: 1.55rem; line-height: 0.7; + transition: transform 120ms ease; +} + +.menu-chevron.expanded { + transform: rotate(90deg); } .menu-separator { @@ -439,11 +483,23 @@ button:disabled { .pulse { --pulse-color: var(--press-color); animation: press-pulse var(--pulse-duration) cubic-bezier(0.22, 1, 0.36, 1) forwards; - border: 4px solid var(--pulse-color); + border: calc(4px * var(--pulse-border-scale, 1)) solid var(--pulse-color); border-radius: 999px; + /* Filled translucent disc behind the ring. Swift's drawGlowIfNeeded + fills an ellipse at ~1.4× the ring radius; here we approximate that + by growing the box-shadow's `spread` (an absolute outward expansion of + the painted disc) proportional to the pulse size. A small blur softens + the disc edge so it reads as a halo at lower glow values. The disc's + alpha comes from --pulse-glow-alpha, separate from its size so we can + mirror Swift's stepped 0.08 → 0.18 alpha jump at intensity ≥ 1.2. */ box-shadow: - 0 0 22px color-mix(in srgb, var(--pulse-color), transparent 68%), - 0 0 52px color-mix(in srgb, var(--pulse-color), transparent 82%); + 0 0 calc(var(--pulse-size) * 0.06 * var(--pulse-glow, 0)) + calc(var(--pulse-size) * 0.28 * var(--pulse-glow, 0)) + color-mix( + in srgb, + var(--pulse-color), + transparent calc((1 - var(--pulse-glow-alpha, 0)) * 100%) + ); height: var(--pulse-size); width: var(--pulse-size); } @@ -547,8 +603,11 @@ button:disabled { background: var(--pulse-color); border: 0; box-shadow: none; - height: 12px; - width: 12px; + /* Mirrors Swift drag dot: 2 * (size * 0.6) * (0.08 + 0.065 * intensity). + --pulse-size is the size CSS var, --drag-dot-scale is derived from + intensity in deriveIntensityVars(). */ + height: calc(var(--pulse-size) * var(--drag-dot-scale, 0.16)); + width: calc(var(--pulse-size) * var(--drag-dot-scale, 0.16)); } .pulse.drag::before { @@ -557,7 +616,7 @@ button:disabled { @keyframes press-pulse { 0% { - opacity: 0.73; + opacity: var(--pulse-opacity, 0.73); transform: translate(-50%, -50%) scale(0.22); } 100% { @@ -588,7 +647,7 @@ button:disabled { @keyframes right-pulse { 0% { - opacity: 0.73; + opacity: var(--pulse-opacity, 0.73); transform: translate(-50%, -50%) scale(0.22); } 100% { @@ -610,7 +669,7 @@ button:disabled { @keyframes middle-pulse { 0% { - opacity: 0.73; + opacity: var(--pulse-opacity, 0.73); transform: translate(-50%, -50%) scale(0.2); } 100% { @@ -675,6 +734,10 @@ button:disabled { display: none; } + .export-pill { + display: none; + } + .showcase { inset: 9vh 20px 20px; } diff --git a/website/app/page.tsx b/website/app/page.tsx index 96ddbff..d321f35 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -1,6 +1,13 @@ "use client"; import { PointerEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + profiles, + profilesById, + toSwiftExport, + type Profile, + type ProfileSettings, +} from "../profiles"; type ClickKind = | "press" @@ -10,33 +17,6 @@ type ClickKind = | "middle" | "middleRelease" | "drag"; -type ThemeName = "blue" | "amber" | "red"; -type ProfileName = "Default" | "Tutorial" | "Presentation"; -type ToggleKey = - | "press" - | "release" - | "right" - | "middle" - | "drag" - | "laser" - | "keys"; - -type DemoSettings = Record & { - pulseDuration: number; - pulseSize: number; - theme: ThemeName; -}; - -type ThemePalette = { - active: string; - drag: string; - laserInner: string; - laserMiddle: string; - laserOuter: string; - laserMain: string; - middle: string; - right: string; -}; type Pulse = { id: number; @@ -116,85 +96,256 @@ function displayKey(event: KeyboardEvent): string | null { return null; } -const profiles: Record = { - Default: { - press: true, - release: true, - right: true, - middle: true, - drag: true, - laser: false, - keys: true, - pulseDuration: 440, - pulseSize: 96, - theme: "blue", - }, - Tutorial: { - press: true, - release: true, - right: true, - middle: true, - drag: true, - laser: false, - keys: true, - pulseDuration: 1080, - pulseSize: 184, - theme: "amber", - }, - Presentation: { - press: true, - release: true, - right: true, - middle: true, - drag: true, - laser: true, - keys: true, - pulseDuration: 620, - pulseSize: 116, - theme: "red", - }, +const profilesByName: Record = Object.fromEntries( + profiles.map((p) => [p.name, p]), +); +const DEFAULT_PROFILE = profilesByName.Default ?? profiles[0]; + +// Mirrors Swift ClickColorPreset.color values from SettingsStore.swift. +const PRESET_COLORS: Partial< + Record +> = { + primary: [0.0, 0.48, 1.0], // approximation of macOS accent + blue: [0.0, 0.74, 1.0], + green: [0.2, 0.9, 0.42], + purple: [0.58, 0.36, 1.0], + pink: [1.0, 0.32, 0.72], + orange: [1.0, 0.46, 0.19], + white: [1.0, 1.0, 1.0], }; -const themePalettes: Record = { - blue: { - active: "var(--aqua)", - drag: "#ebd638", - laserInner: "#ffffff", - laserMiddle: "#ffb0a8", - laserOuter: "rgba(255, 93, 87, 0.25)", - laserMain: "#ff2905", - middle: "#45eb94", - right: "#ff7530", - }, - amber: { - active: "var(--amber)", - drag: "#ffb02e", - laserInner: "#ffffff", - laserMiddle: "#ffb0a8", - laserOuter: "rgba(255, 93, 87, 0.25)", - laserMain: "#ff2905", - middle: "#ffe98f", - right: "#ff9f43", - }, - red: { - active: "var(--coral)", - drag: "#ff6d6a", - laserInner: "#ffffff", - laserMiddle: "#ffb0a8", - laserOuter: "rgba(255, 93, 87, 0.25)", - laserMain: "#ff2905", - middle: "#ff8f83", - right: "#ff5d57", - }, +const DEFAULT_PER_KIND_COLOR: Record< + "press" | "release" | "right" | "middle" | "drag", + [number, number, number] +> = { + press: [0.0, 0.74, 1.0], + release: [0.4, 0.88, 1.0], + right: [1.0, 0.46, 0.19], + middle: [0.27, 0.92, 0.58], + drag: [0.92, 0.84, 0.22], }; +function rgbCss(c: [number, number, number], alpha = 1) { + const r = Math.round(Math.max(0, Math.min(1, c[0])) * 255); + const g = Math.round(Math.max(0, Math.min(1, c[1])) * 255); + const b = Math.round(Math.max(0, Math.min(1, c[2])) * 255); + return alpha === 1 + ? `rgb(${r}, ${g}, ${b})` + : `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function mixComponent(a: number, b: number) { + return (a + b) / 2; +} + +// Mirrors color(for:) in ClickOverlayView.swift. +function resolveColors(s: ProfileSettings) { + const customLeft: [number, number, number] = [ + s.customLeftColorRed, + s.customLeftColorGreen, + s.customLeftColorBlue, + ]; + const customRight: [number, number, number] = [ + s.customRightColorRed, + s.customRightColorGreen, + s.customRightColorBlue, + ]; + const customMiddle: [number, number, number] = [ + s.customMiddleColorRed, + s.customMiddleColorGreen, + s.customMiddleColorBlue, + ]; + const customDrag: [number, number, number] = [ + s.customDragColorRed, + s.customDragColorGreen, + s.customDragColorBlue, + ]; + const customAll: [number, number, number] = [ + s.customColorRed, + s.customColorGreen, + s.customColorBlue, + ]; + + let press: [number, number, number]; + let release: [number, number, number]; + let right: [number, number, number]; + let middle: [number, number, number]; + let drag: [number, number, number]; + + if (s.colorPreset === "custom") { + if (s.customColorMode === "all") { + press = release = right = middle = drag = customAll; + } else { + press = release = customLeft; + right = customRight; + middle = customMiddle; + drag = customDrag; + } + } else { + const preset = PRESET_COLORS[s.colorPreset]; + if (preset) { + press = release = right = middle = drag = preset; + } else { + press = DEFAULT_PER_KIND_COLOR.press; + release = DEFAULT_PER_KIND_COLOR.release; + right = DEFAULT_PER_KIND_COLOR.right; + middle = DEFAULT_PER_KIND_COLOR.middle; + drag = DEFAULT_PER_KIND_COLOR.drag; + } + } + + const laserMain: [number, number, number] = [ + s.laserColorRed, + s.laserColorGreen, + s.laserColorBlue, + ]; + const laserInner: [number, number, number] = [ + s.laserInnerColorRed, + s.laserInnerColorGreen, + s.laserInnerColorBlue, + ]; + const laserMiddle: [number, number, number] = [ + mixComponent(laserMain[0], laserInner[0]), + mixComponent(laserMain[1], laserInner[1]), + mixComponent(laserMain[2], laserInner[2]), + ]; + + return { + press, + release, + right, + middle, + drag, + laserMain, + laserInner, + laserMiddle, + }; +} + +// Map intensity 0.15–1.35 to UI strengths. Mirrors the Swift renderer: +// - opacity = clamp(0.18 + intensity * 0.78) +// - glow halo gated at >= 0.7 (drawGlowIfNeeded), with a hard step-up at +// >= 1.2 (alpha multiplier 0.08 → 0.18, plus a bigger radius factor) +function deriveIntensityVars(intensity: number) { + const clamped = Math.max(0.15, Math.min(1.35, intensity)); + const opacity = Math.min(1, 0.18 + clamped * 0.78); + + // Geometric glow size, drives box-shadow spread (a filled disc behind + // the ring). 0 below intensity 0.7, then ramps; large boost at >= 1.2 so + // Beacon is unmistakable. + let glow = 0; + let glowAlpha = 0; + if (clamped >= 1.2) { + // Beacon zone: big disc, ~18% alpha (matches Swift's `>= 1.2 ? 0.18`). + glow = 0.85 + (clamped - 1.2) * 1.0; // 1.2 -> 0.85, 1.35 -> 1.0 + glowAlpha = 0.55; // visually equivalent to Swift's 0.18 since CSS disc + // accumulates blur+spread, not a raw fill. + } else if (clamped >= 0.7) { + // Bright zone: moderate disc, ~8% alpha. + glow = (clamped - 0.7) * 0.9 + 0.3; // 0.7 -> 0.3, 1.0 -> 0.57 + glowAlpha = 0.28; + } + + // Ring thickness. Matches Swift lineWidth = max(2.25, baseSize * (0.035 + intensity * 0.045)). + const borderScale = 0.5 + clamped * 1.1; + + return { + "--pulse-opacity": opacity.toFixed(3), + "--pulse-glow": glow.toFixed(3), + "--pulse-glow-alpha": glowAlpha.toFixed(3), + "--pulse-border-scale": borderScale.toFixed(3), + // Drag dot diameter in Swift: 2 * (size * 0.6) * (0.08 + 0.065 * intensity). + // Web --pulse-size is (size * 1.4), so the dot scale relative to it is + // (1.2 / 1.4) * (0.08 + 0.065 * intensity) ≈ 0.857 * (...). + "--drag-dot-scale": ((1.2 / 1.4) * (0.08 + 0.065 * clamped)).toFixed(4), + } as React.CSSProperties; +} + +function settingsToCssVars(s: ProfileSettings): React.CSSProperties { + const c = resolveColors(s); + return { + "--press-color": rgbCss(c.press), + "--release-color": rgbCss(c.release), + "--right-color": rgbCss(c.right), + "--middle-color": rgbCss(c.middle), + "--drag-color": rgbCss(c.drag), + "--laser-main": rgbCss(c.laserMain), + "--laser-middle": rgbCss(c.laserMiddle), + "--laser-inner": rgbCss(c.laserInner), + "--laser-outer": rgbCss(c.laserMain, 0.25), + "--active": rgbCss(c.press), + // Map Swift size (32–112) to a comparable pixel value for the web demo. + "--pulse-size": `${Math.round(s.size * 1.4)}px`, + // Swift duration is seconds. + "--pulse-duration": `${Math.round(s.duration * 1000)}ms`, + ...deriveIntensityVars(s.intensity), + } as React.CSSProperties; +} + +// Mirrors StatusController.compactCount: compact-name formatting with up to +// one fractional digit (e.g. 1, 999, 1K, 1.2K, 12K, 1M). +function compactCount(value: number): string { + if (typeof Intl !== "undefined" && "NumberFormat" in Intl) { + try { + return new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); + } catch { + // fall through + } + } + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} + let nextAnimationId = 0; const installCommand = "brew install --cask aurorascharff/clicklight/clicklight"; +// Mirrors Sources/ClickLight/ClickSettingOptions.swift so the submenu shows +// the same presets the macOS app does. +const SIZE_PRESETS: { title: string; value: number }[] = [ + { title: "Small", value: 44 }, + { title: "Medium", value: 64 }, + { title: "Large", value: 88 }, + { title: "Huge", value: 116 }, +]; +const INTENSITY_PRESETS: { title: string; value: number }[] = [ + { title: "Subtle", value: 0.28 }, + { title: "Normal", value: 0.7 }, + { title: "Bright", value: 1.0 }, + { title: "Beacon", value: 1.35 }, +]; +const DURATION_PRESETS: { title: string; value: number }[] = [ + { title: "Snappy", value: 0.28 }, + { title: "Normal", value: 0.48 }, + { title: "Long", value: 0.72 }, + { title: "Very Long", value: 1.0 }, +]; +// Mirrors ClickColorPreset.allCases (omitting `.custom`, which is reached +// by editing the per-click colors in the macOS Settings window). +const COLOR_PRESETS: { id: ProfileSettings["colorPreset"]; title: string }[] = [ + { id: "default", title: "Default" }, + { id: "primary", title: "Primary" }, + { id: "blue", title: "Blue" }, + { id: "green", title: "Green" }, + { id: "purple", title: "Purple" }, + { id: "pink", title: "Pink" }, + { id: "orange", title: "Orange" }, + { id: "white", title: "White" }, +]; + +function approxEqual(a: number, b: number, tolerance = 0.01) { + return Math.abs(a - b) < tolerance; +} + export default function Home() { - const [settings, setSettings] = useState(profiles.Default); - const [profile, setProfile] = useState("Default"); + const [settings, setSettings] = useState( + DEFAULT_PROFILE.settings, + ); + const [profileId, setProfileId] = useState(DEFAULT_PROFILE.id); const [pulses, setPulses] = useState([]); const [activeStroke, setActiveStroke] = useState(null); const [fadingStrokes, setFadingStrokes] = useState([]); @@ -203,6 +354,14 @@ export default function Home() { const [shortcut, setShortcut] = useState(null); const [shortcutFading, setShortcutFading] = useState(false); const [copiedInstall, setCopiedInstall] = useState(false); + // Demo-only display flags. Not persisted in profiles so JSON stays + // compatible with the Swift ClickProfileSettings schema. + const [showMenuBarText, setShowMenuBarText] = useState(true); + const [showMenuBarClickCount, setShowMenuBarClickCount] = useState(true); + const [clickCount, setClickCount] = useState(0); + const [expandedGroup, setExpandedGroup] = useState< + "size" | "intensity" | "duration" | "colors" | null + >(null); const surfaceRef = useRef(null); const pointerDownRef = useRef(false); const downPointRef = useRef(null); @@ -214,14 +373,11 @@ export default function Home() { const cursorFadeTimeoutRef = useRef(null); const cursorRemoveTimeoutRef = useRef(null); - const palette = useMemo( - () => themePalettes[settings.theme], - [settings.theme], - ); + const surfaceStyle = useMemo(() => settingsToCssVars(settings), [settings]); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { - if (!settings.keys || event.repeat) return; + if (!settings.showLiveKeyboardShortcuts || event.repeat) return; // Mirrors Swift HotKeyBinding: require at least one non-shift modifier. if (!event.metaKey && !event.ctrlKey && !event.altKey) return; @@ -263,7 +419,7 @@ export default function Home() { if (shortcutRemoveTimeoutRef.current) window.clearTimeout(shortcutRemoveTimeoutRef.current); }; - }, [settings.keys]); + }, [settings.showLiveKeyboardShortcuts]); function pointFromEvent(event: PointerEvent) { const rect = surfaceRef.current?.getBoundingClientRect(); @@ -293,21 +449,24 @@ export default function Home() { : null; lastDragPointRef.current = downPointRef.current; hasDraggedRef.current = false; + // Mirrors ClickActivityStore.record: each press is one click. Drag adds + // at most one click per gesture, handled in handlePointerMove. + setClickCount((c) => c + 1); - if (event.button === 2 && settings.right) { + if (event.button === 2 && settings.showRightClick) { pressedKindRef.current = "right"; addPulse(event, "right"); return; } - if (event.button === 1 && settings.middle) { + if (event.button === 1 && settings.showMiddleClick) { pressedKindRef.current = "middle"; addPulse(event, "middle"); return; } pressedKindRef.current = "press"; - if (settings.press) addPulse(event, "press"); + if (settings.showPress) addPulse(event, "press"); } function clearCursorTimers() { @@ -349,27 +508,31 @@ export default function Home() { const downPoint = downPointRef.current; if (pointerDownRef.current && downPoint) { const distance = Math.hypot(point.x - downPoint.x, point.y - downPoint.y); - if (distance > 4) hasDraggedRef.current = true; + if (distance > 4 && !hasDraggedRef.current) { + hasDraggedRef.current = true; + // Matches ClickActivityStore: one click per drag gesture. + setClickCount((c) => c + 1); + } } const lastDragPoint = lastDragPointRef.current; if ( pointerDownRef.current && hasDraggedRef.current && - settings.drag && + settings.showDrag && lastDragPoint ) { const dragDistance = Math.hypot( point.x - lastDragPoint.x, point.y - lastDragPoint.y, ); - if (!settings.laser && dragDistance > 18) { + if (!settings.showLaserPointer && dragDistance > 18) { addPulse(event, "drag"); lastDragPointRef.current = nextPoint; - } else if (settings.laser && dragDistance > 8) { + } else if (settings.showLaserPointer && dragDistance > 8) { lastDragPointRef.current = nextPoint; } } - if (!settings.laser) return; + if (!settings.showLaserPointer) return; bumpLaserCursor(nextPoint); if (pointerDownRef.current) { setActiveStroke((current) => { @@ -389,8 +552,8 @@ export default function Home() { } function handlePointerUp(event: PointerEvent) { - if (hasDraggedRef.current && settings.drag) addPulse(event, "drag"); - if (settings.release) { + if (hasDraggedRef.current && settings.showDrag) addPulse(event, "drag"); + if (settings.showRelease) { if (pressedKindRef.current === "right") addPulse(event, "rightRelease"); else if (pressedKindRef.current === "middle") addPulse(event, "middleRelease"); @@ -431,26 +594,51 @@ export default function Home() { setShortcutFading(false); } - function updateProfile(nextProfile: ProfileName) { - setProfile(nextProfile); - setSettings(profiles[nextProfile]); + function applyProfile(id: string) { + const next = profilesById[id]; + if (!next) return; + setProfileId(id); + setSettings(next.settings); clearLaserVisuals(); clearShortcut(); } - function toggle(key: ToggleKey) { + type SettingsToggleKey = + | "showPress" + | "showRelease" + | "showRightClick" + | "showMiddleClick" + | "showDrag" + | "showLaserPointer" + | "showLiveKeyboardShortcuts"; + + function toggle(key: SettingsToggleKey) { setSettings((current) => { - const next = { ...current, [key]: !current[key] }; - if (key === "laser" && !next.laser) { + const next: ProfileSettings = { ...current, [key]: !current[key] }; + if (key === "showLaserPointer" && !next.showLaserPointer) { clearLaserVisuals(); } - if (key === "keys" && !next.keys) { + if ( + key === "showLiveKeyboardShortcuts" && + !next.showLiveKeyboardShortcuts + ) { clearShortcut(); } return next; }); } + function toggleGroup(group: "size" | "intensity" | "duration" | "colors") { + setExpandedGroup((current) => (current === group ? null : group)); + } + + function setSettingsField( + key: K, + value: ProfileSettings[K], + ) { + setSettings((current) => ({ ...current, [key]: value })); + } + function handlePointerLeave() { if (pointerDownRef.current) return; clearCursorTimers(); @@ -472,240 +660,410 @@ export default function Home() { } return ( -
-
+ {pulses.map((pulse) => ( + + ))} + + ); } +function downloadProfile(profile: Profile) { + const json = JSON.stringify(toSwiftExport(profile), null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `clicklight-${profile.name.toLowerCase().replace(/\s+/g, "-")}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// Wraps the live demo state in a Profile envelope so it can be exported +// the same way as a curated profile. A new UUID is minted each time so +// repeated exports import as separate profiles in the macOS app. +function buildCustomProfile(settings: ProfileSettings, name: string): Profile { + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `custom-${Date.now()}`; + return { + id, + name, + description: "Exported from clicklight.dev", + createdAt: new Date().toISOString(), + settings, + }; +} + +function downloadCurrentProfile(settings: ProfileSettings, name: string) { + downloadProfile(buildCustomProfile(settings, name)); +} + function MenuItem({ checked = false, chevron = false, disabled = false, + expanded = false, inset = false, label, onClick, @@ -714,6 +1072,7 @@ function MenuItem({ checked?: boolean; chevron?: boolean; disabled?: boolean; + expanded?: boolean; inset?: boolean; label: string; onClick?: () => void; @@ -731,7 +1090,14 @@ function MenuItem({ {label} {shortcut && {shortcut}} - {chevron && } + {chevron && ( + + )} ); } diff --git a/website/profiles/index.ts b/website/profiles/index.ts new file mode 100644 index 0000000..29bc24c --- /dev/null +++ b/website/profiles/index.ts @@ -0,0 +1,227 @@ +// Profile data for the website demo and downloadable JSON files. +// +// The `settings` shape mirrors Swift's ClickProfileSettings exactly, so any +// JSON downloaded from this site can be imported in the macOS app via +// Settings -> Profiles -> Import. +// +// Reference: Sources/ClickLight/ClickProfileStore.swift + +export type LiveShortcutPosition = "nearPointer" | "bottomCenter"; +export type LiveShortcutSize = "small" | "medium" | "large" | "extraLarge"; +export type ColorPreset = + | "default" + | "primary" + | "blue" + | "green" + | "purple" + | "pink" + | "orange" + | "white" + | "custom"; +export type CustomColorMode = "all" | "byClick"; + +export type ProfileSettings = { + showPress: boolean; + showRelease: boolean; + showRightClick: boolean; + showMiddleClick: boolean; + showDrag: boolean; + showLaserPointer: boolean; + showLiveKeyboardShortcuts: boolean; + liveShortcutPosition: LiveShortcutPosition; + liveShortcutSize: LiveShortcutSize; + size: number; + intensity: number; + duration: number; + colorPreset: ColorPreset; + customColorMode: CustomColorMode; + customColorRed: number; + customColorGreen: number; + customColorBlue: number; + customLeftColorRed: number; + customLeftColorGreen: number; + customLeftColorBlue: number; + customRightColorRed: number; + customRightColorGreen: number; + customRightColorBlue: number; + customMiddleColorRed: number; + customMiddleColorGreen: number; + customMiddleColorBlue: number; + customDragColorRed: number; + customDragColorGreen: number; + customDragColorBlue: number; + laserColorRed: number; + laserColorGreen: number; + laserColorBlue: number; + laserInnerColorRed: number; + laserInnerColorGreen: number; + laserInnerColorBlue: number; +}; + +export type Profile = { + id: string; + name: string; + description: string; + createdAt: string; // ISO 8601, converted to Foundation reference-date on download + settings: ProfileSettings; +}; + +// Foundation's JSONEncoder default for Date is seconds-since-2001-01-01. +const REFERENCE_DATE_OFFSET_SECONDS = 978_307_200; + +function toFoundationDate(iso: string): number { + return Math.round( + (Date.parse(iso) - REFERENCE_DATE_OFFSET_SECONDS * 1000) / 1000, + ); +} + +// Wraps a profile in the same export envelope the Swift app emits. +export function toSwiftExport(profile: Profile) { + return { + version: 1, + profiles: [ + { + id: profile.id, + name: profile.name, + createdAt: toFoundationDate(profile.createdAt), + settings: profile.settings, + }, + ], + }; +} + +const DEFAULT_BLUE: [number, number, number] = [0.0, 0.74, 1.0]; +const DEFAULT_ORANGE: [number, number, number] = [1.0, 0.46, 0.19]; +const DEFAULT_GREEN: [number, number, number] = [0.27, 0.92, 0.58]; +const DEFAULT_DRAG_YELLOW: [number, number, number] = [0.92, 0.84, 0.22]; +const DEFAULT_LASER_RED: [number, number, number] = [ + 1.0, 0.1607843137, 0.0196078431, +]; + +function rgb(c: [number, number, number]) { + return { r: c[0], g: c[1], b: c[2] }; +} + +function baseSettings( + overrides: Partial = {}, +): ProfileSettings { + return { + showPress: true, + showRelease: true, + showRightClick: true, + showMiddleClick: true, + showDrag: true, + showLaserPointer: false, + showLiveKeyboardShortcuts: false, + liveShortcutPosition: "bottomCenter", + liveShortcutSize: "medium", + size: 64, + intensity: 0.7, + duration: 0.48, + colorPreset: "default", + customColorMode: "all", + customColorRed: DEFAULT_BLUE[0], + customColorGreen: DEFAULT_BLUE[1], + customColorBlue: DEFAULT_BLUE[2], + customLeftColorRed: DEFAULT_BLUE[0], + customLeftColorGreen: DEFAULT_BLUE[1], + customLeftColorBlue: DEFAULT_BLUE[2], + customRightColorRed: DEFAULT_ORANGE[0], + customRightColorGreen: DEFAULT_ORANGE[1], + customRightColorBlue: DEFAULT_ORANGE[2], + customMiddleColorRed: DEFAULT_GREEN[0], + customMiddleColorGreen: DEFAULT_GREEN[1], + customMiddleColorBlue: DEFAULT_GREEN[2], + customDragColorRed: DEFAULT_DRAG_YELLOW[0], + customDragColorGreen: DEFAULT_DRAG_YELLOW[1], + customDragColorBlue: DEFAULT_DRAG_YELLOW[2], + laserColorRed: DEFAULT_LASER_RED[0], + laserColorGreen: DEFAULT_LASER_RED[1], + laserColorBlue: DEFAULT_LASER_RED[2], + laserInnerColorRed: 1, + laserInnerColorGreen: 1, + laserInnerColorBlue: 1, + ...overrides, + }; +} + +// Stable IDs so users who download more than once don't accumulate duplicates. +export const profiles: Profile[] = [ + { + id: "1B2EE5C0-0001-4000-8000-000000000001", + name: "Default", + description: + "Balanced blue rings for everyday work — every click type shown, neutral size and timing.", + createdAt: "2026-05-31T00:00:00Z", + settings: baseSettings(), + }, + { + id: "1B2EE5C0-0007-4000-8000-000000000007", + name: "Workshop", + description: + "Default style scaled up — larger rings, slower fade, and live keyboard shortcuts so a room full of people can follow what you're doing.", + createdAt: "2026-05-31T00:00:00Z", + settings: baseSettings({ + showLiveKeyboardShortcuts: true, + liveShortcutSize: "large", + size: 88, + intensity: 1.0, + duration: 0.72, + }), + }, + { + id: "1B2EE5C0-0008-4000-8000-000000000008", + name: "Screen Recording", + description: + "Smooth, slow-fading purple rings tuned to read well in compressed video — gentle intensity so the highlights guide the eye without overwhelming what you're recording.", + createdAt: "2026-05-31T00:00:00Z", + settings: baseSettings({ + showLiveKeyboardShortcuts: false, + size: 64, + intensity: 0.7, + duration: 0.72, + colorPreset: "purple", + }), + }, + { + id: "1B2EE5C0-0003-4000-8000-000000000003", + name: "Presentation", + description: + "Laser pointer on, small punchy red rings, quick fade — the one profile designed for live stage talks where the laser does the pointing and clicks are a secondary cue.", + createdAt: "2026-05-31T00:00:00Z", + settings: baseSettings({ + showLaserPointer: true, + showLiveKeyboardShortcuts: false, + size: 44, + intensity: 0.7, + duration: 0.28, + colorPreset: "custom", + customColorMode: "all", + customColorRed: 1.0, + customColorGreen: 0.17, + customColorBlue: 0.14, + }), + }, + { + id: "1B2EE5C0-0006-4000-8000-000000000006", + name: "Minimal", + description: + "A single tiny fast white dot, only on press. Nothing else — no release, no right, no middle, no drag, no shortcuts. The least intrusive way to still see where you clicked.", + createdAt: "2026-05-31T00:00:00Z", + settings: baseSettings({ + showRelease: false, + showRightClick: false, + showMiddleClick: false, + showDrag: false, + showLiveKeyboardShortcuts: false, + size: 44, + intensity: 0.28, + duration: 0.28, + colorPreset: "white", + }), + }, +]; + +export const profilesById: Record = Object.fromEntries( + profiles.map((p) => [p.id, p]), +);