From 3e35c7f2f379e70cd3aceaee742b660d7b2acf80 Mon Sep 17 00:00:00 2001 From: Stian Larsen <71408982+Stianlars1@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:07:39 +0200 Subject: [PATCH] feat(demo): redesign playground with shared glass controls --- .../src/components/landing/MaterialStage.tsx | 69 ++- .../demo/src/components/landing/PillLabel.tsx | 25 + apps/demo/src/components/playground/App.tsx | 488 +++++------------- .../playground/BackgroundPicker.tsx | 166 +++--- .../src/components/playground/CodePanel.tsx | 80 +++ .../components/playground/ControlMaterial.tsx | 26 + .../src/components/playground/Controls.tsx | 52 +- .../src/components/playground/CopyButton.tsx | 33 ++ .../src/components/playground/GlassButton.tsx | 81 +++ .../components/playground/GlassDetails.tsx | 44 ++ .../components/playground/GlassPreview.tsx | 271 ++++++++++ .../playground/SavedConfiguration.tsx | 99 +++- .../components/playground/ShapeControls.tsx | 13 +- .../demo/src/components/playground/Slider.tsx | 3 +- apps/demo/src/components/playground/config.ts | 86 +-- apps/demo/src/components/playground/icons.tsx | 36 ++ .../components/playground/useCopyFeedback.ts | 41 ++ apps/demo/src/lib/design-system/landing.css | 20 +- .../lib/design-system/pill-presentation.ts | 32 ++ .../demo/src/lib/design-system/playground.css | 4 +- .../playground/control-transitions.css | 30 ++ .../lib/design-system/playground/controls.css | 249 ++++----- .../design-system/playground/foundation.css | 191 +++---- .../playground/glass-controls.css | 373 +++++++++++++ .../design-system/playground/responsive.css | 459 +++++----------- .../design-system/playground/workspace.css | 475 ++++++++++++----- apps/demo/src/lib/landing-pill.ts | 15 + apps/demo/src/lib/material-scenes.ts | 20 + apps/demo/tests/config.test.mjs | 83 ++- docs/design-locks/2026-09-09-demo.md | 22 + 30 files changed, 2339 insertions(+), 1247 deletions(-) create mode 100644 apps/demo/src/components/landing/PillLabel.tsx create mode 100644 apps/demo/src/components/playground/CodePanel.tsx create mode 100644 apps/demo/src/components/playground/ControlMaterial.tsx create mode 100644 apps/demo/src/components/playground/CopyButton.tsx create mode 100644 apps/demo/src/components/playground/GlassButton.tsx create mode 100644 apps/demo/src/components/playground/GlassDetails.tsx create mode 100644 apps/demo/src/components/playground/GlassPreview.tsx create mode 100644 apps/demo/src/components/playground/useCopyFeedback.ts create mode 100644 apps/demo/src/lib/design-system/pill-presentation.ts create mode 100644 apps/demo/src/lib/design-system/playground/control-transitions.css create mode 100644 apps/demo/src/lib/design-system/playground/glass-controls.css create mode 100644 apps/demo/src/lib/landing-pill.ts create mode 100644 apps/demo/src/lib/material-scenes.ts diff --git a/apps/demo/src/components/landing/MaterialStage.tsx b/apps/demo/src/components/landing/MaterialStage.tsx index 30041b0..40aee9e 100644 --- a/apps/demo/src/components/landing/MaterialStage.tsx +++ b/apps/demo/src/components/landing/MaterialStage.tsx @@ -5,18 +5,22 @@ import type { SyntheticEvent } from "react"; import Image from "next/image"; import { LiquidGlass } from "react-glaze"; import { useGlassDrag } from "@/hooks/useGlassDrag"; +import { materialScenes } from "@/lib/material-scenes"; +import { landingPill } from "@/lib/landing-pill"; +import { pillStyle } from "@/lib/design-system/pill-presentation"; +import { PillLabel } from "./PillLabel"; -const scenes = [ - { id: "soft", label: "Soft forms", image: "/art/soft.png" }, - { id: "metal", label: "Hard light", image: "/art/metal.png" }, - { id: "garden", label: "Small world", image: "/art/garden.png" }, -] as const; +const scenes = materialScenes; type SceneImage = { index: number; request: number }; -const imageSizes = "(max-width: 760px) calc(100vw - 32px), (max-width: 1280px) 58vw, 760px"; +const imageSizes = + "(max-width: 760px) calc(100vw - 32px), (max-width: 1280px) 58vw, 760px"; export function MaterialStage() { - const [displayed, setDisplayed] = useState({ index: 0, request: 0 }); + const [displayed, setDisplayed] = useState({ + index: 0, + request: 0, + }); const [pending, setPending] = useState(null); const latestRequest = useRef(0); const [frosted, setFrosted] = useState(false); @@ -35,7 +39,8 @@ export function MaterialStage() { const onGlassReady = useCallback(() => setGlassError(false), []); const onGlassError = useCallback(() => setGlassError(true), []); const chooseScene = (index: number) => { - if (pending?.index === index || (!pending && displayed.index === index)) return; + if (pending?.index === index || (!pending && displayed.index === index)) + return; const request = ++latestRequest.current; setFailedScene(null); setPending(index === displayed.index ? null : { index, request }); @@ -71,7 +76,7 @@ export function MaterialStage() { setFrosted((value) => !value)} onReady={onGlassReady} onError={onGlassError} > - A different feeling. - + @@ -131,11 +118,19 @@ export function MaterialStage() { Alt + arrows to move.

- {pending ? "Loading scene..." : frosted ? "Frosted glass" : "Clear glass"} + {pending + ? "Loading scene..." + : frosted + ? "Frosted glass" + : "Clear glass"} -
+
{scenes.map((option, index) => ( ))}
diff --git a/apps/demo/src/components/landing/PillLabel.tsx b/apps/demo/src/components/landing/PillLabel.tsx new file mode 100644 index 0000000..96aa9b6 --- /dev/null +++ b/apps/demo/src/components/landing/PillLabel.tsx @@ -0,0 +1,25 @@ +import { landingPillArrow, landingPillLabel } from "@/lib/landing-pill"; + +export function PillLabel() { + return ( + <> + {landingPillLabel} + + + ); +} diff --git a/apps/demo/src/components/playground/App.tsx b/apps/demo/src/components/playground/App.tsx index c478190..1cd57d6 100644 --- a/apps/demo/src/components/playground/App.tsx +++ b/apps/demo/src/components/playground/App.tsx @@ -1,50 +1,35 @@ -/* eslint-disable @next/next/no-img-element -- Preview uploaded and local background sources directly. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { ControlMaterial } from "./ControlMaterial"; +import { CopyButton } from "./CopyButton"; +import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { BrandIcon } from "@/components/brand/BrandIcon"; -import { trackDemoEvent } from "@/lib/analytics"; -import { LiquidGlass, PRESETS } from "react-glaze"; -import type { GlassMetrics } from "react-glaze"; -import { - backgrounds, - componentCode, - componentProps, - getExampleStyle, - shareableState, -} from "./config"; +import { PRESETS } from "react-glaze"; +import type { GlassPreset } from "react-glaze"; +import { backgrounds, initialState, shareableState } from "./config"; +import type { StudioState } from "./config"; import { Controls } from "./Controls"; import { SavedConfiguration } from "./SavedConfiguration"; import { readPlaygroundState } from "./saved-config"; -import { useGlassDrag } from "./useGlassDrag"; -import { CopyIcon } from "./icons"; - -import { OpticalBackdrop } from "./OpticalBackdrop"; import { BackgroundPicker } from "./BackgroundPicker"; import { useCustomBackground } from "./useCustomBackground"; -import type { StudioState } from "./config"; -import type { GlassPreset } from "react-glaze"; +import { useCopyFeedback } from "./useCopyFeedback"; +import { GlassPreview } from "./GlassPreview"; +import { CodePanel } from "./CodePanel"; export function App() { - const [state, setState] = useState(readPlaygroundState), - [clicks, setClicks] = useState(0), - [ready, setReady] = useState(false), - [error, setError] = useState(""), - [codeTab, setCodeTab] = useState<"jsx" | "json">("jsx"), - [copied, setCopied] = useState(false); - const { - stage: stageRef, - host: glassRef, - position, - dragging, - reset: resetPosition, - moveTo, - bindings, - } = useGlassDrag(state.example); + const [state, setState] = useState(readPlaygroundState); + const [previewRevision, setPreviewRevision] = useState(0); const custom = useCustomBackground(); + const linkCopy = useCopyFeedback(); const backgroundRevision = useRef(0); const chooseBackground = (background: StudioState["background"]) => { backgroundRevision.current++; - setState((s) => ({ ...s, background })); + setState((s) => ({ ...s, background, referenceLayout: false })); + }; + const replaceState = (next: StudioState) => { + backgroundRevision.current++; + setState(next); + setPreviewRevision((value) => value + 1); }; const choosePreset = (preset: GlassPreset) => setState((s) => ({ @@ -55,352 +40,147 @@ export function App() { enabled: s.settings.enabled, shape: s.settings.shape, radius: s.settings.radius, + ...(s.example === "landing-pill" + ? { lighting: s.settings.lighting } + : {}), }, })); - const matchOpticalLayout = useCallback(() => { - const r = stageRef.current?.getBoundingClientRect(); - if (!r) return; - const portrait = r.width < r.height, - width = r.width * ((2 * 1.42 * (portrait ? 1.1 : 1.08)) / 9.7), - height = width * (portrait ? 1.08 / 1.1 : 0.97 / 1.08); - setState((s) => - Math.abs(Number(s.width) - width) < 0.01 && - Math.abs(Number(s.height) - height) < 0.01 - ? s - : { ...s, width, height }, - ); - moveTo({ - x: 0, - y: portrait ? r.height * 0.08 : (-r.width * 0.12) / 9.7, - }); - }, [stageRef, moveTo]); - const matchReference = () => { - backgroundRevision.current++; - setState((s) => ({ - ...s, - preset: "optical-type", - customShape: false, - settings: { ...PRESETS["optical-type"], enabled: s.settings.enabled }, - rimLight: false, - background: "optical-type", - example: "empty", - motion: false, - referenceLayout: true, - })); - matchOpticalLayout(); - }; - useEffect(() => { - if (!state.referenceLayout || !stageRef.current) return; - const observer = new ResizeObserver(matchOpticalLayout); - observer.observe(stageRef.current); - matchOpticalLayout(); - return () => observer.disconnect(); - }, [state.referenceLayout, stageRef, matchOpticalLayout]); - const metrics = useRef(null); - const onReady = useCallback(() => { - setReady(true); - setError(""); - }, []); - const onError = useCallback((error: Error) => setError(error.message), []); - const onMetrics = useCallback((value: GlassMetrics) => { - metrics.current = value; - (window as Window & { __glassMetrics?: GlassMetrics }).__glassMetrics = - value; - }, []); - const background = backgrounds.find((b) => b.id === state.background); + const shareUrl = new URL(location.href); + shareUrl.searchParams.set("config", JSON.stringify(shareableState(state))); + const shareHref = shareUrl.href; useEffect(() => { - const url = new URL(location.href); - url.searchParams.set("config", JSON.stringify(shareableState(state))); - history.replaceState(null, "", url); - }, [state]); - const code = - codeTab === "jsx" - ? componentCode(state) - : JSON.stringify( - { - component: componentProps(state), - playground: { - background: shareableState(state).background, - motion: state.motion, - example: state.example, - }, - }, - null, - 2, - ); - const copy = async () => { - let succeeded = false; - try { - await navigator.clipboard.writeText(code); - succeeded = true; - } catch { - const textarea = document.createElement("textarea"); - textarea.value = code; - document.body.append(textarea); - textarea.select(); - succeeded = document.execCommand("copy"); - textarea.remove(); - } - setCopied(succeeded); - if (succeeded) { - trackDemoEvent({ - name: "playground_code_copied", - properties: { format: codeTab }, - }); - setTimeout(() => setCopied(false), 1800); - } - }; + history.replaceState(null, "", shareHref); + }, [shareHref]); const shuffle = () => { - backgroundRevision.current++; const choices = backgrounds.filter((b) => b.id !== state.background); const value = new Uint32Array(1); crypto.getRandomValues(value); - setState((s) => ({ - ...s, - background: choices[value[0] % choices.length].id, - })); + chooseBackground(choices[value[0] % choices.length].id); }; return ( -
-
-

- + +
+ + Skip to playground + +
+ React Glaze -

- - Alpha preview -
-
-
-
-
-
- - -
- + + +
+
+
+

A different feeling. Your own.

+

Choose a scene. Shape the glass. Take it with you.

-
- {state.background === "optical-type" ? ( - - ) : ( - - )} - setClicks((n) => n + 1) - : undefined - } - aria-label={ - state.example === "button" ? "Explore the glass" : undefined +
+ Get the code + void linkCopy.copy(shareHref)} + /> + - {state.example === "button" ? ( - "Explore the glass" - ) : state.example === "card" ? ( -
- A new perspective -

- Your own HTML, seen through glass. -

-
- ) : state.example === "type" ? ( - - LOOK -
- AGAIN. -
- ) : null} -
-
-

Drag the glass · Alt + arrow keys

- -
-
- - - Original artwork, lens proportions and optical preset. + + {linkCopy.status === "failed" + ? "Could not copy. Copy the URL from the address bar." + : linkCopy.status === "copied" + ? "Share link copied." + : ""} +
- +
+ { - const selection = ++backgroundRevision.current; - if ( - (await custom.load(file)) && - selection === backgroundRevision.current - ) - setState((s) => ({ ...s, background: "custom" })); - }} - onRemove={() => { + onPreset={choosePreset} + onReference={() => { backgroundRevision.current++; - custom.remove(); - setState((s) => ({ - ...s, - background: - s.background === "custom" ? "spectrum" : s.background, - })); }} - onShuffle={shuffle} + > + { + const selection = ++backgroundRevision.current; + if ( + (await custom.load(file)) && + selection === backgroundRevision.current + ) + setState((s) => ({ + ...s, + background: "custom", + referenceLayout: false, + })); + }} + onRemove={() => { + backgroundRevision.current++; + custom.remove(); + setState((s) => ({ + ...s, + background: + s.background === "custom" ? "soft" : s.background, + })); + }} + /> + + replaceState(initialState)} /> -
- - {error - ? "Could not render glass. Reload to try again." - : !state.settings.enabled - ? "Glass off · Same background and content." - : ready - ? "Glass on · Automatic background." - : "Preparing glass…"} - - - {state.example === "button" - ? `Clicks: ${clicks}` - : "Native HTML content"} - -
-
- { - backgroundRevision.current++; - setState(next); - }} /> -
-
-

Code

-
- - -
- -
-
-              {code}
-            
-

- The background is regular page HTML.{" "} - Package: react-glaze. -

-
-
+
+
+ + +
+
+ -
- - -
+ + + + ); } diff --git a/apps/demo/src/components/playground/BackgroundPicker.tsx b/apps/demo/src/components/playground/BackgroundPicker.tsx index 8a58138..cb7ba7e 100644 --- a/apps/demo/src/components/playground/BackgroundPicker.tsx +++ b/apps/demo/src/components/playground/BackgroundPicker.tsx @@ -1,9 +1,11 @@ +import { GlassDetails } from "./GlassDetails"; +import { GlassButton } from "./GlassButton"; /* eslint-disable @next/next/no-img-element -- Uploaded thumbnails use browser-local image sources. */ import { useRef } from "react"; import { backgrounds } from "./config"; import type { BackgroundId } from "./config"; import type { CustomBackground } from "./useCustomBackground"; -import { ShuffleIcon } from "./icons"; +import { materialScenes } from "@/lib/material-scenes"; interface Props { selected: BackgroundId; custom: CustomBackground | null; @@ -27,77 +29,103 @@ export function BackgroundPicker({ const input = useRef(null); return ( <> -
-
- {backgrounds.map((b) => ( - - ))} - {custom && ( - - )} + {loading + ? "Reading image…" + : custom + ? "Replace image" + : "Upload image"} + + + Shuffle + +
-
- { - const file = e.target.files?.[0]; - e.target.value = ""; - if (file) onUpload(file); - }} - /> - - +
+ + Custom images stay in this tab and are not included in shared URLs. + + {custom && Remove image}
-
-
- - Custom images stay in this tab and are not included in shared URLs. - - {custom && } -
+ {error && (

{error} diff --git a/apps/demo/src/components/playground/CodePanel.tsx b/apps/demo/src/components/playground/CodePanel.tsx new file mode 100644 index 0000000..60b7855 --- /dev/null +++ b/apps/demo/src/components/playground/CodePanel.tsx @@ -0,0 +1,80 @@ +import { CopyButton } from "./CopyButton"; +import { GlassButton } from "./GlassButton"; +import { useState } from "react"; +import { trackDemoEvent } from "@/lib/analytics"; +import { componentCode, componentProps, shareableState } from "./config"; +import type { StudioState } from "./config"; +import { useCopyFeedback } from "./useCopyFeedback"; + +export function CodePanel({ state }: { state: StudioState }) { + const [format, setFormat] = useState<"jsx" | "json">("jsx"); + const clipboard = useCopyFeedback(); + const [copiedFormat, setCopiedFormat] = useState(format); + const code = + format === "jsx" + ? componentCode(state) + : JSON.stringify( + { + component: componentProps(state), + playground: { + background: shareableState(state).background, + motion: state.motion, + example: state.example, + }, + }, + null, + 2, + ); + return ( +

+
+

Make it yours.

+
+ {(["jsx", "json"] as const).map((value) => ( + setFormat(value)} + > + {value.toUpperCase()} + + ))} +
+ { + if (await clipboard.copy(code)) { + setCopiedFormat(format); + trackDemoEvent({ + name: "playground_code_copied", + properties: { format }, + }); + } + }} + /> +
+
+        {code}
+      
+

+ + {clipboard.status === "failed" + ? "Could not copy. Select the code above and copy it manually." + : clipboard.status === "copied" + ? `${copiedFormat.toUpperCase()} copied to clipboard.` + : ""} + +

+

+ The background is your page. The glass is react-glaze. Keep + the styling or bring your own. +

+
+ ); +} diff --git a/apps/demo/src/components/playground/ControlMaterial.tsx b/apps/demo/src/components/playground/ControlMaterial.tsx new file mode 100644 index 0000000..4052b48 --- /dev/null +++ b/apps/demo/src/components/playground/ControlMaterial.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; + +const solidControlQuery = + "(prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active)"; +const ControlMaterialContext = createContext(true); +const subscribe = (notify: () => void) => { + const media = matchMedia(solidControlQuery); + media.addEventListener("change", notify); + return () => media.removeEventListener("change", notify); +}; +const getSnapshot = () => !matchMedia(solidControlQuery).matches; +const getServerSnapshot = () => false; + +export function ControlMaterial({ children }: { children: ReactNode }) { + const enabled = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + return ( + {children} + ); +} + +export const useControlMaterial = () => useContext(ControlMaterialContext); diff --git a/apps/demo/src/components/playground/Controls.tsx b/apps/demo/src/components/playground/Controls.tsx index 921c84d..c229992 100644 --- a/apps/demo/src/components/playground/Controls.tsx +++ b/apps/demo/src/components/playground/Controls.tsx @@ -1,16 +1,18 @@ +import { GlassDetails } from "./GlassDetails"; +import { GlassButton } from "./GlassButton"; import { ShapeControls } from "./ShapeControls"; import { RimLightControls } from "./RimLightControls"; import { Slider } from "./Slider"; -import { initialState } from "./config"; import type { GlassSettings, GlassPreset } from "react-glaze"; import type { StudioState } from "./config"; interface Props { state: StudioState; onPreset: (preset: GlassPreset) => void; + onReset: () => void; setState: React.Dispatch>; } -export function Controls({ state, setState, onPreset }: Props) { +export function Controls({ state, setState, onPreset, onReset }: Props) { const set = ( key: K, value: GlassSettings[K], @@ -37,9 +39,19 @@ export function Controls({ state, setState, onPreset }: Props) { return ( ); } diff --git a/apps/demo/src/components/playground/CopyButton.tsx b/apps/demo/src/components/playground/CopyButton.tsx new file mode 100644 index 0000000..25c9fc2 --- /dev/null +++ b/apps/demo/src/components/playground/CopyButton.tsx @@ -0,0 +1,33 @@ +import type { MouseEventHandler } from "react"; +import { GlassButton } from "./GlassButton"; +import { CheckIcon, CopyIcon } from "./icons"; + +export function CopyButton({ + label, + copied, + onClick, + className = "", +}: { + label: string; + copied: boolean; + onClick: MouseEventHandler; + className?: string; +}) { + return ( + + + {label} + + ); +} diff --git a/apps/demo/src/components/playground/GlassButton.tsx b/apps/demo/src/components/playground/GlassButton.tsx new file mode 100644 index 0000000..4bff03c --- /dev/null +++ b/apps/demo/src/components/playground/GlassButton.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from "react"; +import type { ComponentPropsWithRef, ReactNode } from "react"; +import { LiquidGlass } from "react-glaze"; +import type { GlassOwnProps } from "react-glaze"; +import { useControlMaterial } from "./ControlMaterial"; +import { CheckIcon } from "./icons"; + +const controlMaterial = { + preset: "quiet", + contentMode: "sharp", + lighting: "studio", + thickness: 0.24, + depth: 0.16, + ior: 1.25, + roughness: 0.04, + attenuationColor: "#ffffff", + dispersion: 0, + envMapIntensity: 0.65, + shadowOpacity: 0.08, + maxDpr: 1.5, + rimLight: { mode: "static", strength: 0.3, width: 0.8 }, +} as const satisfies GlassOwnProps; + +function useMaterialStatus() { + const enabled = useControlMaterial(); + const [failed, setFailed] = useState(false); + const onError = useCallback(() => setFailed(true), []); + return { enabled: enabled && !failed, onError }; +} + +export function GlassButton({ + children, + className = "", + shape = "pill", + ...props +}: Omit, "onError"> & { + shape?: "pill" | "rounded"; +}) { + const material = useMaterialStatus(); + const pressed = props["aria-pressed"]; + return ( + + + {children} + {pressed !== undefined && ( + + )} + + + ); +} + +export function GlassSummary({ children }: { children: ReactNode }) { + const material = useMaterialStatus(); + return ( + + {children} + + ); +} diff --git a/apps/demo/src/components/playground/GlassDetails.tsx b/apps/demo/src/components/playground/GlassDetails.tsx new file mode 100644 index 0000000..f257b1c --- /dev/null +++ b/apps/demo/src/components/playground/GlassDetails.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { GlassSummary } from "./GlassButton"; +import { ChevronIcon } from "./icons"; + +export function GlassDetails({ + title, + description, + className, + children, +}: { + title: string; + description?: string; + className: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( +
setOpen(event.currentTarget.open)} + > + + + {title} + {description && {description}} + + + + {children} +
+ ); +} diff --git a/apps/demo/src/components/playground/GlassPreview.tsx b/apps/demo/src/components/playground/GlassPreview.tsx new file mode 100644 index 0000000..f73404f --- /dev/null +++ b/apps/demo/src/components/playground/GlassPreview.tsx @@ -0,0 +1,271 @@ +import { GlassDetails } from "./GlassDetails"; +import { GlassButton } from "./GlassButton"; +import Image from "next/image"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { LiquidGlass, PRESETS } from "react-glaze"; +import type { GlassMetrics, GlassPreset } from "react-glaze"; +import { PillLabel } from "../landing/PillLabel"; +import { backgrounds, componentProps, getExampleStyle } from "./config"; +import type { StudioState } from "./config"; +import type { CustomBackground } from "./useCustomBackground"; +import { useGlassDrag } from "./useGlassDrag"; +import { OpticalBackdrop } from "./OpticalBackdrop"; + +export function GlassPreview({ + state, + setState, + custom, + onPreset, + onReference, + children, +}: { + state: StudioState; + setState: Dispatch>; + custom: CustomBackground | null; + onPreset: (preset: GlassPreset) => void; + onReference: () => void; + children: ReactNode; +}) { + const [clicks, setClicks] = useState(0); + const [ready, setReady] = useState(false); + const [error, setError] = useState(""); + const [loadedImage, setLoadedImage] = useState(""); + const [failedImage, setFailedImage] = useState(""); + const isLandingPill = state.example === "landing-pill"; + const isButton = isLandingPill || state.example === "button"; + const { + stage: stageRef, + host: glassRef, + position, + dragging, + reset: resetPosition, + moveTo, + bindings, + } = useGlassDrag(state.example); + const matchOpticalLayout = useCallback(() => { + const r = stageRef.current?.getBoundingClientRect(); + if (!r) return; + const portrait = r.width < r.height, + width = r.width * ((2 * 1.42 * (portrait ? 1.1 : 1.08)) / 9.7), + height = width * (portrait ? 1.08 / 1.1 : 0.97 / 1.08); + setState((s) => + Math.abs(Number(s.width) - width) < 0.01 && + Math.abs(Number(s.height) - height) < 0.01 + ? s + : { ...s, width, height }, + ); + moveTo({ + x: 0, + y: portrait ? r.height * 0.08 : (-r.width * 0.12) / 9.7, + }); + }, [stageRef, moveTo, setState]); + const matchReference = () => { + onReference(); + setState((s) => ({ + ...s, + preset: "optical-type", + customShape: false, + settings: { ...PRESETS["optical-type"], enabled: s.settings.enabled }, + rimLight: false, + background: "optical-type", + example: "empty", + motion: false, + referenceLayout: true, + })); + matchOpticalLayout(); + }; + useEffect(() => { + if (!state.referenceLayout || !stageRef.current) return; + const observer = new ResizeObserver(matchOpticalLayout); + observer.observe(stageRef.current); + matchOpticalLayout(); + return () => observer.disconnect(); + }, [state.referenceLayout, stageRef, matchOpticalLayout]); + const metrics = useRef(null); + const onReady = useCallback(() => { + setReady(true); + setError(""); + }, []); + const onError = useCallback((error: Error) => setError(error.message), []); + const onMetrics = useCallback((value: GlassMetrics) => { + metrics.current = value; + (window as Window & { __glassMetrics?: GlassMetrics }).__glassMetrics = + value; + }, []); + const background = backgrounds.find((b) => b.id === state.background); + const imageSrc = + (state.background === "custom" ? custom?.src : background?.src) ?? ""; + const imageFailed = imageSrc !== "" && failedImage === imageSrc; + const imageLoading = + state.background !== "optical-type" && loadedImage !== imageSrc; + return ( +
+
+
+ + setState((s) => ({ + ...s, + settings: { ...s.settings, enabled: true }, + })) + } + > + With glass + + + setState((s) => ({ + ...s, + settings: { ...s.settings, enabled: false }, + })) + } + > + Without glass + +
+ +
+
+ {state.background === "optical-type" ? ( + + ) : ( + setFailedImage(imageSrc)} + onLoad={() => { + setLoadedImage(imageSrc); + setFailedImage(""); + }} + src={imageSrc} + alt="" + aria-hidden="true" + /> + )} + { + setClicks((n) => n + 1); + if (isLandingPill) + onPreset( + state.preset === "frosted" ? "reference" : "frosted", + ); + } + : undefined + } + aria-label={ + isLandingPill + ? "A different feeling. Toggle frosted glass" + : state.example === "button" + ? "Explore the glass" + : "Glass preview" + } + > + {isLandingPill ? ( + + ) : state.example === "button" ? ( + "Explore the glass" + ) : state.example === "card" ? ( +
+ A new perspective +

Your own HTML, seen through glass.

+
+ ) : state.example === "type" ? ( + + LOOK +
+ AGAIN. +
+ ) : null} +
+
+
+

+ Drag to explore.{isLandingPill ? " Click to frost." : ""} + Alt + arrows to move. +

+ + Center + +
+
+ + {imageFailed + ? "Image could not load. Choose another scene or reload." + : error + ? "Could not render glass. Reload to try again." + : imageLoading + ? "Loading scene..." + : !state.settings.enabled + ? "Glass off" + : ready + ? state.preset === "frosted" + ? "Frosted glass" + : "Glass ready" + : "Preparing glass..."} + + + {isButton ? `Clicks: ${clicks}` : "Native HTML content"} + +
+ {children} + + {" "} +
+ Match Optical Type + Original artwork, lens proportions and optical preset. +
+
+
+ ); +} diff --git a/apps/demo/src/components/playground/SavedConfiguration.tsx b/apps/demo/src/components/playground/SavedConfiguration.tsx index 190de0e..b8e8d9c 100644 --- a/apps/demo/src/components/playground/SavedConfiguration.tsx +++ b/apps/demo/src/components/playground/SavedConfiguration.tsx @@ -1,51 +1,94 @@ -import { useEffect, useState } from 'react'; -import type { StudioState } from './config'; -import { SAVED_CONFIG_KEY, readSavedConfig, saveConfig, deleteSavedConfig } from './saved-config'; +import { GlassButton } from "./GlassButton"; +import { useEffect, useState } from "react"; +import type { StudioState } from "./config"; +import { + SAVED_CONFIG_KEY, + readSavedConfig, + saveConfig, + deleteSavedConfig, +} from "./saved-config"; -export function SavedConfiguration({ state, onLoad }: { +export function SavedConfiguration({ + state, + onLoad, +}: { state: StudioState; onLoad: (state: StudioState) => void; }) { const [saved, setSaved] = useState(readSavedConfig); - const [message, setMessage] = useState(''); + const [message, setMessage] = useState(""); useEffect(() => { const refresh = (event: StorageEvent) => { if (event.key === SAVED_CONFIG_KEY || event.key === null) { setSaved(readSavedConfig()); - setMessage(''); + setMessage(""); } }; - window.addEventListener('storage', refresh); - return () => window.removeEventListener('storage', refresh); + window.addEventListener("storage", refresh); + return () => window.removeEventListener("storage", refresh); }, []); return (

Saved configuration

-

Save for your next visit. Shared links take priority. Uploaded images are not saved.

+

+ Save for your next visit. Shared links take priority. Uploaded images + are not saved. +

- - - + { + const error = saveConfig(state); + if (!error) setSaved(readSavedConfig()); + setMessage( + error ?? + (state.background === "custom" + ? "Configuration saved. The uploaded image is replaced by Spectrum when loaded." + : "Configuration saved in this browser."), + ); + }} + > + Save configuration + + { + const next = readSavedConfig(); + setSaved(next); + if (next.state) onLoad(next.state); + setMessage( + next.error ?? + (next.state + ? "Saved configuration loaded." + : "No saved configuration."), + ); + }} + > + Load saved + + { + const error = deleteSavedConfig(); + if (!error) setSaved({ state: null, exists: false, error: null }); + setMessage( + error ?? + "Saved configuration deleted. Your current preview is unchanged.", + ); + }} + > + Delete saved +
-

{message || saved.error || (saved.state ? 'A saved configuration is available.' : 'No saved configuration yet.')}

+

+ {message || + saved.error || + (saved.state + ? "A saved configuration is available." + : "No saved configuration yet.")} +

); } diff --git a/apps/demo/src/components/playground/ShapeControls.tsx b/apps/demo/src/components/playground/ShapeControls.tsx index 95c8930..5b25603 100644 --- a/apps/demo/src/components/playground/ShapeControls.tsx +++ b/apps/demo/src/components/playground/ShapeControls.tsx @@ -1,3 +1,4 @@ +import { GlassButton } from "./GlassButton"; import { hasFixedRadius, isRatioShape } from "react-glaze"; import type { GlassShape } from "react-glaze"; import type { Dispatch, SetStateAction } from "react"; @@ -40,7 +41,9 @@ function DimensionControl({ {typeof value === "number" && ( @@ -140,18 +144,18 @@ export function ShapeControls({ {hasFixedRadius(shape) ? (

This shape sets its corner radius.{" "} - +

) : (