From b98badb2468df7dee10c6d330158440fbec414de Mon Sep 17 00:00:00 2001 From: Magnus <21985329+im0xMagnus@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:05:20 +1000 Subject: [PATCH 1/3] fix(recovery): exclude hidden issues from stranded recovery and continuation wakes (#5648) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The recovery subsystem watches assigned issues and re-wakes an agent whose run ended without finishing the work > - Intake can hide a duplicate issue by setting `hiddenAt` while leaving its status and assignee in place > - The stranded-issue query and the terminal-run cleanup both ignore `hiddenAt`, so a hidden issue is re-woken on every cycle > - Nothing on the board shows the hidden issue, so the repeated wakes have no visible cause > - This pull request adds a hidden-issue guard to both predicates and a test for each > - The benefit is that hiding an issue stops recovery work on it, with no other change in behavior for visible issues ## Linked Issues or Issue Description **What happened?** When intake marks an issue as a duplicate it sets `hiddenAt` but leaves the status at `todo` or `in_progress` with the agent still assigned. The stranded-issue recovery timer selects that issue on every tick and queues an `issue_continuation_needed` wake for it. The agent's run on the hidden issue fails or is cancelled, the terminal-run cleanup queues immediate recovery for the same issue, and the cycle repeats indefinitely. Hidden issues are invisible on the board, so nothing a person can see explains the wakes. **Expected behavior** A hidden issue is never a recovery candidate. Stranded-issue reconciliation skips it, and a failed, timed-out or cancelled run on it releases the issue without queuing a continuation. **Steps to reproduce** 1. Assign an issue to an agent and leave it `in_progress`. 2. Hide the issue (set `hiddenAt`, for example by marking it a duplicate through intake) without changing its status or assignee. 3. Let a run on that issue fail, or wait for the stranded-issue recovery timer. 4. Observe a new `issue_continuation_needed` heartbeat run queued for the hidden issue on every cycle. **Paperclip version or commit** Reproduced on `master` when this PR was opened (May 2026). The two predicates are unchanged on current `master`; this branch is rebased onto it. **Deployment mode** Not deployment-specific: both guards are in the server's recovery and heartbeat services and apply in every mode. ## What Changed - `server/src/services/recovery/service.ts`: `isNull(issues.hiddenAt)` added to the `reconcileStrandedAssignedIssues` candidate query, so hidden issues never enter the stranded set. - `server/src/services/heartbeat.ts`: `!issue.hiddenAt` added to `issueNeedsImmediateRecovery`, so terminal-run cleanup releases a hidden issue instead of queuing a continuation. - `server/src/__tests__/heartbeat-process-recovery.test.ts`: one test per guard. A failed run on a hidden issue queues no recovery run, and a hidden stranded issue is left out of reconciliation. ## Verification - `heartbeat-process-recovery.test.ts` covers both guards; CI runs it against embedded Postgres. ## Risks Low. Both changes narrow an existing predicate to exclude rows that already carry `hiddenAt`; visible issues take exactly the path they take today. A hidden issue that genuinely needs recovery would have to be unhidden first, which matches how hidden issues behave everywhere else in the board. ## Model Used The original two-line fix was authored by @im0xMagnus. The rebase onto current `master`, the two regression tests, and this description were produced with Claude (claude-fable-5-1, extended thinking, tool use) driven by a Paperclip maintainer through Prospector's triage flow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Andrew Aymeloglu --- .../heartbeat-process-recovery.test.ts | 51 ++++++++++++++++++- server/src/services/heartbeat.ts | 1 + server/src/services/recovery/service.ts | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index b2f1c703d0..c958b27641 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1325,6 +1325,53 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(agent).toEqual({ status: "running", errorReason: null }); }); + it("does not queue immediate recovery when the failed run's issue is hidden", async () => { + mockAdapterExecute.mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: null, + provider: "test", + model: "test-model", + }); + + const { runId, issueId } = await seedQueuedIssueRunFixture(); + await db + .update(issues) + .set({ hiddenAt: new Date("2026-03-19T00:05:00.000Z") }) + .where(eq(issues.id, issueId)); + const heartbeat = heartbeatService(db); + + await heartbeat.resumeQueuedRuns(); + await waitForRunToSettle(heartbeat, runId); + await heartbeat.waitForRunExecutionDrain(runId); + + const run = await heartbeat.getRun(runId); + const recoveryRuns = await db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.retryOfRunId, runId)); + + expect(run).toMatchObject({ status: "failed" }); + expect(recoveryRuns).toHaveLength(0); + }); + + it("leaves hidden issues out of stranded-issue reconciliation", async () => { + const { issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + }); + await db + .update(issues) + .set({ hiddenAt: new Date("2026-03-19T00:05:00.000Z") }) + .where(eq(issues.id, issueId)); + + const result = await heartbeatService(db).reconcileStrandedAssignedIssues(); + + expect(result.issueIds).not.toContain(issueId); + expect(result.continuationRequeued).toBe(0); + }); + it("keeps a local run active when the recorded pid is still alive", async () => { const child = spawnAliveProcess(); childProcesses.add(child); @@ -6028,7 +6075,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(result.continuationRequeued).toBe(0); expect(result.escalated).toBe(1); expect(result.skipped).toBe(0); - expect(result.issueIds).toEqual([blocked.issueId, unblocked.issueId]); + expect([...result.issueIds].sort()).toEqual( + [blocked.issueId, unblocked.issueId].sort(), + ); const blockedWakeups = await db .select() diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index faa8b46f68..af753d2cf5 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -22814,6 +22814,7 @@ export function heartbeatService( const issueNeedsImmediateRecovery = (issue.status === "todo" || issue.status === "in_progress") && !issue.assigneeUserId && + !issue.hiddenAt && issue.assigneeAgentId === run.agentId && (run.status === "failed" || run.status === "timed_out" || diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 2d8cbb5d86..8d7655d6d6 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -3451,6 +3451,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.status, "in_review"), ), opts?.issueCreatedAtGte ? gte(issues.createdAt, opts.issueCreatedAtGte) : undefined, + isNull(issues.hiddenAt), ), ); From 82ee0a68d470d2c88cec2367bf379aa07942a921 Mon Sep 17 00:00:00 2001 From: Ross Sclafani Date: Thu, 3 Sep 2026 22:22:33 -0500 Subject: [PATCH 2/3] chore(docker): pass PAPERCLIP_ALLOWED_HOSTNAMES through to quickstart (#6846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Operators run Paperclip in many places: localhost dev, LAN servers, Tailscale meshes, cloud VMs > - The server already supports a `PAPERCLIP_ALLOWED_HOSTNAMES` env var for hostname allow-listing (`server/src/config.ts`) > - But `docker/docker-compose.quickstart.yml` did not forward that env var from the host to the container > - So an operator running quickstart on a LAN gets "Hostname '' is not allowed for this Paperclip instance" with no env-only escape hatch — they're forced to run the CLI inside the container to write `config.json` > - This PR adds a one-line passthrough so the existing env var works end-to-end with the quickstart compose file > - The benefit is parity with the server's documented config surface: anything settable via env on a bare-metal run is now settable via env on a quickstart docker run ## Linked Issues or Issue Description **What happened?** Running the quickstart compose file on a LAN host and opening the UI by the machine's LAN address fails with "Hostname '' is not allowed for this Paperclip instance". The server supports `PAPERCLIP_ALLOWED_HOSTNAMES` for exactly this case and `doc/DOCKER.md` tells operators to set it, but `docker/docker-compose.quickstart.yml` never forwards the variable into the container, so setting it on the host has no effect. **Expected behavior** Setting `PAPERCLIP_ALLOWED_HOSTNAMES` on the host before `docker compose up` reaches the server, the same way `PAPERCLIP_PUBLIC_URL` and the provider keys do. **Steps to reproduce** 1. `export PAPERCLIP_ALLOWED_HOSTNAMES=my-lan-host` alongside the other quickstart variables. 2. `docker compose -f docker-compose.quickstart.yml up --build`. 3. Open `http://my-lan-host:3100` and observe the hostname rejection. **Paperclip version or commit** `master` when this PR was opened (May 2026); the quickstart file on current `master` still has no passthrough. The branch is rebased onto current `master`. **Deployment mode** Docker quickstart (`docker-compose.quickstart.yml`), authenticated and private. ## What Changed - `docker/docker-compose.quickstart.yml`: forward `PAPERCLIP_ALLOWED_HOSTNAMES` from the host environment with an empty default, matching the existing pattern used for `PAPERCLIP_PUBLIC_URL`, `OPENAI_API_KEY`, etc. ## Verification ```sh # 1. Set the env var echo \"PAPERCLIP_ALLOWED_HOSTNAMES=localhost,my-lan-ip\" >> .env # 2. Bring up the quickstart docker compose --env-file .env -f docker/docker-compose.quickstart.yml up -d # 3. Confirm the value reached the container docker compose -f docker/docker-compose.quickstart.yml exec paperclip \\ sh -c 'echo \"\$PAPERCLIP_ALLOWED_HOSTNAMES\"' # → localhost,my-lan-ip # 4. Confirm boot-time trusted-origins log includes the LAN host docker compose -f docker/docker-compose.quickstart.yml logs paperclip | grep trustedOrigins # 5. Confirm a request from the LAN host returns 401 (auth required), not the hostname rejection curl -i -H \"Host: my-lan-ip:3100\" http://localhost:3100/api/auth/get-session # → HTTP/1.1 401 Unauthorized ``` Tested locally on Linux with an authenticated/private deployment, migrated DB from another paperclip instance, and a LAN host reaching the container. The image was rebuilt with \`--no-cache\` from a clean checkout of this branch's tip (no other unmerged work in the build context) to confirm the change is self-contained. ## Risks Low risk. - Default value is empty string — behavior identical to before for any operator who doesn't set the var. - Env var name and semantics already implemented and documented on the server side (\`server/src/config.ts\`); this PR only routes the value through compose. - One-line yaml change, no code touched, no tests affected. ## Model Used - Claude (Anthropic) — Opus 4.7 (1M context). Used for the bug isolation, the env-var-vs-config-file choice, and the PR write-up. Authored alongside Ross Sclafani who tested end-to-end against a migrated LAN deployment. ## Checklist - [x] Thinking path traces from project context to this change - [x] Model used specified (with version + capability details) - [x] Checked ROADMAP.md — not a feature, no overlap with planned work - [x] Ran tests locally (\`pnpm install --frozen-lockfile\`, \`pnpm build\` clean; container rebuilt \`--no-cache\` from this branch tip and verified end-to-end) - Added or updated tests — N/A (compose env passthrough; no executable code path) - UI change screenshots — N/A (no UI) - [x] No documentation updates needed (env var already documented server-side) - [x] Considered risks (above) - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Will address all Greptile/reviewer comments before requesting merge --- docker/docker-compose.quickstart.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/docker-compose.quickstart.yml b/docker/docker-compose.quickstart.yml index 41a674916f..f750bda19b 100644 --- a/docker/docker-compose.quickstart.yml +++ b/docker/docker-compose.quickstart.yml @@ -33,6 +33,9 @@ services: PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" # Base URL users will access the UI at PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}" + # Extra hostnames the instance accepts beyond the public URL host + # (LAN or Tailscale aliases), comma-separated; empty means none + PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-}" # ── Required secret for session signing (generate with: openssl rand -hex 32) ── BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" From 9ef3b087c14686327e4915705ae1fe4537fccc33 Mon Sep 17 00:00:00 2001 From: Tonio Date: Thu, 3 Sep 2026 20:52:31 -0700 Subject: [PATCH 3/3] feat(onboarding): round-4 corrections to the connect and agent steps (#12796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the connect and agent steps from the design's own values through the Figma MCP rather than measuring an export, which corrected the arc column inset (433px content, 64px inset — `--sz-68px` goes with the mismeasurement it was minted for) and restored the selected tile's border alongside its fill. Round-4 items: sources named for the provider you sign in with, OpenAI's mark inlined so it can take `currentColor` on a light tile, monochrome autofill via `box-shadow` (Chrome ignores `background-color`), and the agent step's placeholder. Also wires `MODEL_SOURCE_NAMES`, which was added for the rename and never read — the tiles kept passing the display registry's label, so the step still showed "Claude Code" and "Codex" under a heading asking which provider you are signing in to. Covered by a test that fails on the unwired version. --- ui/src/components/OnboardingWizard.test.tsx | 30 ++++++++- ui/src/components/OnboardingWizard.tsx | 63 +++++++++++++++++-- .../onboarding/ModelSourceTiles.tsx | 13 ++-- ui/src/index.css | 27 +++++++- 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 6497046447..ed1a69ba95 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -1849,6 +1849,31 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return { root, queryClient }; } + it("names the tiles for the provider, not the adapter type", async () => { + // `MODEL_SOURCE_NAMES` exists so this row says "Claude" and "OpenAI" — + // which provider you are signing in to, the question the step's heading + // asks — rather than the display registry's tool names, which the agent + // config screens want. It was added with a long comment justifying it and + // then never read, so the row went on rendering whatever the registry + // supplied: "Claude Code" and "Codex" in the app, and the bare type here, + // since this suite's registry mock returns `label: type`. + mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + const { root } = await openStep4({ adapterType: "claude_local" }); + + const labels = [...document.body.querySelectorAll("button[aria-checked]")].map( + (tile) => tile.textContent ?? "", + ); + expect(labels.length, "both recommended sources should render").toBe(2); + expect(labels.some((l) => l.includes("Claude"))).toBe(true); + expect(labels.some((l) => l.includes("OpenAI"))).toBe(true); + // The negative half is the one that fails on the unwired version: the + // registry label is the adapter type, and it must not reach the tile. + expect(labels.join(" ")).not.toContain("claude_local"); + expect(labels.join(" ")).not.toContain("codex_local"); + + await act(async () => root.unmount()); + }); + it("will not advance on a saved adapter the step no longer offers", async () => { // A draft can name an adapter this registry does not carry — a cloud // sandbox without claude_local, an adapter since disabled. The row hides @@ -2036,7 +2061,10 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // the credential switch instead. What is asserted below is unchanged — // changing the source re-reads the signal — only the route there is. // The tile's text is the label plus its credential tag, hence the prefix. - await clickByText((t) => t.startsWith("codex_local")); + // That label is the provider name now, not the adapter type: this row + // asks which provider you are signing in to, so it reads through + // `MODEL_SOURCE_NAMES` rather than the display registry. + await clickByText((t) => t.startsWith("OpenAI")); expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith( "company-new", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 09dc2cf6e8..c6b704fe12 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -201,7 +201,49 @@ function adapterConfigHasAnthropicApiKey(config: Record): boole */ const MODEL_SOURCE_BRAND_MARKS: Record = { claude_local: "/brands/claude-color.svg", - codex_local: "/brands/codex-color.svg", +}; + +/** + * What the connect step calls each source. + * + * Deliberately not the display registry's label, which ten other surfaces read. + * This step asks which *provider* you are signing in with — the panel under the + * row says "Sign in to Anthropic" and "Sign in to OpenAI" — while the agent + * config screens name the tool that runs ("Codex CLI was not found on this + * host"). One rename in the registry would make that message say OpenAI, which + * is vaguer, not clearer. + * + * It is a tension worth naming rather than hiding: DESIGN.md asks for one name + * per concept, and this is two names for one adapter. The concepts are + * different — vendor here, tool there — but if the product decides otherwise, + * this map is the thing to delete. + */ +const MODEL_SOURCE_NAMES: Record = { + claude_local: "Claude", + codex_local: "OpenAI", +}; + +/** + * OpenAI's blossom, inline rather than served from `/brands`. + * + * The supplied asset is a white fill, which was fine while this row only ever + * sat on a dark tile. It follows the reader's system setting now, and white on + * the light tile is invisible. Inlining lets the path take + * `currentColor` and be legible in both, which an `` cannot do. + */ +function OpenAiBlossom({ className }: { className?: string }) { + return ( + + + + ); +} + +const MODEL_SOURCE_INLINE_MARKS: Record> = { + codex_local: OpenAiBlossom, }; /** @@ -228,6 +270,8 @@ function ModelSourceMark({ type: string; Fallback: ComponentType<{ className?: string }>; }) { + const Inline = MODEL_SOURCE_INLINE_MARKS[type]; + if (Inline) return ; const brand = MODEL_SOURCE_BRAND_MARKS[type]; if (!brand) return ; return ; @@ -2175,7 +2219,7 @@ function OnboardingWizardInner({ // tiles stretch and the name field sits under a question far // narrower than itself. isAgentArcStep || step === 1 - ? "w-(--sz-560px) max-w-full px-8 py-10 sm:px-(--sz-68px) sm:py-11" + ? "w-(--sz-560px) max-w-full px-8 py-10 sm:px-(--sz-64px) sm:py-11" : "w-full max-w-md px-8 py-12", )} > @@ -2644,7 +2688,7 @@ function OnboardingWizardInner({ setAgentName(e.target.value)} autoFocus @@ -2675,7 +2719,18 @@ function OnboardingWizardInner({ label="Model source" sources={recommendedAdapters.map((opt) => ({ id: opt.type, - label: opt.label, + // The vendor name where this step has one, the registry's + // tool name where it does not. `MODEL_SOURCE_NAMES` was + // added with the reasoning above it and then never read, + // so the row went on showing "Claude Code" and "Codex" + // — the tool names — under a heading asking which + // provider you are signing in to. + // + // The fallback is what keeps the row rendering if the + // registry ever marks a third adapter `recommended`: + // an unnamed source gets its tool name rather than + // nothing. + label: MODEL_SOURCE_NAMES[opt.type] ?? opt.label, icon: , }))} mode={credentialMode} diff --git a/ui/src/components/onboarding/ModelSourceTiles.tsx b/ui/src/components/onboarding/ModelSourceTiles.tsx index b2cf82c238..92137b53ee 100644 --- a/ui/src/components/onboarding/ModelSourceTiles.tsx +++ b/ui/src/components/onboarding/ModelSourceTiles.tsx @@ -82,16 +82,17 @@ function ModelSourceTile({ // and lending it to focus as well would mean tabbing across the row // looked like picking every tile in turn. "outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)", - // Selection is a lighter surface, not a brighter edge. Both states keep - // the same border — it draws the tile, not the choice — and the fill - // carries the state. A bright stroke on one tile made the row read as - // one outlined object beside one plain one, rather than two of a kind - // with one of them picked. + // Selection is a lighter surface *and* a brighter edge. An earlier pass + // here used the fill alone, reasoning that a bright stroke on one tile + // made the row read as one outlined object beside a plain one. The + // design does both, and it is right: at these sizes one step of fill is + // too quiet to answer "which did I pick?" from across the screen, and + // the stroke is what carries it. // // Hover stops short of the selected fill, so pointing at a tile says // "this one is live" rather than "this one is chosen". selected - ? "border-border bg-accent" + ? "border-foreground/40 bg-accent" : "border-border bg-card hover:bg-accent/40", )} > diff --git a/ui/src/index.css b/ui/src/index.css index 2721a5cccf..ef2d25941c 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -446,6 +446,32 @@ } @layer base { + /* + Autofill, in the app's own colours. + + Chrome paints an autofilled field with a fixed blue-tinted fill of its own — + it ignores `background-color` entirely, which is why nothing in the token + layer reached it and a filled email field came out blue on an otherwise + monochrome screen. `box-shadow` is the one property that does reach it: an + inset shadow thick enough to cover the field repaints the surface, and + `-webkit-text-fill-color` does the same job for the text. + + The transition delay is the standard trick for keeping it: Chrome re-applies + its own fill on interaction, and a delay long enough to outlast the frame is + what stops it flashing back. + */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active, + textarea:-webkit-autofill, + select:-webkit-autofill { + -webkit-text-fill-color: var(--foreground); + caret-color: var(--foreground); + box-shadow: inset 0 0 0 1000px var(--muted); + transition: background-color 100000s ease-in-out 0s; + } + * { @apply border-border outline-ring/50; } @@ -2373,7 +2399,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --sz-calc-13: calc(0.75rem + 0.5rem); /* Extracted from ui/src/components/IssueRow.tsx (ml-[calc(theme(spacing.3)+theme(spacing.2))]). */ --sz-140px: 140px; /* Extracted from ui/src/components/JsonSchemaForm.tsx (min-h-[140px]). */ --sz-52px: 52px; /* Extracted from ui/src/components/KanbanBoard.tsx (w-[52px]). */ - --sz-68px: 68px; /* The onboarding arc's side inset — 560px frame, 424px column. */ --sz-48px: 48px; /* Extracted from ui/src/components/KanbanBoard.tsx (min-w-[48px]). */ --sz-260px: 260px; /* Extracted from ui/src/components/KanbanBoard.tsx (min-w-[260px]). */ --sz-120px: 120px; /* Extracted from ui/src/components/KanbanBoard.tsx (min-h-[120px]). */