feat(desktop): refine onboarding v3 workshop preview

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-28 09:15:56 +01:00
parent f7a3dca3fc
commit d91b5e1664
12 changed files with 664 additions and 431 deletions

View File

@@ -95,6 +95,26 @@
opacity: 0;
}
/* The onboarding key spoiler sits on a white security card even when the
surrounding workshop uses the dark theme. Keep the standard dark particles
visible and the local surface seamless. */
.buzz-onboarding-private-key-spoiler .buzz-spoiler {
--buzz-spoiler-accent: #333;
background: transparent;
}
.buzz-onboarding-private-key-spoiler .buzz-spoiler[data-revealed="true"],
.buzz-onboarding-private-key-spoiler .buzz-spoiler:hover,
.buzz-onboarding-private-key-spoiler .buzz-spoiler:focus-visible {
background: transparent;
}
.buzz-onboarding-private-key-spoiler .buzz-spoiler:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.ProseMirror .buzz-spoiler {
background: transparent;
color: transparent;

View File

@@ -53,6 +53,43 @@ const RANDOM_SEED = 4011505;
const V_MIN = 2;
const V_MAX = 12;
/**
* The particle renderer targets APIs available in current Tauri webviews, but
* some managed or unusually old Windows/Linux runtimes can lag behind. Hosts
* with a non-particle concealment treatment can use this check to fall back
* before hidden content is mounted behind an unusable canvas.
*/
export function supportsSpoilerParticles(): boolean {
if (typeof window === "undefined" || typeof document === "undefined")
return false;
if (
typeof window.PointerEvent === "undefined" ||
typeof window.ResizeObserver === "undefined" ||
typeof window.MutationObserver === "undefined" ||
typeof window.IntersectionObserver === "undefined" ||
typeof window.matchMedia !== "function" ||
typeof window.requestAnimationFrame !== "function" ||
typeof window.cancelAnimationFrame !== "function" ||
typeof document.createRange !== "function"
) {
return false;
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
if (
typeof motionQuery.addEventListener !== "function" ||
typeof motionQuery.removeEventListener !== "function"
) {
return false;
}
try {
return document.createElement("canvas").getContext("2d") !== null;
} catch {
return false;
}
}
export function SpoilerParticles({
active,
contentRef,

View File

@@ -15,14 +15,19 @@ export function SpoilerInline({
block = false,
children,
interactive = true,
revealOnHover = false,
}: {
block?: boolean;
children?: React.ReactNode;
interactive?: boolean;
/** Temporarily reveal for mouse/trackpad hover while preserving click and keyboard access. */
revealOnHover?: boolean;
}) {
const [revealed, setRevealed] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const contentRef = React.useRef<HTMLElement | null>(null);
const isBlock = block || hasBlockMedia(React.Children.toArray(children));
const visuallyRevealed = revealed || hovered;
const setContentElement = React.useCallback((node: HTMLElement | null) => {
contentRef.current = node;
@@ -34,28 +39,33 @@ export function SpoilerInline({
const handlePointerDownCapture = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (revealed) return;
if (visuallyRevealed) return;
event.stopPropagation();
},
[revealed],
[visuallyRevealed],
);
const handleClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed) return;
if (visuallyRevealed) return;
event.preventDefault();
event.stopPropagation();
toggleRevealed();
},
[revealed, toggleRevealed],
[toggleRevealed, visuallyRevealed],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (revealed && isBlock && event.target !== event.currentTarget) return;
// Hover is a temporary reveal: clicking or selecting while the pointer
// is inside must not pin the spoiler open after the pointer leaves.
// Touch and keyboard still toggle `revealed` because they have no hover.
if (revealOnHover && hovered) return;
if (visuallyRevealed && isBlock && event.target !== event.currentTarget)
return;
toggleRevealed();
},
[isBlock, revealed, toggleRevealed],
[hovered, isBlock, revealOnHover, toggleRevealed, visuallyRevealed],
);
const handleKeyDown = React.useCallback(
@@ -67,12 +77,25 @@ export function SpoilerInline({
[toggleRevealed],
);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (revealOnHover && event.pointerType === "mouse") setHovered(true);
},
[revealOnHover],
);
const handlePointerLeave = React.useCallback(() => {
if (revealOnHover) setHovered(false);
}, [revealOnHover]);
const revealProps = {
"aria-label": revealed ? "Hide spoiler" : "Reveal spoiler",
"aria-pressed": revealed,
onClick: handleClick,
onClickCapture: handleClickCapture,
onKeyDown: handleKeyDown,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onPointerDownCapture: handlePointerDownCapture,
role: "button",
tabIndex: 0,
@@ -117,12 +140,12 @@ export function SpoilerInline({
<div
{...revealProps}
className="buzz-spoiler buzz-spoiler--block"
data-revealed={revealed ? "true" : "false"}
data-revealed={visuallyRevealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<SpoilerParticles active={!visuallyRevealed} contentRef={contentRef} />
<div className="buzz-spoiler__content" ref={setContentElement}>
<SpoilerHiddenContext.Provider value={!revealed}>
<SpoilerHiddenContext.Provider value={!visuallyRevealed}>
{children}
</SpoilerHiddenContext.Provider>
</div>
@@ -134,12 +157,12 @@ export function SpoilerInline({
<span
{...revealProps}
className="buzz-spoiler"
data-revealed={revealed ? "true" : "false"}
data-revealed={visuallyRevealed ? "true" : "false"}
data-spoiler=""
>
<SpoilerParticles active={!revealed} contentRef={contentRef} />
<SpoilerParticles active={!visuallyRevealed} contentRef={contentRef} />
<span className="buzz-spoiler__content" ref={setContentElement}>
<SpoilerHiddenContext.Provider value={!revealed}>
<SpoilerHiddenContext.Provider value={!visuallyRevealed}>
{children}
</SpoilerHiddenContext.Provider>
</span>