Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export default function ActionsTab(props: ActionsTabProps) {
});

return (
<div class="bg-base-100">
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
{/* Repo header */}
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposActions().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
<button
Expand Down
19 changes: 12 additions & 7 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { pushNotification } from "../../lib/errors";
import { getClient, getGraphqlRateLimit } from "../../services/github";
import { formatCount } from "../../lib/format";
import { setsEqual } from "../../lib/collections";
import { withScrollLock } from "../../lib/scroll";
import { Tooltip } from "../shared/Tooltip";

// ── Shared dashboard store (module-level to survive navigation) ─────────────
Expand Down Expand Up @@ -173,13 +174,17 @@ async function pollFetch(): Promise<DashboardData> {
} else {
// Phase 1 did NOT fire (cached data existed or subsequent poll).
// Full atomic replacement — all fields (light + heavy) may have
// changed since the last cycle.
setDashboardData({
issues: data.issues,
pullRequests: data.pullRequests,
workflowRuns: data.workflowRuns,
loading: false,
lastRefreshedAt: now,
// changed since the last cycle. Preserve scroll position: SolidJS
// DOM updates are synchronous within the setter, so save/restore
// around it to prevent scroll reset from <For> DOM rebuild.
withScrollLock(() => {
setDashboardData({
issues: data.issues,
pullRequests: data.pullRequests,
workflowRuns: data.workflowRuns,
loading: false,
lastRefreshedAt: now,
});
});
}
rebuildHotSets(data);
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ export default function IssuesTab(props: IssuesTabProps) {
});

return (
<div class="bg-base-100">
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposIssues().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
<button
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/dashboard/PullRequestsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
});

return (
<div class="bg-base-100">
<div class="bg-base-100" data-repo-group={repoGroup.repoFullName}>
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposPRs().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
<button
onClick={() => toggleExpandedRepo("pullRequests", repoGroup.repoFullName)}
Expand Down
388 changes: 248 additions & 140 deletions src/app/components/onboarding/RepoSelector.tsx

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions src/app/components/shared/RepoLockControls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Show, createMemo } from "solid-js";
import { viewState, lockRepo, unlockRepo, moveLockedRepo, type LockedReposTab } from "../../stores/view";
import { Tooltip } from "./Tooltip";
import { withFlipAnimation } from "../../lib/scroll";

interface RepoLockControlsProps {
tab: LockedReposTab;
Expand All @@ -26,7 +27,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
<Tooltip content="Pin to top">
<button
class="btn btn-ghost btn-xs opacity-0 group-hover/repo-header:opacity-100 focus:opacity-100 max-sm:opacity-60 sm:max-lg:opacity-60 transition-opacity"
onClick={() => lockRepo(props.tab, props.repoFullName)}
onClick={() => withFlipAnimation(() => lockRepo(props.tab, props.repoFullName))}
aria-label={`Pin ${props.repoFullName} to top of list`}
>
{/* Heroicons 20px solid: lock-open */}
Expand All @@ -40,7 +41,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
<Tooltip content="Unpin">
<button
class="btn btn-ghost btn-xs"
onClick={() => unlockRepo(props.tab, props.repoFullName)}
onClick={() => withFlipAnimation(() => unlockRepo(props.tab, props.repoFullName))}
aria-label={`Unpin ${props.repoFullName}`}
>
{/* Heroicons 20px solid: lock-closed */}
Expand All @@ -52,7 +53,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
<Tooltip content={lockInfo().isFirst ? "Already at top of pinned list" : "Move up"}>
<button
class="btn btn-ghost btn-xs"
onClick={() => moveLockedRepo(props.tab, props.repoFullName, "up")}
onClick={() => withFlipAnimation(() => moveLockedRepo(props.tab, props.repoFullName, "up"))}
disabled={lockInfo().isFirst}
aria-label={`Move ${props.repoFullName} up`}
>
Expand All @@ -65,7 +66,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {
<Tooltip content={lockInfo().isLast ? "Already at bottom of pinned list" : "Move down"}>
<button
class="btn btn-ghost btn-xs"
onClick={() => moveLockedRepo(props.tab, props.repoFullName, "down")}
onClick={() => withFlipAnimation(() => moveLockedRepo(props.tab, props.repoFullName, "down"))}
disabled={lockInfo().isLast}
aria-label={`Move ${props.repoFullName} down`}
>
Expand Down
23 changes: 23 additions & 0 deletions src/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@
scrollbar-color: color-mix(in oklch, var(--color-base-content) 30%, transparent) var(--color-base-200);
}

/* ── Kobalte accordion animation ─────────────────────────────────────────── */

.kb-accordion-content {
overflow: hidden;
animation: kb-accordion-down 200ms ease-in-out forwards;
}
.kb-accordion-content[data-closed] {
animation: kb-accordion-up 200ms ease-in-out forwards;
}

@keyframes kb-accordion-down {
from { height: 0; }
to { height: var(--kb-accordion-content-height); }
}
@keyframes kb-accordion-up {
from { height: var(--kb-accordion-content-height); }
to { height: 0; }
}

@media (prefers-reduced-motion: reduce) {
.animate-slow-pulse,
.animate-toast-in, .animate-toast-out,
Expand All @@ -145,4 +164,8 @@
.loading {
animation: none;
}
.kb-accordion-content,
.kb-accordion-content[data-closed] {
animation: none;
}
}
70 changes: 70 additions & 0 deletions src/app/lib/scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Wraps a synchronous function call with scroll position preservation.
* SolidJS fine-grained DOM updates complete synchronously within fn(),
* so a single synchronous scrollTo is sufficient — no rAF needed.
*/
export function withScrollLock(fn: () => void): void {
const y = window.scrollY;
try {
fn();
} finally {
window.scrollTo(0, y);
}
}

/**
* FLIP animation for reorderable lists. Records positions of elements
* matching `[data-repo-group]` before fn(), then animates them to their
* new positions after the DOM update.
*
* Consistent with TrackedTab's FLIP: 200ms ease-in-out, respects
* prefers-reduced-motion (falls back to withScrollLock).
*/
export function withFlipAnimation(fn: () => void): void {
if (typeof window === "undefined") { fn(); return; }

// Reduced motion: fall back to instant scroll lock
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
withScrollLock(fn);
return;
}

// First: record positions of all repo group wrappers
const items = document.querySelectorAll<HTMLElement>("[data-repo-group]");
const before = new Map<string, DOMRect>();
for (const el of items) {
const key = el.dataset.repoGroup!;
before.set(key, el.getBoundingClientRect());
}

// Execute state change (SolidJS updates DOM synchronously)
const scrollY = window.scrollY;
fn();

// Last, Invert, Play — reads before writes to avoid layout thrash.
// All getBoundingClientRect calls happen before scrollTo so the browser
// doesn't force a synchronous layout recalculation between them.
requestAnimationFrame(() => {
const afterItems = document.querySelectorAll<HTMLElement>("[data-repo-group]");
const scrollDrift = window.scrollY - scrollY;
const deltas: { el: HTMLElement; dy: number }[] = [];
for (const el of afterItems) {
const key = el.dataset.repoGroup!;
const old = before.get(key);
if (!old) continue;
const now = el.getBoundingClientRect();
// Adjust for scroll drift: old.top was measured at scrollY,
// now.top is measured at the current (possibly drifted) scroll position.
const dy = old.top - now.top - scrollDrift;
if (Math.abs(dy) < 1) continue;
deltas.push({ el, dy });
}
window.scrollTo(0, scrollY);
for (const { el, dy } of deltas) {
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }],
{ duration: 200, easing: "ease-in-out" },
);
}
});
}
45 changes: 45 additions & 0 deletions tests/components/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,51 @@ describe("DashboardPage — onAuthCleared integration", () => {
});
});

