Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/hero/doc-focus-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/hero/doc-prs-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/hero/doc-work-items.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/hero/readme-main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/agent-overview-states.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/checks-expand.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/anim/cross-pr-detail-open.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/anim/cross-sql-hotkey.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/anim/cross-work-item-detail-open.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/file-palette-search.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/file-viewer-diff.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/focus-quick-review.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/merge-toast-countdown.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/pr-card-hover.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/pr-detail-checkout.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/pr-detail-tabs.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/settings-section-switch.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/wizard-stepthrough.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/work-item-palette-tour.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/anim/worktree-palette-enter.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/screenshots/main-prs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/public/screenshots/work-item-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions site/src/pages/gallery.astro
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,43 @@ import SiteFooter from '~/components/SiteFooter.astro';
<img src="/anim/pr-card-hover.gif" alt="PR card — hover reveals action pill bar" loading="lazy" />
</div>
</article>

<article>
<p class="gallery-caption">Cross-window · PR detail · animated</p>
<h2 class="gallery-title">Click a card, <em>detail slides in.</em></h2>
<p class="gallery-lede">
Click any PR card in the sidebar and the PR Detail window slides in from
the right — checks, diff, discussion, all in one panel without losing
the sidebar context.
</p>
<div class="gallery-shelf gallery-shelf--wide">
<img src="/anim/cross-pr-detail-open.gif" alt="PR card click — detail window slides in" loading="lazy" />
</div>
</article>

<article>
<p class="gallery-caption">Cross-window · work item detail · animated</p>
<h2 class="gallery-title">Select an item, <em>detail opens.</em></h2>
<p class="gallery-lede">
Pick a work item from the palette and the detail panel slides in alongside —
title, description, acceptance criteria, comments — without closing the palette.
</p>
<div class="gallery-shelf gallery-shelf--wide">
<img src="/anim/cross-work-item-detail-open.gif" alt="Work item palette — select row, detail slides in" loading="lazy" />
</div>
</article>

<article>
<p class="gallery-caption">Cross-window · SQL · animated</p>
<h2 class="gallery-title">One hotkey, <em>SQL window.</em></h2>
<p class="gallery-lede">
Ctrl+F10 anywhere in the app opens the SQL window inline — schema rail on
the left, editor and results on the right. No separate launch step.
</p>
<div class="gallery-shelf gallery-shelf--wide">
<img src="/anim/cross-sql-hotkey.gif" alt="Sidebar — SQL window opens via hotkey" loading="lazy" />
</div>
</article>
</section>

<SiteFooter />
Expand Down
141 changes: 141 additions & 0 deletions src/BorgDock.Tauri/.storybook/anim-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// .storybook/anim-cursor.ts
//
// Synthetic cursor + click-ripple helpers for animation stories. Storybook's
// play function exercises the UI via @testing-library/userEvent, which fires
// real DOM events but never moves a visible cursor. Without something to
// follow, the resulting GIFs read like the UI animates itself for no reason.
//
// installCursor() injects a position:fixed SVG arrow into the document body.
// moveCursorTo(el) tweens the cursor to the element's center. clickRipple(el)
// emits a brief radial pulse at the element's center to visually confirm a
// click landed.
//
// Usage in a story play function:
//
// import { installCursor, moveCursorTo, clickRipple } from '../../../.storybook/anim-cursor';
//
// play: async ({ canvasElement }) => {
// installCursor();
// const card = await within(canvasElement).findByText(/storybook phase 12/i);
// await moveCursorTo(card, 600);
// clickRipple(card);
// await userEvent.click(card);
// await pause(800);
// }
//
// Removed automatically when the iframe unloads — no per-story teardown needed
// since each Storybook story navigation is a fresh page.

const CURSOR_ID = '__borgdock_anim_cursor';
const CURSOR_SVG = `
<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2 L3 16 L7.4 12.4 L9.7 16.8 L11.6 15.7 L9.4 11.4 L15 11 Z"
fill="#1a1a2e" stroke="#fff" stroke-width="1.4" stroke-linejoin="round" />
</svg>
`;

const RIPPLE_KEYFRAME_ID = '__borgdock_anim_ripple_kf';

function ensureRippleKeyframes(): void {
if (document.getElementById(RIPPLE_KEYFRAME_ID)) return;
const style = document.createElement('style');
style.id = RIPPLE_KEYFRAME_ID;
style.textContent = `
@keyframes __borgdock_anim_ripple {
0% { transform: translate(-50%, -50%) scale(0.55); opacity: 0.85; }
100% { transform: translate(-50%, -50%) scale(2.4); opacity: 0; }
}
`;
document.head.appendChild(style);
}

export function installCursor(initialX = -100, initialY = -100): HTMLElement {
const existing = document.getElementById(CURSOR_ID);
if (existing) return existing;
const cursor = document.createElement('div');
cursor.id = CURSOR_ID;
cursor.innerHTML = CURSOR_SVG;
cursor.style.cssText = [
'position: fixed',
`top: ${initialY}px`,
`left: ${initialX}px`,
'pointer-events: none',
'z-index: 99999',
'transform: translate(-2px, -2px)',
'transition: top 600ms cubic-bezier(0.2, 0.8, 0.2, 1), left 600ms cubic-bezier(0.2, 0.8, 0.2, 1)',
'filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35))',
].join('; ');
document.body.appendChild(cursor);
ensureRippleKeyframes();
return cursor;
}

