feat(web): redesign Designs tab cards — covers, tags, overflow menu, multi-select (#1161)

* feat(web): redesign Designs tab cards — covers, tags, overflow menu, multi-select

- Render real previews on project cards: HTML iframe / image / video / hashed gradient fallback with project initial; lazily fetches the project's primary file when metadata.entryFile is unset, prefers index.html → newest html → image → video.
- Live artifact card thumbnails embed the rendered artifact URL via sandboxed iframe.
- Replace the per-card close button with a `…` overflow menu (Rename, Delete) that opens on hover/click; click-outside and Esc close it.
- Add multi-select mode (toolbar toggle → checkbox per card → "N selected · Delete · Cancel" pill) with batch delete via the existing onDelete prop.
- Add a category tag to every card (Prototype / Live Artifact / Slide / Media) derived from project.metadata.intent / kind / skillId.
- Replace browser prompt() and confirm() with custom modals (rename input + danger-confirm) reusing the existing .modal shell.
- Add `more-horizontal` icon and 16 new i18n keys across all 18 locales (zh-CN/zh-TW localized; others fall back to English).

* test(e2e): update home delete flow for overflow menu + custom confirm modal

The previous flow targeted a per-card X button labelled "delete project <name>"
and asserted on a native `dialog` event. The card UI now exposes a `…` overflow
menu and a styled confirm modal, so reach delete via the menu and assert against
the modal's Cancel / Delete buttons instead.

* fix(web): harden Designs tab preview sandbox

* fix(web): hide Designs select mode in kanban
This commit is contained in:
Eli 2026-05-12 15:08:22 +08:00 committed by GitHub
parent 77f69257a7
commit 9c489aa045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1160 additions and 48 deletions

View file

@ -689,6 +689,15 @@ export function App() {
}
}, [route]);
const handleRenameProject = useCallback(async (id: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setProjects((curr) =>
curr.map((p) => (p.id === id ? { ...p, name: trimmed } : p)),
);
void patchProject(id, { name: trimmed });
}, []);
const handleBack = useCallback(() => {
navigate({ kind: 'home' });
}, []);
@ -929,6 +938,7 @@ export function App() {
onOpenProject={handleOpenProject}
onOpenLiveArtifact={handleOpenLiveArtifact}
onDeleteProject={handleDeleteProject}
onRenameProject={handleRenameProject}
onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem}
onOpenSettings={openSettings}
onAdoptPet={openPetSettings}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../i18n";
import { deleteLiveArtifact, fetchLiveArtifacts } from "../providers/registry";
import { deleteLiveArtifact, fetchLiveArtifacts, fetchProjectFiles, liveArtifactPreviewUrl, projectFileUrl } from "../providers/registry";
import type {
DesignSystemSummary,
LiveArtifactSummary,
@ -55,6 +56,7 @@ interface Props {
onOpen: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, name: string) => void;
}
export function DesignsTab({
@ -64,6 +66,7 @@ export function DesignsTab({
onOpen,
onOpenLiveArtifact,
onDelete,
onRename,
}: Props) {
const t = useT();
const [filter, setFilter] = useState("");
@ -71,6 +74,21 @@ export function DesignsTab({
const [liveArtifactsByProject, setLiveArtifactsByProject] = useState<
Record<string, LiveArtifactSummary[]>
>({});
const [coverByProject, setCoverByProject] = useState<
Record<string, { kind: "html" | "image" | "video"; name: string } | null>
>({});
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const [renameTarget, setRenameTarget] = useState<{ id: string; original: string } | null>(null);
const [renameInput, setRenameInput] = useState("");
const [confirmTarget, setConfirmTarget] = useState<{
title: string;
message: string;
confirmLabel: string;
onConfirm: () => void;
} | null>(null);
const [view, setView] = useState<ViewMode>(() => {
if (typeof window === "undefined") return "grid";
try {
@ -106,12 +124,98 @@ export function DesignsTab({
};
}, [projects]);
useEffect(() => {
let cancelled = false;
if (projects.length === 0) {
setCoverByProject({});
return;
}
void Promise.all(
projects.map(async (project) => {
if (project.metadata?.entryFile) return [project.id, null] as const;
const files = await fetchProjectFiles(project.id);
const html =
files.find((f) => (f.path ?? f.name) === "index.html") ??
files
.filter((f) => f.kind === "html")
.sort((a, b) => b.mtime - a.mtime)[0];
if (html) {
return [
project.id,
{ kind: "html" as const, name: html.path ?? html.name },
] as const;
}
const image = files
.filter((f) => f.kind === "image")
.sort((a, b) => b.mtime - a.mtime)[0];
if (image) {
return [
project.id,
{ kind: "image" as const, name: image.path ?? image.name },
] as const;
}
const video = files
.filter((f) => f.kind === "video")
.sort((a, b) => b.mtime - a.mtime)[0];
if (video) {
return [
project.id,
{ kind: "video" as const, name: video.path ?? video.name },
] as const;
}
return [project.id, null] as const;
}),
).then((entries) => {
if (cancelled) return;
setCoverByProject(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [projects]);
useEffect(() => {
if (!menuOpenId) return;
const onDocClick = (e: MouseEvent) => {
const el = menuContainerRef.current;
if (el && el.contains(e.target as Node)) return;
setMenuOpenId(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpenId(null);
};
window.addEventListener("mousedown", onDocClick);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("mousedown", onDocClick);
window.removeEventListener("keydown", onKey);
};
}, [menuOpenId]);
useEffect(() => {
// Drop selected ids that no longer exist
setSelected((curr) => {
const valid = new Set(projects.map((p) => p.id));
let changed = false;
const next = new Set<string>();
curr.forEach((id) => {
if (valid.has(id)) next.add(id);
else changed = true;
});
return changed ? next : curr;
});
}, [projects]);
useEffect(() => {
try {
window.localStorage.setItem(DESIGNS_VIEW_STORAGE_KEY, view);
} catch {}
}, [view]);
useEffect(() => {
if (view === "kanban" && selectMode) exitSelectMode();
}, [selectMode, view]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
let list: DesignListItem[] = projects
@ -172,19 +276,75 @@ export function DesignsTab({
skills.find((s) => s.id === id)?.name ?? "";
const dsName = (id: string | null) =>
designSystems.find((d) => d.id === id)?.title ?? "";
const toggleSelected = (id: string) => {
setSelected((curr) => {
const next = new Set(curr);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const handleRenameProject = (project: Project) => {
setRenameTarget({ id: project.id, original: project.name });
setRenameInput(project.name);
};
const commitRename = () => {
if (!renameTarget) return;
const trimmed = renameInput.trim();
if (trimmed && trimmed !== renameTarget.original) {
onRename(renameTarget.id, trimmed);
}
setRenameTarget(null);
setRenameInput("");
};
const cancelRename = () => {
setRenameTarget(null);
setRenameInput("");
};
const handleDeleteProject = (project: Project) => {
setConfirmTarget({
title: t("designs.deleteTitle"),
message: t("designs.deleteConfirm", { name: project.name }),
confirmLabel: t("designs.menuDelete"),
onConfirm: () => onDelete(project.id),
});
};
const handleBatchDelete = () => {
const ids = Array.from(selected);
if (ids.length === 0) return;
setConfirmTarget({
title: t("designs.deleteTitle"),
message: t("designs.deleteSelectedConfirm", { n: ids.length }),
confirmLabel: t("designs.deleteSelected"),
onConfirm: () => {
ids.forEach((id) => onDelete(id));
exitSelectMode();
},
});
};
const handleDeleteLiveArtifact = async (
projectId: string,
artifact: LiveArtifactSummary,
) => {
if (!confirm(`${t("common.delete")} "${artifact.title}"?`)) return;
const ok = await deleteLiveArtifact(projectId, artifact.id);
if (!ok) return;
setLiveArtifactsByProject((current) => ({
...current,
[projectId]: (current[projectId] ?? []).filter(
(candidate) => candidate.id !== artifact.id,
),
}));
setConfirmTarget({
title: t("common.delete"),
message: `${t("common.delete")} "${artifact.title}"?`,
confirmLabel: t("designs.menuDelete"),
onConfirm: async () => {
const ok = await deleteLiveArtifact(projectId, artifact.id);
if (!ok) return;
setLiveArtifactsByProject((current) => ({
...current,
[projectId]: (current[projectId] ?? []).filter(
(candidate) => candidate.id !== artifact.id,
),
}));
},
});
};
return (
@ -225,6 +385,37 @@ export function DesignsTab({
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{view === "grid" && selectMode ? (
<div className="designs-select-bar" role="group">
<span className="designs-select-count">
{t("designs.selectedCount", { n: selected.size })}
</span>
<button
type="button"
className="designs-select-delete"
disabled={selected.size === 0}
onClick={handleBatchDelete}
>
{t("designs.deleteSelected")}
</button>
<button
type="button"
className="designs-select-cancel"
onClick={exitSelectMode}
>
{t("designs.cancelSelect")}
</button>
</div>
) : view === "grid" ? (
<button
type="button"
className="designs-select-toggle"
onClick={() => setSelectMode(true)}
>
<Icon name="check" size={13} />
<span>{t("designs.selectMode")}</span>
</button>
) : null}
<div
className="subtab-pill"
role="group"
@ -297,9 +488,17 @@ export function DesignsTab({
className="design-card-thumb live-artifact-thumb"
aria-hidden
>
<span className="live-artifact-thumb-glyph"></span>
<iframe
className="thumb-iframe"
src={liveArtifactPreviewUrl(p.id, artifact.id)}
title=""
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
/>
</div>
<div className="design-card-meta-block">
<ProjectTag category="live-artifact" />
<LiveArtifactBadges
className="design-card-badges"
status={artifact.status}
@ -328,33 +527,105 @@ export function DesignsTab({
const liveCount = liveArtifactsByProject[p.id]?.length ?? 0;
const status = p.status?.value ?? "not_started";
const cover = projectCover(p, coverByProject[p.id] ?? null);
const isSelected = selected.has(p.id);
return (
<div
key={p.id}
className="design-card"
className={`design-card${isSelected ? " is-selected" : ""}${selectMode ? " select-mode" : ""}`}
role="button"
tabIndex={0}
onClick={() => onOpen(p.id)}
onClick={() => {
if (selectMode) toggleSelected(p.id);
else onOpen(p.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(p.id);
if (selectMode) toggleSelected(p.id);
else onOpen(p.id);
}
}}
>
<button
className="design-card-close"
title={t("designs.deleteTitle")}
aria-label={t("designs.deleteAria", { name: p.name })}
onClick={(e) => {
e.stopPropagation();
if (confirm(t("designs.deleteConfirm", { name: p.name })))
onDelete(p.id);
}}
{selectMode ? (
<span
className={`design-card-checkbox${isSelected ? " checked" : ""}`}
aria-hidden
>
{isSelected ? <Icon name="check" size={12} /> : null}
</span>
) : (
<div
className="design-card-menu-anchor"
ref={menuOpenId === p.id ? menuContainerRef : undefined}
>
<button
type="button"
className="design-card-more"
aria-label={t("designs.menuMore")}
aria-haspopup="menu"
aria-expanded={menuOpenId === p.id}
onClick={(e) => {
e.stopPropagation();
setMenuOpenId((cur) => (cur === p.id ? null : p.id));
}}
>
<Icon name="more-horizontal" size={14} />
</button>
{menuOpenId === p.id ? (
<div
className="design-card-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpenId(null);
handleRenameProject(p);
}}
>
<Icon name="pencil" size={12} />
<span>{t("designs.menuRename")}</span>
</button>
<button
type="button"
role="menuitem"
className="danger"
onClick={() => {
setMenuOpenId(null);
handleDeleteProject(p);
}}
>
<Icon name="close" size={12} />
<span>{t("designs.menuDelete")}</span>
</button>
</div>
) : null}
</div>
)}
<div
className={`design-card-thumb project-thumb project-thumb-${cover.kind}`}
style={cover.style}
aria-hidden
>
<Icon name="close" size={12} />
</button>
<div className="design-card-thumb" aria-hidden>
{cover.kind === "image" && cover.src ? (
<img className="thumb-media" src={cover.src} alt="" loading="lazy" />
) : cover.kind === "video" && cover.src ? (
<video className="thumb-media" src={cover.src} muted preload="metadata" playsInline />
) : cover.kind === "html" && cover.src ? (
<iframe
className="thumb-iframe"
src={cover.src}
title=""
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
/>
) : (
<span className="project-thumb-glyph">{cover.initial}</span>
)}
{liveCount > 0 ? (
<span className="design-live-count">
{t("designs.liveCount", { n: liveCount })}
@ -362,6 +633,7 @@ export function DesignsTab({
) : null}
</div>
<div className="design-card-meta-block">
<ProjectTag category={projectCategory(p)} />
<div className="design-card-name" title={p.name}>
{p.name}
</div>
@ -436,12 +708,7 @@ export function DesignsTab({
})}
onClick={(e) => {
e.stopPropagation();
if (
confirm(
t("designs.deleteConfirm", { name: p.name }),
)
)
onDelete(p.id);
handleDeleteProject(p);
}}
>
<Icon name="close" size={12} />
@ -475,6 +742,80 @@ export function DesignsTab({
})}
</div>
)}
{renameTarget ? (
<div className="modal-backdrop" onClick={cancelRename}>
<form
className="modal modal-rename"
onClick={(e) => e.stopPropagation()}
onSubmit={(e) => {
e.preventDefault();
commitRename();
}}
>
<h2>{t("designs.renameTitle")}</h2>
<label>
{t("designs.renamePrompt", { name: renameTarget.original })}
<input
type="text"
value={renameInput}
autoFocus
onChange={(e) => setRenameInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancelRename();
}
}}
/>
</label>
<div className="row">
<button type="button" onClick={cancelRename}>
{t("designs.renameCancel")}
</button>
<button
type="submit"
className="primary"
disabled={
!renameInput.trim() ||
renameInput.trim() === renameTarget.original
}
>
{t("designs.renameSave")}
</button>
</div>
</form>
</div>
) : null}
{confirmTarget ? (
<div className="modal-backdrop" onClick={() => setConfirmTarget(null)}>
<div
className="modal modal-confirm"
onClick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
>
<h2>{confirmTarget.title}</h2>
<p className="modal-confirm-message">{confirmTarget.message}</p>
<div className="row">
<button type="button" onClick={() => setConfirmTarget(null)}>
{t("designs.renameCancel")}
</button>
<button
type="button"
className="primary danger"
autoFocus
onClick={() => {
const run = confirmTarget.onConfirm;
setConfirmTarget(null);
run();
}}
>
{confirmTarget.confirmLabel}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@ -538,3 +879,71 @@ function isOrbitProject(project: Project): boolean {
const metadata = project.metadata as { kind?: unknown } | undefined;
return metadata?.kind === 'orbit';
}
function projectCover(
project: Project,
override: { kind: "html" | "image" | "video"; name: string } | null,
): {
kind: "image" | "video" | "html" | "fallback";
src?: string;
style: CSSProperties;
initial: string;
} {
let h = 0;
for (let i = 0; i < project.id.length; i++) {
h = (h * 31 + project.id.charCodeAt(i)) >>> 0;
}
const hue = h % 360;
const hue2 = (hue + 38) % 360;
const style: CSSProperties = {
background: `radial-gradient(circle at 30% 28%, hsl(${hue} 70% 78% / 0.55), transparent 42%), linear-gradient(135deg, hsl(${hue} 65% 88%), hsl(${hue2} 70% 90%))`,
};
const trimmed = project.name.trim();
const initial = (trimmed ? Array.from(trimmed)[0]! : "?").toUpperCase();
if (override) {
return {
kind: override.kind,
src: projectFileUrl(project.id, override.name),
style,
initial,
};
}
const meta = project.metadata;
const entry = meta?.entryFile;
if (entry) {
const src = projectFileUrl(project.id, entry);
if (meta?.kind === "image") return { kind: "image", src, style, initial };
if (meta?.kind === "video") return { kind: "video", src, style, initial };
if (/\.html?$/i.test(entry)) return { kind: "html", src, style, initial };
}
return { kind: "fallback", style, initial };
}
type ProjectCategory = "prototype" | "live-artifact" | "slide" | "media";
function projectCategory(project: Project): ProjectCategory {
const meta = project.metadata;
if (meta?.intent === "live-artifact" || project.skillId === "live-artifact") {
return "live-artifact";
}
if (meta?.kind === "deck") return "slide";
if (meta?.kind === "image" || meta?.kind === "video" || meta?.kind === "audio") {
return "media";
}
return "prototype";
}
function ProjectTag({ category }: { category: ProjectCategory }) {
const t = useT();
const label =
category === "live-artifact"
? t("designs.tagLiveArtifact")
: category === "slide"
? t("designs.tagSlide")
: category === "media"
? t("designs.tagMedia")
: t("designs.tagPrototype");
return (
<span className={`design-card-tag tag-${category}`}>{label}</span>
);
}

View file

@ -68,6 +68,7 @@ interface Props {
onOpenProject: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDeleteProject: (id: string) => void;
onRenameProject: (id: string, name: string) => void;
onChangeDefaultDesignSystem: (id: string) => void;
onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'language' | 'appearance' | 'notifications' | 'pet' | 'about') => void;
onAdoptPet: () => void;
@ -240,6 +241,7 @@ export function EntryView({
onOpenProject,
onOpenLiveArtifact,
onDeleteProject,
onRenameProject,
onChangeDefaultDesignSystem,
onOpenSettings,
onAdoptPet,
@ -549,6 +551,7 @@ export function EntryView({
onOpen={onOpenProject}
onOpenLiveArtifact={onOpenLiveArtifact}
onDelete={onDeleteProject}
onRename={onRenameProject}
/>
)
) : null}

View file

@ -30,6 +30,7 @@ type IconName =
| 'link'
| 'mic'
| 'minus'
| 'more-horizontal'
| 'orbit'
| 'pencil'
| 'plus'
@ -290,6 +291,14 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
<path d="M5 12h14" />
</svg>
);
case 'more-horizontal':
return (
<svg {...common}>
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</svg>
);
case 'orbit':
// Tilted elliptical orbit + central body + a small satellite riding the
// path. Reads unmistakably as "orbit/automation" rather than the

View file

@ -442,6 +442,22 @@ export const ar: Dict = {
'designs.viewKanban': 'عرض اللوحة',
'designs.kanbanEmptyColumn': 'لا توجد تصاميم',
'designs.deleteAria': 'حذف المشروع {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'النوع',
'examples.surfaceLabel': 'السطح',

View file

@ -330,6 +330,22 @@ export const de: Dict = {
'designs.viewKanban': 'Board-Ansicht',
'designs.kanbanEmptyColumn': 'Keine Designs',
'designs.deleteAria': 'Projekt {name} löschen',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Typ',
'examples.surfaceLabel': 'Oberfläche',

View file

@ -518,6 +518,22 @@ export const en: Dict = {
'designs.viewKanban': 'Board view',
'designs.kanbanEmptyColumn': 'No designs',
'designs.deleteAria': 'Delete project {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Type',
'examples.surfaceLabel': 'Surface',

View file

@ -331,6 +331,22 @@ export const esES: Dict = {
'designs.viewKanban': 'Vista en tablero',
'designs.kanbanEmptyColumn': 'Sin diseños',
'designs.deleteAria': 'Eliminar proyecto {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Tipo',
'examples.surfaceLabel': 'Superficie',

View file

@ -464,6 +464,22 @@ export const fa: Dict = {
'designs.viewKanban': 'نمای برد',
'designs.kanbanEmptyColumn': 'هیچ طرحی نیست',
'designs.deleteAria': 'حذف پروژه {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'نوع',
'examples.surfaceLabel': 'سطح',

View file

@ -442,6 +442,22 @@ export const fr: Dict = {
'designs.viewKanban': 'Vue tableau',
'designs.kanbanEmptyColumn': 'Aucun design',
'designs.deleteAria': 'Supprimer le projet {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Type',
'examples.surfaceLabel': 'Surface',

View file

@ -442,6 +442,22 @@ export const hu: Dict = {
'designs.viewKanban': 'Tábla nézet',
'designs.kanbanEmptyColumn': 'Nincs terv',
'designs.deleteAria': '{name} projekt törlése',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Típus',
'examples.surfaceLabel': 'Felület',

View file

@ -557,6 +557,22 @@ export const id: Dict = {
'designs.viewKanban': 'Tampilan kanban',
'designs.kanbanEmptyColumn': 'Tidak ada desain',
'designs.deleteAria': 'Hapus proyek {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Tipe',
'examples.surfaceLabel': 'Surface',

View file

@ -329,6 +329,22 @@ export const ja: Dict = {
'designs.viewKanban': 'ボード表示',
'designs.kanbanEmptyColumn': 'デザインなし',
'designs.deleteAria': 'プロジェクト {name} を削除',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'タイプ',
'examples.surfaceLabel': 'サーフェス',

View file

@ -442,6 +442,22 @@ export const ko: Dict = {
'designs.viewKanban': '보드 보기',
'designs.kanbanEmptyColumn': '디자인 없음',
'designs.deleteAria': '프로젝트 삭제 {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': '유형',
'examples.surfaceLabel': '대상 표면 (Surface)',

View file

@ -442,6 +442,22 @@ export const pl: Dict = {
'designs.viewKanban': 'Widok tablicy',
'designs.kanbanEmptyColumn': 'Brak projektów',
'designs.deleteAria': 'Usuń projekt {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Typ',
'examples.surfaceLabel': 'Powierzchnia',

View file

@ -463,6 +463,22 @@ export const ptBR: Dict = {
'designs.viewKanban': 'Visualização em quadro',
'designs.kanbanEmptyColumn': 'Sem designs',
'designs.deleteAria': 'Excluir projeto {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Tipo',
'examples.surfaceLabel': 'Superfície',

View file

@ -463,6 +463,22 @@ export const ru: Dict = {
'designs.viewKanban': 'Вид доской',
'designs.kanbanEmptyColumn': 'Нет дизайнов',
'designs.deleteAria': 'Удалить проект {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Тип',
'examples.surfaceLabel': 'Поверхность',

View file

@ -432,6 +432,22 @@ export const th: Dict = {
'designs.viewKanban': 'บอร์ด',
'designs.kanbanEmptyColumn': 'ไม่มีรายการ',
'designs.deleteAria': 'ลบ {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'ประเภท',
'examples.surfaceLabel': 'พื้นที่',

View file

@ -431,6 +431,22 @@ export const tr: Dict = {
'designs.viewKanban': 'Tahta görünümü',
'designs.kanbanEmptyColumn': 'Tasarım yok',
'designs.deleteAria': '{name} projesini sil',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'examples.typeLabel': 'Tip',
'examples.surfaceLabel': 'Yüzey',

View file

@ -455,6 +455,22 @@ export const uk: Dict = {
'designs.viewKanban': 'Вигляд дошки',
'designs.kanbanEmptyColumn': 'Немає дизайнів',
'designs.deleteAria': 'Видалити проект {name}',
'designs.menuMore': 'More actions',
'designs.menuRename': 'Rename',
'designs.menuDelete': 'Delete',
'designs.renamePrompt': 'New name for "{name}"',
'designs.selectMode': 'Select',
'designs.cancelSelect': 'Cancel',
'designs.deleteSelected': 'Delete selected',
'designs.selectedCount': '{n} selected',
'designs.deleteSelectedConfirm': 'Delete {n} project(s)?',
'designs.tagPrototype': 'Prototype',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': 'Rename project',
'designs.renameSave': 'OK',
'designs.renameCancel': 'Cancel',
'designs.badgeLive': 'Live',
'designs.liveArtifactBadgesAria': 'Мітки live-артефакту',
'designs.liveCount': '{n} live-артефактів',

View file

@ -512,6 +512,22 @@ export const zhCN: Dict = {
'designs.viewKanban': '看板视图',
'designs.kanbanEmptyColumn': '暂无设计',
'designs.deleteAria': '删除项目 {name}',
'designs.menuMore': '更多操作',
'designs.menuRename': '重命名',
'designs.menuDelete': '删除',
'designs.renamePrompt': '为「{name}」输入新名称',
'designs.selectMode': '选择',
'designs.cancelSelect': '取消',
'designs.deleteSelected': '删除所选',
'designs.selectedCount': '已选择 {n} 项',
'designs.deleteSelectedConfirm': '确定删除选中的 {n} 个项目?',
'designs.tagPrototype': '原型',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': '重命名项目',
'designs.renameSave': '确定',
'designs.renameCancel': '取消',
'examples.typeLabel': '类型',
'examples.surfaceLabel': '类型',

View file

@ -505,6 +505,22 @@ export const zhTW: Dict = {
'designs.viewKanban': '看板檢視',
'designs.kanbanEmptyColumn': '暫無設計',
'designs.deleteAria': '刪除專案 {name}',
'designs.menuMore': '更多操作',
'designs.menuRename': '重新命名',
'designs.menuDelete': '刪除',
'designs.renamePrompt': '為「{name}」輸入新名稱',
'designs.selectMode': '選擇',
'designs.cancelSelect': '取消',
'designs.deleteSelected': '刪除所選',
'designs.selectedCount': '已選擇 {n} 項',
'designs.deleteSelectedConfirm': '確定刪除選取的 {n} 個專案?',
'designs.tagPrototype': '原型',
'designs.tagLiveArtifact': 'Live Artifact',
'designs.tagSlide': 'Slide',
'designs.tagMedia': 'Media',
'designs.renameTitle': '重新命名專案',
'designs.renameSave': '確定',
'designs.renameCancel': '取消',
'examples.typeLabel': '類型',
'examples.surfaceLabel': '類型',

View file

@ -766,6 +766,22 @@ export interface Dict {
'designs.viewKanban': string;
'designs.kanbanEmptyColumn': string;
'designs.deleteAria': string;
'designs.menuMore': string;
'designs.menuRename': string;
'designs.menuDelete': string;
'designs.renamePrompt': string;
'designs.selectMode': string;
'designs.cancelSelect': string;
'designs.deleteSelected': string;
'designs.selectedCount': string;
'designs.deleteSelectedConfirm': string;
'designs.tagPrototype': string;
'designs.tagLiveArtifact': string;
'designs.tagSlide': string;
'designs.tagMedia': string;
'designs.renameTitle': string;
'designs.renameSave': string;
'designs.renameCancel': string;
// Examples tab
'examples.typeLabel': string;

View file

@ -1436,6 +1436,113 @@ code {
}
.modal .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
/* Compact rename modal */
.modal-rename {
width: 420px;
gap: 14px;
}
.modal-rename input[type="text"] {
width: 100%;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--bg-panel);
color: var(--text-strong);
font-size: 13px;
outline: none;
}
.modal-rename input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.modal-rename .row button {
padding: 8px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-strong);
}
.modal-rename .row button:hover { border-color: var(--border-strong); }
.modal-rename .row button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.modal-rename .row button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Confirm modal — same shell, danger primary */
.modal-confirm {
width: 420px;
gap: 12px;
}
.modal-confirm-message {
margin: 0;
font-size: 13px;
color: var(--text-strong);
line-height: 1.55;
}
.modal-confirm .row { margin-top: 8px; }
.modal-confirm .row button {
padding: 8px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-strong);
}
.modal-confirm .row button:hover { border-color: var(--border-strong); }
.modal-confirm .row button.primary.danger {
background: var(--red);
border-color: var(--red);
color: #fff;
}
.modal-confirm .row button.primary.danger:hover { filter: brightness(0.95); }
/* Project category tags */
.design-card-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 1px 7px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0.01em;
background: var(--bg-subtle);
color: var(--text-muted);
border: 1px solid var(--border);
margin-bottom: 2px;
}
.design-card-tag.tag-prototype {
color: #2348b8;
background: #e8efff;
border-color: rgba(35, 72, 184, 0.18);
}
.design-card-tag.tag-live-artifact {
color: #6d4ff5;
background: rgba(116, 92, 255, 0.12);
border-color: rgba(116, 92, 255, 0.28);
}
.design-card-tag.tag-slide {
color: #b15e00;
background: rgba(255, 159, 64, 0.14);
border-color: rgba(255, 159, 64, 0.32);
}
.design-card-tag.tag-media {
color: #1c8a73;
background: rgba(28, 138, 115, 0.12);
border-color: rgba(28, 138, 115, 0.28);
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop-in {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
@ -6698,6 +6805,142 @@ button.connector-action.is-loading {
.design-kanban-card:focus-within .design-card-close,
.design-card-close:focus-visible { opacity: 1; }
.design-card-close:hover { color: var(--text-strong); border-color: var(--border-strong); }
/* Overflow menu trigger and dropdown on project cards */
.design-card-menu-anchor {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
}
.design-card-more {
width: 26px;
height: 26px;
padding: 0;
border-radius: 6px;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.design-card:hover .design-card-more,
.design-card:focus-within .design-card-more,
.design-card-more[aria-expanded="true"],
.design-card-more:focus-visible { opacity: 1; }
.design-card-more:hover { color: var(--text-strong); border-color: var(--border-strong); }
@media (hover: none) {
.design-card .design-card-more { opacity: 1; }
}
.design-card-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 144px;
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
padding: 4px;
display: flex;
flex-direction: column;
z-index: 50;
}
.design-card-menu button {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 0;
background: transparent;
border-radius: 6px;
color: var(--text-strong);
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.design-card-menu button:hover { background: var(--bg-subtle); }
.design-card-menu button.danger { color: var(--red); }
.design-card-menu button.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); }
/* Multi-select checkbox + selected state */
.design-card-checkbox {
position: absolute;
top: 8px;
left: 8px;
width: 22px;
height: 22px;
border-radius: 6px;
background: var(--bg-panel);
border: 1px solid var(--border-strong);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bg-panel);
z-index: 3;
}
.design-card-checkbox.checked {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.design-card.is-selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 32%, transparent);
}
.design-card.select-mode { cursor: pointer; }
/* Select-mode toolbar controls */
.designs-select-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-strong);
font-size: 12.5px;
cursor: pointer;
}
.designs-select-toggle:hover { border-color: var(--border-strong); }
.designs-select-bar {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 6px 4px 12px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--bg-panel);
}
.designs-select-count {
font-size: 12.5px;
color: var(--text-strong);
}
.designs-select-delete {
padding: 5px 10px;
border-radius: 999px;
border: 0;
background: var(--red);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.designs-select-delete:disabled { opacity: 0.5; cursor: not-allowed; }
.designs-select-cancel {
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.designs-select-cancel:hover { color: var(--text-strong); border-color: var(--border-strong); }
/* Larger comfortable touch target on coarse pointers (tablets/touch laptops).
On touch the hover reveal never fires, so keep the close button visible. */
@media (hover: none) {
@ -6721,6 +6964,55 @@ button.connector-action.is-loading {
.live-artifact-card:hover {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border-strong));
}
.project-thumb::before,
.project-thumb::after {
display: none;
}
.design-card-thumb .thumb-media {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.design-card-thumb .thumb-iframe {
position: absolute;
top: 0;
left: 0;
width: 250%;
height: 250%;
transform: scale(0.4);
transform-origin: top left;
border: 0;
background: var(--bg-panel);
pointer-events: none;
}
.project-thumb-image,
.project-thumb-video,
.project-thumb-html {
background: var(--bg-subtle);
}
.project-thumb-image .project-thumb-glyph,
.project-thumb-video .project-thumb-glyph,
.project-thumb-html .project-thumb-glyph {
display: none;
}
.project-thumb-glyph {
width: 46px;
height: 46px;
border-radius: 14px;
display: grid;
place-items: center;
color: var(--text-strong);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-xs);
font-size: 20px;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
}
.live-artifact-thumb {
background:
radial-gradient(circle at 32% 28%, rgba(116, 92, 255, 0.18), transparent 36%),
@ -17591,21 +17883,21 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
.palette-tweaks {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
min-width: 240px; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-radius: var(--radius-md, 10px);
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.12));
padding: 8px; display: flex; flex-direction: column; gap: 6px; user-select: none;
}
.palette-tweaks-header {
display: flex; flex-direction: column; gap: 1px;
padding: 4px 6px 6px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-subtle, var(--border));
}
.palette-tweaks-title { font-size: 12px; font-weight: 600; color: var(--text); }
.palette-tweaks-sub { font-size: 11px; color: var(--text-muted); }
.palette-tweaks-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.palette-tweaks-item {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-radius: 6px;
padding: 6px 8px; border-radius: var(--radius-sm, 6px);
cursor: pointer; font-size: 12.5px; color: var(--text);
transition: background 100ms ease;
}
@ -17626,7 +17918,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
var(--text-muted) calc(50% + 1px),
transparent calc(50% + 1px)
);
border: 1px dashed var(--border); border-radius: 4px;
border: 1px dashed var(--border-strong, var(--border)); border-radius: 4px;
}
.palette-tweaks-label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.palette-tweaks-check { display: inline-flex; align-items: center; color: var(--text-muted); }

View file

@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesignsTab } from '../../src/components/DesignsTab';
import type { Project } from '../../src/types';
vi.mock('../../src/providers/registry', () => ({
deleteLiveArtifact: vi.fn(),
fetchLiveArtifacts: vi.fn(async () => []),
fetchProjectFiles: vi.fn(async () => []),
liveArtifactPreviewUrl: (projectId: string, artifactId: string) =>
`/api/projects/${projectId}/live-artifacts/${artifactId}/preview`,
projectFileUrl: (projectId: string, fileName: string) =>
`/api/projects/${projectId}/files/${fileName}`,
}));
const project: Project = {
id: 'project-1',
name: 'Landing refresh',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 2,
status: { value: 'not_started' },
};
describe('DesignsTab select mode', () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
cleanup();
});
it('only exposes select mode in grid view', () => {
render(
<DesignsTab
projects={[project]}
skills={[]}
designSystems={[]}
onOpen={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDelete={vi.fn()}
onRename={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
fireEvent.click(screen.getByTestId('designs-view-kanban'));
expect(screen.queryByRole('button', { name: 'Select' })).toBeNull();
});
it('exits select mode when switching to kanban view', () => {
render(
<DesignsTab
projects={[project]}
skills={[]}
designSystems={[]}
onOpen={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDelete={vi.fn()}
onRename={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
expect(screen.getByText('0 selected')).toBeTruthy();
fireEvent.click(screen.getByTestId('designs-view-kanban'));
fireEvent.click(screen.getByTestId('designs-view-grid'));
expect(screen.queryByText('0 selected')).toBeNull();
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
});
});

View file

@ -253,20 +253,25 @@ test('home design card deletion supports cancel and confirm flows', async ({ pag
const designCard = homeDesignCard(page, projectName);
await expect(designCard).toBeVisible();
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain(projectName);
await dialog.dismiss();
});
// Cancel flow: open the overflow menu, choose Delete, then dismiss the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(projectName);
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(confirmDialog).toHaveCount(0);
await expect(designCard).toBeVisible();
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain(projectName);
await dialog.accept();
});
// Confirm flow: same trigger, this time accept the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: new RegExp(`delete project ${escapeRegExp(projectName)}`, 'i') }).click();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await expect(confirmDialog2).toContainText(projectName);
await confirmDialog2.getByRole('button', { name: /^delete$/i }).click();
await expect(homeDesignCard(page, projectName)).toHaveCount(0);
const response = await page.request.get(`/api/projects/${projectId}`);