diff --git a/web/src/lib/components/repo/StarButton.stories.svelte b/web/src/lib/components/repo/StarButton.stories.svelte
index 75e244297..b2874a3a9 100644
--- a/web/src/lib/components/repo/StarButton.stories.svelte
+++ b/web/src/lib/components/repo/StarButton.stories.svelte
@@ -37,29 +37,29 @@
};
const starred = async ({ canvas }: PlayContext) => {
- const button = await canvas.findByRole("button", { name: "Starred" });
+ const button = await canvas.findByRole("button", { name: "Unstar" });
await waitFor(() => expect(button).toBeEnabled());
};
- // clicking star: the icon swaps to a spinner while the write is in flight,
- // then settles on "Starred" with the filled star icon
+ // clicking star: the icon swaps to a spinner while the write is in flight, then the
+ // star swaps to star-off and the label crossfades to "Unstar"
const starring = async ({ canvas }: PlayContext) => {
const button = await canvas.findByRole("button", { name: "Star" });
await userEvent.click(button);
- await waitFor(() => expect(canvas.getByRole("button", { name: "Starred" })).toBeVisible());
+ await waitFor(() => expect(canvas.getByRole("button", { name: "Unstar" })).toBeVisible());
};
- // a hung write: the button stays on "Star" with the icon replaced by a spinner
+ // a hung write: the button commits to "Unstar" straight away and stays there while the
+ // write is still out, with only aria-busy reporting that it has not settled
const starringLoading = async ({ canvas }: PlayContext) => {
const button = await canvas.findByRole("button", { name: "Star" });
await userEvent.click(button);
- await waitFor(() => expect(button).toHaveAttribute("aria-busy", "true"));
- await expect(button).toHaveTextContent("Star");
- await expect(button).toBeDisabled();
+ await waitFor(() => expect(button).toHaveAccessibleName("Unstar"));
+ await expect(button).toHaveAttribute("aria-busy", "true");
};
const unstarring = async ({ canvas }: PlayContext) => {
- const button = await canvas.findByRole("button", { name: "Starred" });
+ const button = await canvas.findByRole("button", { name: "Unstar" });
await userEvent.click(button);
await waitFor(() => expect(canvas.getByRole("button", { name: "Star" })).toBeVisible());
};
diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte
index 215d6ca78..143f17b00 100644
--- a/web/src/lib/components/repo/StarButton.svelte
+++ b/web/src/lib/components/repo/StarButton.svelte
@@ -2,18 +2,20 @@
import { resolve } from "$app/paths";
import { page } from "$app/state";
import Star from "$icon/star";
+ import StarOff from "$icon/star-off";
import TriangleAlert from "$icon/triangle-alert";
import { getAuth } from "$lib/auth.svelte";
import { loginWithReturn } from "$lib/auth/guards";
import { createStar, deleteStar } from "$lib/api/graph";
import Button from "$lib/components/ui/Button.svelte";
import ButtonGroup from "$lib/components/ui/ButtonGroup.svelte";
- import Spinner from "$lib/components/ui/Spinner.svelte";
import Tooltip from "$lib/components/ui/Tooltip.svelte";
import { getProfileCounts } from "$lib/components/profile/counts.svelte";
import { createOptimisticRelation, createOptimisticCount } from "$lib/optimistic.svelte";
import { formatCount } from "$lib/format";
import Bones from "$lib/components/ui/Bones.svelte";
+ import { CROSSFADE_DURATION, CROSSFADE_EASING } from "$lib/motion/spring";
+ import { prefersReducedMotion } from "svelte/motion";
import uiLoadingprimitivesCount from "$lib/bones/ui-loadingprimitives--count.bones.json";
import type { StarChange } from "$lib/components/profile/types";
import type { RepoCounts } from "./types";
@@ -99,35 +101,35 @@
resolve(loginWithReturn(page.url.pathname + page.url.search) as "/login")
);
const starsHref = $derived(resolve(`/${repoOwnerHandle}/${repoName}/stars` as "/"));
+ const fade = $derived(prefersReducedMotion.current ? 0 : CROSSFADE_DURATION);
+ const face = $derived(failed ? "err" : starred ? "on" : "off");
const toggle = async () => {
const agent = auth.agent;
if (!agent || !relation.known || relation.loading || !repoDid) return;
- relation.begin();
+ // every read happens before begin(), which flips the relation to the intended state
+ const wasStarred = relation.active;
+ const existingRkey = relation.rkey;
+ const delta = wasStarred ? -1 : 1;
+ relation.begin(!wasStarred);
starCount.resetFailure();
+ starCount.adjust(delta);
+ profileCounts?.adjust(agent.sub, "stars", delta);
try {
- if (relation.active && relation.rkey) {
- await deleteStar(agent, relation.rkey);
+ if (wasStarred && existingRkey) {
+ await deleteStar(agent, existingRkey);
relation.deleted();
- starCount.adjust(-1);
- profileCounts?.adjust(agent.sub, "stars", -1);
- onCommit?.({
- viewerDid: agent.sub,
- repoDid,
- starred: false,
- rkey: null,
- delta: -1
- });
+ onCommit?.({ viewerDid: agent.sub, repoDid, starred: false, rkey: null, delta });
} else {
const rkey = await createStar(agent, repoDid);
relation.created(rkey);
- starCount.adjust(1);
- profileCounts?.adjust(agent.sub, "stars", 1);
- onCommit?.({ viewerDid: agent.sub, repoDid, starred: true, rkey, delta: 1 });
+ onCommit?.({ viewerDid: agent.sub, repoDid, starred: true, rkey, delta });
}
} catch {
relation.fail();
+ starCount.adjust(-delta);
starCount.fail();
+ profileCounts?.adjust(agent.sub, "stars", -delta);
}
};
@@ -140,22 +142,22 @@
size="small"
{insetShadow}
href={signedIn ? undefined : loginHref}
- disabled={signedIn && (!relation.known || !repoDid || relation.loading)}
+ disabled={signedIn && (!relation.known || !repoDid)}
aria-busy={relation.loading}
onclick={signedIn ? toggle : undefined}
- class="flex-1"
+ class="min-w-20 flex-1"
+ style="--swap: {fade}ms; --swap-ease: {CROSSFADE_EASING}"
>
- {#if relation.loading}
-
- {:else if failed}
-
- {:else}
-
- {/if}
- {starred ? "Starred" : "Star"}
+
+
+
+
+
+
+ Star
+ Unstar
+
+ {starred ? "Unstar" : "Star"}