function rectCenter(el: Element): { x: number; y: number } {
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}

export async function moveCursorTo(target: Element, durationMs = 600): Promise<void> {
const cursor = installCursor();
const { x, y } = rectCenter(target);
// Match the transition duration set on installCursor; if a caller wants a
// different timing, they can update transitionDuration before calling.
cursor.style.transitionDuration = `${durationMs}ms`;
cursor.style.left = `${x}px`;
cursor.style.top = `${y}px`;
await new Promise((resolve) => setTimeout(resolve, durationMs + 40));
}

export function clickRipple(target: Element): void {
ensureRippleKeyframes();
const { x, y } = rectCenter(target);
const ripple = document.createElement('div');
ripple.style.cssText = [
'position: fixed',
`top: ${y}px`,
`left: ${x}px`,
'width: 26px',
'height: 26px',
'border-radius: 50%',
'border: 2.5px solid rgba(124, 90, 255, 0.85)',
'pointer-events: none',
'z-index: 99998',
'animation: __borgdock_anim_ripple 600ms ease-out forwards',
].join('; ');
document.body.appendChild(ripple);
setTimeout(() => ripple.remove(), 700);
}

// Convenience: cursor moves to el, ripples, then performs el.click() and
// waits for the post-click settle. Use when the story doesn't need
// userEvent's full event sequence (mouseenter / mousemove / mouseup
// individually) — for hover-driven UIs prefer moveCursorTo + dispatching
// 'mouseover' explicitly so the hover state actually paints.
export async function clickAt(
target: Element,
opts: { moveMs?: number; settleMs?: number } = {},
): Promise<void> {
const { moveMs = 600, settleMs = 350 } = opts;
await moveCursorTo(target, moveMs);
clickRipple(target);
(target as HTMLElement).click();
await new Promise((resolve) => setTimeout(resolve, settleMs));
}

// Hover helper. Some UIs (PR card pill bar) reveal on :hover via CSS.
// userEvent.hover doesn't always paint the hover state in headless Chromium
// — dispatching mouseover/mouseenter manually does.
export async function hoverAt(target: Element, moveMs = 600): Promise<void> {
await moveCursorTo(target, moveMs);
const events = ['pointerover', 'mouseover', 'pointerenter', 'mouseenter'];
for (const type of events) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
}

export async function unhoverAt(target: Element): Promise<void> {
const events = ['pointerleave', 'mouseleave', 'pointerout', 'mouseout'];
for (const type of events) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
}
}
38 changes: 31 additions & 7 deletions src/BorgDock.Tauri/scripts/capture-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,13 @@ async function main() {
);

const browser = await chromium.launch();
const context = await browser.newContext({ deviceScaleFactor: 2 });
const page = await context.newPage();
// Default context uses DSF=2 for crisp static captures. Animations may
// override per-story via parameters.animation.deviceScaleFactor — we
// create a separate context for those (recreating mid-run is the only
// way Playwright honors a different DSF).
const staticContext = await browser.newContext({ deviceScaleFactor: 2 });
const page = await staticContext.newPage();
let context = staticContext;

// First pass: visit each story and collect parameters.screenshot AND
// parameters.animation. We probe both together to avoid double-navigation
Expand Down Expand Up @@ -338,18 +343,37 @@ async function main() {
}

// Animation pass — slower (frame loop + ffmpeg encode per story).
// Animations are large and benefit more from frame rate than from DSF —
// a fresh context with deviceScaleFactor=1 cuts each page.screenshot()
// by ~4x and gets us closer to the declared fps. Stories can opt back
// into 2x via parameters.animation.deviceScaleFactor.
let animContext = context;
let animPage = page;
if (taggedAnimated.length > 0) {
animContext = await browser.newContext({ deviceScaleFactor: 1 });
animPage = await animContext.newPage();
context = animContext;
}
for (const { story, ap } of taggedAnimated) {
try {
await page.setViewportSize({ width: ap.width, height: ap.height });
await page.goto(`${url}/iframe.html?id=${story.id}&viewMode=story`, {
const dsf = ap.deviceScaleFactor ?? 1;
// Recreate context only if this story wants a non-default DSF.
let ctxPage = animPage;
let ctxScoped = null;
if (dsf !== 1) {
ctxScoped = await browser.newContext({ deviceScaleFactor: dsf });
ctxPage = await ctxScoped.newPage();
}
await ctxPage.setViewportSize({ width: ap.width, height: ap.height });
await ctxPage.goto(`${url}/iframe.html?id=${story.id}&viewMode=story`, {
waitUntil: 'domcontentloaded',
});
// Wait for story params to be populated before starting the frame loop.
await page.waitForFunction(
await ctxPage.waitForFunction(
() => window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters !== undefined,
{ timeout: 15000 },
);
await captureAnimation(page, ap, story.id);
await captureAnimation(ctxPage, ap, story.id);
if (ctxScoped) await ctxScoped.close();
count++;
} catch (err) {
console.error(
Expand Down
Loading