feat: a fork's counts and the way to review them are one control

Four badges beside a button read as four things that happen to sit together.
They are one thing — every segment opens the same page on the part it names — so
they now share a box: one outline, square corners inside it, and the rounding on
the box alone, which means no segment has to know where in the row it sits.

The only rule in the strip is the one before the end cap: everything left of it
is a reading, that is an action.

With room every count says what it counts; below 1280px of bar they keep their
icon and their number, which is the part a reader scans for, and the tooltip
names them either way. The end cap keeps its words at every width — the counts
have icons to fall back on and it has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-07 04:15:55 -07:00
co-authored by Claude Opus 5
parent 16bb5e194a
commit e4504d2843
2 changed files with 186 additions and 98 deletions
@@ -0,0 +1,148 @@
<!--
@component
How far a fork has drifted and the way to review it, as one control rather than a row of separate
marks: the counts and the button share a box, divided but not spaced, and only the two ends of that
box are rounded.
They are one thing — every segment opens the same page on the part it names — so they read as one
shape. Separate badges beside a separate button say four unrelated things happen to sit together.
-->
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
/** What a segment opens: a direction of the comparison, or the drafts beside it. */
export type ForkStatusTarget = 'deploy_to' | 'update' | 'drafts'
interface Props {
/** Items this fork has that its parent lacks. */
ahead?: number
/** Items its parent has that it lacks. */
behind?: number
/** Items that are both, which have to be resolved before either direction can carry them. */
conflicts?: number
/** Work in this fork that is not deployed anywhere yet. */
drafts?: number
/** Opens the page on the part a segment names. */
onOpen: (target: ForkStatusTarget) => void
/** The end cap: the segment that opens the whole comparison. */
action: { label: string; title: string; onclick: () => void }
/** Names the parent in the counts' tooltips. Nullable, like the workspace row it comes from. */
parentWorkspaceId?: string | null
/** What this workspace is called in prose — "fork", or a dev workspace's own word. */
noun?: string
/** The bar is short of room: every segment drops its word and keeps its icon and its count.
* What each one means is in its tooltip either way. */
compact?: boolean
}
let {
ahead = 0,
behind = 0,
conflicts = 0,
drafts = 0,
onOpen,
action,
parentWorkspaceId = '',
noun = 'fork',
compact = false
}: Props = $props()
// The compare page's own tones, so a count in the bar and the rows behind it look alike. Drafts
// share blue with behind and are told apart by the pencil and by naming what they count.
// With room, a segment says what it counts; short of it, the icon and the number carry the
// meaning and the word goes. The icons are the compare page's own, and the tooltip names the
// count either way.
const SEGMENT =
'inline-flex items-center gap-1 px-2 text-2xs whitespace-nowrap transition-colors'
// Each count carries its own fill, the compare page's colour for what it counts. Behind and
// drafts share blue and are told apart by their icon.
const TONES = {
ahead:
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-700/40 dark:text-green-100 dark:hover:bg-green-700/60',
behind:
'bg-blue-50 text-blue-800 hover:bg-blue-100 dark:bg-blue-700/40 dark:text-blue-100 dark:hover:bg-blue-700/60',
conflict:
'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-700/40 dark:text-orange-100 dark:hover:bg-orange-700/60',
draft:
'bg-blue-50 text-blue-800 hover:bg-blue-100 dark:bg-blue-700/40 dark:text-blue-100 dark:hover:bg-blue-700/60'
} as const
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
/** Whether any count stands before the end cap, which is what its rule divides it from. With
* nothing to its left the cap is the whole strip and a leading rule would divide nothing. */
const anyCount = $derived(ahead > 0 || behind > 0 || conflicts > 0 || drafts > 0)
</script>
<!-- `overflow-hidden` on the box is what rounds the two ends and nothing else: each segment keeps
square corners and the box clips them, so no segment has to know where in the row it sits. One
outline around the row and nothing between the segments: their own colours already tell them
apart, and a rule between each pair would cut the row back into the chips this replaced. -->
<div
class="inline-flex items-stretch h-6 rounded-md border bg-surface-tertiary overflow-hidden"
>
{#if ahead > 0}
<button
type="button"
class={twMerge(SEGMENT, TONES.ahead)}
title="{ahead} ahead of {parentWorkspaceId} — review what this {noun} has to give"
onclick={() => onOpen('deploy_to')}
>
<ArrowUp class="w-3 h-3" />
{ahead}{compact ? '' : ' ahead'}
</button>
{/if}
{#if behind > 0}
<button
type="button"
class={twMerge(SEGMENT, TONES.behind)}
title="{behind} behind {parentWorkspaceId} — review what this {noun} has yet to take"
onclick={() => onOpen('update')}
>
<ArrowDown class="w-3 h-3" />
{behind}{compact ? '' : ' behind'}
</button>
{/if}
{#if conflicts > 0}
<!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens the
side it is resolved from before deploying. -->
<button
type="button"
class={twMerge(SEGMENT, TONES.conflict)}
title="{plural(conflicts, 'conflicting item')} — review them"
onclick={() => onOpen('deploy_to')}
>
<AlertTriangle class="w-3 h-3" />
{compact ? conflicts : plural(conflicts, 'conflict')}
</button>
{/if}
{#if drafts > 0}
<button
type="button"
class={twMerge(SEGMENT, TONES.draft)}
title="{plural(
drafts,
'draft'
)} in this {noun}, not deployed anywhere yet — review and deploy"
onclick={() => onOpen('drafts')}
>
<Pencil class="w-3 h-3" />
{compact ? drafts : plural(drafts, 'draft')}
</button>
{/if}
<!-- The end cap carries the whole comparison, and it is here whatever the counts say: a fork
still being counted is exactly when a reader wants the page that counts properly. The strip's
one rule is the one before it: everything to its left is a reading, this is an action. It
keeps its words at every width — the counts have icons to fall back on and it has none. -->
<button
type="button"
class={twMerge(
SEGMENT,
'bg-surface-tertiary text-secondary hover:bg-surface-hover hover:text-primary font-medium',
anyCount && 'border-l'
)}
title={action.title}
onclick={action.onclick}
>
{action.label}
</button>
</div>
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Badge } from './common' import ForkStatusStrip from './ForkStatusStrip.svelte'
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte' import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores' import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
import type { WorkspaceComparison } from '$lib/gen' import type { WorkspaceComparison } from '$lib/gen'
import { fetchWorkspaceComparison } from '$lib/workspaceComparison' import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
import { Button } from './common'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte' import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
@@ -56,6 +55,12 @@
} }
const hasAnswer = $derived(isAnswerable(comparison, loading)) const hasAnswer = $derived(isAnswerable(comparison, loading))
/** Bar width from which every count in the strip says what it counts. Below it they keep their
* icon and their number, which is the part a reader is actually scanning for. Higher than the
* width at which home puts its side trips in the bar, so the two do not arrive together. */
const BAR_FITS_COUNT_LABELS = 1280
const compactStrip = $derived(pageHeader.barWidth < BAR_FITS_COUNT_LABELS)
// Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs). // Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs).
function isUpToDate(c: WorkspaceComparison | undefined): boolean { function isUpToDate(c: WorkspaceComparison | undefined): boolean {
return !!c && !c.skipped_comparison && c.summary.total_diffs === 0 return !!c && !c.skipped_comparison && c.summary.total_diffs === 0
@@ -201,104 +206,39 @@
{#snippet forkAction()} {#snippet forkAction()}
{@const total = comparison?.summary.total_diffs ?? 0} {@const total = comparison?.summary.total_diffs ?? 0}
{@const conflicts = comparison?.summary.conflicts ?? 0} {@const conflicts = comparison?.summary.conflicts ?? 0}
<!-- What the comparison has to say is a mark beside the button, never instead of it: a fork <!-- What the comparison has to say stands beside the strip, never instead of it: a fork still
still being counted, or one whose count failed, is exactly when a reader wants the page being counted, or one whose count failed, is exactly when a reader wants the page that does
that does the counting properly. The button below renders in every state. --> the counting properly. -->
{#if loading} {#if loading}
<span class="text-2xs text-tertiary">Checking for changes…</span> <span class="text-2xs text-tertiary">Checking for changes…</span>
{:else if error} {:else if error}
<span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span> <span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span>
{:else if showDraftsOnly}
<Badge color="blue" small>
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
{:else if total > 0}
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
to give its parent, behind is what it has yet to take. -->
<!-- The colours and the icons the compare page gives these three, so the counts in the bar
and the rows behind the button say the same thing the same way. -->
{#if changesAhead > 0}
<Badge
color="green"
small
clickable
onclick={() => openCompareDir('deploy_to')}
title="{changesAhead} ahead of {parentWorkspaceId} — review what this {currentNoun} has to give"
>
<ArrowUp class="w-3 h-3 inline" />
{changesAhead}
</Badge>
{/if}
{#if changesBehind > 0}
<Badge
color="blue"
small
clickable
onclick={() => openCompareDir('update')}
title="{changesBehind} behind {parentWorkspaceId} — review what this {currentNoun} has yet to take"
>
<ArrowDown class="w-3 h-3 inline" />
{changesBehind}
</Badge>
{/if}
{#if conflicts > 0}
<!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens
the side the reader resolves it from before deploying. -->
<Badge
color="orange"
small
clickable
onclick={() => openCompareDir('deploy_to')}
title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''} — review them"
>
<AlertTriangle class="w-3 h-3 inline" />
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
<!-- Drafts are a different axis from the drift beside them: ahead and behind count deployed
items against the parent, this counts work in this fork that is not deployed anywhere
yet. It shares blue with "behind" and is told apart by the pencil and by naming what it
counts, rather than by an arrow that would read as a third direction. Clicking goes to
the drafts half of the compare page rather than the drawer the button opens. It shows at
every drift — a fork both behind and holding drafts said nothing about the drafts. -->
{#if draftCount > 0}
<Badge
color="blue"
small
clickable
onclick={openDraftCompare}
title="{draftCount} draft{draftCount !== 1
? 's'
: ''} in this {currentNoun}, not deployed anywhere yet — review and deploy"
>
<Pencil class="w-3 h-3 inline" />
{draftCount} draft{draftCount !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
{#if showDraftsOnly}
<Button
variant="subtle"
unifiedSize="sm"
onclick={openDraftCompare}
title={`Review and deploy this workspace's drafts`}
>
Review & deploy drafts
</Button>
{:else}
<Button
variant="subtle"
unifiedSize="sm"
onclick={openComparisonDrawer}
title={hasAnswer
? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`
: `Review this ${currentNoun} against ${parentWorkspaceId}`}
>
{#if !hasAnswer || changesAhead > 0}
Review changes
{:else}
Review & Update {currentNoun}
{/if}
</Button>
{/if} {/if}
<!-- The counts and the way to review them are one control: every segment opens the same page on
the part it names, so they share a box rather than sitting beside each other as four
unrelated marks. -->
<ForkStatusStrip
ahead={changesAhead}
behind={changesBehind}
{conflicts}
drafts={draftCount}
{parentWorkspaceId}
noun={currentNoun}
compact={compactStrip}
onOpen={(target) =>
target === 'drafts' ? openDraftCompare() : openCompareDir(target)}
action={showDraftsOnly
? {
label: 'Review & deploy drafts',
title: `Review and deploy this workspace's drafts`,
onclick: openDraftCompare
}
: {
label: !hasAnswer || changesAhead > 0 ? 'Review changes' : `Review & Update ${currentNoun}`,
title: hasAnswer
? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`
: `Review this ${currentNoun} against ${parentWorkspaceId}`,
onclick: openComparisonDrawer
}}
/>
{/snippet} {/snippet}