Reconcile the frontend with the round-1 backend contracts

Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.

**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.

`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.

**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.

**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.

**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.

**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.

Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 12:02:34 -07:00
co-authored by Claude Opus 5
parent 17f031a5d7
commit 7e1f8df1ff
14 changed files with 952 additions and 74 deletions
@@ -26,6 +26,29 @@ function cell(bytes: number, present: boolean) {
return present ? formatBytes(bytes) : "—";
}
const SNAPSHOT_HELP =
"This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from.";
/** Why a snapshot figure is the whole image rather than a share of one. */
const SPLIT_UNKNOWN_HELP =
"Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape.";
/**
* How `snapshot_attributed_bytes` was arrived at, in the row's own terms.
*
* Rust computes the number in one function so the column and the Total cannot
* be derived from two different rules again — but the branches do not mean the
* same thing to a reader, so the sub-line has to say which one this row is.
* `snapshot_above_base_bytes` is `null` in exactly the branch where the figure
* *is* the whole image, which is what makes it the test.
*/
function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } {
if (row.snapshot_above_base_bytes !== null) {
return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null };
}
return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP };
}
/**
* The per-project table — the mental model users actually have of this app.
*
@@ -64,8 +87,10 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
<th scope="col" className="font-medium py-1.5 pr-3">
Project
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Snapshot
<Tooltip text={SNAPSHOT_HELP} />
<span className="sr-only"> {SNAPSHOT_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Layers
@@ -121,22 +146,33 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
</span>
</th>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{/* `null` means the split could not be measured. Rendering it
as 0 B would be the one guessed number in this table. */}
{cell(
row.snapshot_above_base_bytes ?? -1,
row.snapshot_exists && row.snapshot_above_base_bytes !== null,
)}
{row.snapshot_exists && (
<span className="block text-[11px] text-[var(--text-secondary)]">
{/* The base is shared by every project, so charging it to
each row would show the same 4.7 GB eight times. The
headline figure is what is unique to this project;
the total is here for anyone reconciling against
`docker images`. */}
{formatBytes(row.snapshot_bytes)} with base
</span>
)}
{/* `snapshot_attributed_bytes`, and nothing else. This column
used to render `snapshot_above_base_bytes` and fall back
to `—` while the Total was computed from
`snapshot_bytes - snapshot_shared_bytes` regardless — so a
row could show `—` here and still carry a whole 4.7 GB
base image in its Total, once per project. One field, one
rule, computed once in Rust: the parts add up. */}
{row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"}
{row.snapshot_exists && (() => {
const { note, help } = attributionNote(row);
return help === null ? (
<span className="block text-[11px] text-[var(--text-secondary)]">
{note}
</span>
) : (
// Same treatment as the Layers column: `Tooltip` portals
// a plain div with no `role` and no `aria-describedby`,
// so the explanation is also emitted as screen-reader
// text rather than living in the tooltip alone.
<span className="block text-[11px] text-[var(--text-secondary)]">
<Tooltip text={help}>
<span>{note}</span>
</Tooltip>
<span className="sr-only"> &mdash; {help}</span>
</span>
);
})()}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
{!row.snapshot_exists ? (
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
import DiskSettings from "./DiskSettings";
import type {
DestructiveItem,
DiskUsageReport,
ProjectDiskRow,
ReclaimItem,
@@ -45,6 +46,9 @@ const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
home_volume_present: true,
config_volume_bytes: 427_000_000,
config_volume_present: true,
// The one figure the Snapshot column shows and the Total is built from.
// 8.44 + 0.868 + 4.86 + 0.427 == 14.596, and the table is expected to add up.
snapshot_attributed_bytes: 8_440_966_715,
total_bytes: 14_595_966_715,
migrating: false,
...over,
@@ -115,6 +119,26 @@ const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
...over,
});
/** An orphaned volume, as `list_reclaimable` now describes it: a
* `DestructiveItem`, never a `ReclaimItem`. `project_name` carries the
* *volume* name, because there is no project to name — that is the definition
* of the variant, and it is what `destroy` compares the typed string against. */
const orphan = (over: Partial<DestructiveItem> = {}): DestructiveItem => ({
target: {
kind: "orphan_volume",
name: "triple-c-claude-config-gone",
project_id: "gone",
},
project_id: "gone",
project_name: "triple-c-claude-config-gone",
label: "triple-c-claude-config-gone (config volume)",
loses:
"Named for project id gone, which is not in Triple-C's project list, and no container is attached to it. Docker created it on 2026-03-14T09:00:00Z. This is a `.claude` volume — it held that project's Claude credential, plugins and session transcripts. Not recoverable. Type the volume name to confirm.",
bytes: 900_000,
blocked: null,
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
@@ -196,14 +220,43 @@ describe("DiskSettings", () => {
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
});
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
it("builds the Snapshot column and the Total from the same attributed figure", async () => {
// The bug this pins: the column rendered `snapshot_above_base_bytes` and
// fell back to `—`, while the total was `snapshot_bytes -
// snapshot_shared_bytes` regardless — which in the fallback branch is the
// whole 4.7 GB base image, charged to every row and then added again as a
// base-image row in the globals. One field, computed once in Rust.
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
});
it("says a snapshot figure is the whole image rather than passing it off as a share", async () => {
// `snapshot_above_base_bytes` is null in exactly one branch: nothing
// measurably shares layers with the snapshot *and* its base is gone. The
// attributed figure is then the whole image — an honest cost, not a guess
// and not zero — but it does not mean what the other rows' figures mean,
// so the sub-line has to say which one this is.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
report({
projects: [
row({
snapshot_shared_bytes: 0,
snapshot_above_base_bytes: null,
snapshot_attributed_bytes: 12_273_392_374,
total_bytes: 18_428_392_374,
}),
],
}),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
expect(within(projectRow).getAllByText("").length).toBeGreaterThan(0);
expect(within(projectRow).getByText("12.3 GB")).toBeInTheDocument();
expect(projectRow.textContent).toMatch(/whole image — base unknown/);
// And it must not still claim the "N with base" split it cannot measure.
expect(projectRow.textContent).not.toMatch(/with base/);
});
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
@@ -424,6 +477,79 @@ describe("DiskSettings", () => {
);
});
it("never offers an orphaned volume as a tick in the safe bucket", async () => {
// It used to be a `ReclaimTarget` at `Safety::Safe` — a tick and the group
// Reclaim button, no confirmation — for a volume holding a Claude
// credential and every transcript a project ever had. The Rust variant is
// gone; this pins that the frontend cannot resurrect it.
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
expect(safe.textContent).not.toMatch(/triple-c-claude-config-gone/);
// And it is reachable — an item that matches no project row would
// otherwise simply vanish from the UI.
expect(await screen.findByTestId("disk-orphan-bucket")).toBeInTheDocument();
});
it("keeps orphaned volumes out of the per-project table", async () => {
// The table keys off `project_id`, and an orphan's id matches no row by
// definition. Passing them in anyway is how one would leak into the wrong
// project's overflow menu if a row ever shared the id.
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(projectRow.textContent).not.toMatch(/triple-c-claude-config-gone/);
});
it("says what a config volume actually holds, not 'volume data'", async () => {
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
await renderAndScan();
const bucket = await screen.findByTestId("disk-orphan-bucket");
expect(bucket.textContent).toMatch(/Claude login credential/i);
expect(bucket.textContent).toMatch(/every plugin and skill installed into it/i);
expect(bucket.textContent).toMatch(/every conversation transcript it ever had/i);
// The derivation caveat travels with the offer, not only with the totals.
expect(bucket.textContent).toMatch(/not.*inferred from a project being stopped/i);
});
it("confirms an orphaned volume against its own name, never a project's", async () => {
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
destroyProjectDiskObject.mockResolvedValue({ results: [], total_freed_bytes: 0 });
await renderAndScan();
const bucket = await screen.findByTestId("disk-orphan-bucket");
await act(async () => {
fireEvent.click(within(bucket).getByRole("button", { name: /Delete/ }));
});
const dialog = screen.getByRole("dialog");
// Asking for "the exact project name" would be asking for a string that
// does not exist.
expect(within(dialog).getByRole("status")).toHaveTextContent(
"Waiting for the exact volume name.",
);
const input = within(dialog).getByLabelText(/Type/);
const confirm = within(dialog).getByRole("button", { name: "Delete volume" });
// The project id parsed out of the name is display only and must not open
// the gate.
fireEvent.change(input, { target: { value: "gone" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "triple-c-claude-config-gone" } });
expect(confirm).toBeEnabled();
await act(async () => {
fireEvent.click(confirm);
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "orphan_volume", name: "triple-c-claude-config-gone", project_id: "gone" },
"triple-c-claude-config-gone",
);
// One volume, one confirmation — `reclaim` never sees it.
expect(reclaim).not.toHaveBeenCalled();
});
it("explains a suppressed orphan list instead of showing an empty one", async () => {
// With the project store unreadable every project's volumes look
// unclaimed. Showing nothing is right; showing nothing *silently* is not.
@@ -647,7 +773,7 @@ describe("DiskSettings", () => {
result({ target: { kind: "migration_pins" } }),
result({ target: { kind: "probe_containers" } }),
result({ target: { kind: "build_cache", all: true }, ok: false }),
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
result({ target: { kind: "scrub_containers" }, ok: false }),
],
total_freed_bytes: 1_200_000_000,
});
+164 -26
View File
@@ -13,6 +13,23 @@ function targetKey(target: ReclaimTarget): string {
return JSON.stringify(target);
}
/** The same, for a destructive object — never ticked, but still listed. */
function destructiveKey(item: DestructiveItem): string {
return JSON.stringify(item.target);
}
/**
* An orphaned volume is confirmed against **its own name**, not a project's.
*
* There is no project to name: the whole definition of the variant is that its
* id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan
* arm before it ever looks a project up. `DestructiveItem.project_name` carries
* the volume name for exactly these items, which is what the gate compares.
*/
function isOrphanVolume(item: DestructiveItem): boolean {
return item.target.kind === "orphan_volume";
}
/**
* Where the disk went, and how to get it back.
*
@@ -25,17 +42,31 @@ function targetKey(target: ReclaimTarget): string {
*
* ## Why the buckets are separated the way they are
*
* Safe work (dangling images, ownerless pins, build cache, volumes whose
* project id is not in the project store) gets one list of ticks and one
* button, because none of it can lose anything a user has. Note what the last
* of those is derived from: membership in Triple-C's own project list, never
* "this project has no container" — an idle live project looks exactly like a
* deleted one from the daemon's side, and mistaking the two would delete
* credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a
* re-download and is confirmed one at a time. Destructive work — a live
* project's volumes, its snapshot, a live rollback pin — is not in either list:
* it is reached only from that project's own row, behind a typed confirmation,
* and the backend refuses it in bulk by taking a different type entirely.
* Safe work (dangling images, ownerless pins, build cache) gets one list of
* ticks and one button, because none of it can lose anything a user has.
* Semi-safe work (compaction, cache clearing) is a rewrite or a re-download and
* is confirmed one at a time. Destructive work — a live project's volumes, its
* snapshot, a live rollback pin, **and an orphaned volume** — is not in either
* list: it is reached one object at a time, behind a typed confirmation, and
* the backend refuses it in bulk by taking a different type entirely.
*
* ## Why orphaned volumes are down there and not in the tick list
*
* They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group
* Reclaim button, no confirmation. The object behind that tick is a
* `triple-c-claude-config-*` volume holding a Claude OAuth credential, every
* plugin and skill installed into that project, and every conversation
* transcript it ever had — and the *same volume* for a project still in the
* store required typing the project's name. The only difference between the two
* is a lookup against `projects.json`, which this app has been wrong about
* before: a second instance's project is absent from an in-memory list, a
* corrupt store empties it, a restored data directory empties it too. It once
* flagged two live projects as orphaned.
*
* So "no matching project" means one thing only — the id is not in the project
* list. It is never inferred from a project being stopped, having no container
* or having no image; an idle live project looks identical from the daemon's
* side. Each volume is deleted on its own, against its own name typed out.
*/
export default function DiskSettings() {
const {
@@ -68,6 +99,14 @@ export default function DiskSettings() {
if (!plan) setTicked(new Set());
}, [plan]);
// Split before anything renders. The per-project table keys off
// `project_id`, and an orphan's id matches no row by definition — so without
// this split those items are simply invisible, which is how a variant that
// moved from the tick list to the destructive list can vanish from the UI
// entirely rather than reappear behind a confirmation.
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
const projectDestructive = plan?.destructive.filter((d) => !isOrphanVolume(d)) ?? [];
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
const selected = safeItems.filter(
@@ -201,7 +240,7 @@ export default function DiskSettings() {
</h3>
<DiskProjectTable
rows={report.projects}
destructive={plan?.destructive ?? []}
destructive={projectDestructive}
onDestroy={openDestroying}
/>
</section>
@@ -282,8 +321,9 @@ export default function DiskSettings() {
volume&rsquo;s project id is not in your project list &mdash; it is{" "}
<em>not</em> inferred from a project being stopped or having no image. A project you have not opened in a
while has no container and no snapshot either, and that is normal, so
each of these is ticked individually and shows the date Docker created
it.
nothing here is deleted in a group: each one is listed below on its own,
with the date Docker created it, and removing it takes typing that
volume&rsquo;s name.
</p>
)}
<p className="text-[11px] text-[var(--text-secondary)]">
@@ -455,6 +495,72 @@ export default function DiskSettings() {
</section>
)}
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
{orphanVolumes.length > 0 && (
<section className="space-y-2" data-testid="disk-orphan-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Volumes with no matching project
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
A volume here is one whose project id is not in your project list. That
is <em>all</em> it means &mdash; it is <em>not</em> inferred from a
project being stopped, having no container or having no image. An idle
live project looks exactly the same from Docker&rsquo;s side, and that
inference has already flagged two live projects here once.
</p>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Deleting a{" "}
<span className="font-mono">triple-c-claude-config-*</span> volume
deletes{" "}
<strong className="text-[var(--text-primary)]">
the Claude login credential that project signed in with, every plugin
and skill installed into it, and every conversation transcript it ever
had
</strong>
. A <span className="font-mono">triple-c-home-*</span> volume holds its
dotfiles, shell history and installed toolchains. There is no other copy
of either and nothing regenerates, so each one is deleted on its own,
against that volume&rsquo;s name typed out &mdash; never as part of a
group.
</p>
<ul className="space-y-1.5">
{orphanVolumes.map((item) => (
<li
key={destructiveKey(item)}
className="flex items-start justify-between gap-3"
data-testid={`disk-orphan-${item.project_name}`}
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)] font-mono break-all">
{item.label}
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.loses}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{formatBytes(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openDestroying(item)}
>
Delete&hellip;
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Sweep ------------------------------------------------------ */}
<section className="flex items-center gap-3 flex-wrap">
<Button size="sm" disabled={working} onClick={runSweep}>
@@ -590,11 +696,24 @@ export default function DiskSettings() {
)}
{/* --- Destructive confirmation --------------------------------------- */}
{destroying && (
{destroying && (() => {
// An orphaned volume has no project, so nothing about this dialog can
// be phrased in terms of one: the gate takes the volume's own name (as
// `disk.rs`'s `destroy` does), and the name is never lower-cased on its
// way to the title, because the comparison the backend makes is
// case-sensitive and a mangled name in the heading is a name the user
// cannot type.
const orphan = isOrphanVolume(destroying);
return (
<TypedConfirmModal
title={`Delete ${destroying.label.toLowerCase()}`}
title={
orphan
? `Delete volume ${destroying.project_name}`
: `Delete ${destroying.label.toLowerCase()}`
}
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
subject={orphan ? "volume name" : "project name"}
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
busy={working}
// A failure here has to land inside the dialog. The panel's own
// error line is at the top of several screens of scroll, and this
@@ -610,20 +729,39 @@ export default function DiskSettings() {
if (ok) setDestroying(null);
}}
>
<p>
This removes{" "}
<strong className="text-[var(--text-primary)]">
{destroying.project_name}
</strong>
&rsquo;s {destroying.label.toLowerCase()}, freeing{" "}
{formatBytes(destroying.bytes)}.
</p>
{orphan ? (
<p>
This removes the volume{" "}
<strong className="text-[var(--text-primary)] font-mono break-all">
{destroying.project_name}
</strong>
, freeing {formatBytes(destroying.bytes)}. It is offered here for one
reason only: no project in your list has its id. That is a lookup against
a file, not a judgement about whether anything is using the volume.
</p>
) : (
<p>
This removes{" "}
<strong className="text-[var(--text-primary)]">
{destroying.project_name}
</strong>
&rsquo;s {destroying.label.toLowerCase()}, freeing{" "}
{formatBytes(destroying.bytes)}.
</p>
)}
<p className="text-[var(--error)]">{destroying.loses}</p>
{orphan && (
<p>
Nothing here can undo this. If you recognise that project id, close this
and leave the volume alone until you are certain.
</p>
)}
<p>
Your mounted project folders live on the host and are not affected by this.
</p>
</TypedConfirmModal>
)}
);
})()}
</div>
);
}