Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions templates/slides/.agents/skills/design-systems/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ Do not call `create-design-system` locally from `.fig` uploads. Do not call
`import-document` for `.fig` files; it only handles metadata and will miss the
Builder indexing flow.

## Deleting a Design System

`delete-design-system` requires admin access or higher (owner or admin share
role) and removes the system, its shares, and the `designSystemId` link on
every linked deck the caller can edit — decks the caller can't edit keep a
dangling reference instead of being silently mutated, reported back as
`decksSkippedForAccess` (clear it later with `patch-deck`'s
`patch-deck-fields`, `designSystemId: null`). Those decks keep the tokens
already baked into their slides — deletion never rewrites deck content — so a
deck can look on-brand while no longer being linked to a system. If the
deleted system was the caller's default, another of their design systems is
promoted to default so future deck creation doesn't silently drop to "no
design system". Deletion does not remove an upstream Builder-indexed design
system.

## Applying to Slides

Before creating or extending a system, read the `creative-context` skill and
Expand Down
145 changes: 145 additions & 0 deletions templates/slides/actions/delete-design-system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { defineAction } from "@agent-native/core";
import {
assertAccess,
resolveAccess,
type ShareRole,
} from "@agent-native/core/sharing";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { withDeckLock } from "./patch-deck.js";

function canEditDeckRole(role: "owner" | ShareRole) {
return role === "owner" || role === "admin" || role === "editor";
}

type UnlinkResult =
| { deckId: string; status: "unlinked" }
| { deckId: string; status: "skipped-no-access" };

// Runs under the same per-deck lock patch-deck/save-deck/update-slide use for
// all deck writes, re-reading the row inside the lock so a concurrent slide
// edit can't be clobbered by this read-modify-write. Decks the caller can't
// edit are left pointing at the deleted id rather than silently mutated —
// admin access on a shared design system does not imply edit access on every
// deck that happens to reference it.
function unlinkDeck(
deckId: string,
designSystemId: string,
): Promise<UnlinkResult> {
return withDeckLock(deckId, async () => {
const access = await resolveAccess("deck", deckId);
if (!access || !canEditDeckRole(access.role)) {
return { deckId, status: "skipped-no-access" };
}

const db = getDb();
const [deck] = await db
.select({
designSystemId: schema.decks.designSystemId,
data: schema.decks.data,
})
.from(schema.decks)
.where(eq(schema.decks.id, deckId));
if (!deck || deck.designSystemId !== designSystemId) {
return { deckId, status: "unlinked" };
}

const data = JSON.parse(deck.data);
if ("designSystemId" in data) delete data.designSystemId;
await db
.update(schema.decks)
.set({
designSystemId: null,
data: JSON.stringify(data),
updatedAt: new Date().toISOString(),
})
.where(eq(schema.decks.id, deckId));
return { deckId, status: "unlinked" };
});
}

