diff --git a/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte
new file mode 100644
index 000000000..1746bb190
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineCard.svelte b/web/src/lib/components/repo/pipelines/PipelineCard.svelte
new file mode 100644
index 000000000..c56a41ff6
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineCard.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte
new file mode 100644
index 000000000..7be23d6c6
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte
@@ -0,0 +1,154 @@
+
+
+
+
+{#snippet triggerLabel()}
+ {#if pipeline.trigger.kind === "push"}
+
+ Push to
+ {pipeline.trigger.targetRef}
+ {:else if pipeline.trigger.kind === "pull_request"}
+
+ Pull request
+ {pipeline.trigger.targetRef}
+
+ {pipeline.trigger.sourceLabel}
+ {:else}
+
+ Manual dispatch
+ {/if}
+{/snippet}
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineEmptyState.svelte b/web/src/lib/components/repo/pipelines/PipelineEmptyState.svelte
new file mode 100644
index 000000000..86551aa4f
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineEmptyState.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+{#snippet step(index: number, body: Snippet)}
+
+ {index}
+ {@render body()}
+
+{/snippet}
+
+
+
+
+
+
+
No pipelines have been run yet
+
Get started by configuring CI/CD for this repository.
+
+
+
+ {@render step(1, spindle)}
+ {@render step(2, configure)}
+ {@render step(3, trigger)}
+
+
+
+{#snippet spindle()}
+ Choose a spindle in your repository settings
+{/snippet}
+
+{#snippet configure()}
+
+ Configure your CI/CD
+ pipeline
+{/snippet}
+
+{#snippet trigger()}
+ Trigger a workflow with a push or pull request
+{/snippet}
diff --git a/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte
new file mode 100644
index 000000000..7a837b9a7
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineList.svelte b/web/src/lib/components/repo/pipelines/PipelineList.svelte
new file mode 100644
index 000000000..72efb51f6
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineList.svelte
@@ -0,0 +1,28 @@
+
+
+{#if pipelines.length > 0}
+
+ {#each pipelines as pipeline (pipeline.id)}
+
+ {/each}
+
+{:else if filtered}
+
+{:else}
+
+{/if}
diff --git a/web/src/lib/components/repo/pipelines/PipelineSearch.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineSearch.stories.svelte
new file mode 100644
index 000000000..169edb50c
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineSearch.stories.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineSearch.svelte b/web/src/lib/components/repo/pipelines/PipelineSearch.svelte
new file mode 100644
index 000000000..b226cbdda
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineSearch.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineStatusIcon.svelte b/web/src/lib/components/repo/pipelines/PipelineStatusIcon.svelte
new file mode 100644
index 000000000..bffc99455
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineStatusIcon.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineToolbar.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineToolbar.stories.svelte
new file mode 100644
index 000000000..e6b7293ae
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineToolbar.stories.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineToolbar.svelte b/web/src/lib/components/repo/pipelines/PipelineToolbar.svelte
new file mode 100644
index 000000000..75007d724
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineToolbar.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {total} pipeline {total === 1 ? "run" : "runs"}
+
+
diff --git a/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte
new file mode 100644
index 000000000..b608f522b
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte
@@ -0,0 +1,81 @@
+
+
+
+
+{#if count === 0}
+
+
+
+ Waiting for a spindle…
+
+{:else}
+
+ {#snippet trigger()}
+
+ {label}
+
+
+ {/snippet}
+
+ {#each pipeline.workflows as workflow (workflow.name)}
+
+
+
+
+
+ {workflow.name}
+
+
+ {workflow.duration > 0
+ ? formatDuration(workflow.duration)
+ : STATUS_LABELS[workflow.status]}
+
+
+
+ {/each}
+
+{/if}
diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts
new file mode 100644
index 000000000..eaf706d1e
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/mock.ts
@@ -0,0 +1,88 @@
+// fake data for the pipelines tab until it is wired to bobbin
+
+import type { PipelineSummary } from "$lib/components/repo/types";
+
+const minutes = (n: number) => n * 60_000;
+
+// fixed base so the times do not depend on when this runs
+const base = Date.parse("2026-08-04T09:00:00Z");
+const ago = (mins: number) => new Date(base - minutes(mins)).toISOString();
+
+export const pipelines: PipelineSummary[] = [
+ // a run still in flight
+ {
+ id: "01K1XJ4WPQ0000000000000001",
+ sha: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b",
+ createdAt: ago(3),
+ trigger: { kind: "push", targetRef: "master" },
+ workflows: [
+ { name: "build", status: "success", duration: minutes(1) + 12_000 },
+ { name: "test", status: "running", duration: 0 },
+ { name: "lint", status: "pending", duration: 0 }
+ ]
+ },
+ // mixed outcome
+ {
+ id: "01K1XJ4WPQ0000000000000002",
+ sha: "3d7e91c05fa2b8461d0e5c7a9b3f2e1d4c6a8b0f",
+ createdAt: ago(47),
+ trigger: {
+ kind: "pull_request",
+ targetRef: "master",
+ sourceLabel: "oppi.li/core:fix-packfile-timeout",
+ pullPath: "/tangled.org/core/pulls/42"
+ },
+ workflows: [
+ { name: "build", status: "success", duration: minutes(2) + 4_000 },
+ { name: "test", status: "failed", duration: minutes(6) + 31_000, error: "3 tests failed" },
+ { name: "lint", status: "success", duration: 41_000 }
+ ]
+ },
+ // all green
+ {
+ id: "01K1XJ4WPQ0000000000000003",
+ sha: "c1b4a7e2f9308d65b2a1c4e7f0d3b6a9c2e5f8b1",
+ createdAt: ago(190),
+ trigger: { kind: "push", targetRef: "master" },
+ workflows: [
+ { name: "build", status: "success", duration: minutes(1) + 58_000 },
+ { name: "test", status: "success", duration: minutes(5) + 12_000 },
+ { name: "lint", status: "success", duration: 39_000 }
+ ]
+ },
+ // a spindle that never answered
+ {
+ id: "01K1XJ4WPQ0000000000000004",
+ sha: "7a0f3b8c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f70",
+ createdAt: ago(320),
+ trigger: { kind: "manual" },
+ workflows: []
+ },
+ // timed out
+ {
+ id: "01K1XJ4WPQ0000000000000005",
+ sha: "e5d4c3b2a1908f7e6d5c4b3a2918f7e6d5c4b3a2",
+ createdAt: ago(1_450),
+ trigger: {
+ kind: "pull_request",
+ targetRef: "release-1.4",
+ sourceLabel: "bump-deps"
+ },
+ workflows: [
+ { name: "build", status: "success", duration: minutes(2) + 9_000 },
+ { name: "e2e", status: "timeout", duration: minutes(30) }
+ ]
+ },
+ // cancelled mid-run
+ {
+ id: "01K1XJ4WPQ0000000000000006",
+ sha: "b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9",
+ createdAt: ago(2_880),
+ // the appview strips refs/heads/ and refs/tags/ before it reaches the card
+ trigger: { kind: "push", targetRef: "v1.3.0" },
+ workflows: [
+ { name: "build", status: "cancelled", duration: 18_000 },
+ { name: "release", status: "cancelled", duration: 0 }
+ ]
+ }
+];
diff --git a/web/src/lib/components/repo/pipelines/pipeline.ts b/web/src/lib/components/repo/pipelines/pipeline.ts
new file mode 100644
index 000000000..f0f4dfb90
--- /dev/null
+++ b/web/src/lib/components/repo/pipelines/pipeline.ts
@@ -0,0 +1,89 @@
+// mirrors the helpers on types.Pipeline in types/pipeline.go
+
+import type {
+ PipelineStatus,
+ PipelineSummary,
+ PipelineTriggerKind
+} from "$lib/components/repo/types";
+
+/** the trigger tabs */
+export type PipelineFilter = "all" | PipelineTriggerKind;
+
+/** what search looks at. a real bobbin query has to cover the same fields. */
+export const matchesQuery = (pipeline: PipelineSummary, query: string): boolean => {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return true;
+
+ const haystack = [
+ pipeline.sha,
+ pipeline.trigger.kind === "manual" ? "manual dispatch" : pipeline.trigger.targetRef,
+ pipeline.trigger.kind === "pull_request" ? pipeline.trigger.sourceLabel : "",
+ ...pipeline.workflows.map((workflow) => workflow.name)
+ ];
+ return haystack.some((field) => field.toLowerCase().includes(needle));
+};
+
+/** the order the breakdown lists statuses in */
+export const STATUS_ORDER: PipelineStatus[] = [
+ "success",
+ "failed",
+ "timeout",
+ "cancelled",
+ "running",
+ "pending"
+];
+
+export const STATUS_LABELS: Record = {
+ pending: "Pending",
+ running: "Running",
+ success: "Passed",
+ failed: "Failed",
+ timeout: "Timed out",
+ cancelled: "Cancelled"
+};
+
+export const statusCounts = (pipeline: PipelineSummary): Record => {
+ const counts = {
+ pending: 0,
+ running: 0,
+ success: 0,
+ failed: 0,
+ timeout: 0,
+ cancelled: 0
+ };
+ for (const workflow of pipeline.workflows) counts[workflow.status]++;
+ return counts;
+};
+
+/** one status for the whole run. anything still going wins, so an in-flight run says so. */
+export const aggregateStatus = (pipeline: PipelineSummary): PipelineStatus => {
+ // no workflows yet means no spindle has picked it up
+ if (pipeline.workflows.length === 0) return "pending";
+
+ const counts = statusCounts(pipeline);
+ if (counts.running > 0) return "running";
+ if (counts.pending > 0) return "pending";
+ if (counts.failed > 0) return "failed";
+ if (counts.timeout > 0) return "timeout";
+ if (counts.cancelled > 0) return "cancelled";
+ return "success";
+};
+
+/** how many workflows are done — everything except pending and running */
+export const finishedCount = (pipeline: PipelineSummary): number => {
+ const counts = statusCounts(pipeline);
+ return counts.success + counts.failed + counts.timeout + counts.cancelled;
+};
+
+/** "2/3 passed, 1/3 failed" */
+export const longSummary = (pipeline: PipelineSummary): string => {
+ const counts = statusCounts(pipeline);
+ const total = pipeline.workflows.length;
+ return STATUS_ORDER.filter((status) => counts[status] > 0)
+ .map((status) => `${counts[status]}/${total} ${STATUS_LABELS[status].toLowerCase()}`)
+ .join(", ");
+};
+
+/** sum of the workflows, not wall time across the run */
+export const totalDuration = (pipeline: PipelineSummary): number =>
+ pipeline.workflows.reduce((sum, workflow) => sum + workflow.duration, 0);
diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts
index 0c3d8622e..f137fdbff 100644
--- a/web/src/lib/components/repo/types.ts
+++ b/web/src/lib/components/repo/types.ts
@@ -49,3 +49,39 @@ export interface IssueSummary {
createdAt: string;
commentCount: number;
}
+
+/** the spindle's workflow states, same strings as sh.tangled.ci.pipeline */
+export type PipelineStatus = "pending" | "running" | "success" | "failed" | "timeout" | "cancelled";
+
+export interface WorkflowSummary {
+ name: string;
+ status: PipelineStatus;
+ /** wall time in ms; 0 until the workflow finishes */
+ duration: number;
+ /** first line of the spindle's error, when it failed before running */
+ error?: string;
+}
+
+export type PipelineTrigger =
+ | { kind: "push"; targetRef: string }
+ | {
+ kind: "pull_request";
+ targetRef: string;
+ /** where the head came from — `handle/repo:branch` when it is a fork */
+ sourceLabel: string;
+ /** the pull page, absent when the pull record is gone */
+ pullPath?: string;
+ }
+ | { kind: "manual" };
+
+export type PipelineTriggerKind = PipelineTrigger["kind"];
+
+export interface PipelineSummary {
+ id: string;
+ /** the commit the spindle checked out */
+ sha: string;
+ createdAt: string;
+ trigger: PipelineTrigger;
+ /** empty while the spindle has not reported back yet */
+ workflows: WorkflowSummary[];
+}
diff --git a/web/src/lib/components/ui/Button.svelte b/web/src/lib/components/ui/Button.svelte
index 2ecf0efc2..4ea6cacaa 100644
--- a/web/src/lib/components/ui/Button.svelte
+++ b/web/src/lib/components/ui/Button.svelte
@@ -126,6 +126,7 @@
class?: string;
loading?: boolean;
spinnerClass?: string;
+ ref?: HTMLAnchorElement | HTMLButtonElement;
children?: Snippet;
}
@@ -141,6 +142,7 @@
class: className,
loading = false,
spinnerClass,
+ ref = $bindable(),
children,
...rest
}: Props = $props();
@@ -176,6 +178,7 @@
{#if href}
{:else}