diff --git a/apps/native/crates/local-api/src/routes/intercept/mod.rs b/apps/native/crates/local-api/src/routes/intercept/mod.rs index a837df34f6..cc0c507f80 100644 --- a/apps/native/crates/local-api/src/routes/intercept/mod.rs +++ b/apps/native/crates/local-api/src/routes/intercept/mod.rs @@ -27,6 +27,7 @@ //! | `POST /api/:org/tools/COLLECTION_THREAD_MESSAGES_LIST` | §3.1 | [`thread_tools`] | //! | `GET /api/:org/watch` | local thread lifecycle SSE | [`watch`] | //! | `POST /api/:org/sandbox/:virtualMcpId/:branch/{read,write,unlink,mkdir,rename,glob,grep}` | native sandbox filesystem bridge | [`sandbox_fs`] | +//! | any `/api/:org/sandbox/*` carrying [`FAST_PREVIEW_HEADER`] | sandbox-less by definition | never intercepted (`None`) | //! | any `/api/:org/decopilot/*` | retired native chat transport | local `410`, never forwarded | //! | any other `/api/:org/tools/:toolName` | — | not intercepted (`None`) | //! @@ -73,21 +74,27 @@ pub(crate) use sandbox_lifecycle::{ pub(crate) mod watch; use axum::body::Bytes; -use axum::http::Method; +use axum::http::{HeaderMap, Method}; use axum::response::{IntoResponse, Response}; use crate::error::ApiError; use crate::state::AppState; -/// The single entry point `routes/upstream.rs::proxy` calls before its -/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare -/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or -/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to -/// strip anymore) with its query string supplied separately for `/watch`'s -/// optional `types` filter. Other intercepted routes do not read query -/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride -/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim" -/// contract, map §3.1. +/// Header the webview sets when the project it is acting for is Fast Preview. +/// +/// A routing hint, not a trust boundary: it only decides whether to answer +/// LOCALLY, and upstream re-derives the flag from the vMCP's own metadata +/// before serving anything. A wrong value can therefore only send the request +/// to the authority, never around it. +pub const FAST_PREVIEW_HEADER: &str = "x-deco-fast-preview"; + +fn declares_fast_preview(headers: &HeaderMap) -> bool { + headers + .get(FAST_PREVIEW_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + /// Whether `` in `/api//` is really an organization. /// /// NOT every second path segment is one: `/api/config`, `/api/auth/*` and the @@ -103,12 +110,22 @@ fn is_org_scoped(segment: &str, rest: &[&str]) -> bool { !segment.starts_with('_') && rest.first() == Some(&"tools") } +/// The single entry point `routes/upstream.rs::proxy` calls before its +/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare +/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or +/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to +/// strip anymore) with its query string supplied separately for `/watch`'s +/// optional `types` filter. Other intercepted routes do not read query +/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride +/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim" +/// contract, map §3.1. pub async fn try_intercept( state: &AppState, method: &Method, path: &str, query: Option<&str>, body: &Bytes, + headers: &HeaderMap, ) -> Option { let mut segs = path.trim_start_matches('/').split('/'); if segs.next()? != "api" { @@ -117,6 +134,17 @@ pub async fn try_intercept( let org = segs.next().filter(|s| !s.is_empty())?; let rest: Vec<&str> = segs.collect(); + // Fast Preview is sandbox-less by definition, so nothing under + // `/sandbox/*` can be answered from this machine — upstream serves those + // routes from the GitHub API. Declining here rather than letting each + // sandbox interceptor decide is what makes the flag authoritative: the + // worktree handle is derived from the REPOSITORY, so a desktop sandbox + // left over from vibecoding on the same repo otherwise claims the route + // for a branch it has never checked out. + if rest.first().copied() == Some("sandbox") && declares_fast_preview(headers) { + return None; + } + // Start warming this organization's filesystem. A genuinely org-scoped // request here is exactly "the app booted into this org" or "the user // switched to it", so the mounts come up while the user is still @@ -254,6 +282,30 @@ mod tests { assert!(is_org_scoped("gimenes-guarana-works", &["tools", "X"])); } + /// The regression: the worktree handle is derived from the REPOSITORY, so + /// a desktop sandbox left over from vibecoding on the same repo claimed + /// `/sandbox/*/git/*` for a Fast Preview branch it had never checked out + /// and answered `repository not initialized` — forever, since the query + /// stopped retrying. + #[test] + fn fast_preview_is_declared_by_the_header_only_when_set() { + use axum::http::{HeaderMap, HeaderValue}; + + let mut on = HeaderMap::new(); + on.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("1")); + assert!(super::declares_fast_preview(&on)); + + let mut worded = HeaderMap::new(); + worded.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("TRUE")); + assert!(super::declares_fast_preview(&worded)); + + let mut off = HeaderMap::new(); + off.insert(super::FAST_PREVIEW_HEADER, HeaderValue::from_static("0")); + assert!(!super::declares_fast_preview(&off)); + + assert!(!super::declares_fast_preview(&HeaderMap::new())); + } + use super::*; #[tokio::test] @@ -266,6 +318,7 @@ mod tests { "/api/acme/tools/SOME_OTHER_TOOL", None, &Bytes::from_static(b"{}"), + &HeaderMap::new(), ) .await; assert!(res.is_none()); @@ -275,17 +328,23 @@ mod tests { async fn non_org_scoped_paths_are_not_intercepted() { let dir = tempfile::tempdir().unwrap(); let state = test_state(dir.path()); - assert!( - try_intercept(&state, &Method::GET, "/api/config", None, &Bytes::new(),) - .await - .is_none() - ); + assert!(try_intercept( + &state, + &Method::GET, + "/api/config", + None, + &Bytes::new(), + &HeaderMap::new(), + ) + .await + .is_none()); assert!(try_intercept( &state, &Method::GET, "/api/auth/get-session", None, &Bytes::new(), + &HeaderMap::new(), ) .await .is_none()); @@ -301,6 +360,7 @@ mod tests { "/api/acme/decopilot/some-future-route", None, &Bytes::new(), + &HeaderMap::new(), ) .await; let res = res diff --git a/apps/native/crates/local-api/src/routes/upstream.rs b/apps/native/crates/local-api/src/routes/upstream.rs index 6f6a3a8d65..c7df58b985 100644 --- a/apps/native/crates/local-api/src/routes/upstream.rs +++ b/apps/native/crates/local-api/src/routes/upstream.rs @@ -171,8 +171,15 @@ pub async fn proxy(State(state): State, req: Request) -> Response { // whether there's a valid session), so it must never wait on, or be // gated by, this proxy's auth machinery. See that module's doc comment // for the full route table and the map citations behind each entry. - if let Some(response) = - intercept::try_intercept(&state, &parts.method, &path, parts.uri.query(), &body_bytes).await + if let Some(response) = intercept::try_intercept( + &state, + &parts.method, + &path, + parts.uri.query(), + &body_bytes, + &parts.headers, + ) + .await { return response; } diff --git a/apps/web/src/components/sections-editor/use-delete-block.ts b/apps/web/src/components/sections-editor/use-delete-block.ts index 9bcdc0a9e1..122032d0c1 100644 --- a/apps/web/src/components/sections-editor/use-delete-block.ts +++ b/apps/web/src/components/sections-editor/use-delete-block.ts @@ -46,9 +46,13 @@ export function useDeleteBlock({ { delete: [blockKey] }, ); setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft); - // Same as use-save-block: the commit moved the head, refresh the - // header's branch meta in place of any interval polling. - void queryClient.invalidateQueries({ + /** + * Same as use-save-block: the commit moved the head, refresh the + * header's branch meta in place of any interval polling — and await + * it, so observers of this mutation aren't released onto a status + * that is still the pre-delete one. + */ + await queryClient.invalidateQueries({ queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch), }); return { ok: true as const, existed: true }; diff --git a/apps/web/src/components/sections-editor/use-save-block.ts b/apps/web/src/components/sections-editor/use-save-block.ts index a6ad712d6a..29f8693db5 100644 --- a/apps/web/src/components/sections-editor/use-save-block.ts +++ b/apps/web/src/components/sections-editor/use-save-block.ts @@ -54,10 +54,17 @@ export function useSaveBlock({ { set: { [blockKey]: data } }, ); setDecofileDraft(queryClient, { orgSlug, virtualMcpId, branch }, draft); - // The landed commit moved the branch head — refresh the header's - // branch meta now. This write is the ONLY in-app head mutation, which - // is what lets the status query drop interval polling entirely. - void queryClient.invalidateQueries({ + /** + * The landed commit moved the branch head — refresh the header's + * branch meta now. This write is the ONLY in-app head mutation, which + * is what lets the status query drop interval polling entirely. + * + * Awaited, not fired and forgotten: observers key "is a save in + * flight" off this mutation, and releasing them before the re-read + * lands renders the PREVIOUS status as if it were current — a clean + * "Up to date" over an edit that already exists. + */ + await queryClient.invalidateQueries({ queryKey: sandboxGitStatusQueryKey(orgSlug, virtualMcpId, branch), }); return draft; diff --git a/apps/web/src/components/thread/github/cms-header-actions.tsx b/apps/web/src/components/thread/github/cms-header-actions.tsx new file mode 100644 index 0000000000..4764c0d35a --- /dev/null +++ b/apps/web/src/components/thread/github/cms-header-actions.tsx @@ -0,0 +1,373 @@ +/** + * Header actions for **Fast Preview** (CMS) mode — the sandbox-less, + * content-only editing surface. + * + * `HeaderActions` is the vibecoding renderer: it mounts the sandbox event + * stream, the sandbox lifecycle and the publish gate, and five of its states + * dispatch chat prompts. Fast Preview has none of those — no daemon, no coding + * agent, no chat — so it renders this component instead, driven by the + * {@link selectCmsHeaderButton} state machine and a single split button. + * + * The branch happens at the mount point (`VirtualMcpHeaderInfo`) rather than + * inside `HeaderActions`, so in Fast Preview the sandbox hooks never mount. + */ + +import type { BranchMeta } from "@decocms/sandbox/shared"; +import { + branchUserLabel, + generateBranchName, +} from "@decocms/shared/branch-name"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { + SplitButton, + type SplitButtonMenuItem, +} from "@decocms/ui/components/split-button.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { + useIsMutating, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { GitPullRequest, RefreshCw01, Rocket02 } from "@untitledui/icons"; +import { GitHubIcon } from "@/components/icons/github-icon.tsx"; +import { useT } from "@/i18n/use-t"; +import { authClient } from "@/lib/auth-client.ts"; +import { resolveGithubAttachment } from "@/lib/github-repo.ts"; +import { KEYS } from "@/lib/query-keys"; +import { useProjectContext, useVirtualMCP } from "@/sdk"; +import { resolveFastPreview } from "@/sdk/fast-preview"; +import { decofileWriteMutationKey } from "../../sections-editor/decofile-api.ts"; +import { useChatTask } from "../../chat/index"; +import { + isCmsStateSettling, + selectCmsHeaderButton, + type CmsAction, +} from "./cms-panel-state.ts"; +import { PublishDialog, type PublishDialogIntent } from "./publish-dialog.tsx"; +import { + fetchGitStatus, + hasPublishableLocalWork, + normalizePublishPolicy, + readGitHeadBranch, + rebaseGitBranch, + sandboxGitStatusQueryKey, +} from "./sandbox-git-api.ts"; +import { useChecks, usePrByBranch } from "./use-pr-data.ts"; +import { usePrReviews } from "./use-pr-reviews.ts"; + +interface Props { + virtualMcpId: string; +} + +/** `open-pr` covers both the GitHub links; `key` separates them from each other. */ +function actionIcon(action: CmsAction, key?: string) { + if (action === "open-pr" || key === "resolve-on-github") { + return ; + } + if (action === "publish") return ; + if (action === "get-latest" || action === "retry-status") { + return ; + } + return ; +} + +export function CmsHeaderActions({ virtualMcpId }: Props) { + const t = useT(); + const { org } = useProjectContext(); + const queryClient = useQueryClient(); + const { data: session } = authClient.useSession(); + const vm = useVirtualMCP(virtualMcpId); + const { currentBranch: branch, setCurrentTaskBranch } = useChatTask(); + const [publishOpen, setPublishOpen] = useState(false); + const [dialogIntent, setDialogIntent] = + useState("publish-only"); + + const attachment = resolveGithubAttachment(vm); + const githubRepo = + attachment.status === "attached" || attachment.status === "public-clone" + ? attachment.repo + : null; + const { previewServerUrl } = resolveFastPreview(vm?.metadata); + + /** Poll-free on purpose: every call forwards to GitHub; save hooks invalidate this key. */ + const statusQuery = useQuery({ + queryKey: sandboxGitStatusQueryKey(org.slug, virtualMcpId, branch ?? ""), + queryFn: () => + fetchGitStatus(org.slug, virtualMcpId, branch ?? "", { + fastPreview: true, + }), + enabled: !!branch, + staleTime: 15_000, + }); + const status = statusQuery.data ?? null; + const branchMeta: BranchMeta = status + ? { + kind: "ready", + branch: readGitHeadBranch(status) ?? branch ?? "", + base: status.base ?? "main", + workingTreeDirty: hasPublishableLocalWork(status), + unpushed: status.unpushed ?? 0, + aheadOfBase: status.aheadOfBase ?? 0, + behindBase: status.behindBase ?? 0, + headSha: status.headSha ?? "", + } + : { kind: "unknown" }; + + const githubHeadBranch = + (branchMeta.kind === "ready" ? branchMeta.branch : null) ?? branch ?? null; + const baseBranch = branchMeta.kind === "ready" ? branchMeta.base : "main"; + + const prQuery = usePrByBranch({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + branch: githubHeadBranch, + }); + const pr = prQuery.data ?? null; + + const checksQuery = useChecks({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + prNumber: pr && pr.state === "open" ? pr.number : null, + }); + + const reviewsQuery = usePrReviews({ + orgId: org.id, + orgSlug: org.slug, + connectionId: githubRepo?.connectionId ?? "", + owner: githubRepo?.owner ?? "", + repo: githubRepo?.name ?? "", + prNumber: pr && pr.state === "open" ? pr.number : null, + }); + + const settling = isCmsStateSettling({ + pr, + prQuery, + checksQuery, + reviewsQuery, + }); + + const refreshPrState = async () => { + await Promise.all([ + prQuery.refetch(), + checksQuery.refetch(), + reviewsQuery.refetch(), + ]); + }; + + /** + * A squash-merge leaves the published commits on the branch, so the editor + * has to move to a fresh one or the next edit would re-publish work that is + * already live. Modelled as a mutation so `isPending` — not a hand-rolled + * flag — is what tells the state machine a publish is still settling. + */ + const publishCompletion = useMutation({ + mutationFn: async () => { + await setCurrentTaskBranch( + generateBranchName(branchUserLabel(session?.user)), + ); + }, + /** The dialog is already closed by now, so a toast is the only surface. */ + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); + + const getLatest = useMutation({ + mutationFn: async (target: { branch: string; base: string }) => { + await rebaseGitBranch( + org.slug, + virtualMcpId, + target.branch, + target.base, + { onConflict: "branch-wins" }, + { fastPreview: true }, + ); + return target; + }, + /** Invalidates drift AND the editor's content: the merge moved the head. */ + onSuccess: async (target) => { + toast.success( + t("thread.headerActions.syncedWithBase", { base: target.base }), + ); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: sandboxGitStatusQueryKey( + org.slug, + virtualMcpId, + branch ?? target.branch, + ), + }), + queryClient.invalidateQueries({ + queryKey: KEYS.decofile( + `${org.slug}/${virtualMcpId}/${target.branch}`, + ), + }), + ]); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); + + /** Same in-flight signal the preview's autosave indicator reads. */ + const saving = + useIsMutating({ + mutationKey: decofileWriteMutationKey( + org.slug, + virtualMcpId, + branch ?? "", + ), + }) > 0; + + /** + * Detached: repo linked via a GitHub connection that's no longer aggregated. + * Render a reconnect pill instead of nothing so the user has a recovery path. + */ + if (attachment.status === "detached") { + return ( + + + + + + + + + {t("thread.headerActions.githubConnectionRemoved")} + + + + ); + } + if (!githubRepo) return null; + /** + * No task branch yet: the status query is disabled, so its data never + * arrives and the state machine would sit on "Loading…" forever. There is + * also nothing to publish without a branch — render nothing rather than a + * spinner that resolves to nothing. + */ + if (!branch) return null; + + /** + * Only the tail of the publish — the branch switch that follows the merge. + * + * While the dialog is open it owns the progress UI for its own + * push → rebase → open PR → squash-merge sequence, and it sits over this + * button. Treating "dialog open" as publishing would label the button + * "Publishing…" the whole time the editor is still *reading* the diff and + * deciding, which is precisely what "Review & Publish" promises they get to + * do first. + */ + const publishing = publishCompletion.isPending; + + const button = selectCmsHeaderButton({ + branch: branchMeta, + pr, + checks: checksQuery.data ?? [], + reviews: reviewsQuery.data ?? null, + publishing, + saving, + syncing: getLatest.isPending, + statusRetrying: statusQuery.isFetching && !!statusQuery.error, + statusError: + statusQuery.error instanceof Error + ? statusQuery.error.message + : statusQuery.error + ? String(statusQuery.error) + : null, + loading: Boolean(settling), + t, + }); + + const openDialog = (intent: PublishDialogIntent) => { + setDialogIntent(intent); + setPublishOpen(true); + }; + + const dispatch = (action: CmsAction) => { + switch (action) { + case "publish": + openDialog("publish-only"); + return; + case "request-approval": + openDialog("open-pr"); + return; + case "get-latest": + if (!githubHeadBranch || getLatest.isPending) return; + getLatest.mutate({ branch: githubHeadBranch, base: baseBranch }); + return; + case "retry-status": + void statusQuery.refetch(); + return; + case "open-pr": + if (pr?.htmlUrl) { + window.open(pr.htmlUrl, "_blank", "noopener,noreferrer"); + } + return; + } + }; + + const items: SplitButtonMenuItem[] = button.menu.map((item) => ({ + key: item.key, + label: item.label, + icon: actionIcon(item.action, item.key), + ...(item.tooltip ? { tooltip: item.tooltip } : {}), + onSelect: () => dispatch(item.action), + })); + + const action = button.action; + + return ( + <> + dispatch(action) : undefined} + /> + {branch ? ( + publishCompletion.mutateAsync()} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/thread/github/cms-panel-state.test.ts b/apps/web/src/components/thread/github/cms-panel-state.test.ts new file mode 100644 index 0000000000..3b23ab1bd1 --- /dev/null +++ b/apps/web/src/components/thread/github/cms-panel-state.test.ts @@ -0,0 +1,832 @@ +import { describe, expect, test } from "bun:test"; +import type { BranchMeta } from "@decocms/sandbox/shared"; +import { + isCmsStateSettling, + selectCmsHeaderButton, + type SelectCmsHeaderButtonInput, +} from "./cms-panel-state"; +import type { CheckRun, PrSummary } from "./use-pr-data"; +import type { PrReviewSignals } from "./use-pr-reviews"; +import type { TFunction, TranslationKey } from "@/i18n/use-t.ts"; +import type { InterpolationVars } from "@/i18n/interpolate.ts"; +import { thread as threadEn } from "@/i18n/en/thread.ts"; + +const mockT: TFunction = (key: TranslationKey, vars?: InterpolationVars) => { + const template = + (threadEn as Record)[key as string] ?? (key as string); + if (!vars) return template; + return Object.entries(vars).reduce( + (s, [k, v]) => s.replace(`{${k}}`, String(v)), + template, + ); +}; + +type ReadyBranch = Extract; + +/** + * Fast Preview has no working tree: `workingTreeDirty` and `unpushed` are + * pinned to their empty values here because the daemon never reports anything + * else in this mode. + */ +function ready(over: Partial = {}): ReadyBranch { + return { + kind: "ready", + branch: "content/x", + base: "main", + workingTreeDirty: false, + unpushed: 0, + aheadOfBase: 0, + behindBase: 0, + headSha: "abc123", + ...over, + }; +} + +function input( + over: Partial = {}, +): SelectCmsHeaderButtonInput { + return { + branch: ready(), + pr: null, + checks: [], + reviews: null, + publishing: false, + saving: false, + loading: false, + statusError: null, + syncing: false, + statusRetrying: false, + t: mockT, + ...over, + }; +} + +function pr(over: Partial = {}): PrSummary { + return { + number: 42, + title: "Update homepage copy", + body: "", + state: "open", + merged: false, + mergedAt: null, + base: "main", + head: "content/x", + headSha: "abc123", + headRepoFullName: "acme/web", + htmlUrl: "https://github.com/acme/web/pull/42", + author: "me", + ...over, + }; +} + +function check(over: Partial = {}): CheckRun { + return { + id: "1", + name: "lint", + status: "completed", + conclusion: "success", + htmlUrl: "", + durationMs: null, + ...over, + }; +} + +function reviews(over: Partial = {}): PrReviewSignals { + return { + draft: false, + mergeableState: "clean", + unresolvedConversations: 0, + missingRequiredApprovals: false, + ...over, + }; +} + +const running = check({ + id: "2", + name: "build", + status: "in_progress", + conclusion: null, +}); +const failed = check({ id: "3", name: "unit", conclusion: "failure" }); + +function menuKeys(menu: { key: string }[]): string[] { + return menu.map((m) => m.key); +} + +describe("selectCmsHeaderButton — 1. loading", () => { + test("loading flag → Loading… (disabled, spinner, no menu)", () => { + const r = selectCmsHeaderButton(input({ loading: true })); + expect(r.label).toBe("Loading…"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("branch not ready → Loading…", () => { + const r = selectCmsHeaderButton(input({ branch: { kind: "unknown" } })); + expect(r.label).toBe("Loading…"); + expect(r.loading).toBe(true); + }); + + test("loading beats publishing", () => { + const r = selectCmsHeaderButton(input({ loading: true, publishing: true })); + expect(r.label).toBe("Loading…"); + }); + + test("branch not ready beats an open PR ready to publish", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.label).toBe("Loading…"); + }); +}); + +describe("selectCmsHeaderButton — 2. publishing", () => { + test("publishing → Publishing… (disabled, spinner, no menu)", () => { + const r = selectCmsHeaderButton(input({ publishing: true })); + expect(r.label).toBe("Publishing…"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("publishing beats every PR state, including conflicts", () => { + const r = selectCmsHeaderButton( + input({ + publishing: true, + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(r.label).toBe("Publishing…"); + expect(r.menu).toEqual([]); + }); +}); + +describe("selectCmsHeaderButton — 3. needs attention (conflicts)", () => { + test("open PR + dirty → Get latest with Resolve on GitHub menu", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(r.label).toBe("Get latest"); + expect(r.action).toBe("get-latest"); + expect(r.variant).toBe("default"); + expect(r.tooltip).toBe("Bring in new changes from production"); + expect(r.menu).toEqual([ + { + key: "resolve-on-github", + label: "Resolve on GitHub", + action: "open-pr", + }, + ]); + }); + + test("conflicts beat draft and beat failing checks", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [failed], + reviews: reviews({ mergeableState: "dirty", draft: true }), + }), + ); + expect(r.label).toBe("Get latest"); + expect(r.variant).toBe("default"); + expect(r.tooltip).toBe("Bring in new changes from production"); + }); + + test("conflicts + behind base → no duplicate Get latest in the menu", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 5 }), + pr: pr(), + reviews: reviews({ mergeableState: "dirty" }), + }), + ); + expect(menuKeys(r.menu)).toEqual(["resolve-on-github"]); + }); +}); + +describe("selectCmsHeaderButton — 4. waiting for approval", () => { + test("open PR + blocked → Waiting for review (opens the PR)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.label).toBe("Waiting for review"); + expect(r.action).toBe("open-pr"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBeFalsy(); + expect(r.menu).toEqual([ + { key: "view-on-github", label: "View on GitHub", action: "open-pr" }, + ]); + }); + + test("open PR + draft (clean mergeable state) → Waiting for review", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ draft: true }), + }), + ); + expect(r.label).toBe("Waiting for review"); + expect(r.action).toBe("open-pr"); + }); + + test("no checks → outline, no spinner, no tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("outline"); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("all checks passed → outline, no spinner", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), check({ id: "9", name: "types" })], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("outline"); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("check running → spinner (not pulse) + progress tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), running], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.loading).toBe(true); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + expect(r.variant).toBe("outline"); + }); + + test("check failed, none running → warning + failing tooltip, not disabled", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.variant).toBe("warning"); + expect(r.tooltip).toBe("1 of 2 checks are not passing"); + expect(r.disabled).toBeFalsy(); + expect(r.action).toBe("open-pr"); + expect(r.loading).toBeFalsy(); + }); + + test("mixed failed + running → running wins (spinner, outline)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed, running], + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(r.loading).toBe(true); + expect(r.variant).toBe("outline"); + expect(r.tooltip).toBe("Running checks 2 of 3 done"); + }); +}); + +describe("selectCmsHeaderButton — 5. ready to publish", () => { + test("open PR + clean → Review & Publish (brand)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + expect(r.variant).toBe("brand"); + expect(r.menu).toEqual([ + { key: "view-on-github", label: "View on GitHub", action: "open-pr" }, + ]); + }); + + test("reviews still loading (null → unknown) → Review & Publish", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), pr: pr(), reviews: null }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + }); + + test.each(["unstable", "behind", "unknown"] as const)( + "mergeableState=%s → Review & Publish", + (mergeableState) => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.variant).toBe("brand"); + }, + ); + + test("no checks → brand, no spinner, no tooltip", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(r.variant).toBe("brand"); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("all checks passed → brand, no spinner", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), check({ id: "9", name: "types" })], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("brand"); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBeUndefined(); + }); + + test("check running → still, tooltip only (never animates a clickable button)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), running], + reviews: reviews(), + }), + ); + expect(r.loading).toBeFalsy(); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + expect(r.variant).toBe("brand"); + expect(r.action).toBe("publish"); + }); + + test("check failed, none running → warning overrides brand, still publishable", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [check(), failed, check({ id: "4", conclusion: "timed_out" })], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("warning"); + expect(r.tooltip).toBe("2 of 3 checks are not passing"); + expect(r.action).toBe("publish"); + expect(r.disabled).toBeFalsy(); + }); + + test("mixed failed + running → running wins (brand, no warning)", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + checks: [failed, running], + reviews: reviews(), + }), + ); + expect(r.loading).toBeFalsy(); + expect(r.variant).toBe("brand"); + expect(r.tooltip).toBe("Running checks 1 of 2 done"); + }); +}); + +describe("selectCmsHeaderButton — 6. draft (no open PR)", () => { + test("ahead of base, no PR → Review & Publish + Submit for review", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 3 }) }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + expect(r.variant).toBe("brand"); + expect(r.menu).toEqual([ + { + key: "request-approval", + label: "Submit for review", + action: "request-approval", + }, + ]); + }); + + test("ahead of a merged PR the branch has moved past → Draft", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3, headSha: "after-merge" }), + pr: pr({ + state: "closed", + merged: true, + mergedAt: "2026-04-22", + headSha: "at-merge", + }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(menuKeys(r.menu)).toEqual(["request-approval"]); + }); + + test("ahead of base + closed PR → Draft", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3 }), + pr: pr({ state: "closed", merged: false }), + }), + ); + expect(r.label).toBe("Review & Publish"); + expect(r.action).toBe("publish"); + }); + + test("checks are ignored without an open PR", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 3 }), checks: [failed, running] }), + ); + expect(r.variant).toBe("brand"); + expect(r.tooltip).toBeUndefined(); + expect(r.loading).toBeFalsy(); + }); +}); + +describe("selectCmsHeaderButton — 7. up to date", () => { + test("nothing ahead, no PR → Up to date (disabled, empty menu)", () => { + const r = selectCmsHeaderButton(input()); + expect(r.label).toBe("Up to date"); + expect(r.variant).toBe("outline"); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("disabled primary still carries a Get latest menu when behind", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 4 }) }), + ); + expect(r.label).toBe("Up to date"); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([ + { + key: "get-latest", + label: "Get latest", + action: "get-latest", + tooltip: "Bring in new changes from production", + }, + ]); + }); +}); + +describe("selectCmsHeaderButton — Get latest in every menu when behind", () => { + test("state 4 (waiting for approval) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 1 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); + + test("state 5 (ready to publish) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 1 }), + pr: pr(), + reviews: reviews(), + }), + ); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); + + test("state 6 (draft) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2, behindBase: 1 }) }), + ); + expect(menuKeys(r.menu)).toEqual(["request-approval", "get-latest"]); + }); + + test("state 7 (up to date) appends Get latest", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 1 }) }), + ); + expect(menuKeys(r.menu)).toEqual(["get-latest"]); + }); + + test("behindBase = 0 → no Get latest anywhere", () => { + for (const r of [ + selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews({ mergeableState: "blocked" }), + }), + ), + selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + pr: pr(), + reviews: reviews(), + }), + ), + selectCmsHeaderButton(input({ branch: ready({ aheadOfBase: 2 }) })), + selectCmsHeaderButton(input()), + ]) { + expect(menuKeys(r.menu)).not.toContain("get-latest"); + } + }); + + test("Get latest is appended alongside the check treatment", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + pr: pr(), + checks: [failed], + reviews: reviews(), + }), + ); + expect(r.variant).toBe("warning"); + expect(menuKeys(r.menu)).toEqual(["view-on-github", "get-latest"]); + }); +}); + +describe("isCmsStateSettling", () => { + const idle = { isPending: false, fetchStatus: "idle" }; + const inFlight = { isPending: true, fetchStatus: "fetching" }; + + test("the PR query alone can hold the window", () => { + expect( + isCmsStateSettling({ + pr: null, + prQuery: inFlight, + checksQuery: idle, + reviewsQuery: idle, + }), + ).toBe(true); + }); + + test("checks still in flight keep it settling — the flicker this prevents", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: inFlight, + reviewsQuery: idle, + }), + ).toBe(true); + }); + + test("reviews still in flight keep it settling", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: idle, + reviewsQuery: inFlight, + }), + ).toBe(true); + }); + + test("settled once all three are idle", () => { + expect( + isCmsStateSettling({ + pr: pr(), + prQuery: idle, + checksQuery: idle, + reviewsQuery: idle, + }), + ).toBe(false); + }); + + test("without an open PR the dependent queries never run, so they cannot hold it", () => { + for (const p of [null, pr({ state: "closed", merged: true })]) { + expect( + isCmsStateSettling({ + pr: p, + prQuery: idle, + checksQuery: inFlight, + reviewsQuery: inFlight, + }), + ).toBe(false); + } + }); +}); + +describe("status read failure", () => { + test("a failed status read offers Retry instead of spinning forever", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + statusError: "repository not initialized", + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.retry"]); + expect(r.action).toBe("retry-status"); + expect(r.loading).toBeFalsy(); + expect(r.disabled).toBeFalsy(); + expect(r.tooltip).toBe("repository not initialized"); + }); + + test("a stale error never overrides branch metadata that did arrive", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2 }), + statusError: "repository not initialized", + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); + + test("no error still reads as Loading while the branch is unknown", () => { + const r = selectCmsHeaderButton(input({ branch: { kind: "unknown" } })); + expect(r.label).toBe(threadEn["thread.headerActions.loading"]); + expect(r.loading).toBe(true); + }); +}); + +describe("in-flight actions", () => { + test("a Get latest merge holds the button with a spinner", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, behindBase: 3 }), + syncing: true, + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.gettingLatest"]); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("publishing outranks syncing", () => { + const r = selectCmsHeaderButton(input({ publishing: true, syncing: true })); + expect(r.label).toBe(threadEn["thread.cmsActions.publishing"]); + }); + + test("a retry in flight spins on the Retry button", () => { + const r = selectCmsHeaderButton( + input({ + branch: { kind: "unknown" }, + statusError: "repository not initialized", + statusRetrying: true, + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.retry"]); + expect(r.loading).toBe(true); + expect(r.disabled).toBe(true); + }); +}); + +describe("post-write status refresh", () => { + test("a save still in flight never renders the pre-write status", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 0, workingTreeDirty: false }), + saving: true, + }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.saving"]); + expect(r.loading).toBe(true); + expect(r.menu).toEqual([]); + }); + + test("releases to the real state once the re-read lands", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 1 }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); + + test("a genuinely clean branch still settles on Up to date", () => { + const r = selectCmsHeaderButton(input({ branch: ready() })); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + }); +}); + +describe("saving", () => { + test("an in-flight block write holds the button", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), saving: true }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.saving"]); + expect(r.disabled).toBe(true); + expect(r.loading).toBe(true); + expect(r.action).toBeUndefined(); + expect(r.menu).toEqual([]); + }); + + test("Get latest is withheld while saving, even when behind", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ behindBase: 4 }), saving: true }), + ); + expect(r.menu).toEqual([]); + }); + + test("publishing outranks saving", () => { + const r = selectCmsHeaderButton(input({ publishing: true, saving: true })); + expect(r.label).toBe(threadEn["thread.cmsActions.publishing"]); + }); + + test("saving is transparent once the write settles", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 2 }), saving: false }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + }); +}); + +describe("uncommitted work", () => { + test("a dirty tree with nothing committed is still Draft", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 0, workingTreeDirty: true }) }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + expect(r.disabled).toBeFalsy(); + }); + + test("a clean branch level with base is Up to date", () => { + const r = selectCmsHeaderButton( + input({ branch: ready({ aheadOfBase: 0, workingTreeDirty: false }) }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + expect(r.disabled).toBe(true); + }); + + test("a dirty tree beats a merged PR at the same head", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ headSha: "merged-sha", workingTreeDirty: true }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + }); +}); + +describe("merged pull request", () => { + test("stays 'Up to date' when the branch is level with a merged PR", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 2, headSha: "merged-sha" }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.headerActions.upToDate"]); + expect(r.disabled).toBe(true); + expect(r.action).toBeUndefined(); + }); + + test("offers to publish again once the branch advances past the merge", () => { + const r = selectCmsHeaderButton( + input({ + branch: ready({ aheadOfBase: 3, headSha: "new-sha" }), + pr: pr({ state: "closed", merged: true, headSha: "merged-sha" }), + }), + ); + expect(r.label).toBe(threadEn["thread.cmsActions.reviewAndPublish"]); + expect(r.action).toBe("publish"); + }); +}); diff --git a/apps/web/src/components/thread/github/cms-panel-state.ts b/apps/web/src/components/thread/github/cms-panel-state.ts new file mode 100644 index 0000000000..f3b4148480 --- /dev/null +++ b/apps/web/src/components/thread/github/cms-panel-state.ts @@ -0,0 +1,359 @@ +/** Fast Preview (CMS) header button state machine; see ./panel-state.ts for vibecoding's. */ + +import type { BranchMeta } from "@decocms/sandbox/shared"; +import type { TFunction } from "@/i18n/use-t.ts"; +import { + isCheckFailed, + isCheckInProgress, + isPrStateActivelyLoading, +} from "./panel-state.ts"; +import type { CheckRun, PrSummary } from "./use-pr-data.ts"; +import type { PrReviewSignals } from "./use-pr-reviews.ts"; + +/** + * What a click resolves to. The renderer maps these to mutations; the state + * machine never performs them. + * + * - `publish` — merge the PR (opening one first when there isn't one yet). + * - `request-approval` — open the PR so a reviewer can approve it. + * - `get-latest` — bring `base` into the working branch. + * - `open-pr` — open the PR on GitHub in a new tab. + */ +export type CmsAction = + | "publish" + | "request-approval" + | "get-latest" + | "open-pr" + | "retry-status"; + +/** One entry of the split button's dropdown half. `key` is the React key. */ +export interface CmsMenuItem { + key: string; + label: string; + action: CmsAction; + tooltip?: string; +} + +/** + * Descriptor returned by {@link selectCmsHeaderButton}. + * + * An absent `action` means the primary half is an inert status pill. `loading` + * puts a spinner in the primary half and is reserved for a genuine wait. + * `disabled` and a non-empty `menu` can coexist: "Up to date" has nothing to + * publish yet still offers "Get latest" when the branch is behind. + */ +export interface CmsHeaderButton { + label: string; + /** Absent = inert status pill. */ + action?: CmsAction; + variant: "brand" | "warning" | "outline" | "default"; + disabled?: boolean; + /** Spinner in the primary half. */ + loading?: boolean; + tooltip?: string; + menu: CmsMenuItem[]; +} + +export interface SelectCmsHeaderButtonInput { + branch: BranchMeta; + pr: PrSummary | null; + checks: CheckRun[]; + reviews: PrReviewSignals | null; + /** A publish is in flight — optimistic, set at click time. */ + publishing: boolean; + /** A block write is in flight; the branch state is mid-change. */ + saving: boolean; + /** A "Get latest" merge is in flight. */ + syncing: boolean; + /** A failed status read is being retried. */ + statusRetrying: boolean; + /** Branch/PR/check data is still being fetched. */ + loading: boolean; + /** Why the branch status could not be read, if it could not. */ + statusError: string | null; + t: TFunction; +} + +function viewOnGithubItem(t: TFunction): CmsMenuItem { + return { + key: "view-on-github", + label: t("thread.cmsActions.viewOnGithub"), + action: "open-pr", + }; +} + +/** + * Appends "Get latest" to `menu` when the branch has commits on `base` it + * hasn't taken in yet. Applied to every state whose primary action isn't + * already `get-latest`, including the disabled "Up to date" pill. + */ +function withGetLatest( + menu: CmsMenuItem[], + branch: BranchMeta, + t: TFunction, +): CmsMenuItem[] { + if (branch.kind !== "ready" || branch.behindBase <= 0) return menu; + return [ + ...menu, + { + key: "get-latest", + label: t("thread.cmsActions.getLatest"), + action: "get-latest", + tooltip: t("thread.cmsActions.getLatestTooltip"), + }, + ]; +} + +/** + * Folds check-run status into a button descriptor. Only meaningful for the two + * open-PR states — checks exist only once a PR does. + * + * Running beats failing: while anything is still queued the failure set isn't + * final. A failure never disables the button; publishing with red checks is a + * judgement call the editor is allowed to make, so it only recolours to + * `warning` and explains itself in the tooltip. + * + * Only a genuine wait animates. A button the editor may click right now must + * sit still; the tooltip carries the progress. + */ +function applyCheckTreatment( + button: CmsHeaderButton, + checks: CheckRun[], + runningTreatment: "loading" | "none", + t: TFunction, +): CmsHeaderButton { + const total = checks.length; + if (total === 0) return button; + + const running = checks.filter(isCheckInProgress).length; + if (running > 0) { + const done = checks.filter((c) => c.status === "completed").length; + const tooltip = t("thread.cmsActions.checksRunning", { done, total }); + return runningTreatment === "loading" + ? { ...button, loading: true, tooltip } + : { ...button, tooltip }; + } + + const failed = checks.filter(isCheckFailed).length; + if (failed > 0) { + return { + ...button, + variant: "warning", + tooltip: t("thread.cmsActions.checksFailing", { failed, total }), + }; + } + + return button; +} + +/** + * Whether the branch sits exactly on a PR that has already been merged. + * + * A squash-merge leaves the branch's own commits on origin with their original + * SHAs, so `aheadOfBase` stays positive after a successful publish. Publishing + * moves the editor to a fresh branch, but when that hasn't happened the branch + * is level with its merged PR — and offering to publish again would open a + * second pull request for content that is already live. + */ +function isLevelWithMergedPr( + pr: PrSummary | null, + branch: BranchMeta, +): boolean { + return Boolean( + pr?.merged && + pr.headSha && + branch.kind === "ready" && + !branch.workingTreeDirty && + branch.headSha === pr.headSha, + ); +} + +interface QueryLoadState { + isPending: boolean; + fetchStatus: string; +} + +/** + * Whether the PR picture is still assembling, as ONE window rather than three. + * + * `checks` and `reviews` cannot start until `pr` returns their number, so + * treating only `pr` as loading paints a confident state from data still + * missing the part that can contradict it — and a failing check landing a beat + * later recolours a green button to warning in front of the editor, which + * reads as a bug rather than as convergence. + */ +export function isCmsStateSettling(input: { + pr: PrSummary | null; + prQuery: QueryLoadState; + checksQuery: QueryLoadState; + reviewsQuery: QueryLoadState; +}): boolean { + if (isPrStateActivelyLoading(input.prQuery)) return true; + if (input.pr?.state !== "open" || input.pr.merged) return false; + return ( + isPrStateActivelyLoading(input.checksQuery) || + isPrStateActivelyLoading(input.reviewsQuery) + ); +} + +/** Not live yet: one `/git/status` backend commits each save, the other doesn't. */ +function hasUnpublishedWork( + branch: Extract, +): boolean { + return branch.aheadOfBase > 0 || branch.workingTreeDirty; +} + +/** Picks the Fast Preview header button; first match wins, so order is behavior. */ +export function selectCmsHeaderButton( + input: SelectCmsHeaderButtonInput, +): CmsHeaderButton { + const { branch, pr, checks, reviews, publishing, saving, loading, t } = input; + + // A failed status read is not a slow one; spinning on it never resolves. + if (branch.kind !== "ready" && input.statusError) { + return { + label: t("thread.cmsActions.retry"), + action: "retry-status", + variant: "outline", + disabled: input.statusRetrying, + loading: input.statusRetrying, + tooltip: input.statusError, + menu: [], + }; + } + + // Fetching and "branch metadata not here yet" are one state to the editor. + if (loading || branch.kind !== "ready") { + return { + label: t("thread.headerActions.loading"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + if (publishing) { + return { + label: t("thread.cmsActions.publishing"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + /** A merge is rewriting the branch under the editor. */ + if (input.syncing) { + return { + label: t("thread.cmsActions.gettingLatest"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + /** + * Spans the re-read too: the write hooks await their status invalidation, so + * this stays true until the fresh branch meta is in hand — otherwise the + * previous status renders as if current, a clean "Up to date" over an edit + * that already exists. Publishing mid-write would ship half an edit. + */ + if (saving) { + return { + label: t("thread.headerActions.saving"), + variant: "outline", + disabled: true, + loading: true, + menu: [], + }; + } + + const openPr = pr && pr.state === "open" && !pr.merged ? pr : null; + + if (openPr) { + const mergeableState = reviews?.mergeableState ?? "unknown"; + + // Conflicts outrank CI, and "Get latest" is already the primary here. + if (mergeableState === "dirty") { + return { + label: t("thread.cmsActions.getLatest"), + action: "get-latest", + variant: "default", + tooltip: t("thread.cmsActions.getLatestTooltip"), + menu: [ + { + key: "resolve-on-github", + label: t("thread.cmsActions.resolveOnGithub"), + action: "open-pr", + }, + ], + }; + } + + // Blocked on a human; the primary opens the PR, the only place they act. + if (mergeableState === "blocked" || reviews?.draft) { + return applyCheckTreatment( + { + label: t("thread.cmsActions.waitingForReview"), + action: "open-pr", + variant: "outline", + menu: withGetLatest([viewOnGithubItem(t)], branch, t), + }, + checks, + "loading", + t, + ); + } + + // clean / unstable / behind / unknown — all publishable. + return applyCheckTreatment( + { + label: t("thread.cmsActions.reviewAndPublish"), + action: "publish", + variant: "brand", + menu: withGetLatest([viewOnGithubItem(t)], branch, t), + }, + checks, + "none", + t, + ); + } + + if (isLevelWithMergedPr(pr, branch)) { + return { + label: t("thread.headerActions.upToDate"), + variant: "outline", + disabled: true, + menu: withGetLatest([], branch, t), + }; + } + + if (hasUnpublishedWork(branch)) { + return { + label: t("thread.cmsActions.reviewAndPublish"), + action: "publish", + variant: "brand", + menu: withGetLatest( + [ + { + key: "request-approval", + label: t("thread.headerActions.submitForReview"), + action: "request-approval", + }, + ], + branch, + t, + ), + }; + } + + // Disabled primary, live menu: nothing to publish but base may have moved. + return { + label: t("thread.headerActions.upToDate"), + variant: "outline", + disabled: true, + menu: withGetLatest([], branch, t), + }; +} diff --git a/apps/web/src/components/thread/github/panel-state.ts b/apps/web/src/components/thread/github/panel-state.ts index 93f69f41d3..24ad56b09a 100644 --- a/apps/web/src/components/thread/github/panel-state.ts +++ b/apps/web/src/components/thread/github/panel-state.ts @@ -72,14 +72,16 @@ const FAILED_CONCLUSIONS = new Set([ "action_required", ]); -function isCheckFailed(c: CheckRun): boolean { +/** Shared with the Fast Preview state machine (`cms-panel-state.ts`). */ +export function isCheckFailed(c: CheckRun): boolean { return ( c.status === "completed" && FAILED_CONCLUSIONS.has(c.conclusion as FailedConclusion) ); } -function isCheckInProgress(c: CheckRun): boolean { +/** Shared with the Fast Preview state machine (`cms-panel-state.ts`). */ +export function isCheckInProgress(c: CheckRun): boolean { return c.status === "queued" || c.status === "in_progress"; } diff --git a/apps/web/src/components/thread/github/sandbox-git-api.test.ts b/apps/web/src/components/thread/github/sandbox-git-api.test.ts index c95aefb3cb..feffe0e012 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.test.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_PUBLISH_POLICY, hasGitLocalWork, hasLocalWorkToPush, + hasPublishableLocalWork, hasUnpublishedWork, isDecoOnlyDiff, needsSmartReviewJudgment, @@ -238,6 +239,66 @@ describe("hasGitLocalWork", () => { }); }); +describe("hasPublishableLocalWork", () => { + const GENERATED = [ + ".deco/generate.digests.json", + ".deco/meta.gen.json", + "blocks.gen.json", + "static/tailwind.css", + ]; + + test("false for null and a clean tree", () => { + expect(hasPublishableLocalWork(null)).toBe(false); + expect(hasPublishableLocalWork(cleanStatus)).toBe(false); + }); + + test("false when only generated artifacts changed", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + modified: GENERATED, + staged: [".deco/generate.digests.json"], + }), + ).toBe(false); + }); + + test("true when a block changed alongside generated artifacts", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + modified: [...GENERATED, ".deco/blocks/hero.json"], + }), + ).toBe(true); + }); + + test("true for conflicts even with no listed paths", () => { + expect( + hasPublishableLocalWork({ ...cleanStatus, conflicted: ["a.ts"] }), + ).toBe(true); + }); + + test("counts created, deleted and untracked content", () => { + expect( + hasPublishableLocalWork({ + ...cleanStatus, + created: [".deco/blocks/a.json"], + }), + ).toBe(true); + expect( + hasPublishableLocalWork({ + ...cleanStatus, + deleted: [".deco/blocks/a.json"], + }), + ).toBe(true); + expect( + hasPublishableLocalWork({ + ...cleanStatus, + not_added: [".deco/blocks/a.json"], + }), + ).toBe(true); + }); +}); + describe("shouldUseBaseDiff", () => { const opts = { openPrFromCommits: false, commitToOpenPr: false }; diff --git a/apps/web/src/components/thread/github/sandbox-git-api.ts b/apps/web/src/components/thread/github/sandbox-git-api.ts index df6eb9dbd7..8cb25f35a7 100644 --- a/apps/web/src/components/thread/github/sandbox-git-api.ts +++ b/apps/web/src/components/thread/github/sandbox-git-api.ts @@ -88,18 +88,42 @@ async function parseJson(res: Response): Promise { return body; } +/** + * Tells the desktop app not to answer this call from a local sandbox. + * + * Fast Preview is sandbox-less, so `/sandbox/*` must reach the API that serves + * it from GitHub. The desktop interceptor keys its worktree handle off the + * REPOSITORY, so a sandbox left over from vibecoding on the same repo would + * otherwise claim the route. A hint only — the API re-derives the flag from + * the vMCP's metadata, so a wrong value routes to the authority, never past it. + */ +export const FAST_PREVIEW_HEADER = "x-deco-fast-preview"; + +export interface SandboxGitCallOptions { + fastPreview?: boolean; +} + /** Never cache sandbox git/fs calls — 410 Gone must not stick in disk cache. */ -function sandboxFetch(url: string, init?: RequestInit): Promise { - return fetch(url, { cache: "no-store", ...init }); +function sandboxFetch( + url: string, + init?: RequestInit, + call?: SandboxGitCallOptions, +): Promise { + const headers = new Headers(init?.headers); + if (call?.fastPreview) headers.set(FAST_PREVIEW_HEADER, "1"); + return fetch(url, { cache: "no-store", ...init, headers }); } export async function fetchGitStatus( orgSlug: string, virtualMcpId: string, branch: string, + call?: SandboxGitCallOptions, ): Promise { const res = await sandboxFetch( buildSandboxGitUrl(orgSlug, virtualMcpId, branch, "status"), + undefined, + call, ); return parseJson(res); } @@ -201,6 +225,7 @@ export async function rebaseGitBranch( */ onConflict?: "branch-wins"; }, + call?: SandboxGitCallOptions, ): Promise { const res = await sandboxFetch( buildSandboxGitUrl(orgSlug, virtualMcpId, branch, "rebase"), @@ -209,6 +234,7 @@ export async function rebaseGitBranch( headers: { "content-type": "application/json" }, body: JSON.stringify({ base, ...opts }), }, + call, ); await parseJson(res); } @@ -225,6 +251,31 @@ function isTailwindCssPath(path: string): boolean { ); } +/** Rewritten as a side effect of any save, and never reverted by an undo. */ +function isGeneratedArtifactPath(path: string): boolean { + return ( + isBlocksGenJsonPath(path) || + isTailwindCssPath(path) || + path.endsWith("generate.digests.json") || + path.endsWith("meta.gen.json") + ); +} + +/** Uncommitted work that would actually change the site. */ +export function hasPublishableLocalWork( + status: GitStatus | null | undefined, +): boolean { + if (!status) return false; + if (status.conflicted.length > 0 || status.renamed.length > 0) return true; + return [ + ...status.modified, + ...status.created, + ...status.deleted, + ...status.not_added, + ...status.staged, + ].some((path) => !isGeneratedArtifactPath(path)); +} + /** * CMS artifacts live under a `.deco/` directory. The `/.deco/` (and `/.deco`) * forms also match projects whose package path isn't the repo root diff --git a/apps/web/src/i18n/en/thread.ts b/apps/web/src/i18n/en/thread.ts index 63d95e913a..7319cd74a5 100644 --- a/apps/web/src/i18n/en/thread.ts +++ b/apps/web/src/i18n/en/thread.ts @@ -43,6 +43,19 @@ export const thread = { "thread.checksTab.rerun": "Re-run", "thread.checksTab.success": "Success", "thread.checksTab.viewRun": "View run", + "thread.cmsActions.checksFailing": + "{failed} of {total} checks are not passing", + "thread.cmsActions.checksRunning": "Running checks {done} of {total} done", + "thread.cmsActions.getLatest": "Get latest", + "thread.cmsActions.getLatestTooltip": "Bring in new changes from production", + "thread.cmsActions.moreActionsAriaLabel": "More actions", + "thread.cmsActions.publishing": "Publishing…", + "thread.cmsActions.gettingLatest": "Getting latest…", + "thread.cmsActions.retry": "Retry", + "thread.cmsActions.resolveOnGithub": "Resolve on GitHub", + "thread.cmsActions.reviewAndPublish": "Review & Publish", + "thread.cmsActions.viewOnGithub": "View on GitHub", + "thread.cmsActions.waitingForReview": "Waiting for review", "thread.gitTab.by": "by @{author}", "thread.gitTab.closed": "✗ Closed", "thread.gitTab.couldNotLoadPrState": diff --git a/apps/web/src/i18n/pt-br/thread.ts b/apps/web/src/i18n/pt-br/thread.ts index fd80592c51..24a6c83a9f 100644 --- a/apps/web/src/i18n/pt-br/thread.ts +++ b/apps/web/src/i18n/pt-br/thread.ts @@ -46,6 +46,20 @@ export const thread = { "thread.checksTab.rerun": "Executar novamente", "thread.checksTab.success": "Sucesso", "thread.checksTab.viewRun": "Ver execução", + "thread.cmsActions.checksFailing": + "{failed} de {total} verificações não estão passando", + "thread.cmsActions.checksRunning": "Verificando {done} de {total} concluídas", + "thread.cmsActions.getLatest": "Obter atualizações", + "thread.cmsActions.getLatestTooltip": + "Trazer as novas alterações da produção", + "thread.cmsActions.moreActionsAriaLabel": "Mais ações", + "thread.cmsActions.publishing": "Publicando…", + "thread.cmsActions.gettingLatest": "Obtendo atualizações…", + "thread.cmsActions.retry": "Tentar novamente", + "thread.cmsActions.resolveOnGithub": "Resolver no GitHub", + "thread.cmsActions.reviewAndPublish": "Revisar e publicar", + "thread.cmsActions.viewOnGithub": "Ver no GitHub", + "thread.cmsActions.waitingForReview": "Aguardando revisão", "thread.gitTab.by": "por @{author}", "thread.gitTab.closed": "✗ Fechado", "thread.gitTab.couldNotLoadPrState": diff --git a/apps/web/src/views/virtual-mcp/header-info.tsx b/apps/web/src/views/virtual-mcp/header-info.tsx index b8497f8dab..d29f68c94b 100644 --- a/apps/web/src/views/virtual-mcp/header-info.tsx +++ b/apps/web/src/views/virtual-mcp/header-info.tsx @@ -1,5 +1,7 @@ import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { agentShowsGithubHeaderActions } from "@/lib/agent-capabilities"; +import { resolveFastPreview } from "@/sdk/fast-preview"; +import { CmsHeaderActions } from "../../components/thread/github/cms-header-actions.tsx"; import { HeaderActions } from "../../components/thread/github/header-actions.tsx"; import { DevAgentControl } from "../../components/dev-agent/dev-agent-control.tsx"; import { OpenInBoardButton } from "../../components/thread/open-in-board-button.tsx"; @@ -7,18 +9,28 @@ import { OpenInBoardButton } from "../../components/thread/open-in-board-button. /** * The agent's header actions (dev-agent control + GitHub publish/PR buttons), * rendered inline into the main panel header's right cluster. + * + * Fast Preview swaps in the CMS renderer here, not inside `HeaderActions`, so + * the sandbox hooks that renderer mounts (events, lifecycle, publish gate) + * never mount on a surface that has no sandbox. */ export function VirtualMcpHeaderInfo({ virtualMcp, }: { virtualMcp: VirtualMCPEntity; }) { + const fastPreviewActive = resolveFastPreview(virtualMcp.metadata).active; + return (
{agentShowsGithubHeaderActions(virtualMcp) ? ( - + fastPreviewActive ? ( + + ) : ( + + ) ) : null}
); diff --git a/packages/ui/src/components/button.stories.tsx b/packages/ui/src/components/button.stories.tsx index 0b64816f77..d612423237 100644 --- a/packages/ui/src/components/button.stories.tsx +++ b/packages/ui/src/components/button.stories.tsx @@ -20,6 +20,7 @@ const meta = { "ghost", "destructive", "success", + "warning", "special", "link", ], @@ -45,6 +46,7 @@ export const Variants: Story = { + diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index b687e3c428..d5a11e5694 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -18,6 +18,8 @@ const buttonVariants = cva( "bg-secondary text-secondary-foreground hover:bg-secondary/80", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/20 dark:focus-visible:ring-success/40", + warning: + "bg-warning text-warning-foreground hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40", brand: "bg-brand text-brand-foreground hover:bg-brand/90 focus-visible:ring-brand/20 dark:focus-visible:ring-brand/40", special: diff --git a/packages/ui/src/components/split-button.stories.tsx b/packages/ui/src/components/split-button.stories.tsx new file mode 100644 index 0000000000..28fabd43d7 --- /dev/null +++ b/packages/ui/src/components/split-button.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GitBranch01 } from "@untitledui/icons"; +import { SplitButton, type SplitButtonMenuItem } from "./split-button.tsx"; + +const items: SplitButtonMenuItem[] = [ + { key: "review", label: "Request review", onSelect: () => {} }, + { key: "draft", label: "Convert to draft", onSelect: () => {} }, + { + key: "force", + label: "Force publish", + disabled: true, + tooltip: "Only maintainers can force publish", + onSelect: () => {}, + }, +]; + +const meta = { + title: "Components/SplitButton", + component: SplitButton, + args: { + label: "Publish to main", + menuAriaLabel: "More publish options", + variant: "default", + size: "default", + items, + onClick: () => {}, + }, + argTypes: { + variant: { + control: "select", + options: [ + "default", + "secondary", + "outline", + "destructive", + "success", + "warning", + "brand", + "special", + ], + }, + size: { + control: "select", + options: ["xs", "sm", "default", "lg", "xl"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No `items` — the chevron half is not rendered, leaving a plain button. */ +export const WithoutMenu: Story = { + args: { items: [] }, +}; + +/** The primary action is unavailable, but the menu still offers alternatives. */ +export const DisabledPrimaryWithMenu: Story = { + args: { + label: "Up to date", + disabled: true, + tooltip: "There is nothing new to publish", + }, +}; + +export const Loading: Story = { + args: { label: "Publishing", loading: true }, +}; + +/** Brightness breathes; under `prefers-reduced-motion` it dims statically. */ +export const Pulse: Story = { + args: { pulse: true, variant: "brand" }, +}; + +export const WithIcon: Story = { + args: { icon: }, +}; + +export const Variants: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; + +export const Sizes: Story = { + render: (args) => ( +
+ + + + +
+ ), +}; diff --git a/packages/ui/src/components/split-button.tsx b/packages/ui/src/components/split-button.tsx new file mode 100644 index 0000000000..54c48a4ebe --- /dev/null +++ b/packages/ui/src/components/split-button.tsx @@ -0,0 +1,172 @@ +"use client"; + +import type * as React from "react"; +import { ChevronDown } from "@untitledui/icons"; + +import { cn } from "../lib/utils.ts"; +import { Button } from "./button.tsx"; +import { ButtonGroup } from "./button-group.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown-menu.tsx"; +import { Spinner } from "./spinner.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.tsx"; + +type ButtonProps = React.ComponentProps; + +export interface SplitButtonMenuItem { + /** Stable identity for the item; also its React key. */ + key: string; + label: string; + onSelect: () => void; + disabled?: boolean; + tooltip?: string; + /** Rendered before the label. */ + icon?: React.ReactNode; +} + +export interface SplitButtonProps { + label: string; + onClick?: () => void; + variant?: ButtonProps["variant"]; + size?: ButtonProps["size"]; + /** + * Disables the primary half ONLY. The menu half stays operable so a control + * whose main action is unavailable ("Up to date") can still offer actions. + */ + disabled?: boolean; + /** Shows a spinner in the primary half and swallows its clicks. */ + loading?: boolean; + /** Breathing brightness on the whole control; static dimming without motion. */ + pulse?: boolean; + /** Tooltip on the primary half — shown even while it is disabled. */ + tooltip?: string; + icon?: React.ReactNode; + /** With no items the chevron half is not rendered at all. */ + items?: SplitButtonMenuItem[]; + /** Accessible name for the chevron trigger. Required: this package is i18n-free. */ + menuAriaLabel: string; + className?: string; +} + +function SplitButtonMenuEntry({ item }: { item: SplitButtonMenuItem }) { + const entry = ( + { + item.onSelect(); + }} + > + {item.icon} + {item.label} + + ); + + if (!item.tooltip) { + return entry; + } + + return ( + + {/* Wrapper is the trigger: a disabled item is pointer-events-none. */} + + {entry} + + {item.tooltip} + + ); +} + +/** + * A primary action with an attached dropdown half — `[ Primary | v ]`. + * + * Without `items` it collapses to a plain single button (same rounding as + * `Button`); with them, the halves share one control and only the primary + * responds to `disabled`. + */ +export function SplitButton({ + label, + onClick, + variant = "default", + size = "default", + disabled = false, + loading = false, + pulse = false, + tooltip, + icon, + items, + menuAriaLabel, + className, +}: SplitButtonProps) { + const hasMenu = (items?.length ?? 0) > 0; + + const primary = ( + + ); + + return ( + + {tooltip ? ( + + {/* Wrapper is the trigger: a disabled button swallows hover and focus. */} + + + {primary} + + + {tooltip} + + ) : ( + primary + )} + + {hasMenu ? ( + + + + + + {items?.map((item) => ( + + ))} + + + ) : null} + + ); +} diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index ce954ae29b..3f91bcb40b 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -281,6 +281,8 @@ infinite; --animate-preview-ramp: preview-ramp 20s var(--ease-out-cubic) forwards; --animate-card-land: card-land 240ms var(--ease-out-quint) both; + --animate-pulse-brightness: pulse-brightness 1.8s var(--ease-in-out-cubic) + infinite; } /* Animations */ @@ -308,6 +310,20 @@ --ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86); } +/* Draws the eye to a control that is waiting on the user without moving it — + only its own brightness breathes. Callers pair it with + `motion-reduce:animate-none` plus a static dimmed treatment so the state + survives when motion is off. */ +@keyframes pulse-brightness { + 0%, + 100% { + filter: brightness(1); + } + 50% { + filter: brightness(1.15); + } +} + /* A kanban card settling into the lane it was just dropped in. With live gap preview the card is already sitting in its destination when the pointer is released, so there's no distance left to travel — the motion has to be the