export default defineAction({
description:
"Delete a design system. Requires admin access or higher. Decks linked to it that " +
"the caller can edit are unlinked; others keep a dangling reference (clear it with " +
"patch-deck's patch-deck-fields, designSystemId: null). If the deleted system was the " +
"caller's default, another of their design systems is promoted to default.",
schema: z.object({
id: z.string().min(1).describe("Design system ID to delete"),
}),
run: async ({ id }) => {
const access = await assertAccess("design-system", id, "admin");

const db = getDb();

const linkedDeckIds = (
await db
.select({ id: schema.decks.id })
.from(schema.decks)
.where(eq(schema.decks.designSystemId, id))
).map((row) => row.id);

// Delete the design system (and its shares) before touching linked decks.
// Once the row is gone, apply-design-system/create-deck's
// assertAccess("design-system", ...) check fails for anyone trying to
// attach a fresh link, shrinking the window for a deck to end up pointing
// at a design system we're about to remove. If this was the owner's
// default, promote another of their design systems in the same
// transaction so deck creation never silently drops to "no design
// system" just because the default happened to be deleted.
await db.transaction(async (tx) => {
Comment thread
liamdebeasi marked this conversation as resolved.
await tx
.delete(schema.designSystemShares)
.where(eq(schema.designSystemShares.resourceId, id));

await tx
.delete(schema.designSystems)
.where(eq(schema.designSystems.id, id));
Comment thread
builder-io-integration[bot] marked this conversation as resolved.

if (access.resource.isDefault) {
const [next] = await tx
.select({ id: schema.designSystems.id })
.from(schema.designSystems)
.where(
eq(schema.designSystems.ownerEmail, access.resource.ownerEmail),
)
.orderBy(desc(schema.designSystems.updatedAt))
.limit(1);
if (next) {
await tx
.update(schema.designSystems)
.set({ isDefault: true, updatedAt: new Date().toISOString() })
.where(eq(schema.designSystems.id, next.id));
}
}
});

// Best-effort cleanup: the design system is already gone, so a deck we
// can't touch (missing access) or fail to update (write error) is left
// dangling rather than retried here. Both cases are reported back instead
// of swallowed, so the caller can decide whether to intervene.
const settled = await Promise.allSettled(
linkedDeckIds.map((deckId) => unlinkDeck(deckId, id)),
);

const decksSkippedForAccess: string[] = [];
const decksFailedToUnlink: string[] = [];
settled.forEach((result, index) => {
const deckId = linkedDeckIds[index];
if (result.status === "rejected") {
decksFailedToUnlink.push(deckId);
} else if (result.value.status === "skipped-no-access") {
decksSkippedForAccess.push(deckId);
Comment thread
liamdebeasi marked this conversation as resolved.
}
});

return {
Comment thread
liamdebeasi marked this conversation as resolved.
id,
deleted: true,
...(decksSkippedForAccess.length > 0 ? { decksSkippedForAccess } : {}),
...(decksFailedToUnlink.length > 0 ? { decksFailedToUnlink } : {}),
};
},
});
96 changes: 94 additions & 2 deletions templates/slides/actions/list-design-systems.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { defineAction } from "@agent-native/core";
import { accessFilter } from "@agent-native/core/sharing";
import { desc } from "drizzle-orm";
import {
getRequestOrgId,
getRequestUserEmail,
} from "@agent-native/core/server/request-context";
import {
accessFilter,
ROLE_RANK,
type ShareRole,
} from "@agent-native/core/sharing";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";

type EffectiveRole = "owner" | ShareRole;

function canManageRole(role: EffectiveRole) {
return role === "owner" || role === "admin";
}

// Mirrors the core access model (assertAccess/resolveAccess), which compares
// emails with `lower(column) = lowercased-input` so a share or ownership
// grant survives casing differences between the stored principal and the
// caller's session email.
function normalizeEmail(email: string | undefined): string | null {
const normalized = email?.trim().toLowerCase();
return normalized || null;
}

function strongerRole(current: ShareRole | null, next: ShareRole): ShareRole {
if (!current || ROLE_RANK[next] > ROLE_RANK[current]) return next;
return current;
}