describe("DashboardPage — scroll preservation on poll refresh", () => {
// MOCK INVARIANT: fetchAllData is mocked via vi.fn() and never calls its
// onLightData callback, so phaseOneFired is always false inside pollFetch().
// This means every poll cycle takes the withScrollLock branch (not the
// fine-grained produce() path). If fetchAllData is ever changed to invoke
// onLightData in tests, phaseOneFired will become true and withScrollLock
// will NOT be called, silently breaking this test.
//
// window.scrollTo is the correct behavioral proxy for withScrollLock:
// withScrollLock captures scrollY then calls window.scrollTo(0, y) after
// the setter. Asserting scrollTo was called with the saved position is
// equivalent to asserting withScrollLock ran and completed successfully.
it("preserves scroll position when setDashboardData replaces arrays", async () => {
const issues = [makeIssue({ id: 1, title: "Scroll test issue" })];
vi.mocked(pollService.fetchAllData).mockResolvedValue({
issues,
pullRequests: [],
workflowRuns: [],
errors: [],
});

render(() => <DashboardPage />);
await waitFor(() => {
screen.getByText("owner/repo");
});

// Simulate user scrolled down
document.documentElement.scrollTop = 500;
vi.spyOn(window, "scrollTo");

// Trigger a second poll (subsequent refresh — the path that uses withScrollLock).
// phaseOneFired is false (mock never calls onLightData), so withScrollLock
// wraps the full atomic setDashboardData replacement and restores scroll.
if (capturedFetchAll) {
await capturedFetchAll();
}

// window.scrollTo(0, 500) is the observable side-effect of withScrollLock
// saving and restoring the pre-update scroll position.
expect(window.scrollTo).toHaveBeenCalledWith(0, 500);
vi.restoreAllMocks();
document.documentElement.scrollTop = 0;
});
});

describe("DashboardPage — onHotData integration", () => {
it("applies hot poll PR status updates to the store", async () => {
const testPR = makePullRequest({
Expand Down
Loading