mirror of
https://github.com/OpenWhispr/openwhispr.git
synced 2026-10-21 06:40:37 +08:00
* feat(dictation): liquid cancel button and pill spacing polish The hover cancel control now pours out of the recording pill as a fused liquid skin: an SDF smooth-union (smin) of the pill capsule and the button circle, traced by marching squares into one SVG outline, animated over 280ms on the house curve. The blend ramps with a smoothstep so the skin matches the bare pill exactly at mount, and the emergence rAF loop re-renders only the leaf component. Inside the Live Transcript panel the button keeps its classic bordered look with the same emergence motion. The compact pill widens 92 -> 98 for deliberate 6/12 edge insets (icon / waveform side), the icon-waveform gap becomes an owned constant, and the RECORDING window widens 128 -> 142 so pill + gap + cancel fit exactly at the 4px dock insets - the cancel button previously clipped outside the window on hover. Live-transcript pill vars and the panel grow apertures move with the footprint contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dictation): harden liquid cancel emergence * fix(dictation): match the liquid skin's stroke to the pill state it replaces The fused skin hardcoded the recording chrome (border-hover stroke), so hovering during processing/thinking — whose pills wear the softer border/60 — popped the outline color the moment the skin took over. The stroke now comes from VOICE_PILL_STATE_STROKE, the SVG mirror of STATE_APPEARANCE's border classes (kept in voicePillPresentation.js so the component file stays Fast Refresh-clean). Fill stays surface-1: every state the skin can appear over shares it. * fix(dictation): tween the liquid skin between pill footprints The skin's capsule geometry came straight from the footprint constants, which flip discretely, while the real pill animates width/height over GROW_TRANSITION (300ms) — and a fused pill is headless, so the skin is the only chrome on screen. Clicking the pill to start recording popped a full 98px outline the moment the entrance expansion began (overshooting the growing pill by up to ~58px), and stopping or cancelling snapped the skin to a 40px circle while the pill's contents were still shrinking under it. The leaf now chases the footprint contract with the pill's own duration and curve (usePillFootprintTween), and keeps chasing while hidden so an emergence that starts mid-flight picks up the pill's current in-between size. Reduced motion snaps, matching the global rule that drops width/height from transition-property. The t=0 tangency the tween relies on (skin ≡ pill for any capsule ≥28px tall) is pinned for in-between footprints, and stitch()'s eps assumption is documented. * fix(dictation): drive the liquid skin's chrome from CSS so it tracks pill state The inline stroke map couldn't express everything the pill's own chrome does: the hovered pill sits on surface-3 (the skin's hardcoded surface-1 fill popped brighter when a de-fusing skin unmounted over a hovered pill), the idle pill wears a softer border in dark theme (dark:border-border/50), and the pill transitions border/background colors over 220ms while the skin's snapped. The skin's path is now styled by .liquid-cancel-skin[data-pill-state] rules that mirror STATE_APPEARANCE per state with the same 220ms color transition (fill and stroke transitions deliberately survive the global reduced-motion rule). The VOICE_PILL_STATE_STROKE map goes away with its only consumer. * fix(dictation): drop the inline hover shadow while the liquid skin owns the chrome The fused rule's box-shadow: none is a stylesheet rule, so the inline floatingHover shadow outranked it and painted a phantom capsule shadow over the skin for the 280ms a de-fusing skin lingers on a hovered pill. * fix(dictation): stop resizing the pill window on recording edges Resizing the transparent always-on-top window paints one compositor frame of the stale texture inside the new bounds before the renderer catches up, and no resize mask can cover it — every recording start grew the window and every stop shrank it back, kicking the pill each time (intermittently on start, reliably on stop). - BASE and RECORDING now share one 208x120 box, so recording edges never call setBounds; the ladder keys stay distinct for ranking - the restore-to-BASE path short-circuits when bounds are unchanged instead of running the mask handshake and a no-op setBounds - the entrance hold drops 420ms -> 260ms for the floating pill now that it no longer hides a native grow; recording under the open assistant panel keeps the 420ms hold so the expansion still waits out the footer's actions->pill handoff * fix(dictation): linger the swallowed cancel skin under the pill's returning chrome Fuse-in was already masked: the skin mounts under the pill while its chrome fades out over 220ms, and the two capsules are identical so the paint never changes. Un-fuse had no mask — at t=0 the skin unmounted instantly while the pill's chrome faded back in from transparent, dipping the capsule for the fade duration on every hover-out. Keep painting the swallowed (t=0, geometry-identical) skin under the returning chrome until the fade lands, then unmount it. Re-fusing during the linger cancels it. * fix(dictation): mask the panel-return shrink behind a pill fade Closing the assistant or live-transcript panel snapped the native window from panel bounds back to the pill box the instant the panel unmounted — racing the pill's 320ms dock travel by a frame, and the big shrink paints one compositor frame of the old texture inside the new bounds, blanking the just-settled pill (the artifact the dictation-error handoff already documents). Panel returns now reuse that handoff pattern: fade the pill out over its 150ms opacity transition, resize while nothing is visible, chase any superseding menu/toast target, and reveal once bounds settle. A panel reopening mid-fade re-takes the window and reveals the pill without shrinking under it. * fix(dictation): let the panel exit fade ride the contraction The surface faded out in 140ms while its clip-path contraction runs 280ms, so closing read as a blink-vanish two frames in — the panel never visibly condensed into the pill. Split the durations by direction: the entrance keeps the fast 140ms fade (solid quickly, the morph carries), the exit fades over 240ms so the condense motion stays visible to the end. * fix(overlay): only re-apply Spaces membership when it actually drifted setupAlwaysOnTop lands on hot paths (assistant panel open/close, window show) and re-applied setVisibleOnAllWorkspaces every time. Re-asserting unchanged collection behavior makes the window server momentarily pull the window out of the active Space, which can blink the entire visible window. Guard it (and setFullScreenable) behind the current state so enforcement is idempotent. * fix(assistant): never request app activation for the panel on macOS Opening the panel called focus(), which asks NSApp to activate; when the user has another OpenWhispr window (control panel) on a different Space, macOS answers a granted activation by sliding the whole desktop over to it — and slides back when close blurs. That whole-screen slide was the 'massive flash' on panel open and close. It reproduces only after real user interaction (macOS denies synthetic activations), which is why it was intermittent. The overlay is a non-activating panel: skip focus() and blur() on macOS. Clicking the panel's input still makes it key (typing and Escape work from then on) without activating the app, and opening the panel no longer steals the keyboard from the app the user was in. Windows/Linux keep the explicit focus/blur handoff. * fix(agent): give the companion pill the liquid cancel button The opposite-edge companion still carried the pre-liquid cancel — a bordered circle popping in with no motion, next to a main pill whose cancel pours out as a fused skin. Adopt LiquidCancelButton with the same wiring as App.jsx: footprint targets from the shared contract, fused everywhere except inside the Live Transcript panel, and the pill goes headless while the skin owns the fused surface. * fix(assistant): close as one beat — fold the pill into the panel exit The close choreography read as two beats: the surface condensed into the pill's corner, and then the pill immediately faded out for the masked window shrink and faded back in. Suppress the pill for the whole of assistant.closing instead: it fades with the closing content, stays hidden through the contraction, travel, and shrink (the panel-return mask picks up at unmount without a visible gap), and materializes once at its settled dock. * fix(onboarding): manual titlebar drag for the transparent control panel The control panel window is transparent on macOS (for the compact onboarding frame's rounded corners), and Electron ignores -webkit-app-region: drag entirely on transparent windows — the hiddenInset titlebar's native drag dies with it. Onboarding (login, permissions, every step) and the signed-in panel have been undraggable on macOS since #1670 introduced the transparency. Verified with synthetic CGEvent drags: identical input moves the window when opaque and does nothing when transparent. Recreate the titlebar in JS: a mousedown in the top 48px that is not aimed at an interactive control moves the native window through the shared DragManager (which gains a per-drag window override; the pill's target-window flow and its position-ownership bookkeeping are untouched). Mounted once at the panel-window root, covering onboarding, reauthentication, and the control panel. Where app-region does work (the opaque Windows/Linux onboarding strip), the system consumes the mousedown first and the hook stays idle. Verified end-to-end with synthetic drags against the transparent window: full-fidelity tracking on both axes, work-area clamping intact. * fix(onboarding): make the compact hero draggable window chrome The compact screens' dithered hero reads as the window's titlebar, but only the top 48px was draggable — grabbing the visually obvious band (welcome screen, permissions) did nothing. Size the shell's drag strip to the full 192px hero on compact screens and declare it as a data-window-drag-zone, which the JS drag fallback honors beyond its universal 48px band. Interactive overlays in the band (compact Back, permissions Skip) already carry no-drag and stay clickable through the fallback's interactive-element exclusion. Also move the fallback's mousedown listener to the capture phase so a step's own mouse handling cannot swallow the titlebar gesture, and keep the dev CDP hook behind OW_DEBUG_CDP for future diagnostics. * style: prettier format for useControlPanelWindowDrag * refactor(dictation): drop redundant pill interaction flag pillVisuallySuppressed now includes assistant.closing, so the separate pillInteractionSuppressed alias was identical to it. * fix(onboarding): keep the compact Back reachable under the widened drag band The shell's drag strip grew 48px -> the full 192px hero on compact screens, so it now covers the sign-in/sign-up screen's Back control at y=52. That control predates the widening (#1670) and sits inside the step tree, where nothing can out-stack the z-50 band: the step wrapper's entry animation keeps a filling transform, so it owns a stacking context and caps every descendant regardless of z-index. The result was a dead end — elementFromPoint returned the band, a real click delivered zero events to the button, and the only other way off that screen is an sr-only toggle. Portal it to body at z-60, the same recipe CompactPermissionsStep already uses for its Continue; no-drag keeps both the JS drag fallback and the Windows/Linux app-region off it. Embedded in SignInDialog there is no band, so it stays inline. The hero keeps its full 192px drag footprint. AuthenticationStep's shallow-render test has no DOM, so it gains a react-dom mock whose createPortal returns its children (the harness walks elements rather than mounting) plus a scoped document stub. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * fix(onboarding): scope the titlebar drag fallback to macOS and require movement Two narrowings to the JS titlebar drag. Platform: the fallback exists because a transparent window drops `-webkit-app-region` entirely, and the control panel is only transparent on macOS. Windows/Linux keep an opaque window where app-region works, so the hook was pure overshoot there — its universal 48px band is wider than ControlPanel's own 40px strips, adding an 8px sliver of drag just below the real titlebar. It now stays off outside darwin instead of second-guessing regions the system already owns. Threshold: the drag started on mousedown, so a plain click on the titlebar ran a full start/stop cycle whose first tracking tick applies DragManager's clampToWorkArea — snapping a window the user had parked past a screen edge. It now waits for 3px of travel. No fidelity cost: DragManager captures its cursor-to-window offset when it starts, so beginning at first movement still picks the window up exactly under the cursor. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * refactor(dictation): give the pill cancel geometry one definition The cancel control's 28px size and 8px gap were written out in LiquidCancelButton, in WINDOW_SIZES.RECORDING's fit comment, and again in the fusion test — three copies of a cross-process contract that only comments tied together. The footprint transition was duplicated too: VoicePill built it from LISTENING_ENTRANCE_TIMING plus a literal curve, while the skin's tween re-derived the same duration independently, so a change to one would silently drift the fused outline off the real pill. VOICE_PILL_CANCEL and VOICE_PILL_GROW_TRANSITION now live beside VOICE_PILL_FOOTPRINT, where the contract is already documented, and every consumer reads them. windowConfig's comment points at that source instead of restating the numbers, and the fusion test pins its fixtures against it so they cannot drift from what the component renders. Values are unchanged; the rendered geometry is identical. Also drops an `overflow: visible` that was already the default. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * test(assistant): pin both platform paths for the panel focus contract The panel-open expectation branched on the host's own process.platform, so whichever path CI was not running went unverified — and darwin, the platform this contract was written for, is the one no Linux runner covers. A withPlatform helper (the idiom already used elsewhere in this file) now runs both, plus the close/blur side that had no coverage at all. Also corrects the method's doc comment: it still claimed the panel "needs keyboard focus so Escape can dismiss it reliably", directly above code that deliberately never focuses on macOS. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * refactor: name the pill visibility handoff for both its callers createDictationErrorPillHandoff now backs two unrelated handoffs — the dictation-error return and the panel return this branch added — so the name was wrong at one of its two call sites and its doc comment described only the error case. Pure rename of the factory, its file, its test, and the options interface; no behaviour change. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * fix(dictation): let a dictation error bail the panel-return chase The panel-return handoff chases the size owner's latest target before revealing the pill, but unlike its dictation-error twin it had no escape when an error takes the window over on its own path. Add the same actionCountRef guard so the two chases behave alike and the loop cannot keep re-requesting a size another owner is already moving. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * fix(dictation): keep a live recording visible through the panel close Folding the pill into the panel exit ORed assistant.closing straight into pillVisuallySuppressed, which overrode the close-intent ownership handoff two other pieces of code exist to guarantee: voicePillOwnsActivity hands a running recording back to the pill at close INTENT precisely because beginClose hides the companion on that same tick, and shouldSuppressPillForAssistantActions carves out assistantClosing && hasLiveActivity for the same reason. With the unconditional term both were unreachable, so a plain dictation started during the ~580ms close (setAssistantPanelOpen(false) fires at close intent, so the main process accepts one again) recorded with no visible owner at all — the "both-hidden gap" that carve-out is named after. Compose the suppression in a pure resolvePillVisualSuppression instead, so the rule lives beside the two policies it has to agree with and is testable: nothing checked it at any level, which is how it got dropped. Live activity opts out of the closing fold; an idle close still reads as one beat. The panel-return mask stays unconditional — it is bounded by the native shrink it covers, not by the panel's exit choreography, and guarding it would hold a visible pill at its new dock inside still-panel sized bounds. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * fix(dictation): size the cancel button from the shared contract VOICE_PILL_CANCEL was introduced as the one definition of the cancel geometry, but the rendered button kept a literal size-7 the skin cannot see. The outline traces its circle at CANCEL_BUTTON_SIZE / 2 and the slot reserves CANCEL_BUTTON_SIZE, so changing the constant would have moved the fused skin and the slot while leaving the real control at 28px. Size the element from the constant instead, finishing what the extraction started. Also stop gating the plain-mode discard on the emergence. The slot-width guard exists because the fused bud overlaps the pill until its slot clears one button width, but plain mode scales its own hit area and is the Live Transcript panel's persistent discard control — it was clickable at mount before the emergence motion existed, and the shared guard left it inert for the first ~218ms of the 280ms animation. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * fix(onboarding): stop the widened drag band swallowing the step content Three related holes in the macOS titlebar fallback. The drag latch outlived a lost button. Deferring the drag to the first mousemove armed an `origin` that only mouseup and blur cleared, so a release the window never saw left the next unpressed move free to start a drag with no button held. Bail (and disarm) on event.buttons === 0, and take mouseup on the capture phase like its two siblings so a handler in between cannot strand the gesture. data-no-window-drag matched nothing. The selector shipped with the hook but no element ever carried it, while the panel's real chrome opts out with -webkit-app-region: no-drag — which the fallback cannot read, and which is the only drag path on macOS since the window is transparent there. A press on those wrappers' padding dragged the window; only one landing on the inner button was spared by closest("button"). Mark the three of them. The compact band swallowed events on every platform. It carries app-region: drag, so it is hit-testable by design and a sibling of the step scroller inside an overflow-hidden main: at 192px it ate wheel scrolling over the top of a compact step, reachable because setOnboardingWindowMode clamps the compact window to the work area height and the permissions list then overflows. Keep the native strip at 48px on every platform and declare the hero separately as a pointer-events-none zone, so only the macOS fallback widens. That means matching declared zones by their bounds rather than by hit testing the target, which is what lets a zone stay out of the event path entirely. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 * test(auth): assert the compact Back really leaves the step tree The portal exists so the compact Back can out-stack the onboarding shell's drag band, but the harness mocked createPortal as a passthrough and stubbed document to { body: {} }, so the test passed identically with the portal removed — verified by deleting it. Record the container the mock is handed and assert it is document.body, which does fail on that mutation. Claude-Session: https://claude.ai/code/session_017f33LXZQ3jgYcZobmkqKm4 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Marshall Bose <mashallbose@gmail.com> Co-authored-by: Gabriel Stein <gabrielstein416@gmail.com> Co-authored-by: boseq <307062491+boseq@users.noreply.github.com>
87 lines
2.8 KiB
JavaScript
87 lines
2.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Wrapper script to run Electron with proper environment.
|
|
* This unsets ELECTRON_RUN_AS_NODE which can be inherited from parent processes
|
|
* (e.g., when running from Claude Code or other Node.js-based tools).
|
|
*/
|
|
|
|
const { spawn } = require("child_process");
|
|
const path = require("path");
|
|
const {
|
|
OZONE_PLATFORM_PREFIX,
|
|
XWAYLAND_FLAG,
|
|
shouldForceXWayland,
|
|
} = require("../src/helpers/xwayland");
|
|
|
|
// Remove ELECTRON_RUN_AS_NODE from environment
|
|
delete process.env.ELECTRON_RUN_AS_NODE;
|
|
|
|
// Get the app directory (parent of scripts directory)
|
|
const appDir = path.resolve(__dirname, "..");
|
|
|
|
function resolveElectronPath() {
|
|
try {
|
|
return require("electron");
|
|
} catch (err) {
|
|
console.error(
|
|
"[run-electron] Electron is installed as an npm package, but its platform binary is missing."
|
|
);
|
|
console.error(
|
|
"[run-electron] This usually means npm lifecycle scripts were skipped or the Electron download failed."
|
|
);
|
|
if (process.env.ELECTRON_SKIP_BINARY_DOWNLOAD) {
|
|
console.error(
|
|
"[run-electron] ELECTRON_SKIP_BINARY_DOWNLOAD is set; unset it before reinstalling Electron."
|
|
);
|
|
}
|
|
console.error(
|
|
"[run-electron] Try: npm config set ignore-scripts false && npm rebuild electron"
|
|
);
|
|
console.error(
|
|
"[run-electron] If that still fails, remove node_modules and run npm install again."
|
|
);
|
|
console.error(err && err.stack ? err.stack : err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Get the electron path
|
|
const electronPath = resolveElectronPath();
|
|
|
|
// Pass through any command line arguments
|
|
const args = process.argv.slice(2);
|
|
|
|
console.log("[run-electron] Starting Electron with cleaned environment...");
|
|
console.log("[run-electron] Electron path:", electronPath);
|
|
console.log("[run-electron] App dir:", appDir);
|
|
console.log("[run-electron] Args:", args);
|
|
|
|
// Adding the flag here avoids the self-relaunch in main.js, which kills concurrently in dev mode.
|
|
if (shouldForceXWayland(args)) {
|
|
args.push(XWAYLAND_FLAG);
|
|
console.log("[run-electron] Wayland detected, forcing XWayland");
|
|
}
|
|
|
|
// Chromium flags must come before the app path, app args after.
|
|
const chromiumFlags = args.filter((a) => a.startsWith(OZONE_PLATFORM_PREFIX));
|
|
// Dev-only: OW_DEBUG_CDP=<port> exposes the Chrome DevTools Protocol so tools
|
|
// can inspect the renderers (used for animation/window diagnostics).
|
|
if (process.env.OW_DEBUG_CDP) {
|
|
chromiumFlags.push(`--remote-debugging-port=${process.env.OW_DEBUG_CDP}`);
|
|
}
|
|
const appArgs = args.filter((a) => !a.startsWith(OZONE_PLATFORM_PREFIX));
|
|
const child = spawn(electronPath, [...chromiumFlags, appDir, ...appArgs], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
cwd: appDir,
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
process.exit(code || 0);
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
console.error("[run-electron] Failed to start Electron:", err);
|
|
process.exit(1);
|
|
});
|