From decfb68b5c8b973c01b16e742eddc01821d5df97 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Thu, 2 Jul 2026 21:14:58 +0200 Subject: [PATCH 1/5] feat(figma): live-preview onboarding, eager sync, per-file state, and UX polish Figma plugin: - Live-preview sync goes live automatically: configuring a valid file key (or copying the share link) starts syncing, and the plugin eagerly provisions the backend project on first open so the share link / pairing QR exist from the start. - Scope onboarding-seen and the phone-pairing token per-file (was per-user global): new projects now show the onboarding tour on first visit and no longer inherit a phantom "paired but offline" phone (which also wrongly collapsed the first configurator step). - Wire the onboarding / explanation clips into the tour and the Live preview tab (remote-hosted, off-bundle), each with controls, a spinner+placeholder while buffering, and an "Open full size video" link; preload them so paging between steps doesn't re-buffer. Add a "Preview a preset on your device" tour step. - Animate the Live-preview step hand-off (green pulse -> collapse -> fade in -> open) so an accepted file key is legible instead of snapping to step 2. - Point the Help tab example-file link at the real Figma file. PulsarApp: - Hide the Figma-preview WebView loader once content is ready (and make it non-blocking) so a Figma login page isn't trapped behind the spinner. - Show the "preset playing" banner on the Figma screen too, not just Home. --- PulsarApp/app/(tabs)/figma.tsx | 63 +++++-- PulsarApp/app/(tabs)/index.tsx | 16 +- PulsarApp/components/NowPlayingToast.tsx | 27 +++ figma/manifest.json | 4 +- figma/src/main/code.ts | 24 ++- figma/src/shared/types.ts | 6 + .../ui/components/LivePreviewTab.module.css | 90 +++++++++- figma/src/ui/components/LivePreviewTab.tsx | 157 +++++++++++++++--- .../components/OnboardingOverlay.module.css | 89 ++++++++-- figma/src/ui/components/OnboardingOverlay.tsx | 113 ++++++++++--- figma/src/ui/components/onboardingContent.ts | 35 ++-- figma/src/ui/hooks/usePreviewSync.ts | 90 ++++++++-- figma/src/ui/lib/onboardingMedia.ts | 17 ++ 13 files changed, 605 insertions(+), 126 deletions(-) create mode 100644 PulsarApp/components/NowPlayingToast.tsx create mode 100644 figma/src/ui/lib/onboardingMedia.ts diff --git a/PulsarApp/app/(tabs)/figma.tsx b/PulsarApp/app/(tabs)/figma.tsx index eb8d02bc..b396ae96 100644 --- a/PulsarApp/app/(tabs)/figma.tsx +++ b/PulsarApp/app/(tabs)/figma.tsx @@ -16,6 +16,7 @@ import Card from '@/components/Card'; import Point from '@/components/Point'; import SvgIcon from '@/components/SvgIcon'; import ConnectionList from '@/components/home/ConnectionList'; +import NowPlayingToast from '@/components/NowPlayingToast'; import { Margins } from '@/constants/theme'; const defaultEdges = { @@ -60,10 +61,15 @@ export default function FigmaScreen() { const params = useLocalSearchParams<{ token?: string }>(); const token = typeof params.token === 'string' ? params.token : ''; - if (token) { - return ; - } - return ; + // The "preset received" banner overlays both modes (live preview + explainer), + // so a preset played from the plugin is visible while the user sits on the + // Figma screen too - not only on the home tab. + return ( + + {token ? : } + + + ); } function FigmaPreviewWebView({ token }: { token: string }) { @@ -80,6 +86,15 @@ function FigmaPreviewWebView({ token }: { token: string }) { const router = useRouter(); const { lastPreviewUpdate } = useConnections(); + // Manage the loading overlay ourselves instead of the WebView's built-in + // startInLoadingState. That built-in overlay only clears on the *initial* + // load, so when the preview fails and the WebView instead lands on Figma's + // login page, the spinner stayed up and swallowed taps on the login button. + // Here we drop it as soon as any content finishes loading (onLoadEnd fires on + // success and failure), and the overlay is pointerEvents="none" so it can + // never trap a tap even if it lingers. + const [loading, setLoading] = useState(true); + // Leave the active preview and return to the Figma list/explainer by clearing // the route's token (FigmaScreen renders the explainer when it's empty). const closePreview = useCallback(() => { @@ -153,21 +168,23 @@ function FigmaPreviewWebView({ token }: { token: string }) { )} - ( - + + setLoading(false)} + style={styles.webview} + /> + {loading && ( + )} - style={styles.webview} - /> + ); } @@ -248,6 +265,9 @@ function FigmaExplainer() { } const styles = StyleSheet.create({ + screen: { + flex: 1, + }, safeArea: { flex: 1, }, @@ -272,7 +292,16 @@ const styles = StyleSheet.create({ closeLabel: { color: '#001A72', }, - loader: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + loader: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff', + }, scrollContent: { paddingBottom: 40 }, titleContainer: { flexDirection: 'row', diff --git a/PulsarApp/app/(tabs)/index.tsx b/PulsarApp/app/(tabs)/index.tsx index e953cbfc..373ea95a 100644 --- a/PulsarApp/app/(tabs)/index.tsx +++ b/PulsarApp/app/(tabs)/index.tsx @@ -13,13 +13,13 @@ import QRScanner from '@/components/QRScanner'; import AddConnectionCard from '@/components/home/AddConnectionCard'; import ConnectionList from '@/components/home/ConnectionList'; import HapticsSupportBanner from '@/components/home/HapticsSupportBanner'; -import PatternIsPlaying from '@/components/home/PatternIsPlaying'; +import NowPlayingToast from '@/components/NowPlayingToast'; import { useConnections } from '@/contexts/ConnectionsContext'; const logo = require('@/assets/images/logo.png'); export default function HomeScreen() { - const { connections, addByCode, remove, reconnect, lastReceived } = useConnections(); + const { connections, addByCode, remove, reconnect } = useConnections(); const router = useRouter(); const [connectingCode, setConnectingCode] = useState(''); @@ -131,11 +131,7 @@ export default function HomeScreen() { {/* Floating "preset received" banner — pinned above the tab bar so it's always visible regardless of how many connections fill the list. */} - {lastReceived && ( - - - - )} + setScannerOpen(false)} onScan={handleScan} /> @@ -151,12 +147,6 @@ const styles = StyleSheet.create({ // extra safe-area inset (the SafeAreaView omits the bottom edge above). paddingBottom: 12, }, - toast: { - position: 'absolute', - left: 15, - right: 15, - bottom: 20, - }, titleContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/PulsarApp/components/NowPlayingToast.tsx b/PulsarApp/components/NowPlayingToast.tsx new file mode 100644 index 00000000..1e7703c6 --- /dev/null +++ b/PulsarApp/components/NowPlayingToast.tsx @@ -0,0 +1,27 @@ +import { StyleSheet, View } from 'react-native'; + +import PatternIsPlaying from '@/components/home/PatternIsPlaying'; +import { useConnections } from '@/contexts/ConnectionsContext'; + +// Floating "preset received" banner - pinned near the bottom so it stays visible +// on any screen while a producer (e.g. the Figma plugin) plays presets on the +// phone. Reads the shared `lastReceived` state, so it works regardless of which +// tab is showing. Non-interactive (pointerEvents="none") so it never eats taps. +export default function NowPlayingToast() { + const { lastReceived } = useConnections(); + if (!lastReceived) return null; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + toast: { + position: 'absolute', + left: 15, + right: 15, + bottom: 20, + }, +}); diff --git a/figma/manifest.json b/figma/manifest.json index 7e35fe42..fa57a172 100644 --- a/figma/manifest.json +++ b/figma/manifest.json @@ -12,7 +12,9 @@ "networkAccess": { "allowedDomains": [ "https://pulsar-server.swmansion.com", - "wss://pulsar-server.swmansion.com" + "wss://pulsar-server.swmansion.com", + "https://github.com", + "https://github-production-user-asset-6210df.s3.amazonaws.com" ] } } diff --git a/figma/src/main/code.ts b/figma/src/main/code.ts index 9fdad278..7f7e8e54 100644 --- a/figma/src/main/code.ts +++ b/figma/src/main/code.ts @@ -24,6 +24,10 @@ const BINDING_KEY = 'pulsar:binding'; // inheritance never overrides it. const BINDING_NEGATED_KEY = 'pulsar:binding-negated'; const SETTINGS_KEY = 'pulsar:settings'; +// Phone-pairing token. Namespaced per-file (see fileScopedKey) so a phone paired +// on one design file doesn't show up as an offline/"paired" phantom in every +// other file the user opens. Per-user (clientStorage), so collaborators pair +// their own phones. const TOKEN_KEY = 'pulsar:hapticsToken'; // Per-file share tokens: { [fileKey]: token }. Small and precious - this is the // only record tying a design file to its server-side preview row, so it is @@ -47,8 +51,9 @@ const LEGACY_PREVIEW_TOKEN_KEY = 'pulsar:previewToken'; const PROJECT_CACHE_PREFIX = 'pulsar:project:'; const FAVOURITES_KEY = 'pulsar:favourites'; const CUSTOM_PRESETS_KEY = 'pulsar:customPresets'; -// Per-user flag: has the first-run onboarding tour been shown? Stored in -// clientStorage (not per-file), so the tour auto-opens once per machine. +// Flag: has the first-run onboarding tour been shown? Namespaced per-file (see +// fileScopedKey) and per-user (clientStorage), so the tour auto-opens once for +// each new project instead of only once per machine. const ONBOARDING_SEEN_KEY = 'pulsar:onboardingSeen'; const WINDOW_SIZE_KEY = 'pulsar:windowSize'; // Stable per-document id stored in root pluginData; replaces `figma.fileKey`, @@ -112,6 +117,13 @@ function resolveFileKey(): string { return id; } +// Namespace a clientStorage key to the current file so per-user state that must +// NOT leak between design files (the phone pairing, the onboarding-seen flag) +// starts fresh in a new project instead of inheriting the last file's value. +function fileScopedKey(base: string): string { + return `${base}:${resolveFileKey()}`; +} + // The user-supplied real Figma file key (or share URL) for this document, stored // in root pluginData so it's remembered per-file and shared with collaborators. function getFigmaFileKey(): string { @@ -127,7 +139,7 @@ async function loadSettings(): Promise { } async function loadToken(): Promise { - return (await figma.clientStorage.getAsync(TOKEN_KEY)) ?? null; + return (await figma.clientStorage.getAsync(fileScopedKey(TOKEN_KEY))) ?? null; } type ProjectCache = { @@ -276,7 +288,7 @@ async function loadCustomPresets(): Promise { } async function loadOnboardingSeen(): Promise { - return (await figma.clientStorage.getAsync(ONBOARDING_SEEN_KEY)) === true; + return (await figma.clientStorage.getAsync(fileScopedKey(ONBOARDING_SEEN_KEY))) === true; } function readBinding(node: BaseNode): BindingMeta | null { @@ -707,7 +719,7 @@ figma.ui.onmessage = async (msg: UiToMain) => { await figma.clientStorage.setAsync(SETTINGS_KEY, msg.settings); break; case 'persist-haptics-token': - await figma.clientStorage.setAsync(TOKEN_KEY, msg.token); + await figma.clientStorage.setAsync(fileScopedKey(TOKEN_KEY), msg.token); break; case 'persist-favourites': await figma.clientStorage.setAsync(FAVOURITES_KEY, msg.favourites); @@ -716,7 +728,7 @@ figma.ui.onmessage = async (msg: UiToMain) => { await figma.clientStorage.setAsync(CUSTOM_PRESETS_KEY, msg.presets); break; case 'persist-onboarding-seen': - await figma.clientStorage.setAsync(ONBOARDING_SEEN_KEY, msg.seen); + await figma.clientStorage.setAsync(fileScopedKey(ONBOARDING_SEEN_KEY), msg.seen); break; case 'persist-file-key': setFigmaFileKey(msg.figmaFileKey); diff --git a/figma/src/shared/types.ts b/figma/src/shared/types.ts index 3f30629d..3fe9f450 100644 --- a/figma/src/shared/types.ts +++ b/figma/src/shared/types.ts @@ -99,6 +99,11 @@ export interface BoundItem { // - 'autosync': debounced background save - only updates an *existing* // project; never creates a token on its own (avoids spamming the backend // for files the user never chose to share). +// - 'bootstrap': fired once on first open to eagerly provision the backend +// project (mint the token / share + preview tokens) if this file has none +// yet - so the share link and pairing QR exist from the start. Proceeds +// even before the real Figma file key is set, and never overwrites an +// existing project. // 'pair' resolves the file's share (public) token for the phone-pairing QR: // publishes if needed (so the unified QR can carry the preview token), then // hands the public token back to the caller via ensureShared(). Like 'qr'/'copy' @@ -110,6 +115,7 @@ export type PreviewPurpose = | 'qr' | 'sync' | 'autosync' + | 'bootstrap' | 'pair'; // Severity of an in-plugin toast. Drives its accent colour, icon, and default diff --git a/figma/src/ui/components/LivePreviewTab.module.css b/figma/src/ui/components/LivePreviewTab.module.css index ead209d9..150a7bdc 100644 --- a/figma/src/ui/components/LivePreviewTab.module.css +++ b/figma/src/ui/components/LivePreviewTab.module.css @@ -83,18 +83,30 @@ button.sync-now:disabled { opacity: 0.5; cursor: default; } .configurator-step { display: flex; flex-direction: column; - gap: 10px; padding: 12px; border: 2px solid var(--color-blue-50); border-radius: var(--radius); box-shadow: var(--shadow-card); background: var(--surface); - transition: opacity 200ms ease, border-color 200ms ease, box-shadow 200ms ease; + transition: opacity 240ms ease, filter 240ms ease, border-color 200ms ease, box-shadow 200ms ease; } .configurator-step.done { border-color: var(--color-green-50); box-shadow: -3px 3px 0 var(--color-green-50); } +/* One-shot green ripple + number pop when a step is first completed, so an + accepted input registers before the accordion hands off to the next step. */ +.configurator-step.celebrate { animation: step-celebrate 0.85s ease-out; } +@keyframes step-celebrate { + 0% { box-shadow: -3px 3px 0 var(--color-green-50), 0 0 0 0 rgba(30, 166, 114, 0.55); } + 40% { box-shadow: -3px 3px 0 var(--color-green-50), 0 0 0 8px rgba(30, 166, 114, 0); } + 100% { box-shadow: -3px 3px 0 var(--color-green-50), 0 0 0 0 rgba(30, 166, 114, 0); } +} +.configurator-step.celebrate .configurator-step-num { animation: step-num-pop 0.85s ease-out; } +@keyframes step-num-pop { + 0%, 100% { transform: scale(1); } + 30% { transform: scale(1.2); } +} /* Warn = a live problem (e.g. the phone link dropped): amber border + fill so the issue is impossible to miss. */ .configurator-step.warn { @@ -125,7 +137,7 @@ button.sync-now:disabled { opacity: 0.5; cursor: default; } .configurator-step-chevron { flex-shrink: 0; display: flex; align-self: center; } .configurator-step-chevron img { display: block; opacity: 0.5; transition: transform 160ms ease; } -.configurator-step[open] .configurator-step-chevron img { transform: rotate(180deg); opacity: 0.85; } +.configurator-step.expanded .configurator-step-chevron img { transform: rotate(180deg); opacity: 0.85; } .configurator-step-num { flex-shrink: 0; width: 24px; @@ -160,10 +172,40 @@ button.sync-now:disabled { opacity: 0.5; cursor: default; } .configurator-step-dot.connected { background: var(--color-green-50); } .configurator-step-dot.warn { background: #e8990c; } .configurator-step-dot.pending { background: var(--color-blue-50); } -.configurator-step-body { display: flex; flex-direction: column; gap: 10px; } +/* Animated collapse: a 0fr→1fr grid row clips the body to 0 height when + collapsed and grows it to its natural height when expanded, so open/close + animates without measuring content (native
can't animate). */ +.configurator-step-collapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 300ms ease; +} +.configurator-step.expanded .configurator-step-collapse { grid-template-rows: 1fr; } +.configurator-step-body { + display: flex; + flex-direction: column; + gap: 10px; + /* Required for the 0fr row to actually clip the content. */ + min-height: 0; + overflow: hidden; + /* The header↔body spacing lives here (clipped away when collapsed) so a + collapsed step has no dangling gap under its header. */ + padding-top: 10px; +} +@media (prefers-reduced-motion: reduce) { + .configurator-step.celebrate, + .configurator-step.celebrate .configurator-step-num { animation: none; } + .configurator-step-collapse { transition: none; } +} /* --- Media slot (a short demo clip / annotated capture goes here) --- */ +.configurator-media-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} .configurator-media { + position: relative; margin: 0; border: 1px dashed var(--color-blue-30); border-radius: var(--radius); @@ -175,6 +217,46 @@ button.sync-now:disabled { opacity: 0.5; cursor: default; } overflow: hidden; } .configurator-media-img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* Spinner + caption shown over the clip until it has buffered enough to play. */ +.configurator-media-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--color-blue-10); + color: var(--color-blue-60); +} +.configurator-media-spinner { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid var(--color-blue-30); + border-top-color: var(--color-blue-60); + animation: media-spin 0.8s linear infinite; +} +@keyframes media-spin { + to { transform: rotate(360deg); } +} +.configurator-media-link { + align-self: flex-end; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + background: none; + border: none; + box-shadow: none; + cursor: pointer; + font-size: var(--fs-2xs); + font-weight: 600; + color: var(--color-blue-60); +} +.configurator-media-link:hover { text-decoration: underline; } +.configurator-media-link:active { box-shadow: none; transform: none; } +.configurator-media-link img { opacity: 0.85; } .configurator-media-ph { display: flex; flex-direction: column; diff --git a/figma/src/ui/components/LivePreviewTab.tsx b/figma/src/ui/components/LivePreviewTab.tsx index 19056f5a..c1367d3c 100644 --- a/figma/src/ui/components/LivePreviewTab.tsx +++ b/figma/src/ui/components/LivePreviewTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import styles from './LivePreviewTab.module.css'; import PhonePanel from './PhonePanel'; import { phoneStatusOf, type PhoneConnection } from '../hooks/usePhoneConnection'; @@ -8,6 +8,9 @@ import iconCheck from '../assets/icon-check.svg'; import iconChevron from '../assets/icon-chevron-down.svg'; import iconPlay from '../assets/icon-play.svg'; import iconRefresh from '../assets/icon-refresh.svg'; +import iconExternalLink from '../assets/icon-external-link.svg'; +import { ONBOARDING_VIDEOS } from '../lib/onboardingMedia'; +import { send } from '../figmaBridge'; // Server-sync status copy + dot colour for the footer pill (moved here from the // Share tab) - reflects whether the shared preview's data matches the server. @@ -24,30 +27,71 @@ const SYNC_META: Record on purpose - inlining a GIF would bloat the single-file -// plugin bundle (see the note in the PR/summary). +// A media slot for a short demo. Pass `src` (a remote clip) to play it muted + +// looping; until it has buffered enough to play we overlay a spinner + caption, +// and on a load error we fall back to the labelled placeholder. function ConfiguratorMedia({ src, caption }: { src?: string; caption: string }) { + // 'loading' until the clip can play, 'ready' once it can, 'error' if it fails. + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(src ? 'loading' : 'error'); + const showVideo = !!src && status !== 'error'; return ( -
- {src ? ( - {caption} - ) : ( -
- - {caption} - Demo -
+
+
+ {showVideo ? ( + <> +
+ {src && ( + )} -
+ ); } // One numbered step card. `done` flips it to the green completed look (check in -// the disc); `locked` dims it and blocks interaction until it's the user's turn. +// the disc); `locked` dims it and blocks interaction until it's the user's turn; +// `celebrate` plays a one-shot green pulse when the step is first completed. // Collapsible: the header toggles the body via the controlled `open`/`onToggle` -// pair (locked steps can't be opened). Built on
for free a11y. +// pair (locked steps can't be opened). The
stays natively open so its +// body stays mounted for the CSS open/close animation; `expanded` drives that +// animation, and the collapsed body is made `inert` so it's out of the tab order. function ConfiguratorStep({ index, title, @@ -55,6 +99,7 @@ function ConfiguratorStep({ done, locked, warn, + celebrate, statusDot, open, onToggle, @@ -67,22 +112,36 @@ function ConfiguratorStep({ locked: boolean; // Amber "needs attention" treatment (e.g. the phone link dropped). warn?: boolean; + // One-shot green pulse the moment the step is completed. + celebrate?: boolean; // Coloured status dot next to the title: green ok / amber problem / blue pending. statusDot?: 'connected' | 'warn' | 'pending'; open: boolean; onToggle: () => void; children: ReactNode; }) { + const expanded = open && !locked; + const collapseRef = useRef(null); + // The collapsed body stays in the DOM (clipped to 0 height) so open/close can + // animate - mark it inert so its inputs/buttons stay out of the tab order and + // the a11y tree while hidden. + useEffect(() => { + const el = collapseRef.current; + if (el) el.inert = !expanded; + }, [expanded]); return (
{ // Controlled: drive open state from React, not the native toggle. e.preventDefault(); @@ -107,7 +166,9 @@ function ConfiguratorStep({ )} -
{children}
+
+
{children}
+
); } @@ -154,11 +215,55 @@ export default function LivePreviewTab({ // collapse. A phone problem force-opens step 2 so the error is never hidden. // The user can still click any unlocked header to review a step. const [expandedStep, setExpandedStep] = useState(activeStep); + // Staged step-1 → step-2 hand-off so an accepted file key is legible instead + // of snapping straight to step 2: pulse step 1 green, then collapse it, then + // fade step 2 in from its dimmed/locked look, then open it. + const [celebrateStep1, setCelebrateStep1] = useState(false); + const [holdStep2Dim, setHoldStep2Dim] = useState(false); + // Synchronous guard so the normal "jump to the active step" effect below never + // overrides the hand-off mid-flight (state updates aren't visible to the other + // effect in the same commit; a ref is). + const advancingRef = useRef(false); + const prevStep1DoneRef = useRef(step1Done); + + useEffect(() => { + const wasDone = prevStep1DoneRef.current; + prevStep1DoneRef.current = step1Done; + // Only run the sequence on a fresh step-1 completion (not on mount, and not + // when step 2 is already done - nothing to hand off to). + if (!(step1Done && !wasDone && !step2Done)) return; + + advancingRef.current = true; + setCelebrateStep1(true); + setHoldStep2Dim(true); + setExpandedStep(1); // hold step 1 open while it pulses green + const timers = [ + setTimeout(() => setExpandedStep(0), 750), // collapse step 1 + setTimeout(() => setHoldStep2Dim(false), 1050), // fade step 2 in (after collapse) + setTimeout(() => { + // open the now-revealed step 2 + setCelebrateStep1(false); + advancingRef.current = false; + setExpandedStep(2); + }, 1400) + ]; + return () => { + timers.forEach(clearTimeout); + // Never get stuck mid-hand-off if step 1 is cleared or the tab unmounts. + advancingRef.current = false; + setCelebrateStep1(false); + setHoldStep2Dim(false); + }; + }, [step1Done, step2Done]); + useEffect(() => { + if (advancingRef.current) return; setExpandedStep(phoneNotOk ? 2 : activeStep); }, [activeStep, phoneNotOk]); - const toggleStep = (step: number) => + const toggleStep = (step: number) => { + if (advancingRef.current) return; // don't let a click interrupt the hand-off setExpandedStep((cur) => (cur === step ? 0 : step)); + }; return (
@@ -186,10 +291,11 @@ export default function LivePreviewTab({ blurb="Pulsar can’t read this on its own - paste the file’s link once and it’s saved here." done={step1Done} locked={false} + celebrate={celebrateStep1} open={expandedStep === 1} onToggle={() => toggleStep(1)} > - +

In Figma’s top bar, open Share → Copy link, then paste it below. A full link or the raw key both work. @@ -226,12 +332,13 @@ export default function LivePreviewTab({ // the amber warning treatment so the issue stands out. done={step2Done && !phoneNotOk} warn={phoneNotOk} - statusDot={step1Done ? phoneStatus : undefined} - locked={!step1Done} + statusDot={step1Done && !holdStep2Dim ? phoneStatus : undefined} + // Stay dimmed/locked-looking through the hand-off, then fade in. + locked={!step1Done || holdStep2Dim} open={expandedStep === 2} onToggle={() => toggleStep(2)} > - +

Scanning opens your live preview in the Pulsar app - tap any bound element and feel its haptics on your device in real time, as you edit. diff --git a/figma/src/ui/components/OnboardingOverlay.module.css b/figma/src/ui/components/OnboardingOverlay.module.css index 92efedca..2a07a749 100644 --- a/figma/src/ui/components/OnboardingOverlay.module.css +++ b/figma/src/ui/components/OnboardingOverlay.module.css @@ -79,20 +79,71 @@ } .skip:active { box-shadow: none; } -/* Demo media slot - real GIF or the labelled placeholder, kept at a stable - aspect ratio so swapping between steps doesn't jump the layout. */ -.media { +/* Demo media. Every step's clip is mounted at once (stacked, only the active one + shown) so each buffers a single time and paging between steps is instant + rather than re-loading. The stack keeps a stable aspect ratio so the layout + doesn't jump and the spinner is sized before a clip resolves. */ +.media-col { + display: flex; + flex-direction: column; + gap: 4px; +} +/* Collapsed on the welcome / finish screens, which carry no demo clip. */ +.media-hidden { display: none; } +.media-stack { + position: relative; + aspect-ratio: 16 / 10; border-radius: var(--radius); overflow: hidden; + background: var(--color-blue-10); } -.gif { display: block; width: 100%; height: auto; border-radius: var(--radius); } -.gif-placeholder { +/* One stacked clip - only the active step's is shown; the rest stay mounted + (and buffered) behind it. */ +.stack-item { + position: absolute; + inset: 0; + opacity: 0; + visibility: hidden; + transition: opacity 200ms ease; +} +.stack-item.active { opacity: 1; visibility: visible; } +.stack-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; } +/* Spinner + caption shown over a clip until it has buffered enough to play. */ +.stack-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + text-align: center; + background: var(--color-blue-10); + color: var(--color-blue-60); + font-size: var(--fs-xs); + font-weight: 600; +} +.stack-spinner { + width: 26px; + height: 26px; + border-radius: 50%; + border: 2px solid var(--color-blue-30); + border-top-color: var(--color-blue-60); + animation: tour-media-spin 0.8s linear infinite; +} +@keyframes tour-media-spin { + to { transform: rotate(360deg); } +} +/* Fallback box when a clip is missing or fails to load. */ +.stack-placeholder { + position: absolute; + inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; - aspect-ratio: 16 / 10; padding: 16px; text-align: center; color: var(--color-blue-60); @@ -100,14 +151,25 @@ font-weight: 600; background: var(--color-blue-10); border: 1px dashed var(--color-blue-30); - border-radius: var(--radius); } -/* Gently pulse the play glyph so the placeholder reads as a future demo. */ -.gif-placeholder img { opacity: 0.7; animation: play-pulse 2.4s ease-in-out infinite; } -@keyframes play-pulse { - 0%, 100% { transform: scale(1); opacity: 0.6; } - 50% { transform: scale(1.12); opacity: 0.9; } +/* "Open full size video" link tucked just under the active clip. */ +.media-link { + align-self: flex-end; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + background: none; + border: none; + box-shadow: none; + cursor: pointer; + font-size: var(--fs-2xs); + font-weight: 600; + color: var(--color-blue-60); } +.media-link:hover { text-decoration: underline; } +.media-link:active { box-shadow: none; transform: none; } +.media-link img { opacity: 0.85; } .gif-tag { font-size: var(--fs-2xs); font-weight: 600; @@ -177,8 +239,7 @@ .overlay, .card, .screen, - .welcome :global(.pulsar-logo), - .gif-placeholder img { + .welcome :global(.pulsar-logo) { animation: none; } } diff --git a/figma/src/ui/components/OnboardingOverlay.tsx b/figma/src/ui/components/OnboardingOverlay.tsx index edbf815f..57e54abf 100644 --- a/figma/src/ui/components/OnboardingOverlay.tsx +++ b/figma/src/ui/components/OnboardingOverlay.tsx @@ -1,10 +1,12 @@ import styles from './OnboardingOverlay.module.css'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ONBOARDING_STEPS, WELCOME, OUTRO, type OnboardingStep } from './onboardingContent'; import PulsarLogo from './PulsarLogo'; import iconClose from '../assets/icon-close.svg'; import iconChevron from '../assets/icon-chevron-down.svg'; import iconPlay from '../assets/icon-play.svg'; +import iconExternalLink from '../assets/icon-external-link.svg'; +import { send } from '../figmaBridge'; // The tour leads with a welcome screen (brand mark + greeting), runs the feature // steps, and ends on a send-off. The welcome / finish screens share a brand @@ -20,6 +22,59 @@ const SCREENS: Screen[] = [ { kind: 'finish' } ]; +// One step's demo clip within the persistent media stack. Every step's clip is +// mounted for the tour's lifetime so it buffers exactly once (no re-load each +// time you page to it); only the `active` one is shown and playing. Shows a +// spinner + caption until it can play, and a labelled placeholder if it has no +// clip / fails to load. +function StackVideo({ video, caption, active }: { video?: string; caption: string; active: boolean }) { + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(video ? 'loading' : 'error'); + const ref = useRef(null); + // Only the active clip plays; the rest stay paused but buffered so switching to + // them is instant. (Explicit play/pause instead of the autoPlay attribute so a + // clip that becomes active after it loaded still starts.) + useEffect(() => { + const v = ref.current; + if (!v || status !== 'ready') return; + if (active) v.play().catch(() => {}); + else v.pause(); + }, [active, status]); + const showVideo = !!video && status !== 'error'; + return ( +

+ {showVideo ? ( + <> +
+ ); +} + // First-run onboarding tour. A full-window overlay that walks through the three // core flows (bind a preset → live preview → share) one screen at a time. It is // always cancellable (Skip / Esc / the X), and the Help tab can relaunch it. @@ -85,8 +140,40 @@ export default function OnboardingOverlay({ onClose }: { onClose: () => void }) + {/* Persistent media stack: kept OUTSIDE the keyed screen below so the + video elements survive navigation - each clip buffers once and is + instant on later steps. Collapsed on welcome / finish (no clip). */} +
+
+ {ONBOARDING_STEPS.map((step) => ( + + ))} +
+ {screen.kind === 'feature' && screen.video && ( + + )} +
+ {/* Keyed on `index` so the wrapper remounts each navigation, replaying - the slide-in keyframe from the side matching the paging direction. */} + the slide-in keyframe from the side matching the paging direction. + Holds only the copy now (the media stack above is persistent). */}
= 0 ? styles['slide-next'] : styles['slide-prev']}`} @@ -102,24 +189,10 @@ export default function OnboardingOverlay({ onClose }: { onClose: () => void })

) : ( - <> -
- {screen.gif ? ( - {screen.gifPlaceholder} - ) : ( - - )} -
- -
-

{screen.title}

-

{screen.body}

-
- +
+

{screen.title}

+

{screen.body}

+
)}
diff --git a/figma/src/ui/components/onboardingContent.ts b/figma/src/ui/components/onboardingContent.ts index c03b61fc..bbd1bc2c 100644 --- a/figma/src/ui/components/onboardingContent.ts +++ b/figma/src/ui/components/onboardingContent.ts @@ -3,20 +3,20 @@ // (OnboardingPanel) read from here, so the "how do I…" copy never drifts // between the two surfaces. -// One step of the first-run carousel. `gif` is the (eventual) animated demo for -// the step - left undefined for now so the overlay renders a labelled -// placeholder box. Drop a built asset import here to light it up: -// import gifConnectPreset from '../assets/onboarding-connect-preset.gif'; -// gif: gifConnectPreset +import { ONBOARDING_VIDEOS } from '../lib/onboardingMedia'; + +// One step of the first-run carousel. `video` is the short looping demo clip for +// the step (rendered muted + autoplay in the tour); left undefined it falls back +// to the labelled placeholder box. export interface OnboardingStep { id: string; title: string; // Short, single-idea explanation shown under the demo. One concept per step // keeps the tour skimmable (progressive disclosure). body: string; - // Caption shown inside the placeholder until a real GIF is wired in. + // Caption shown inside the placeholder box when there's no clip yet. gifPlaceholder: string; - gif?: string; + video?: string; } // Intro screen shown first in the tour (not part of the FAQ - it's a greeting, @@ -37,19 +37,29 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ id: 'bind-preset', title: 'Connect a preset to a component', body: 'Open the Presets tab, pick a haptic, then select a layer or component on the canvas and click Add. Pulsar attaches the preset to that node so it fires whenever the component is tapped in a preview.', - gifPlaceholder: 'Binding a preset to a component' + gifPlaceholder: 'Binding a preset to a component', + video: ONBOARDING_VIDEOS.bind }, { id: 'live-preview', title: 'Connect a live preview', body: 'On the Live preview tab, paste your Figma file link and scan the QR code with the Pulsar mobile app. Your prototype runs on the phone and plays real haptics as you tap the components you bound.', - gifPlaceholder: 'Pairing a phone for live preview' + gifPlaceholder: 'Pairing a phone for live preview', + video: ONBOARDING_VIDEOS.livePreview + }, + { + id: 'preview-on-device', + title: 'Preview a preset on your device', + body: 'Once a phone is paired, hit Play on any preset in the Presets tab and feel it on your device right away. Choosing haptics by feel - not just by name - makes it far easier to pick the right preset for each component.', + gifPlaceholder: 'Playing a preset on the paired phone', + video: ONBOARDING_VIDEOS.previewOnDevice }, { id: 'share-design', title: 'Share a design with a developer', body: 'Open the Share tab and publish a share link. Developers open it to feel every bound haptic and copy ready-to-use SDK code for iOS, Android, React Native and more - no Figma access required.', - gifPlaceholder: 'Sharing a design with a developer' + gifPlaceholder: 'Sharing a design with a developer', + video: ONBOARDING_VIDEOS.share } ]; @@ -101,9 +111,8 @@ export const FAQ: FaqEntry[] = [ } ]; -// Resource links surfaced on the Help tab. Update these once the public Figma -// file and example app are live (placeholders point at sensible defaults). +// Resource links surfaced on the Help tab. export const FIGMA_DESIGN_URL = - 'https://www.figma.com/community/file/pulsar-onboarding-example'; + 'https://www.figma.com/design/i85oRvw6pl12o6c0X1zP6E/Pulsar---Haptics-Figma-Plugin?node-id=0-1'; export const EXAMPLE_APP_URL = 'https://docs.swmansion.com/pulsar/figma-preview/'; export const SUPPORT_EMAIL = 'projects@swmansion.com'; diff --git a/figma/src/ui/hooks/usePreviewSync.ts b/figma/src/ui/hooks/usePreviewSync.ts index 27128dd6..7ca0f57c 100644 --- a/figma/src/ui/hooks/usePreviewSync.ts +++ b/figma/src/ui/hooks/usePreviewSync.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { CUSTOM_TAG, type CatalogEntry, type Settings } from '../../shared/types'; import { onMessage, send } from '../figmaBridge'; import { copyToClipboard } from '../lib/clipboard'; -import { extractFileKey } from '../lib/fileKey'; +import { extractFileKey, isFileKeyValid } from '../lib/fileKey'; import { stableStringify } from '../lib/stableStringify'; import { diffPayloads, @@ -26,7 +26,8 @@ import type { ToastOptions } from '../components/Toast'; const PREVIEW_DIFF_MAX_BYTES = 24_000; // Live-preview sync state, surfaced as a pill in the Live preview tab. -// idle - nothing shared for this file yet (no server row). +// idle - no server row yet (transient before first-open provisioning +// completes, or if that provisioning couldn't run/failed). // syncing - a push/fetch is in flight. // synced - local state matches what's on the server. // unsynced - local edits not yet pushed (a debounced save is pending). @@ -93,6 +94,11 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP const syncLockRef = useRef>(Promise.resolve()); // Debounce timer for background auto-save. const autosyncTimerRef = useRef | null>(null); + // Flips true once this file's persisted project state has loaded (handleProject + // ran, or there was no server id to load). Gates the "configuring the live + // preview auto-starts sync" trigger so a user-typed file key can't create a + // second server row before the stored token has been read back in. + const projectReadyRef = useRef(false); const [syncStatus, setSyncStatus] = useState('idle'); // Share-link visibility for the current file. true = anyone with the link can // view; false = the link is revoked server-side until the user shares again. @@ -208,7 +214,16 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP lastSyncedPayloadRef.current = null; setSyncStatus('idle'); } - send({ type: 'request-preview-data', purpose: 'autosync' }); + // The persisted token (if any) is now loaded, so it's safe to let a + // subsequent file-key configuration auto-create/sync without racing this. + projectReadyRef.current = true; + // No server row for this file yet → eagerly provision one now (first open), + // so the token / share link / pairing QR exist from the start. Otherwise + // just push any local drift since the last snapshot. + send({ + type: 'request-preview-data', + purpose: tokenRef.current ? 'autosync' : 'bootstrap' + }); }; // A local edit happened. Mark the file dirty (only meaningful once it has a @@ -257,6 +272,10 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP // ensureShared() request: resolve the file's public token (or null when // the file isn't preview-ready) and pair code-only without UI nagging. const isPair = purpose === 'pair'; + // First-open provisioning: create the backend row if this file has none + // yet, before the file is even configured, and without any share/URL + // side-effects or error toasts. + const isBootstrap = purpose === 'bootstrap'; // Two distinct keys: // - `fileKey`: the plugin's stable minted id (m.fileKey), used purely to @@ -267,14 +286,17 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP // override (a full share URL or raw key). const fileKey = m.fileKey ?? ''; const embedFileKey = figmaFileKey ? extractFileKey(figmaFileKey) : ''; - if (!fileKey || !embedFileKey) { + // Bootstrap provisions the row before the file is configured, so it runs + // without the real Figma file key (the embed key is filled in later once + // the user pastes it). Every other purpose needs it to build the embed. + if (!fileKey || (!embedFileKey && !isBootstrap)) { // Pairing proceeds code-only when the file isn't preview-ready - resolve // null without nagging the user with a warning toast. if (isPair) { settlePair(null); return; } - if (silent) return; + if (silent || isBootstrap) return; notify( 'Add this file’s key in the Share tab (Figma file key) to enable the live preview.', { level: 'warning' } @@ -315,7 +337,9 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP isCustom: !!b.customPattern || (Array.isArray(data.tags) && data.tags.includes(CUSTOM_TAG)) }); } - if (elements.length === 0) { + // Bootstrap still provisions an (empty) row with no bindings - that's the + // point of first-open provisioning; real bindings get pushed later. + if (elements.length === 0 && !isBootstrap) { // No bindings → nothing to preview. Pair code-only, quietly. if (isPair) { settlePair(null); @@ -331,7 +355,8 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP return; } const payload = { - // The preview embeds this as the Figma file key - must be the real key. + // The preview embeds this as the Figma file key - the real key once + // configured, or empty for a first-open bootstrap row (filled in later). fileKey: embedFileKey, nodeId: m.presentNodeId, frame: m.presentNodeBox, @@ -470,17 +495,32 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP }; void runExclusive(async () => { + // Bootstrap only provisions a *missing* row: if a token already exists + // (created since we fired, or loaded from cache), there's nothing to do + // and we must not push the empty bootstrap payload over real data. + if (isBootstrap && tokenRef.current) { + setSyncStatus('synced'); + return; + } setSyncStatus('syncing'); let token: string | null; try { - token = await ensurePublished(!silent); + // A configured live preview (valid Figma file key) is treated as intent + // to share, so even a silent autosync may create the server row and + // start syncing - the user no longer has to explicitly copy a link or + // pair a phone first for the sync indicator to go live. Bootstrap always + // creates (that's its whole job). + const configured = isFileKeyValid(figmaFileKey); + token = await ensurePublished(!silent || configured || isBootstrap); } catch (err) { - setSyncStatus('error'); + // Bootstrap is a silent first-open convenience: on failure just fall + // back to idle and let a later configure/sync retry - no error pill. + setSyncStatus(isBootstrap ? 'idle' : 'error'); if (isPair) { settlePair(null); return; } - if (!silent) { + if (!silent && !isBootstrap) { notify(`Could not upload preview data: ${(err as Error).message}`, { level: 'error' }); } return; @@ -520,7 +560,9 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP } // Beyond the sync itself, share actions also need a URL / QR / clipboard. - if (purpose === 'autosync' || purpose === 'sync') return; + // Bootstrap is provisioning-only - it never opens/copies or touches the + // share-link visibility. + if (purpose === 'autosync' || purpose === 'sync' || isBootstrap) return; // Any explicit share re-opens the link to the public: if the user had // made it private, handing the link out again necessarily makes it @@ -592,10 +634,32 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP // this file's persisted token + cached config (or settle to idle). const initProject = (fileKey: string) => { fileKeyRef.current = fileKey; - if (fileKey) send({ type: 'get-project', fileKey }); - else setSyncStatus('idle'); + if (fileKey) { + send({ type: 'get-project', fileKey }); + } else { + // No server id to load - nothing to race, so the configure-to-sync trigger + // can run immediately. + projectReadyRef.current = true; + setSyncStatus('idle'); + } }; + // Configuring the live preview auto-starts syncing: the moment the Figma file + // key becomes valid, publish to the backend so the sync indicator goes live + // without the user having to copy a share link or pair a phone first. Only on + // the false→true transition (not every keystroke once valid), and only after + // the persisted project state has loaded, so we never create a duplicate row + // ahead of the stored token (the init/reopen path syncs via handleProject). + const fileKeyValidRef = useRef(false); + useEffect(() => { + const valid = isFileKeyValid(figmaFileKey); + const wasValid = fileKeyValidRef.current; + fileKeyValidRef.current = valid; + if (valid && !wasValid && projectReadyRef.current) { + send({ type: 'request-preview-data', purpose: 'autosync' }); + } + }, [figmaFileKey]); + const showInLivePreview = () => send({ type: 'request-preview-data', purpose: 'open' }); const copyShareLink = () => send({ type: 'request-preview-data', purpose: 'copy' }); const copyShareToken = () => send({ type: 'request-preview-data', purpose: 'copy-token' }); diff --git a/figma/src/ui/lib/onboardingMedia.ts b/figma/src/ui/lib/onboardingMedia.ts new file mode 100644 index 00000000..26505db7 --- /dev/null +++ b/figma/src/ui/lib/onboardingMedia.ts @@ -0,0 +1,17 @@ +// Remote-hosted onboarding / explanation clips. Loaded over the network (see +// manifest.json → networkAccess.allowedDomains) rather than imported into the +// bundle, so the single-file plugin UI stays ~1 MB instead of inlining ~37 MB +// of video as base64. +// +// NOTE: these are GitHub user-attachment URLs. They work (public, video/mp4, +// range-request-able), but GitHub is NOT a supported CDN - the asset is tied to +// the comment it was uploaded in, the underlying S3 URL is short-signed, and it +// can be rate-limited or removed. Move these to a durable host (e.g. +// pulsar-server.swmansion.com, already allowlisted) before a public release. +export const ONBOARDING_VIDEOS = { + bind: 'https://github.com/user-attachments/assets/126d5e7b-73d5-4509-abe9-baede9c0368b', + link: 'https://github.com/user-attachments/assets/b97a471c-c4c2-42f3-a7f4-4af8bd4afbd9', + livePreview: 'https://github.com/user-attachments/assets/14e64152-4e44-4b8d-b379-22deaf731b62', + previewOnDevice: 'https://github.com/user-attachments/assets/9be36fb7-a1fd-496f-b1ad-57061e58d1f3', + share: 'https://github.com/user-attachments/assets/08d240de-60b6-4486-9adf-a7f0154876b0' +} as const; From 0cb5345638d3461b2186676ca219df607a9b64f5 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 13 Jul 2026 11:34:34 +0200 Subject: [PATCH 2/5] Resize --- figma/preview/src/App.tsx | 1 + figma/preview/src/components/HapticList.tsx | 32 +++++++-- figma/preview/src/styles.css | 41 ++++++++++++ .../components/OnboardingOverlay.module.css | 37 ++++++++++- figma/src/ui/components/OnboardingOverlay.tsx | 63 +++++++++++++++++- .../src/ui/components/PresetDetail.module.css | 25 +------ .../src/ui/components/ResizeHandle.module.css | 31 ++++++++- figma/src/ui/components/ResizeHandle.tsx | 65 ++++++++++++++----- figma/src/ui/components/TagsGuide.module.css | 16 +---- figma/src/ui/lib/onboardingMedia.ts | 2 +- figma/src/ui/styles/common.css | 22 +++++++ 11 files changed, 273 insertions(+), 62 deletions(-) diff --git a/figma/preview/src/App.tsx b/figma/preview/src/App.tsx index 0b3f3f64..d3230c7b 100644 --- a/figma/preview/src/App.tsx +++ b/figma/preview/src/App.tsx @@ -446,6 +446,7 @@ export default function App() { onPlay={playFromList} onOpenFrame={navigateToFrame} onShowDetails={setDetailsId} + isMobile={isMobile} footer={openOnPhoneLink ? : undefined} /> )} diff --git a/figma/preview/src/components/HapticList.tsx b/figma/preview/src/components/HapticList.tsx index c89c7154..34296bc2 100644 --- a/figma/preview/src/components/HapticList.tsx +++ b/figma/preview/src/components/HapticList.tsx @@ -1,6 +1,10 @@ import { useMemo, type ReactNode } from 'react'; import type { ElementInfo, FrameInfo } from '../types'; +// Synthetic bucket id for bindings with no owning frame. Not a real Figma +// node-id, so it can't be opened in the prototype. +const UNASSIGNED = '__unassigned'; + export function HapticList({ elements, frames, @@ -12,6 +16,7 @@ export function HapticList({ onPlay, onOpenFrame, onShowDetails, + isMobile = false, footer }: { elements: ElementInfo[]; @@ -22,10 +27,14 @@ export function HapticList({ activeId: string; onActivate: (id: string) => void; onPlay: (id: string) => void; - // Open the frame this element lives in (the preview jumps to that screen). - // Optional so the list can render without navigation wired up. - onOpenFrame?: (frameId: string) => void; + // Open a frame in the prototype (the preview jumps to that screen). Passing the + // name lets the host label the screen while it loads. Optional so the list can + // render without navigation wired up. + onOpenFrame?: (frameId: string, frameName?: string) => void; onShowDetails: (id: string) => void; + // Desktop only surfaces the per-screen "Open screen" button (the side panel + // sits next to the prototype there); on mobile a row tap already opens it. + isMobile?: boolean; // Optional pinned content below the scrollable list (e.g. the open-on-phone QR). footer?: ReactNode; }) { @@ -34,7 +43,6 @@ export function HapticList({ // order in the frames Map). Bindings with no frameId fall into a synthetic // "Unassigned" bucket so they're still reachable. const groups = useMemo(() => { - const UNASSIGNED = '__unassigned'; const byFrame = new Map(); for (const el of elements) { const key = el.frameId ?? UNASSIGNED; @@ -95,6 +103,22 @@ export function HapticList({ className={`screen-head${group.id === currentFrameId ? ' active' : ''}`} title={group.name} > + {/* The screen currently on view gets an "Active screen" label; + every other real screen gets a desktop button to load it into + the prototype iframe (reuses PrototypeView's keep-alive cache). */} + {group.id === currentFrameId ? ( + Active + ) : onOpenFrame && !isMobile && group.id !== UNASSIGNED ? ( + + ) : null} {group.name} {group.items.length} diff --git a/figma/preview/src/styles.css b/figma/preview/src/styles.css index ce6930ee..77120bee 100644 --- a/figma/preview/src/styles.css +++ b/figma/preview/src/styles.css @@ -669,6 +669,47 @@ body { color: var(--surface); } +/* Label on the screen currently shown in the prototype. */ +.active-screen-label { + flex-shrink: 0; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + background: var(--color-primary); + color: var(--surface); + font-size: var(--fs-2xs); + font-weight: 700; + letter-spacing: 0; + text-transform: none; +} + +/* Per-screen "Open screen" button - loads that frame into the prototype iframe. */ +.open-screen-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: var(--color-blue-10); + border: 1px solid var(--color-blue-30); + border-radius: 10px; + color: var(--color-blue-60); + font: inherit; + font-size: var(--fs-2xs); + font-weight: 600; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease; +} +.open-screen-btn:hover { + background: var(--color-blue-20); + border-color: var(--color-blue-50); + color: var(--color-primary); +} +.open-screen-btn:active { transform: translate(-1px, 1px); } + /* Inline name within the el-row haptic line - wrapped in a span so we can place a "Custom" pill on the same line without breaking the dot::before. */ .el-haptic-name { diff --git a/figma/src/ui/components/OnboardingOverlay.module.css b/figma/src/ui/components/OnboardingOverlay.module.css index 2a07a749..72f13ef3 100644 --- a/figma/src/ui/components/OnboardingOverlay.module.css +++ b/figma/src/ui/components/OnboardingOverlay.module.css @@ -18,6 +18,7 @@ to { opacity: 1; } } .card { + position: relative; background: var(--surface); border: 2px solid var(--color-blue-50); border-radius: var(--radius); @@ -29,6 +30,30 @@ gap: 14px; animation: card-in 240ms cubic-bezier(0.16, 1, 0.3, 1) both; } + +/* Finish-screen confetti - covers the whole card, sits above the content but + never intercepts pointer events. */ +.confetti { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + border-radius: var(--radius); + z-index: 3; +} +.confetti-piece { + position: absolute; + top: -16px; + opacity: 0; + animation-name: confetti-fall; + animation-timing-function: cubic-bezier(0.35, 0.2, 0.7, 1); + animation-iteration-count: 1; + animation-fill-mode: forwards; +} +@keyframes confetti-fall { + 0% { transform: translate(0, -16px) rotate(0deg); opacity: 1; } + 100% { transform: translate(var(--drift), var(--fall)) rotate(var(--spin)); opacity: 0; } +} @keyframes card-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } @@ -205,6 +230,14 @@ /* Step copy. */ .body { text-align: center; padding: 0 4px; } .title { margin: 0 0 6px; font-size: var(--fs-lg); font-weight: 700; color: var(--color-primary); } +/* Finish screen: pop the "You're all set!" title and settle it on green. The + base colour is green too, so it stays green even with animations disabled. */ +.title-finish { color: var(--color-green-50); animation: title-to-green 620ms cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes title-to-green { + 0% { color: var(--color-primary); transform: scale(0.9); } + 55% { transform: scale(1.08); } + 100% { color: var(--color-green-50); transform: scale(1); } +} .text { margin: 0; font-size: var(--fs-sm); line-height: 1.5; color: var(--muted); } /* Progress dots - also clickable to jump to a step. */ @@ -239,7 +272,9 @@ .overlay, .card, .screen, - .welcome :global(.pulsar-logo) { + .welcome :global(.pulsar-logo), + .title-finish, + .confetti-piece { animation: none; } } diff --git a/figma/src/ui/components/OnboardingOverlay.tsx b/figma/src/ui/components/OnboardingOverlay.tsx index 57e54abf..731a117d 100644 --- a/figma/src/ui/components/OnboardingOverlay.tsx +++ b/figma/src/ui/components/OnboardingOverlay.tsx @@ -1,5 +1,5 @@ import styles from './OnboardingOverlay.module.css'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { ONBOARDING_STEPS, WELCOME, OUTRO, type OnboardingStep } from './onboardingContent'; import PulsarLogo from './PulsarLogo'; import iconClose from '../assets/icon-close.svg'; @@ -75,6 +75,58 @@ function StackVideo({ video, caption, active }: { video?: string; caption: strin ); } +// Self-contained CSS confetti for the finish screen - a burst of small pieces +// that fall + drift + spin, each randomised once on mount. No external lib +// (the plugin CSP blocks them) and pointer-events:none so it never eats clicks. +const CONFETTI_COLORS = ['#1ea672', '#2b59ff', '#ffd166', '#ff6b6b', '#4dd4ac', '#a0c4ff', '#f78fb3']; + +function Confetti() { + const pieces = useMemo( + () => + Array.from({ length: 44 }, (_, i) => { + const size = 6 + Math.random() * 6; + return { + id: i, + left: Math.random() * 100, + delay: Math.random() * 0.5, + duration: 1.9 + Math.random() * 1.5, + drift: `${(Math.random() - 0.5) * 90}px`, + fall: `${360 + Math.random() * 260}px`, + spin: `${(Math.random() > 0.5 ? 1 : -1) * (240 + Math.random() * 480)}deg`, + color: CONFETTI_COLORS[i % CONFETTI_COLORS.length], + width: size, + height: size * (0.35 + Math.random() * 0.35), + round: Math.random() > 0.7 + }; + }), + [] + ); + return ( + + ); +} + // First-run onboarding tour. A full-window overlay that walks through the three // core flows (bind a preset → live preview → share) one screen at a time. It is // always cancellable (Skip / Esc / the X), and the Help tab can relaunch it. @@ -122,6 +174,9 @@ export default function OnboardingOverlay({ onClose }: { onClose: () => void }) return (
+ {/* Keyed on the screen so a fresh burst mounts each time the finish + screen is reached (e.g. paging back and forward). */} + {screen.kind === 'finish' && }
{/* The welcome screen is a greeting, not a numbered step. */} {screen.kind === 'feature' && ( @@ -181,7 +236,11 @@ export default function OnboardingOverlay({ onClose }: { onClose: () => void }) {screen.kind === 'welcome' || screen.kind === 'finish' ? (
-

+

{screen.kind === 'welcome' ? WELCOME.title : OUTRO.title}

diff --git a/figma/src/ui/components/PresetDetail.module.css b/figma/src/ui/components/PresetDetail.module.css index 0c894852..5ce0320d 100644 --- a/figma/src/ui/components/PresetDetail.module.css +++ b/figma/src/ui/components/PresetDetail.module.css @@ -234,26 +234,5 @@ cursor: default; } -/* Docs-aligned scrollbars on every scrollable surface inside the modal. - Thin transparent track, blue-tinted thumb that darkens on hover. */ -.modal-body, -.code-block { - scrollbar-width: thin; - scrollbar-color: var(--color-blue-20) transparent; -} -.modal-body::-webkit-scrollbar, -.code-block::-webkit-scrollbar { width: 8px; height: 8px; } -.modal-body::-webkit-scrollbar-track, -.code-block::-webkit-scrollbar-track { background: transparent; } -.modal-body::-webkit-scrollbar-thumb, -.code-block::-webkit-scrollbar-thumb { - background: var(--color-blue-20); - border-radius: 4px; - border: 2px solid transparent; - background-clip: padding-box; -} -.modal-body::-webkit-scrollbar-thumb:hover, -.code-block::-webkit-scrollbar-thumb:hover { - background: var(--color-blue-30); - background-clip: padding-box; -} +/* Scrollbars for the modal body / code block are handled by the global + custom-scrollbar rules in common.css. */ diff --git a/figma/src/ui/components/ResizeHandle.module.css b/figma/src/ui/components/ResizeHandle.module.css index da3445a0..dd030d2d 100644 --- a/figma/src/ui/components/ResizeHandle.module.css +++ b/figma/src/ui/components/ResizeHandle.module.css @@ -1,4 +1,8 @@ -/* Bottom-right drag affordance for resizing the plugin window. */ +/* Drag affordances for resizing the plugin window. Figma anchors the window at + its top-left, so only the right edge, bottom edge and bottom-right corner can + resize. */ + +/* Bottom-right corner grip (both axes). */ .resize-handle { position: fixed; right: 0; @@ -12,3 +16,28 @@ touch-action: none; } .resize-handle img { display: block; } + +/* Right edge (width). Kept narrow so it overlaps the vertical scrollbar as + little as possible; stops above the corner grip. */ +.resize-edge-right { + position: fixed; + top: 0; + right: 0; + bottom: 22px; + width: 5px; + z-index: 1001; + cursor: ew-resize; + touch-action: none; +} + +/* Bottom edge (height); stops left of the corner grip. */ +.resize-edge-bottom { + position: fixed; + left: 0; + right: 22px; + bottom: 0; + height: 5px; + z-index: 1001; + cursor: ns-resize; + touch-action: none; +} diff --git a/figma/src/ui/components/ResizeHandle.tsx b/figma/src/ui/components/ResizeHandle.tsx index cdb6e75a..263404a9 100644 --- a/figma/src/ui/components/ResizeHandle.tsx +++ b/figma/src/ui/components/ResizeHandle.tsx @@ -9,19 +9,38 @@ import gripIcon from '../assets/icon-resize-grip.svg'; const MIN = { width: 320, height: 460 }; const MAX = { width: 1200, height: 1200 }; -function sizeFromEvent(e: React.PointerEvent) { - // The iframe fills the plugin window, so the pointer position relative to the - // iframe viewport is the desired window size. +const clampW = (v: number) => Math.max(MIN.width, Math.min(MAX.width, Math.ceil(v))); +const clampH = (v: number) => Math.max(MIN.height, Math.min(MAX.height, Math.ceil(v))); + +// Which edge a handle drives. Figma anchors the plugin window at its top-left and +// only exposes figma.ui.resize(w, h) - there's no reposition - so only the right +// edge (width), the bottom edge (height), and the bottom-right corner (both) can +// move; the top/left edges have nothing to anchor against. +type Edge = 'right' | 'bottom' | 'corner'; + +// The iframe fills the plugin window, so the pointer position relative to the +// iframe viewport IS the desired window size. An edge handle changes only its own +// axis; the other axis holds the current window size (innerWidth/innerHeight). +function sizeFromEvent(e: React.PointerEvent, edge: Edge) { return { - width: Math.max(MIN.width, Math.min(MAX.width, Math.ceil(e.clientX))), - height: Math.max(MIN.height, Math.min(MAX.height, Math.ceil(e.clientY))) + width: edge === 'bottom' ? window.innerWidth : clampW(e.clientX), + height: edge === 'right' ? window.innerHeight : clampH(e.clientY) }; } -// Bottom-right corner grip. Figma plugin windows don't resize from their OS -// edges - the only way to resize is an in-UI handle that calls -// figma.ui.resize() on the main thread. -export default function ResizeHandle() { +// A single drag affordance. Corner shows the grip glyph; the edges are thin +// invisible strips that only change the cursor on hover. +function ResizeGrip({ + edge, + className, + title, + children +}: { + edge: Edge; + className: string; + title: string; + children?: React.ReactNode; +}) { const dragging = useRef(false); function onPointerDown(e: React.PointerEvent) { @@ -32,28 +51,42 @@ export default function ResizeHandle() { function onPointerMove(e: React.PointerEvent) { if (!dragging.current) return; - const { width, height } = sizeFromEvent(e); - send({ type: 'resize', width, height }); + send({ type: 'resize', ...sizeFromEvent(e, edge) }); } function onPointerUp(e: React.PointerEvent) { if (!dragging.current) return; dragging.current = false; e.currentTarget.releasePointerCapture(e.pointerId); - const { width, height } = sizeFromEvent(e); // commit: persist the final size so it's restored next time. - send({ type: 'resize', width, height, commit: true }); + send({ type: 'resize', ...sizeFromEvent(e, edge), commit: true }); } return (

- + {children}
); } + +// Plugin-window resize affordances. Figma plugin windows don't resize from their +// OS edges - the only way to resize is in-UI handles that call figma.ui.resize() +// on the main thread. A grip in the bottom-right corner drives both axes; thin +// strips down the right edge and along the bottom edge drive one axis each. +export default function ResizeHandle() { + return ( + <> + + + + + + + ); +} diff --git a/figma/src/ui/components/TagsGuide.module.css b/figma/src/ui/components/TagsGuide.module.css index 1251ac04..5afabc54 100644 --- a/figma/src/ui/components/TagsGuide.module.css +++ b/figma/src/ui/components/TagsGuide.module.css @@ -140,17 +140,5 @@ } .tag-usage-label { font-weight: 700; } -/* Docs-aligned scrollbar - thin transparent track, blue-tinted thumb. */ -.modal-body { scrollbar-width: thin; scrollbar-color: var(--color-blue-20) transparent; } -.modal-body::-webkit-scrollbar { width: 8px; } -.modal-body::-webkit-scrollbar-track { background: transparent; } -.modal-body::-webkit-scrollbar-thumb { - background: var(--color-blue-20); - border-radius: 4px; - border: 2px solid transparent; - background-clip: padding-box; -} -.modal-body::-webkit-scrollbar-thumb:hover { - background: var(--color-blue-30); - background-clip: padding-box; -} +/* The modal-body scrollbar is handled by the global custom-scrollbar rules in + common.css. The horizontal .docs-tabs strip keeps its own thinner bar below. */ diff --git a/figma/src/ui/lib/onboardingMedia.ts b/figma/src/ui/lib/onboardingMedia.ts index 26505db7..b40a0207 100644 --- a/figma/src/ui/lib/onboardingMedia.ts +++ b/figma/src/ui/lib/onboardingMedia.ts @@ -9,7 +9,7 @@ // can be rate-limited or removed. Move these to a durable host (e.g. // pulsar-server.swmansion.com, already allowlisted) before a public release. export const ONBOARDING_VIDEOS = { - bind: 'https://github.com/user-attachments/assets/126d5e7b-73d5-4509-abe9-baede9c0368b', + bind: 'https://github.com/user-attachments/assets/371b8423-5a60-46dc-8499-e8e17131550a', link: 'https://github.com/user-attachments/assets/b97a471c-c4c2-42f3-a7f4-4af8bd4afbd9', livePreview: 'https://github.com/user-attachments/assets/14e64152-4e44-4b8d-b379-22deaf731b62', previewOnDevice: 'https://github.com/user-attachments/assets/9be36fb7-a1fd-496f-b1ad-57061e58d1f3', diff --git a/figma/src/ui/styles/common.css b/figma/src/ui/styles/common.css index f992498c..8fb65549 100644 --- a/figma/src/ui/styles/common.css +++ b/figma/src/ui/styles/common.css @@ -54,6 +54,28 @@ body, -moz-osx-font-smoothing: grayscale; } +/* Custom scrollbars across the whole plugin window - thin transparent track with + a blue-tinted thumb that darkens on hover (matches the docs styling). Applies + to the window itself and every scroll container; an element that opts out + (e.g. a compact tag row) keeps its own more-specific `display: none` rule. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-blue-20) transparent; +} +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--color-blue-20); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-blue-30); + background-clip: padding-box; +} +::-webkit-scrollbar-corner { background: transparent; } + /* ---- Buttons ---- */ button { font-family: inherit; From 2e8c7a6c370a96d4dd31df58dc38ec241af4c0ba Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 13 Jul 2026 12:16:28 +0200 Subject: [PATCH 3/5] DevMode --- figma/src/main/code.ts | 32 ++++++++ figma/src/shared/types.ts | 7 +- figma/src/ui/App.tsx | 13 +++- figma/src/ui/components/DebugPanel.module.css | 32 ++++++++ figma/src/ui/components/DebugPanel.tsx | 73 +++++++++++++++++++ figma/src/ui/lib/settings.ts | 6 ++ 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 figma/src/ui/components/DebugPanel.module.css create mode 100644 figma/src/ui/components/DebugPanel.tsx diff --git a/figma/src/main/code.ts b/figma/src/main/code.ts index 7f7e8e54..61492302 100644 --- a/figma/src/main/code.ts +++ b/figma/src/main/code.ts @@ -768,5 +768,37 @@ figma.ui.onmessage = async (msg: UiToMain) => { } break; } + // Developer-mode maintenance actions from the debug tab. + case 'debug-action': { + if (msg.action === 'clear-storage') { + // Wipe every clientStorage key (settings, tokens, caches, favourites, + // onboarding, window size…) and the per-file root pluginData (the minted + // file id + the user-entered Figma file key). + const keys = await figma.clientStorage.keysAsync(); + await Promise.all(keys.map((k) => figma.clientStorage.deleteAsync(k))); + figma.root.setPluginData(FILE_ID_KEY, ''); + figma.root.setPluginData(FIGMA_FILE_KEY, ''); + postToUi({ + type: 'toast', + message: 'Cleared all Pulsar plugin data. Reopen the plugin for a fresh state.', + level: 'success', + duration: 6000 + }); + } else if (msg.action === 'reset-onboarding') { + await figma.clientStorage.deleteAsync(fileScopedKey(ONBOARDING_SEEN_KEY)); + postToUi({ type: 'toast', message: 'Onboarding reset for this file.', level: 'success' }); + } else if (msg.action === 'log-storage') { + const keys = await figma.clientStorage.keysAsync(); + const store: Record = {}; + for (const k of keys) store[k] = await figma.clientStorage.getAsync(k); + console.log('[Pulsar debug] clientStorage', store); + console.log('[Pulsar debug] root pluginData', { + fileId: figma.root.getPluginData(FILE_ID_KEY), + figmaFileKey: figma.root.getPluginData(FIGMA_FILE_KEY) + }); + postToUi({ type: 'toast', message: 'Logged plugin storage to the console.', level: 'info' }); + } + break; + } } }; diff --git a/figma/src/shared/types.ts b/figma/src/shared/types.ts index 3fe9f450..6a56e238 100644 --- a/figma/src/shared/types.ts +++ b/figma/src/shared/types.ts @@ -46,6 +46,9 @@ export type Settings = { previewBaseUrlOverride: string; }; +// A one-off maintenance action fired from the debug tab (developer mode only). +export type DebugAction = 'clear-storage' | 'reset-onboarding' | 'log-storage'; + // One bound node, as forwarded from the main thread to the UI when building the // live-preview payload. The UI resolves presetId -> full PresetData before launch. // Axis-aligned bounding box in absolute canvas coordinates. @@ -162,7 +165,9 @@ export type UiToMain = // Persist the user-entered real Figma file key (or share URL) for this // document into root pluginData (per-file, shared with collaborators). | { type: 'persist-file-key'; figmaFileKey: string } - | { type: 'resize'; width: number; height: number; commit?: boolean }; + | { type: 'resize'; width: number; height: number; commit?: boolean } + // Developer-mode maintenance action from the debug tab. + | { type: 'debug-action'; action: DebugAction }; // Messages: Main -> UI export type MainToUi = diff --git a/figma/src/ui/App.tsx b/figma/src/ui/App.tsx index 6824e97f..bb8e2900 100644 --- a/figma/src/ui/App.tsx +++ b/figma/src/ui/App.tsx @@ -11,6 +11,7 @@ import LivePreviewTab from './components/LivePreviewTab'; import LivePreviewPanel from './components/LivePreviewPanel'; import OnboardingPanel from './components/OnboardingPanel'; import OnboardingOverlay from './components/OnboardingOverlay'; +import DebugPanel from './components/DebugPanel'; import { usePhoneConnection, phoneStatusOf, @@ -22,7 +23,7 @@ import PulsarLogo from './components/PulsarLogo'; import ResizeHandle from './components/ResizeHandle'; import { useToast } from './components/Toast'; import { usePreviewSync } from './hooks/usePreviewSync'; -import { DEFAULT_SETTINGS } from './lib/settings'; +import { DEFAULT_SETTINGS, DEV_MODE } from './lib/settings'; import { isFileKeyValid } from './lib/fileKey'; import { playPreset, stopAll } from './audio/player'; @@ -31,7 +32,8 @@ export type { SyncStatus } from './hooks/usePreviewSync'; // 'live' = the "Live preview" tab (file key + phone pairing); 'preview' = the // "Share" tab (share link / sync). The internal names predate the split. -type Tab = 'presets' | 'live' | 'preview' | 'onboarding'; +// 'debug' only appears in developer mode. +type Tab = 'presets' | 'live' | 'preview' | 'onboarding' | 'debug'; export default function App() { const { notify } = useToast(); @@ -310,7 +312,10 @@ export default function App() { Pulsar
- {(['presets', 'live', 'preview', 'onboarding'] as const).map((t) => ( + {(['presets', 'live', 'preview', 'onboarding', 'debug'] as const) + // The debug tab only exists in developer mode (compile-time flag). + .filter((t) => t !== 'debug' || DEV_MODE) + .map((t) => ( setShowOnboarding(true)} /> )} + {tab === 'debug' && setShowOnboarding(true)} />} + {showOnboarding && ( { diff --git a/figma/src/ui/components/DebugPanel.module.css b/figma/src/ui/components/DebugPanel.module.css new file mode 100644 index 00000000..af70fa65 --- /dev/null +++ b/figma/src/ui/components/DebugPanel.module.css @@ -0,0 +1,32 @@ +.debug-panel { padding: 12px; gap: 14px; } +.debug-intro { margin: 4px 0 0; } + +.debug-actions { display: flex; flex-direction: column; gap: 8px; } + +/* Each action is a full-width card button: bold label + a wrapping hint. */ +.debug-btn { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + width: 100%; + padding: 10px 12px; + text-align: left; +} +.debug-btn-label { font-weight: 700; font-size: var(--fs-sm); } +.debug-btn-hint { + font-weight: 400; + font-size: var(--fs-2xs); + line-height: 1.4; + color: var(--muted); + white-space: normal; +} + +/* Destructive action: red chrome, and a filled face once armed for confirm. */ +.debug-btn.danger { + border-color: #d2392b; + box-shadow: -2px 2px 0 #d2392b; + color: #d2392b; +} +.debug-btn.danger .debug-btn-hint { color: #b4463b; } +.debug-btn.danger.armed { background: #fdecea; } diff --git a/figma/src/ui/components/DebugPanel.tsx b/figma/src/ui/components/DebugPanel.tsx new file mode 100644 index 00000000..c391dce9 --- /dev/null +++ b/figma/src/ui/components/DebugPanel.tsx @@ -0,0 +1,73 @@ +import { useRef, useState } from 'react'; +import styles from './DebugPanel.module.css'; +import { send } from '../figmaBridge'; +import type { DebugAction } from '../../shared/types'; + +// Debug tab - only mounted in developer mode (see the toggle in the Help tab). +// A flat list of one-off maintenance actions. Storage-touching actions run on +// the main thread (clientStorage / root pluginData live there); UI-only actions +// take a callback. +export default function DebugPanel({ onShowOnboarding }: { onShowOnboarding: () => void }) { + const run = (action: DebugAction) => send({ type: 'debug-action', action }); + + // Two-step confirm for the destructive clear - window.confirm/alert are + // unavailable in the sandboxed plugin iframe, so arm-then-fire in the UI. + const [armed, setArmed] = useState(false); + const armTimer = useRef | null>(null); + const clearAll = () => { + if (!armed) { + setArmed(true); + if (armTimer.current) clearTimeout(armTimer.current); + armTimer.current = setTimeout(() => setArmed(false), 3000); + return; + } + if (armTimer.current) clearTimeout(armTimer.current); + setArmed(false); + run('clear-storage'); + }; + + return ( +
+
+
Debug
+

+ Developer-only maintenance actions. Turn this tab off in Help → Developer mode. +

+
+ +
+ + + + + + + +
+
+ ); +} diff --git a/figma/src/ui/lib/settings.ts b/figma/src/ui/lib/settings.ts index 5d2eda73..c83e536c 100644 --- a/figma/src/ui/lib/settings.ts +++ b/figma/src/ui/lib/settings.ts @@ -6,3 +6,9 @@ export const DEFAULT_SETTINGS: Settings = { fileKeyOverride: '', previewBaseUrlOverride: '' }; + +// Development flag. Flip to `true` to reveal the in-plugin Debug tab (maintenance +// actions like clearing stored data). Read directly by the UI (not a persisted +// setting), so this single line is the only switch. Keep `false` on commits so +// the debug tab never ships to users. +export const DEV_MODE = false; From 2b4f9e6b104163c785bac0704740d8a3afb2d278 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 13 Jul 2026 13:52:51 +0200 Subject: [PATCH 4/5] Figma cleanup review --- figma/src/main/code.ts | 129 +++++++++------------ figma/src/ui/App.tsx | 61 ++-------- figma/src/ui/components/Filters.tsx | 15 +-- figma/src/ui/components/LivePreviewTab.tsx | 12 +- figma/src/ui/components/PhonePanel.tsx | 3 +- figma/src/ui/hooks/usePersistOnChange.ts | 19 +++ figma/src/ui/hooks/usePhoneConnection.ts | 11 +- figma/src/ui/hooks/usePreviewSync.ts | 91 ++++++--------- figma/src/ui/lib/collections.ts | 9 ++ figma/src/ui/lib/previewServer.ts | 7 +- figma/src/ui/lib/qr.ts | 4 + 11 files changed, 160 insertions(+), 201 deletions(-) create mode 100644 figma/src/ui/hooks/usePersistOnChange.ts create mode 100644 figma/src/ui/lib/collections.ts create mode 100644 figma/src/ui/lib/qr.ts diff --git a/figma/src/main/code.ts b/figma/src/main/code.ts index 61492302..7e3c029a 100644 --- a/figma/src/main/code.ts +++ b/figma/src/main/code.ts @@ -153,16 +153,36 @@ type ProjectCache = { lastAccess: number; }; -async function loadProjectTokens(): Promise> { - const raw = await figma.clientStorage.getAsync(PROJECT_TOKENS_KEY); - return raw && typeof raw === 'object' ? (raw as Record) : {}; +// A per-file string map stored under one clientStorage key ({ [fileKey]: value }). +// Backs the three project-token maps (share / public / preview), which differ +// only in their storage key. +function makeTokenStore(storageKey: string) { + const load = async (): Promise> => { + const raw = await figma.clientStorage.getAsync(storageKey); + return raw && typeof raw === 'object' ? (raw as Record) : {}; + }; + return { + load, + async get(fileKey: string): Promise { + return (await load())[fileKey] ?? null; + }, + async set(fileKey: string, value: string): Promise { + const map = await load(); + map[fileKey] = value; + await figma.clientStorage.setAsync(storageKey, map); + } + }; } -// Resolve the share token for a file, migrating the legacy global token into -// the per-file map the first time a file asks (so existing shares survive the -// upgrade instead of orphaning a server row). +const shareTokens = makeTokenStore(PROJECT_TOKENS_KEY); +const publicTokens = makeTokenStore(PROJECT_PUBLIC_TOKENS_KEY); +const previewTokens = makeTokenStore(PROJECT_PREVIEW_TOKENS_KEY); + +// Resolve the secret edit token for a file, migrating the legacy global token +// into the per-file map the first time a file asks (so existing shares survive +// the upgrade instead of orphaning a server row). async function getProjectToken(fileKey: string): Promise { - const tokens = await loadProjectTokens(); + const tokens = await shareTokens.load(); if (tokens[fileKey]) return tokens[fileKey]; const legacy = await figma.clientStorage.getAsync(LEGACY_PREVIEW_TOKEN_KEY); if (typeof legacy === 'string' && legacy.length > 0) { @@ -173,48 +193,13 @@ async function getProjectToken(fileKey: string): Promise { } return null; } - -async function setProjectToken(fileKey: string, token: string): Promise { - const tokens = await loadProjectTokens(); - tokens[fileKey] = token; - await figma.clientStorage.setAsync(PROJECT_TOKENS_KEY, tokens); -} - -async function loadProjectPublicTokens(): Promise> { - const raw = await figma.clientStorage.getAsync(PROJECT_PUBLIC_TOKENS_KEY); - return raw && typeof raw === 'object' ? (raw as Record) : {}; -} - -// Resolve the read-only share token for a file. Null for legacy shares (pre -// public-token); the plugin recovers it from the server on the next reconcile. -async function getProjectPublicToken(fileKey: string): Promise { - const tokens = await loadProjectPublicTokens(); - return tokens[fileKey] ?? null; -} - -async function setProjectPublicToken(fileKey: string, publicToken: string): Promise { - const tokens = await loadProjectPublicTokens(); - tokens[fileKey] = publicToken; - await figma.clientStorage.setAsync(PROJECT_PUBLIC_TOKENS_KEY, tokens); -} - -async function loadProjectPreviewTokens(): Promise> { - const raw = await figma.clientStorage.getAsync(PROJECT_PREVIEW_TOKENS_KEY); - return raw && typeof raw === 'object' ? (raw as Record) : {}; -} - -// Resolve the private preview token for a file. Null for legacy shares (pre -// preview-token); the plugin recovers it from the server on the next reconcile. -async function getProjectPreviewToken(fileKey: string): Promise { - const tokens = await loadProjectPreviewTokens(); - return tokens[fileKey] ?? null; -} - -async function setProjectPreviewToken(fileKey: string, previewToken: string): Promise { - const tokens = await loadProjectPreviewTokens(); - tokens[fileKey] = previewToken; - await figma.clientStorage.setAsync(PROJECT_PREVIEW_TOKENS_KEY, tokens); -} +const setProjectToken = shareTokens.set; +// Read-only share + preview tokens. Null for legacy shares (pre-split); the +// plugin recovers them from the server on the next reconcile. +const getProjectPublicToken = publicTokens.get; +const setProjectPublicToken = publicTokens.set; +const getProjectPreviewToken = previewTokens.get; +const setProjectPreviewToken = previewTokens.set; async function getProjectCache(fileKey: string): Promise { const raw = await figma.clientStorage.getAsync(PROJECT_CACHE_PREFIX + fileKey); @@ -323,25 +308,32 @@ function boxOf(node: BaseNode): NodeBox | null { // Also returns a `frames` map (frameId → absolute box) covering every distinct // frame-like ancestor of those bound nodes, so the preview can reposition its // highlight overlay after Figma navigates between frames. +// Iterate every node on the current page carrying a haptic binding, with its +// decoded binding and its top-level prototype frame. Matches by the specific +// BINDING_KEY, not just "has any plugin data": when a binding is cleared (unbind) +// Figma drops the node from this result immediately, so an unbound node can't +// leak in even if some other transient state lingers. +async function forEachBoundNode( + cb: (node: SceneNode, binding: BindingMeta, frame: SceneNode | null) => void +): Promise { + await figma.currentPage.loadAsync(); + const nodes = figma.currentPage.findAllWithCriteria({ pluginData: { keys: [BINDING_KEY] } }); + for (const node of nodes) { + const binding = readBinding(node); + if (!binding) continue; + cb(node, binding, topLevelFrameAncestor(node)); + } +} + async function collectPreviewBindings(): Promise<{ bindings: PreviewBinding[]; frames: Record; }> { - await figma.currentPage.loadAsync(); - // Match by the specific binding key, not just "has any plugin data". When - // BINDING_KEY is cleared (unbind), Figma drops the node from this result - // immediately, so an unbound node can't leak into the bound list even if - // some other transient state lingers. - const nodes = figma.currentPage.findAllWithCriteria({ pluginData: { keys: [BINDING_KEY] } }); const out: PreviewBinding[] = []; const frames: Record = {}; - for (const node of nodes) { - const binding = readBinding(node); - if (!binding) continue; + await forEachBoundNode((node, binding, frame) => { const descendantIds = 'findAll' in node ? (node as ChildrenMixin & BaseNode).findAll(() => true).map((d) => d.id) : []; - const frame = topLevelFrameAncestor(node); - const frameId = frame ? frame.id : null; if (frame && !(frame.id in frames)) { const box = boxOf(frame); if (box) frames[frame.id] = { name: frame.name, box }; @@ -353,10 +345,10 @@ async function collectPreviewBindings(): Promise<{ presetName: binding.presetName, customPattern: binding.customPattern, box: boxOf(node), - frameId, + frameId: frame ? frame.id : null, descendantIds }); - } + }); return { bindings: out, frames }; } @@ -364,17 +356,8 @@ async function collectPreviewBindings(): Promise<{ // Each item carries its top-level frame id + name so the UI can group entries // by screen, mirroring the preview's "Haptic elements" sidebar. async function collectBoundItems(): Promise { - await figma.currentPage.loadAsync(); - // Match by the specific binding key, not just "has any plugin data". When - // BINDING_KEY is cleared (unbind), Figma drops the node from this result - // immediately, so an unbound node can't leak into the bound list even if - // some other transient state lingers. - const nodes = figma.currentPage.findAllWithCriteria({ pluginData: { keys: [BINDING_KEY] } }); const out: BoundItem[] = []; - for (const node of nodes) { - const binding = readBinding(node); - if (!binding) continue; - const frame = topLevelFrameAncestor(node); + await forEachBoundNode((node, binding, frame) => { out.push({ nodeId: node.id, nodeName: node.name, @@ -384,7 +367,7 @@ async function collectBoundItems(): Promise { frameId: frame ? frame.id : null, frameName: frame ? frame.name : null }); - } + }); return out; } diff --git a/figma/src/ui/App.tsx b/figma/src/ui/App.tsx index bb8e2900..eb43bf34 100644 --- a/figma/src/ui/App.tsx +++ b/figma/src/ui/App.tsx @@ -23,6 +23,8 @@ import PulsarLogo from './components/PulsarLogo'; import ResizeHandle from './components/ResizeHandle'; import { useToast } from './components/Toast'; import { usePreviewSync } from './hooks/usePreviewSync'; +import { usePersistOnChange } from './hooks/usePersistOnChange'; +import { toggleInSet } from './lib/collections'; import { DEFAULT_SETTINGS, DEV_MODE } from './lib/settings'; import { isFileKeyValid } from './lib/fileKey'; import { playPreset, stopAll } from './audio/player'; @@ -138,67 +140,26 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Persist settings whenever they change (skip the very first render to avoid - // overwriting what the main thread loaded). - const [didInit, setDidInit] = useState(false); - useEffect(() => { - if (!didInit) { - setDidInit(true); - return; - } - send({ type: 'persist-settings', settings }); - }, [settings]); + // Persist each piece of loaded state back to the main thread when it changes, + // skipping the first render so a freshly-loaded value isn't written straight + // back (which would clobber it before `init` restores it). The haptics token is + // the exception - it persists on mount too (see below). + usePersistOnChange(settings, () => send({ type: 'persist-settings', settings })); + usePersistOnChange(figmaFileKey, () => send({ type: 'persist-file-key', figmaFileKey })); + usePersistOnChange(favourites, () => send({ type: 'persist-favourites', favourites: [...favourites] })); + usePersistOnChange(customPresets, () => send({ type: 'persist-custom-presets', presets: customPresets })); useEffect(() => { send({ type: 'persist-haptics-token', token: hapticsToken }); }, [hapticsToken]); - // Persist the per-file Figma file key on change, skipping the first render so - // we don't clobber the stored value before `init` arrives. - const [didInitFileKey, setDidInitFileKey] = useState(false); - useEffect(() => { - if (!didInitFileKey) { - setDidInitFileKey(true); - return; - } - send({ type: 'persist-file-key', figmaFileKey }); - }, [figmaFileKey]); - - // Persist favourites on change, skipping the first render so we don't clobber - // the stored set before `init` arrives from the main thread. - const [didInitFav, setDidInitFav] = useState(false); - useEffect(() => { - if (!didInitFav) { - setDidInitFav(true); - return; - } - send({ type: 'persist-favourites', favourites: [...favourites] }); - }, [favourites]); - - // Persist custom presets on change, skipping the first render so we don't - // clobber what `init` loaded. - const [didInitCustom, setDidInitCustom] = useState(false); - useEffect(() => { - if (!didInitCustom) { - setDidInitCustom(true); - return; - } - send({ type: 'persist-custom-presets', presets: customPresets }); - }, [customPresets]); - const addCustomPreset = (entry: CatalogEntry) => setCustomPresets((prev) => [entry, ...prev]); const updateCustomPreset = (id: string, entry: CatalogEntry) => setCustomPresets((prev) => prev.map((e) => (e.id === id ? entry : e))); const removeCustomPreset = (id: string) => setCustomPresets((prev) => prev.filter((e) => e.id !== id)); - const toggleFavourite = (id: string) => { - setFavourites((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - }; + const toggleFavourite = (id: string) => setFavourites((prev) => toggleInSet(prev, id)); // The play-preset handler captured stale settings/token on mount. Rebind it on changes. useEffect(() => { diff --git a/figma/src/ui/components/Filters.tsx b/figma/src/ui/components/Filters.tsx index 2afbcc41..cd281920 100644 --- a/figma/src/ui/components/Filters.tsx +++ b/figma/src/ui/components/Filters.tsx @@ -1,6 +1,7 @@ import styles from './Filters.module.css'; import { useMemo } from 'react'; import type { CatalogEntry } from '../../shared/types'; +import { toggleInSet } from '../lib/collections'; import iconSliders from '../assets/icon-sliders-horizontal.svg'; import iconChevron from '../assets/icon-chevron-down.svg'; import iconInfo from '../assets/icon-info.svg'; @@ -48,17 +49,9 @@ export default function Filters({ }) { const update = (patch: Partial) => setState({ ...state, ...patch }); - const toggleTag = (tag: string) => { - const next = new Set(state.tags); - next.has(tag) ? next.delete(tag) : next.add(tag); - update({ tags: next }); - }; - - const toggleSystem = (preset: string) => { - const next = new Set(state.systemPresets); - next.has(preset) ? next.delete(preset) : next.add(preset); - update({ systemPresets: next }); - }; + const toggleTag = (tag: string) => update({ tags: toggleInSet(state.tags, tag) }); + const toggleSystem = (preset: string) => + update({ systemPresets: toggleInSet(state.systemPresets, preset) }); const activeCount = state.tags.size + state.systemPresets.size; diff --git a/figma/src/ui/components/LivePreviewTab.tsx b/figma/src/ui/components/LivePreviewTab.tsx index c1367d3c..a0270282 100644 --- a/figma/src/ui/components/LivePreviewTab.tsx +++ b/figma/src/ui/components/LivePreviewTab.tsx @@ -22,6 +22,12 @@ const SYNC_META: Record setExpandedStep(0), 750), // collapse step 1 - setTimeout(() => setHoldStep2Dim(false), 1050), // fade step 2 in (after collapse) + setTimeout(() => setExpandedStep(0), HANDOFF_COLLAPSE_MS), // collapse step 1 + setTimeout(() => setHoldStep2Dim(false), HANDOFF_UNLOCK_MS), // fade step 2 in setTimeout(() => { // open the now-revealed step 2 setCelebrateStep1(false); advancingRef.current = false; setExpandedStep(2); - }, 1400) + }, HANDOFF_OPEN_MS) ]; return () => { timers.forEach(clearTimeout); diff --git a/figma/src/ui/components/PhonePanel.tsx b/figma/src/ui/components/PhonePanel.tsx index adf9e405..69874f8d 100644 --- a/figma/src/ui/components/PhonePanel.tsx +++ b/figma/src/ui/components/PhonePanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import QRCode from 'qrcode'; import { send } from '../figmaBridge'; import type { PhonePhase } from '../hooks/usePhoneConnection'; +import { QR_COLORS } from '../lib/qr'; import iconSmartphone from '../assets/icon-smartphone.svg'; import iconRefresh from '../assets/icon-refresh.svg'; import storeApple from '../assets/store-apple.svg'; @@ -47,7 +48,7 @@ export default function PhonePanel({ useEffect(() => { if (!showStoreQr || storeQr) return; let cancelled = false; - const opts = { margin: 1, color: { dark: '#001a72', light: '#e1f3fa' }, width: 200 }; + const opts = { margin: 1, color: QR_COLORS, width: 200 }; Promise.all([ QRCode.toDataURL(APPLE_STORE_URL, opts), QRCode.toDataURL(GOOGLE_STORE_URL, opts) diff --git a/figma/src/ui/hooks/usePersistOnChange.ts b/figma/src/ui/hooks/usePersistOnChange.ts new file mode 100644 index 00000000..3a24f499 --- /dev/null +++ b/figma/src/ui/hooks/usePersistOnChange.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; + +// Run `persist` whenever `value` changes, but skip the initial mount so a value +// just loaded from the main thread (via `init`) isn't immediately written back - +// which would clobber the stored value before it's restored. Uses a ref flag, so +// unlike a `useState` guard it doesn't cost an extra render. +export function usePersistOnChange(value: T, persist: () => void) { + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + return; + } + persist(); + // Intentionally keyed on `value` only - `persist` is re-created each render + // but we only want to run it on a real value change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); +} diff --git a/figma/src/ui/hooks/usePhoneConnection.ts b/figma/src/ui/hooks/usePhoneConnection.ts index 72a594ff..14a943ff 100644 --- a/figma/src/ui/hooks/usePhoneConnection.ts +++ b/figma/src/ui/hooks/usePhoneConnection.ts @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import QRCode from 'qrcode'; - -const API_SERVER_URL = 'https://pulsar-server.swmansion.com'; -const SOCKET_SERVER_URL = 'wss://pulsar-server.swmansion.com'; +import { API_SERVER_URL, SOCKET_SERVER_URL } from '../lib/previewServer'; +import { QR_COLORS } from '../lib/qr'; // Keep the sender socket alive so the server's heartbeat sweep doesn't reap it // while the phone is away. Matches the docs Connection component's interval. @@ -144,11 +143,7 @@ export function usePhoneConnection({ ? `pulsarapp://figma?token=${encodeURIComponent(pairPreviewToken)}` + `&code=${encodeURIComponent(code)}&name=${encodeURIComponent(CONNECTION_NAME)}` : `pulsarapp:///?code=${encodeURIComponent(code)}&name=${encodeURIComponent(CONNECTION_NAME)}`; - QRCode.toDataURL(deepLink, { - margin: 1, - color: { dark: '#001a72', light: '#e1f3fa' }, - width: 220 - }) + QRCode.toDataURL(deepLink, { margin: 1, color: QR_COLORS, width: 220 }) .then(setQrDataUrl) .catch(() => setQrDataUrl(null)); }, [code, pairPreviewToken]); diff --git a/figma/src/ui/hooks/usePreviewSync.ts b/figma/src/ui/hooks/usePreviewSync.ts index 7ca0f57c..27327db7 100644 --- a/figma/src/ui/hooks/usePreviewSync.ts +++ b/figma/src/ui/hooks/usePreviewSync.ts @@ -429,66 +429,51 @@ export function usePreviewSync({ settings, figmaFileKey, presetById, notify, onP send({ type: 'persist-project-cache', fileKey, config: payload, baseRevision: revision }); }; - let token = tokenRef.current; - // Skip the round-trip when nothing actually changed since last sync. - if (token && json === lastSyncedJsonRef.current) return token; + const token = tokenRef.current; - if (!token) { - // Creating the project: no preview can be open against a token that - // didn't exist yet, so there's nothing to relay. - if (!allowCreate) return null; + // Create a fresh server row and adopt its tokens/revision. (No preview + // can be open against a token that didn't exist yet, so nothing to relay.) + const createAndAdopt = async (): Promise => { const created = await createProject(payload); adopt(created.token, created.publicToken, created.previewToken, created.revision); return created.token; - } - - const res = await updateProject(token, payload, baseRevisionRef.current); - if (res.kind === 'ok') { - baseRevisionRef.current = res.revision; + }; + // The server row is gone and we can't/shouldn't recreate it now - drop + // the local token + revision snapshot so the next change starts clean. + const forgetToken = (): null => { + tokenRef.current = null; + baseRevisionRef.current = null; + lastSyncedJsonRef.current = null; + lastSyncedPayloadRef.current = null; + return null; + }; + // Record a confirmed update: adopt the new revision, refresh the cache, + // and relay the change to any open preview. + const commitUpdate = (revision: number, forced: boolean): string => { + baseRevisionRef.current = revision; lastSyncedJsonRef.current = json; lastSyncedPayloadRef.current = nextPayload; - send({ type: 'persist-project-cache', fileKey, config: payload, baseRevision: res.revision }); - emitUpdate(res.revision, false); - return token; - } - if (res.kind === 'gone') { - // Token vanished server-side. Recreate when allowed, else forget it. - if (!allowCreate) { - tokenRef.current = null; - baseRevisionRef.current = null; - lastSyncedJsonRef.current = null; - lastSyncedPayloadRef.current = null; - return null; - } - const created = await createProject(payload); - adopt(created.token, created.publicToken, created.previewToken, created.revision); - return created.token; - } - // Conflict: someone changed the row since our base. The server is the - // source of truth for the base revision, so adopt it - but the Figma - // document is the live design, so re-publish our payload on top - // (last-writer-wins, now that we're rebased on the server's revision). + send({ type: 'persist-project-cache', fileKey, config: payload, baseRevision: revision }); + emitUpdate(revision, forced); + return token as string; + }; + + // Skip the round-trip when nothing actually changed since last sync. + if (token && json === lastSyncedJsonRef.current) return token; + if (!token) return allowCreate ? createAndAdopt() : null; + + const res = await updateProject(token, payload, baseRevisionRef.current); + if (res.kind === 'ok') return commitUpdate(res.revision, false); + // Token vanished server-side: recreate when allowed, else forget it. + if (res.kind === 'gone') return allowCreate ? createAndAdopt() : forgetToken(); + + // Conflict: someone changed the row since our base. Adopt the server's + // revision (it's the source of truth for the base) but re-publish our + // payload on top - the Figma document is the live design (last-writer-wins). const forced = await updateProject(token, payload, res.revision); - if (forced.kind === 'ok') { - baseRevisionRef.current = forced.revision; - lastSyncedJsonRef.current = json; - lastSyncedPayloadRef.current = nextPayload; - send({ type: 'persist-project-cache', fileKey, config: payload, baseRevision: forced.revision }); - emitUpdate(forced.revision, true); - return token; - } - if (forced.kind === 'gone') { - if (!allowCreate) { - tokenRef.current = null; - baseRevisionRef.current = null; - lastSyncedJsonRef.current = null; - lastSyncedPayloadRef.current = null; - return null; - } - const created = await createProject(payload); - adopt(created.token, created.publicToken, created.previewToken, created.revision); - return created.token; - } + if (forced.kind === 'ok') return commitUpdate(forced.revision, true); + if (forced.kind === 'gone') return allowCreate ? createAndAdopt() : forgetToken(); + // Lost a second race - bail; the next change will retry from the new base. baseRevisionRef.current = forced.revision; throw new Error('Sync conflict - will retry on the next change.'); diff --git a/figma/src/ui/lib/collections.ts b/figma/src/ui/lib/collections.ts new file mode 100644 index 00000000..78e47dc3 --- /dev/null +++ b/figma/src/ui/lib/collections.ts @@ -0,0 +1,9 @@ +// Return a new Set with `key` toggled - added if absent, removed if present. +// Copies (never mutates the input), so it's safe to use directly in a React +// state updater: setThing((prev) => toggleInSet(prev, key)). +export function toggleInSet(set: Set, key: T): Set { + const next = new Set(set); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; +} diff --git a/figma/src/ui/lib/previewServer.ts b/figma/src/ui/lib/previewServer.ts index 62b31dac..3fe47f30 100644 --- a/figma/src/ui/lib/previewServer.ts +++ b/figma/src/ui/lib/previewServer.ts @@ -5,9 +5,12 @@ // project; the client remembers the revision it last synced (its "base") so it // can detect when the row changed underneath it and reconcile. -// Pulsar backend that stores the preview payload by token. Kept in sync with -// PhonePanel.API_SERVER_URL. +// Pulsar backend that stores the preview payload by token. The single source of +// truth for the server host - other modules (usePhoneConnection, PhonePanel) +// import these rather than re-declaring them. export const API_SERVER_URL = 'https://pulsar-server.swmansion.com'; +// The same server over WebSocket, for the pairing/relay sockets. +export const SOCKET_SERVER_URL = 'wss://pulsar-server.swmansion.com'; // Default live-preview app URL. Pinned at build time: localhost while // developing the plugin (vite dev → import.meta.env.DEV === true), production diff --git a/figma/src/ui/lib/qr.ts b/figma/src/ui/lib/qr.ts new file mode 100644 index 00000000..b9953d1d --- /dev/null +++ b/figma/src/ui/lib/qr.ts @@ -0,0 +1,4 @@ +// Shared QR styling for the pairing code and the app-store codes. The hex is +// hardcoded (not CSS vars) because QR generation renders to a canvas: #001a72 is +// --color-primary, #e1f3fa is --color-blue-10. +export const QR_COLORS = { dark: '#001a72', light: '#e1f3fa' } as const; From 4f4e4bc25965383e783f3dc32af0961255ceae46 Mon Sep 17 00:00:00 2001 From: Krzysztof Piaskowy Date: Mon, 13 Jul 2026 14:07:24 +0200 Subject: [PATCH 5/5] cleanup review preview app --- figma/preview/dist-embed/index.html | 174 ----------- figma/preview/src/App.tsx | 9 +- figma/preview/src/components/HapticList.tsx | 4 +- figma/preview/src/components/OpenOnPhone.tsx | 10 +- .../src/components/PresetDetailsModal.tsx | 271 +----------------- figma/preview/src/lib/hostBridge.ts | 17 +- figma/preview/src/lib/snippets.ts | 264 +++++++++++++++++ figma/preview/src/lib/useEscapeKey.ts | 18 ++ figma/preview/src/lib/useFigmaMessages.ts | 7 +- figma/preview/src/lib/useFullscreen.ts | 12 +- figma/preview/src/main.tsx | 3 - figma/preview/src/styles.css | 2 + figma/preview/src/types.ts | 3 +- 13 files changed, 317 insertions(+), 477 deletions(-) delete mode 100644 figma/preview/dist-embed/index.html create mode 100644 figma/preview/src/lib/snippets.ts create mode 100644 figma/preview/src/lib/useEscapeKey.ts diff --git a/figma/preview/dist-embed/index.html b/figma/preview/dist-embed/index.html deleted file mode 100644 index 19b84001..00000000 --- a/figma/preview/dist-embed/index.html +++ /dev/null @@ -1,174 +0,0 @@ - - - - - - Pulsar · Live Preview - - - - - - - -
- - diff --git a/figma/preview/src/App.tsx b/figma/preview/src/App.tsx index d3230c7b..3454e7f9 100644 --- a/figma/preview/src/App.tsx +++ b/figma/preview/src/App.tsx @@ -19,6 +19,8 @@ import fullscreenIcon from './assets/icon-fullscreen.svg'; const ACTIVE_MS = 900; const TAP_DEBOUNCE_MS = 250; +// Reads to ride out read-after-write lag when a minimum revision is required. +const REFETCH_ATTEMPTS = 4; // True when the center point of `inner` lies within `outer`. Used as a fallback // "which frame does this element belong to?" test for payloads that don't carry @@ -66,10 +68,13 @@ export default function App() { // minimum revision is known, retry briefly to ride out read-after-write lag so // we never settle on a snapshot older than the change that triggered us. const refetch = useCallback(async (minRevision?: number) => { - for (let attempt = 0; attempt < 4; attempt++) { + // Up to REFETCH_ATTEMPTS reads; the last one is accepted even if the server + // is still behind minRevision (better a slightly stale snapshot than none). + for (let attempt = 0; attempt < REFETCH_ATTEMPTS; attempt++) { const res = await readPayload(); if (res.status === 'ok') { - if (minRevision != null && res.revision < minRevision && attempt < 3) { + const isLastAttempt = attempt === REFETCH_ATTEMPTS - 1; + if (minRevision != null && res.revision < minRevision && !isLastAttempt) { await new Promise((r) => setTimeout(r, 250)); continue; } diff --git a/figma/preview/src/components/HapticList.tsx b/figma/preview/src/components/HapticList.tsx index 34296bc2..326ee3ab 100644 --- a/figma/preview/src/components/HapticList.tsx +++ b/figma/preview/src/components/HapticList.tsx @@ -135,8 +135,8 @@ export function HapticList({ onPlay(el.id); }} > -
-
+
+
{el.presetName} {el.isCustom && Custom} diff --git a/figma/preview/src/components/OpenOnPhone.tsx b/figma/preview/src/components/OpenOnPhone.tsx index 925820aa..d805e899 100644 --- a/figma/preview/src/components/OpenOnPhone.tsx +++ b/figma/preview/src/components/OpenOnPhone.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useEscapeKey } from '../lib/useEscapeKey'; import QRCode from 'qrcode'; import storeApple from '../assets/store-apple.svg'; import storeGoogle from '../assets/store-google.png'; @@ -85,14 +86,7 @@ export function OpenOnPhone({ deepLink }: { deepLink: string }) { }, [collapsed, showDownloadQr, downloadQr]); // Close the enlarged view on Escape. - useEffect(() => { - if (!expanded) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setExpanded(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [expanded]); + useEscapeKey(() => setExpanded(false), expanded); // Don't render the section until the QR is ready (avoids an empty box flash). if (!qr) return null; diff --git a/figma/preview/src/components/PresetDetailsModal.tsx b/figma/preview/src/components/PresetDetailsModal.tsx index 2df87727..1f667cca 100644 --- a/figma/preview/src/components/PresetDetailsModal.tsx +++ b/figma/preview/src/components/PresetDetailsModal.tsx @@ -1,268 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useEscapeKey } from '../lib/useEscapeKey'; +import { LANGS, type Lang, builtInSnippet, customSnippet } from '../lib/snippets'; import type { PresetData } from '../types'; import closeIcon from '../assets/icon-close.svg'; import copyIcon from '../assets/icon-copy.svg'; import checkIcon from '../assets/icon-check.svg'; -// Order chosen to match the docs SDK overview page. -const LANGS = ['Swift', 'Android', 'KMP', 'React Native', 'Flutter', 'Web'] as const; -type Lang = (typeof LANGS)[number]; - -// Maps PascalCase / "Title Case" preset names ("DogBark", "TickTock", "My Buzz") -// to the camelCase identifier each SDK actually uses (dogBark, tickTock, -// myBuzz). The docs use this casing in every authoritative example we have. -const camel = (s: string): string => { - const stripped = s.replace(/[^A-Za-z0-9]+/g, ' ').trim(); - const parts = stripped.split(/\s+/); - if (parts.length === 0) return s; - return parts - .map((p, i) => - i === 0 ? p.charAt(0).toLowerCase() + p.slice(1) : p.charAt(0).toUpperCase() + p.slice(1) - ) - .join(''); -}; - -// SDK-specific code generators for a built-in preset. Each returns the -// recommended invocation as quoted from the matching `docs/src/content/docs/sdk/*.mdx` -// page (see PresetDetailsModal-snippets audit for source citations). -function builtInSnippet(lang: Lang, name: string): string { - const id = camel(name); - switch (lang) { - case 'Swift': - return `import Pulsar - -let pulsar = Pulsar() -pulsar.getPresets().${id}()`; - case 'Android': - return `import com.swmansion.pulsar.Pulsar - -val pulsar = Pulsar(context) -pulsar.getPresets().${id}()`; - case 'KMP': - return `import com.swmansion.pulsar.kmp.Pulsar - -val pulsar = Pulsar.create() -pulsar.getPresets().play("${name}")`; - case 'React Native': - return `import { Presets } from 'react-native-pulsar'; - -Presets.${id}();`; - case 'Flutter': - return `import 'package:pulsar_haptics/pulsar.dart'; - -final pulsar = Pulsar(); -await pulsar.getPresets().${id}();`; - case 'Web': - return `import Pulsar from 'pulsar-haptics'; - -const pulsar = new Pulsar(); -await pulsar.getPresets().play('${id}');`; - } -} - -// Build SDK-specific representations of the preset's discrete + continuous -// arrays, formatted as the SDK's own native types. - -function customSwift(data: PresetData): string { - const amp = data.continuousPattern.amplitude - .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) - .join(',\n'); - const freq = data.continuousPattern.frequency - .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) - .join(',\n'); - const disc = data.discretePattern - .map( - (p) => - ` DiscretePoint(time: ${p.time}, amplitude: ${p.amplitude}, frequency: ${p.frequency})` - ) - .join(',\n'); - return `import Pulsar - -let pulsar = Pulsar() -let composer = pulsar.getPatternComposer() - -let pattern = PatternData( - continuousPattern: ContinuousPattern( - amplitude: [ -${amp} - ], - frequency: [ -${freq} - ] - ), - discretePattern: [ -${disc} - ] -) - -composer.playPattern(hapticsData: pattern)`; -} - -function customKotlin(data: PresetData, kmp: boolean): string { - const amp = data.continuousPattern.amplitude - .map((p) => ` ValuePoint(time = ${p.time}, value = ${p.value}f)`) - .join(',\n'); - const freq = data.continuousPattern.frequency - .map((p) => ` ValuePoint(time = ${p.time}, value = ${p.value}f)`) - .join(',\n'); - // Discrete point class is `ConfigPoint` in Kotlin (Android + KMP), per docs. - const disc = data.discretePattern - .map( - (p) => - ` ConfigPoint(time = ${p.time}, amplitude = ${p.amplitude}f, frequency = ${p.frequency}f)` - ) - .join(',\n'); - const importLine = kmp - ? 'import com.swmansion.pulsar.kmp.Pulsar' - : 'import com.swmansion.pulsar.Pulsar'; - const init = kmp ? 'val pulsar = Pulsar.create()' : 'val pulsar = Pulsar(context)'; - const playCall = kmp - ? 'composer.playPattern(pattern)' - : 'composer.parsePattern(pattern)\ncomposer.play()'; - return `${importLine} - -${init} -val composer = pulsar.getPatternComposer() - -val pattern = PatternData( - continuousPattern = ContinuousPattern( - amplitude = listOf( -${amp} - ), - frequency = listOf( -${freq} - ) - ), - discretePattern = listOf( -${disc} - ) -) - -${playCall}`; -} - -function customReactNative(data: PresetData): string { - // Render as JS-object literal (unquoted keys) for closer parity with the - // docs' react-native.mdx example. - const stringifyPattern = (obj: unknown, indent = 2): string => { - const pad = ' '.repeat(indent); - if (Array.isArray(obj)) { - if (obj.length === 0) return '[]'; - return `[\n${obj - .map((item) => pad + stringifyPattern(item, indent + 2)) - .join(',\n')}\n${' '.repeat(indent - 2)}]`; - } - if (obj && typeof obj === 'object') { - const entries = Object.entries(obj as Record); - return `{ ${entries.map(([k, v]) => `${k}: ${stringifyPattern(v, indent + 2)}`).join(', ')} }`; - } - return JSON.stringify(obj); - }; - const pat = { - discretePattern: data.discretePattern, - continuousPattern: data.continuousPattern - }; - return `import { usePatternComposer } from 'react-native-pulsar'; -import { Pressable, Text } from 'react-native'; - -const pattern = ${stringifyPattern(pat)}; - -function MyButton() { - const composer = usePatternComposer(pattern); - return ( - composer.play()}> - Tap me - - ); -}`; -} - -function customFlutter(data: PresetData): string { - const amp = data.continuousPattern.amplitude - .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) - .join(',\n'); - const freq = data.continuousPattern.frequency - .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) - .join(',\n'); - const disc = data.discretePattern - .map( - (p) => - ` DiscretePoint(time: ${p.time}, amplitude: ${p.amplitude}, frequency: ${p.frequency})` - ) - .join(',\n'); - return `import 'package:pulsar_haptics/pulsar.dart'; - -final pulsar = Pulsar(); -final composer = pulsar.getPatternComposer(); - -final pattern = PatternData( - continuousPattern: ContinuousPattern( - amplitude: const [ -${amp} - ], - frequency: const [ -${freq} - ], - ), - discretePattern: const [ -${disc} - ], -); - -await composer.playPattern(pattern);`; -} - -function customWeb(data: PresetData): string { - // The web SDK uses a segment-based HapticPattern, not the iOS-style - // discrete/continuous shape - see web/Pulsar/src/types.ts. We render the - // most-fidelity-preserving translation: - // - each discretePattern point → a short "continuous" segment (30 ms) - // - the continuousPattern envelope → one "line" segment spanning the - // preset duration with the amplitude/frequency arrays as control points. - const lineSegment = - data.continuousPattern.amplitude.length > 0 || data.continuousPattern.frequency.length > 0 - ? ` { - type: 'line', - timestamp: 0, - duration: ${data.duration}, - intensity: ${JSON.stringify(data.continuousPattern.amplitude)}, - frequency: ${JSON.stringify(data.continuousPattern.frequency)}, - }` - : ''; - const discreteSegments = data.discretePattern.map( - (p) => ` { type: 'continuous', timestamp: ${p.time}, duration: 30 }` - ); - const all = [...discreteSegments, lineSegment].filter(Boolean).join(',\n'); - return `import Pulsar, { type HapticPattern } from 'pulsar-haptics'; - -const pulsar = new Pulsar(); -const composer = pulsar.getPatternComposer(); - -const pattern: HapticPattern = [ -${all} -]; - -composer.parse(pattern); -composer.play();`; -} - -function customSnippet(lang: Lang, data: PresetData): string { - switch (lang) { - case 'Swift': - return customSwift(data); - case 'Android': - return customKotlin(data, false); - case 'KMP': - return customKotlin(data, true); - case 'React Native': - return customReactNative(data); - case 'Flutter': - return customFlutter(data); - case 'Web': - return customWeb(data); - } -} - export function PresetDetailsModal({ data, elementName, @@ -278,13 +21,7 @@ export function PresetDetailsModal({ const snippet = isCustom ? customSnippet(lang, data) : builtInSnippet(lang, data.name); const json = JSON.stringify(data, null, 2); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose]); + useEscapeKey(onClose); const [copiedKey, setCopiedKey] = useState<'snippet' | 'json' | null>(null); const copy = (text: string, key: 'snippet' | 'json') => { diff --git a/figma/preview/src/lib/hostBridge.ts b/figma/preview/src/lib/hostBridge.ts index dc2357df..791d08e4 100644 --- a/figma/preview/src/lib/hostBridge.ts +++ b/figma/preview/src/lib/hostBridge.ts @@ -12,17 +12,20 @@ interface ReactNativeWebView { postMessage(message: string): void; } +// The injected global, declared so we can read it without `as any` casts. +declare global { + interface Window { + ReactNativeWebView?: ReactNativeWebView; + } +} + // Resolve the native bridge from this window or the parent (srcdoc embed), // returning undefined when neither is reachable (plain web, cross-origin parent). export function getHostBridge(): ReactNativeWebView | undefined { - const own = (window as any).ReactNativeWebView as ReactNativeWebView | undefined; - if (own) return own; + if (window.ReactNativeWebView) return window.ReactNativeWebView; try { - if (window.parent !== window) { - const parentBridge = (window.parent as any).ReactNativeWebView as - | ReactNativeWebView - | undefined; - if (parentBridge) return parentBridge; + if (window.parent !== window && window.parent.ReactNativeWebView) { + return window.parent.ReactNativeWebView; } } catch { // Cross-origin parent - no reachable bridge. diff --git a/figma/preview/src/lib/snippets.ts b/figma/preview/src/lib/snippets.ts new file mode 100644 index 00000000..fabed146 --- /dev/null +++ b/figma/preview/src/lib/snippets.ts @@ -0,0 +1,264 @@ +import type { PresetData } from '../types'; + +// SDK code-snippet generators for the preset-details modal. Pure functions (no +// React) so they can be unit-tested and kept out of the component. Each snippet +// is quoted from the matching `docs/src/content/docs/sdk/*.mdx` page. + +// Order chosen to match the docs SDK overview page. +export const LANGS = ['Swift', 'Android', 'KMP', 'React Native', 'Flutter', 'Web'] as const; +export type Lang = (typeof LANGS)[number]; + +// Maps PascalCase / "Title Case" preset names ("DogBark", "TickTock", "My Buzz") +// to the camelCase identifier each SDK actually uses (dogBark, tickTock, +// myBuzz). The docs use this casing in every authoritative example we have. +const camel = (s: string): string => { + const stripped = s.replace(/[^A-Za-z0-9]+/g, ' ').trim(); + const parts = stripped.split(/\s+/); + if (parts.length === 0) return s; + return parts + .map((p, i) => + i === 0 ? p.charAt(0).toLowerCase() + p.slice(1) : p.charAt(0).toUpperCase() + p.slice(1) + ) + .join(''); +}; + +// SDK-specific code generators for a built-in preset. Each returns the +// recommended invocation as quoted from the matching `docs/src/content/docs/sdk/*.mdx` +// page (see PresetDetailsModal-snippets audit for source citations). +export function builtInSnippet(lang: Lang, name: string): string { + const id = camel(name); + switch (lang) { + case 'Swift': + return `import Pulsar + +let pulsar = Pulsar() +pulsar.getPresets().${id}()`; + case 'Android': + return `import com.swmansion.pulsar.Pulsar + +val pulsar = Pulsar(context) +pulsar.getPresets().${id}()`; + case 'KMP': + return `import com.swmansion.pulsar.kmp.Pulsar + +val pulsar = Pulsar.create() +pulsar.getPresets().play("${name}")`; + case 'React Native': + return `import { Presets } from 'react-native-pulsar'; + +Presets.${id}();`; + case 'Flutter': + return `import 'package:pulsar_haptics/pulsar.dart'; + +final pulsar = Pulsar(); +await pulsar.getPresets().${id}();`; + case 'Web': + return `import Pulsar from 'pulsar-haptics'; + +const pulsar = new Pulsar(); +await pulsar.getPresets().play('${id}');`; + } +} + +// Build SDK-specific representations of the preset's discrete + continuous +// arrays, formatted as the SDK's own native types. + +function customSwift(data: PresetData): string { + const amp = data.continuousPattern.amplitude + .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) + .join(',\n'); + const freq = data.continuousPattern.frequency + .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) + .join(',\n'); + const disc = data.discretePattern + .map( + (p) => + ` DiscretePoint(time: ${p.time}, amplitude: ${p.amplitude}, frequency: ${p.frequency})` + ) + .join(',\n'); + return `import Pulsar + +let pulsar = Pulsar() +let composer = pulsar.getPatternComposer() + +let pattern = PatternData( + continuousPattern: ContinuousPattern( + amplitude: [ +${amp} + ], + frequency: [ +${freq} + ] + ), + discretePattern: [ +${disc} + ] +) + +composer.playPattern(hapticsData: pattern)`; +} + +function customKotlin(data: PresetData, kmp: boolean): string { + const amp = data.continuousPattern.amplitude + .map((p) => ` ValuePoint(time = ${p.time}, value = ${p.value}f)`) + .join(',\n'); + const freq = data.continuousPattern.frequency + .map((p) => ` ValuePoint(time = ${p.time}, value = ${p.value}f)`) + .join(',\n'); + // Discrete point class is `ConfigPoint` in Kotlin (Android + KMP), per docs. + const disc = data.discretePattern + .map( + (p) => + ` ConfigPoint(time = ${p.time}, amplitude = ${p.amplitude}f, frequency = ${p.frequency}f)` + ) + .join(',\n'); + const importLine = kmp + ? 'import com.swmansion.pulsar.kmp.Pulsar' + : 'import com.swmansion.pulsar.Pulsar'; + const init = kmp ? 'val pulsar = Pulsar.create()' : 'val pulsar = Pulsar(context)'; + const playCall = kmp + ? 'composer.playPattern(pattern)' + : 'composer.parsePattern(pattern)\ncomposer.play()'; + return `${importLine} + +${init} +val composer = pulsar.getPatternComposer() + +val pattern = PatternData( + continuousPattern = ContinuousPattern( + amplitude = listOf( +${amp} + ), + frequency = listOf( +${freq} + ) + ), + discretePattern = listOf( +${disc} + ) +) + +${playCall}`; +} + +function customReactNative(data: PresetData): string { + // Render as JS-object literal (unquoted keys) for closer parity with the + // docs' react-native.mdx example. + const stringifyPattern = (obj: unknown, indent = 2): string => { + const pad = ' '.repeat(indent); + if (Array.isArray(obj)) { + if (obj.length === 0) return '[]'; + return `[\n${obj + .map((item) => pad + stringifyPattern(item, indent + 2)) + .join(',\n')}\n${' '.repeat(indent - 2)}]`; + } + if (obj && typeof obj === 'object') { + const entries = Object.entries(obj as Record); + return `{ ${entries.map(([k, v]) => `${k}: ${stringifyPattern(v, indent + 2)}`).join(', ')} }`; + } + return JSON.stringify(obj); + }; + const pat = { + discretePattern: data.discretePattern, + continuousPattern: data.continuousPattern + }; + return `import { usePatternComposer } from 'react-native-pulsar'; +import { Pressable, Text } from 'react-native'; + +const pattern = ${stringifyPattern(pat)}; + +function MyButton() { + const composer = usePatternComposer(pattern); + return ( + composer.play()}> + Tap me + + ); +}`; +} + +function customFlutter(data: PresetData): string { + const amp = data.continuousPattern.amplitude + .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) + .join(',\n'); + const freq = data.continuousPattern.frequency + .map((p) => ` ValuePoint(time: ${p.time}, value: ${p.value})`) + .join(',\n'); + const disc = data.discretePattern + .map( + (p) => + ` DiscretePoint(time: ${p.time}, amplitude: ${p.amplitude}, frequency: ${p.frequency})` + ) + .join(',\n'); + return `import 'package:pulsar_haptics/pulsar.dart'; + +final pulsar = Pulsar(); +final composer = pulsar.getPatternComposer(); + +final pattern = PatternData( + continuousPattern: ContinuousPattern( + amplitude: const [ +${amp} + ], + frequency: const [ +${freq} + ], + ), + discretePattern: const [ +${disc} + ], +); + +await composer.playPattern(pattern);`; +} + +function customWeb(data: PresetData): string { + // The web SDK uses a segment-based HapticPattern, not the iOS-style + // discrete/continuous shape - see web/Pulsar/src/types.ts. We render the + // most-fidelity-preserving translation: + // - each discretePattern point → a short "continuous" segment (30 ms) + // - the continuousPattern envelope → one "line" segment spanning the + // preset duration with the amplitude/frequency arrays as control points. + const lineSegment = + data.continuousPattern.amplitude.length > 0 || data.continuousPattern.frequency.length > 0 + ? ` { + type: 'line', + timestamp: 0, + duration: ${data.duration}, + intensity: ${JSON.stringify(data.continuousPattern.amplitude)}, + frequency: ${JSON.stringify(data.continuousPattern.frequency)}, + }` + : ''; + const discreteSegments = data.discretePattern.map( + (p) => ` { type: 'continuous', timestamp: ${p.time}, duration: 30 }` + ); + const all = [...discreteSegments, lineSegment].filter(Boolean).join(',\n'); + return `import Pulsar, { type HapticPattern } from 'pulsar-haptics'; + +const pulsar = new Pulsar(); +const composer = pulsar.getPatternComposer(); + +const pattern: HapticPattern = [ +${all} +]; + +composer.parse(pattern); +composer.play();`; +} + +export function customSnippet(lang: Lang, data: PresetData): string { + switch (lang) { + case 'Swift': + return customSwift(data); + case 'Android': + return customKotlin(data, false); + case 'KMP': + return customKotlin(data, true); + case 'React Native': + return customReactNative(data); + case 'Flutter': + return customFlutter(data); + case 'Web': + return customWeb(data); + } +} diff --git a/figma/preview/src/lib/useEscapeKey.ts b/figma/preview/src/lib/useEscapeKey.ts new file mode 100644 index 00000000..77dd1a8d --- /dev/null +++ b/figma/preview/src/lib/useEscapeKey.ts @@ -0,0 +1,18 @@ +import { useEffect, useRef } from 'react'; + +// Call `onEscape` when the user presses Escape, while `enabled` (default true). +// For dismissible overlays - modals, an enlarged view, in-app fullscreen. Keeps +// the latest handler in a ref so it never re-subscribes just because the caller +// passed a fresh closure. +export function useEscapeKey(onEscape: () => void, enabled = true) { + const onEscapeRef = useRef(onEscape); + onEscapeRef.current = onEscape; + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onEscapeRef.current(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [enabled]); +} diff --git a/figma/preview/src/lib/useFigmaMessages.ts b/figma/preview/src/lib/useFigmaMessages.ts index 0bbd3a8a..6cf6347f 100644 --- a/figma/preview/src/lib/useFigmaMessages.ts +++ b/figma/preview/src/lib/useFigmaMessages.ts @@ -6,7 +6,6 @@ import { FIGMA_ORIGINS, type FigmaEmbedEvent } from './embed'; // useCallback) or accept that the listener re-binds when they change. export function useFigmaMessages(handlers: { onInitialLoad?: () => void; - onPresentedNodeChanged?: (presentedNodeId: string) => void; onMousePressOrRelease?: (targetNodeId: string) => void; onLoginScreen?: () => void; }) { @@ -19,9 +18,9 @@ export function useFigmaMessages(handlers: { case 'INITIAL_LOAD': handlers.onInitialLoad?.(); break; - case 'PRESENTED_NODE_CHANGED': - handlers.onPresentedNodeChanged?.(msg.data?.presentedNodeId ?? ''); - break; + // PRESENTED_NODE_CHANGED is intentionally handled inside PrototypeView + // (it attributes the event to the active iframe in the keep-alive cache), + // so it's deliberately not routed here. case 'MOUSE_PRESS_OR_RELEASE': handlers.onMousePressOrRelease?.(msg.data?.targetNodeId ?? ''); break; diff --git a/figma/preview/src/lib/useFullscreen.ts b/figma/preview/src/lib/useFullscreen.ts index 98a9d8ff..72ed76e9 100644 --- a/figma/preview/src/lib/useFullscreen.ts +++ b/figma/preview/src/lib/useFullscreen.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; +import { useEscapeKey } from './useEscapeKey'; // In-app "fullscreen": hide the app chrome (header, panel, card) so the Figma // content fills the whole browser viewport. Not the browser's OS fullscreen. @@ -9,14 +10,7 @@ export function useFullscreen() { const enter = useCallback(() => setIsFullscreen(true), []); const exit = useCallback(() => setIsFullscreen(false), []); - useEffect(() => { - if (!isFullscreen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsFullscreen(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [isFullscreen]); + useEscapeKey(exit, isFullscreen); return { isFullscreen, enter, exit }; } diff --git a/figma/preview/src/main.tsx b/figma/preview/src/main.tsx index 984e5dc2..5b585d3c 100644 --- a/figma/preview/src/main.tsx +++ b/figma/preview/src/main.tsx @@ -3,9 +3,6 @@ import { createRoot } from 'react-dom/client'; import App from './App'; import './styles.css'; -// A new payload arrives via the URL hash; reload to re-parse cleanly. -window.addEventListener('hashchange', () => location.reload()); - createRoot(document.getElementById('root')!).render( diff --git a/figma/preview/src/styles.css b/figma/preview/src/styles.css index 77120bee..587789ca 100644 --- a/figma/preview/src/styles.css +++ b/figma/preview/src/styles.css @@ -594,6 +594,8 @@ body { transition: transform 60ms ease, box-shadow 60ms ease; } .el-row:active { transform: translate(-1px, 1px); box-shadow: -1px 1px 0 var(--color-primary) !important; } +.el-row-inner { display: flex; align-items: flex-start; gap: 6px; } +.el-row-main { flex: 1; min-width: 0; } .el-row.active { background: #fff; border-color: var(--color-primary); diff --git a/figma/preview/src/types.ts b/figma/preview/src/types.ts index f2d6f174..d9a74e9e 100644 --- a/figma/preview/src/types.ts +++ b/figma/preview/src/types.ts @@ -44,7 +44,8 @@ export interface FrameInfo { box: NodeBox; } -// The base64'd payload the plugin puts in the URL hash. +// The design payload the plugin publishes to the server; the preview fetches it +// by the `?token=` share token (see lib/payload.ts). export interface PreviewPayload { fileKey: string; nodeId: string | null;