export default defineAction({
description:
"List all design systems accessible to the current user. " +
Expand All @@ -19,6 +47,8 @@ export default defineAction({
http: { method: "GET" },
run: async (args) => {
const db = getDb();
const userEmail = normalizeEmail(getRequestUserEmail());
const orgId = getRequestOrgId();
// Project only the columns this list returns. The default path returns
// `data`, but neither path returns the heavy `assets` blob — a bare
// `.select()` would load it off every row for nothing.
Expand All @@ -30,6 +60,8 @@ export default defineAction({
data: schema.designSystems.data,
isDefault: schema.designSystems.isDefault,
visibility: schema.designSystems.visibility,
ownerEmail: schema.designSystems.ownerEmail,
orgId: schema.designSystems.orgId,
createdAt: schema.designSystems.createdAt,
updatedAt: schema.designSystems.updatedAt,
})
Expand All @@ -41,12 +73,70 @@ export default defineAction({
return { count: 0, designSystems: [] };
}

// Resolve every row's role from a single batched shares query instead of
// calling resolveAccess() per row, which would re-load each resource and
// its shares (N+1) and fan out an unbounded Promise.all as the list grows.
const principalClauses: NonNullable<ReturnType<typeof and>>[] = [];
if (userEmail) {
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
principalClauses.push(
and(
eq(schema.designSystemShares.principalType, "user"),
sql`lower(${schema.designSystemShares.principalId}) = ${userEmail}`,
)!,
);
}
if (orgId) {
principalClauses.push(
and(
eq(schema.designSystemShares.principalType, "org"),
eq(schema.designSystemShares.principalId, orgId),
)!,
);
}

const shareRoleById = new Map<string, ShareRole>();
if (principalClauses.length > 0) {
const shareRows = await db
.select({
resourceId: schema.designSystemShares.resourceId,
role: schema.designSystemShares.role,
})
.from(schema.designSystemShares)
.where(
and(
inArray(
schema.designSystemShares.resourceId,
rows.map((row) => row.id),
),
or(...principalClauses),
),
);
for (const share of shareRows) {
shareRoleById.set(
share.resourceId,
strongerRole(shareRoleById.get(share.resourceId) ?? null, share.role),
);
}
}

const items = rows.map((row) => {
let role: EffectiveRole = shareRoleById.get(row.id) ?? "viewer";
if (
userEmail &&
normalizeEmail(row.ownerEmail) === userEmail &&
(!row.orgId || row.orgId === orgId)
) {
role = "owner";
}
const canManage = canManageRole(role);

if (args.compact === "true") {
return {
id: row.id,
title: row.title,
isDefault: row.isDefault,
accessRole: role,
canManage,
};
}
return {
Expand All @@ -56,6 +146,8 @@ export default defineAction({
data: row.data,
isDefault: row.isDefault,
visibility: row.visibility,
accessRole: role,
canManage,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { useT } from "@agent-native/core/client/i18n";
import { ShareButton } from "@agent-native/core/client/sharing";
import { VisibilityBadge } from "@agent-native/toolkit/sharing";
import { IconPalette, IconStar, IconStarFilled } from "@tabler/icons-react";
import {
IconDots,
IconPalette,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react";

import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
Expand All @@ -16,8 +30,10 @@ interface DesignSystemCardProps {
data: DesignSystemData;
isDefault: boolean;
visibility?: "private" | "org" | "public" | null;
canManage?: boolean;
onClick: () => void;
onSetDefault: () => void;
onDelete: () => void;
}

function firstFontName(stack: string): string {
Expand All @@ -30,9 +46,12 @@ export function DesignSystemCard({
data,
isDefault,
visibility,
canManage,
onClick,
onSetDefault,
onDelete,
}: DesignSystemCardProps) {
const t = useT();
const swatchColors = [
{ label: "Primary", color: data.colors.primary },
{ label: "Secondary", color: data.colors.secondary },
Expand Down Expand Up @@ -93,6 +112,29 @@ export function DesignSystemCard({
resourceId={id}
resourceTitle={title}
/>
{canManage && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("designSystems.moreActions")}
className="h-9 w-9 rounded-md bg-background/80 backdrop-blur-sm border border-border/40 hover:bg-background cursor-pointer"
>
<IconDots className="w-4 h-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onDelete}
className="text-red-400 focus:text-red-400"
>
<IconTrash className="w-3.5 h-3.5 me-2" />
{t("designSystems.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

{/* Typography preview */}
Expand Down
2 changes: 2 additions & 0 deletions templates/slides/app/hooks/use-design-systems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type DesignSystemSummary = {
data: string;
isDefault: boolean;
visibility?: "private" | "org" | "public" | null;
accessRole?: "owner" | "admin" | "editor" | "viewer";
canManage?: boolean;
createdAt: string;
};

Expand Down
Loading
Loading