From 085963a1e23ac76429497e2ceea697a992cddfe4 Mon Sep 17 00:00:00 2001 From: Chris Seifert Date: Sun, 24 May 2026 23:14:26 -0400 Subject: [PATCH] fix(web): show published design systems as "Published" on project cards (#2849) A published design-system project still showed its last generation run status on its card. When that run had failed, the published system read as "Failed" on the home Recent projects strip and in the Projects grid. The system is published and live, so showing a stale run failure there is wrong. Cards now read "Published" when the backing design system's status is published, keyed off the design system summary instead of the project run status. Every other project keeps its run status. A shared isPublishedDesignSystemProject helper lets the home strip and the Designs grid apply one rule. The label uses a new designs.status.published key in the locale files, with a green style that matches the existing succeeded color. --- apps/web/src/components/DesignsTab.tsx | 9 ++- apps/web/src/components/HomeView.tsx | 1 + .../src/components/RecentProjectsStrip.tsx | 21 +++--- .../src/components/design-system-project.ts | 22 +++++++ apps/web/src/i18n/locales/ar.ts | 1 + apps/web/src/i18n/locales/de.ts | 1 + apps/web/src/i18n/locales/en.ts | 1 + apps/web/src/i18n/locales/es-ES.ts | 1 + apps/web/src/i18n/locales/fa.ts | 1 + apps/web/src/i18n/locales/fr.ts | 1 + apps/web/src/i18n/locales/hu.ts | 1 + apps/web/src/i18n/locales/id.ts | 1 + apps/web/src/i18n/locales/it.ts | 1 + apps/web/src/i18n/locales/ja.ts | 1 + apps/web/src/i18n/locales/ko.ts | 1 + apps/web/src/i18n/locales/pl.ts | 1 + apps/web/src/i18n/locales/pt-BR.ts | 1 + apps/web/src/i18n/locales/ru.ts | 1 + apps/web/src/i18n/locales/th.ts | 1 + apps/web/src/i18n/locales/tr.ts | 1 + apps/web/src/i18n/locales/uk.ts | 1 + apps/web/src/i18n/locales/zh-CN.ts | 1 + apps/web/src/i18n/locales/zh-TW.ts | 1 + apps/web/src/i18n/types.ts | 1 + apps/web/src/index.css | 3 + apps/web/src/styles/home/recent-projects.css | 3 + .../components/design-system-project.test.ts | 64 +++++++++++++++++++ 27 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/design-system-project.ts create mode 100644 apps/web/tests/components/design-system-project.test.ts diff --git a/apps/web/src/components/DesignsTab.tsx b/apps/web/src/components/DesignsTab.tsx index 179a6ae01..9de1cb67e 100644 --- a/apps/web/src/components/DesignsTab.tsx +++ b/apps/web/src/components/DesignsTab.tsx @@ -19,6 +19,7 @@ import type { SkillSummary, } from "../types"; import { Icon } from "./Icon"; +import { isDesignSystemProject, isPublishedDesignSystemProject } from "./design-system-project"; import { LiveArtifactBadges } from "./LiveArtifactBadges"; import { Toast } from "./Toast"; @@ -633,6 +634,7 @@ export function DesignsTab({ const cover = projectCover(p, coverByProject[p.id] ?? null); const isSelected = selected.has(p.id); const designSystemProject = isDesignSystemProject(p); + const publishedDesignSystem = isPublishedDesignSystemProject(p, designSystems); return (
- {statusLabel(status, t)} + {publishedDesignSystem ? t("designs.status.published") : statusLabel(status, t)} {sub === "recent" || sub === "yours" ? ( @@ -1044,9 +1046,6 @@ function isOrbitProject(project: Project): boolean { return metadata?.kind === 'orbit'; } -function isDesignSystemProject(project: Project): boolean { - return project.metadata?.importedFrom === "design-system"; -} function projectCover( project: Project, diff --git a/apps/web/src/components/HomeView.tsx b/apps/web/src/components/HomeView.tsx index 4f274f807..f0969ac2f 100644 --- a/apps/web/src/components/HomeView.tsx +++ b/apps/web/src/components/HomeView.tsx @@ -1326,6 +1326,7 @@ export function HomeView({ { // P0 ui_click area=recent_projects element=project_card — emit diff --git a/apps/web/src/components/RecentProjectsStrip.tsx b/apps/web/src/components/RecentProjectsStrip.tsx index 160149e6d..30438b42b 100644 --- a/apps/web/src/components/RecentProjectsStrip.tsx +++ b/apps/web/src/components/RecentProjectsStrip.tsx @@ -10,12 +10,16 @@ import type { CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { fetchProjectFiles, projectFileUrl } from '../providers/registry'; -import type { Project, ProjectDisplayStatus, ProjectFile } from '../types'; +import type { DesignSystemSummary, Project, ProjectDisplayStatus, ProjectFile } from '../types'; import { Icon } from './Icon'; import { STATUS_LABEL_KEYS } from './DesignsTab'; +import { isDesignSystemProject, isPublishedDesignSystemProject } from './design-system-project'; interface Props { projects: Project[]; + /** Used only to show a "Published" status for design-system projects whose + * backing system is published (independent of the project's run status). */ + designSystems?: DesignSystemSummary[]; /** Retained for call-site compatibility; the strip skips rendering * while the list is loading so we never need a loading state. */ loading?: boolean; @@ -24,6 +28,8 @@ interface Props { limit?: number; } +const EMPTY_DESIGN_SYSTEMS: DesignSystemSummary[] = []; + const DECK_PREVIEW_WIDTH = 1280; const DECK_PREVIEW_HEIGHT = 720; const deckCoverCache = new Map(); @@ -31,6 +37,7 @@ const deckCoverInflight = new Map>(); export function RecentProjectsStrip({ projects, + designSystems = EMPTY_DESIGN_SYSTEMS, onOpen, onViewAll, limit = 6, @@ -142,8 +149,10 @@ export function RecentProjectsStrip({ const cover = projectCover(project, coverByProject[project.id] ?? null); const designSystemProject = isDesignSystemProject(project); const status: ProjectDisplayStatus = project.status?.value ?? 'not_started'; + const publishedDesignSystem = isPublishedDesignSystemProject(project, designSystems); const isActive = - status === 'running' || status === 'queued' || status === 'awaiting_input'; + !publishedDesignSystem && + (status === 'running' || status === 'queued' || status === 'awaiting_input'); return (
{isActive ? ( ) : null} - {statusLabel(status, t)} + {publishedDesignSystem ? t('designs.status.published') : statusLabel(status, t)} · {relativeTime(project.updatedAt, t)} @@ -465,10 +474,6 @@ function ProjectTag({ category }: { category: ProjectCategory }) { return {label}; } -function isDesignSystemProject(project: Project): boolean { - return project.metadata?.importedFrom === 'design-system'; -} - function DesignSystemProjectTag() { return Design System; } diff --git a/apps/web/src/components/design-system-project.ts b/apps/web/src/components/design-system-project.ts new file mode 100644 index 000000000..e1a55ec5f --- /dev/null +++ b/apps/web/src/components/design-system-project.ts @@ -0,0 +1,22 @@ +import type { DesignSystemSummary, Project } from '../types'; + +/** A project imported from / backing a design system. */ +export function isDesignSystemProject(project: Project): boolean { + return project.metadata?.importedFrom === 'design-system'; +} + +/** + * True when a project is a design system whose backing system is published. + * The publish state lives on the DesignSystemSummary (keyed by designSystemId), + * not on the project's run status, so a published system whose last generation + * run failed should still read as published in project cards. + */ +export function isPublishedDesignSystemProject( + project: Project, + designSystems: readonly DesignSystemSummary[], +): boolean { + if (!isDesignSystemProject(project) || !project.designSystemId) return false; + return designSystems.some( + (system) => system.id === project.designSystemId && system.status === 'published', + ); +} diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index de6f6ad1f..d7e4a82de 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -596,6 +596,7 @@ export const ar: Dict = { 'designs.status.running': 'جاري التشغيل', 'designs.status.awaitingInput': 'يحتاج مدخلات', 'designs.status.succeeded': 'اكتمل', + 'designs.status.published': 'تم النشر', 'designs.status.failed': 'فشل', 'designs.status.canceled': 'ملغى', 'designs.viewToggleAria': 'وضع العرض', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index dcd3f9146..6ec997df9 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -484,6 +484,7 @@ export const de: Dict = { 'designs.status.running': 'Läuft', 'designs.status.awaitingInput': 'Eingabe nötig', 'designs.status.succeeded': 'Abgeschlossen', + 'designs.status.published': 'Veröffentlicht', 'designs.status.failed': 'Fehlgeschlagen', 'designs.status.canceled': 'Abgebrochen', 'designs.viewToggleAria': 'Ansichtsmodus', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 8ede25760..d2d2e0ac4 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1180,6 +1180,7 @@ export const en: Dict = { 'designs.status.running': 'Running', 'designs.status.awaitingInput': 'Needs input', 'designs.status.succeeded': 'Completed', + 'designs.status.published': 'Published', 'designs.status.failed': 'Failed', 'designs.status.canceled': 'Canceled', 'designs.viewToggleAria': 'View mode', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 9841dbeda..bc3bc92be 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -485,6 +485,7 @@ export const esES: Dict = { 'designs.status.running': 'En ejecución', 'designs.status.awaitingInput': 'Necesita respuesta', 'designs.status.succeeded': 'Completado', + 'designs.status.published': 'Publicado', 'designs.status.failed': 'Fallido', 'designs.status.canceled': 'Cancelado', 'designs.viewToggleAria': 'Modo de vista', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index abb88dd3f..3e241f56e 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -618,6 +618,7 @@ export const fa: Dict = { 'designs.status.running': 'در حال اجرا', 'designs.status.awaitingInput': 'نیازمند ورودی', 'designs.status.succeeded': 'تکمیل شد', + 'designs.status.published': 'منتشر شد', 'designs.status.failed': 'ناموفق', 'designs.status.canceled': 'لغو شد', 'designs.viewToggleAria': 'حالت نمایش', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index e33bea448..b5e265b37 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -612,6 +612,7 @@ export const fr: Dict = { 'designs.status.running': 'En cours', 'designs.status.awaitingInput': 'Entrée requise', 'designs.status.succeeded': 'Terminé', + 'designs.status.published': 'Publié', 'designs.status.failed': 'Échoué', 'designs.status.canceled': 'Annulé', 'designs.viewToggleAria': 'Mode d\'affichage', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index a009f1693..9a974203a 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -596,6 +596,7 @@ export const hu: Dict = { 'designs.status.running': 'Fut', 'designs.status.awaitingInput': 'Bevitelre vár', 'designs.status.succeeded': 'Befejezve', + 'designs.status.published': 'Közzétéve', 'designs.status.failed': 'Sikertelen', 'designs.status.canceled': 'Megszakítva', 'designs.viewToggleAria': 'Nézet módja', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 863002270..a82e7cf12 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -709,6 +709,7 @@ export const id: Dict = { 'designs.status.running': 'Berjalan', 'designs.status.awaitingInput': 'Menunggu input', 'designs.status.succeeded': 'Selesai', + 'designs.status.published': 'Diterbitkan', 'designs.status.failed': 'Gagal', 'designs.status.canceled': 'Dibatalkan', 'designs.viewToggleAria': 'Mode tampilan', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index ea613a89e..44858aee8 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -564,6 +564,7 @@ export const it: Dict = { 'designs.status.running': 'In corso', 'designs.status.awaitingInput': 'Input richiesto', 'designs.status.succeeded': 'Completato', + 'designs.status.published': 'Pubblicato', 'designs.status.failed': 'Fallito', 'designs.status.canceled': 'Annullato', 'designs.viewToggleAria': 'Modalità di visualizzazione', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 06931459f..876e1fffd 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -483,6 +483,7 @@ export const ja: Dict = { 'designs.status.running': '実行中', 'designs.status.awaitingInput': '入力待ち', 'designs.status.succeeded': '完了', + 'designs.status.published': '公開済み', 'designs.status.failed': '失敗', 'designs.status.canceled': 'キャンセル済み', 'designs.viewToggleAria': '表示モード', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 84a2867b4..01b7390fb 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -596,6 +596,7 @@ export const ko: Dict = { 'designs.status.running': '실행 중', 'designs.status.awaitingInput': '입력 대기 중', 'designs.status.succeeded': '완료됨', + 'designs.status.published': '게시됨', 'designs.status.failed': '실패', 'designs.status.canceled': '취소됨', 'designs.viewToggleAria': '보기 모드', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index a084ba97c..c0194255f 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -596,6 +596,7 @@ export const pl: Dict = { 'designs.status.running': 'Uruchomiony', 'designs.status.awaitingInput': 'Wymaga danych', 'designs.status.succeeded': 'Zakończono', + 'designs.status.published': 'Opublikowano', 'designs.status.failed': 'Błąd', 'designs.status.canceled': 'Anulowano', 'designs.viewToggleAria': 'Tryb widoku', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 3326cd3cf..6fb5973c3 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -617,6 +617,7 @@ export const ptBR: Dict = { 'designs.status.running': 'Em execução', 'designs.status.awaitingInput': 'Aguardando resposta', 'designs.status.succeeded': 'Concluído', + 'designs.status.published': 'Publicado', 'designs.status.failed': 'Falhou', 'designs.status.canceled': 'Cancelado', 'designs.viewToggleAria': 'Modo de visualização', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 05282302c..60a2348e3 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -617,6 +617,7 @@ export const ru: Dict = { 'designs.status.running': 'Выполняется', 'designs.status.awaitingInput': 'Нужен ввод', 'designs.status.succeeded': 'Завершено', + 'designs.status.published': 'Опубликовано', 'designs.status.failed': 'Ошибка', 'designs.status.canceled': 'Отменено', 'designs.viewToggleAria': 'Режим просмотра', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 2b37e30fc..3ec686969 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -572,6 +572,7 @@ export const th: Dict = { 'designs.status.running': 'กำลังทำงาน', 'designs.status.awaitingInput': 'ต้องการข้อมูลเพิ่ม', 'designs.status.succeeded': 'สำเร็จ', + 'designs.status.published': 'เผยแพร่แล้ว', 'designs.status.failed': 'ล้มเหลว', 'designs.status.canceled': 'ยกเลิกแล้ว', 'designs.viewToggleAria': 'โหมดมุมมอง', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 66990b691..d761e0018 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -585,6 +585,7 @@ export const tr: Dict = { 'designs.status.running': 'Çalışıyor', 'designs.status.awaitingInput': 'Girdi gerekli', 'designs.status.succeeded': 'Tamamlandı', + 'designs.status.published': 'Yayımlandı', 'designs.status.failed': 'Başarısız', 'designs.status.canceled': 'İptal edildi', 'designs.viewToggleAria': 'Görüntüleme modu', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index b032615b3..cd931e1fd 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -609,6 +609,7 @@ export const uk: Dict = { 'designs.status.running': 'Виконується', 'designs.status.awaitingInput': 'Чекає підтвердження', 'designs.status.succeeded': 'Завершено', + 'designs.status.published': 'Опубліковано', 'designs.status.failed': 'Помилка', 'designs.status.canceled': 'Скасовано', 'designs.viewToggleAria': 'Режим перегляду', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index eb341a259..b20ddf494 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1172,6 +1172,7 @@ export const zhCN: Dict = { 'designs.status.running': '运行中', 'designs.status.awaitingInput': '等待回复', 'designs.status.succeeded': '已完成', + 'designs.status.published': '已发布', 'designs.status.failed': '失败', 'designs.status.canceled': '已取消', 'designs.viewToggleAria': '视图模式', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index a3633fc64..4c33918d1 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -788,6 +788,7 @@ export const zhTW: Dict = { 'designs.status.running': '執行中', 'designs.status.awaitingInput': '等待回覆', 'designs.status.succeeded': '已完成', + 'designs.status.published': '已發佈', 'designs.status.failed': '失敗', 'designs.status.canceled': '已取消', 'designs.viewToggleAria': '檢視模式', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index ffc42a424..668372271 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1486,6 +1486,7 @@ export interface Dict { 'designs.status.succeeded': string; 'designs.status.failed': string; 'designs.status.canceled': string; + 'designs.status.published': string; 'designs.viewToggleAria': string; 'designs.viewGrid': string; 'designs.viewKanban': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 814728228..25318aade 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -9115,6 +9115,9 @@ button.connector-action.is-loading { .design-card-status-failed { color: var(--red); } +.design-card-status-published { + color: var(--green); +} .design-card-close { position: absolute; top: 8px; diff --git a/apps/web/src/styles/home/recent-projects.css b/apps/web/src/styles/home/recent-projects.css index 6f0f719da..2512fb23e 100644 --- a/apps/web/src/styles/home/recent-projects.css +++ b/apps/web/src/styles/home/recent-projects.css @@ -199,6 +199,9 @@ .recent-projects__card-status-failed { color: var(--red); } +.recent-projects__card-status-published { + color: var(--green); +} .recent-projects__card-status-dot { width: 6px; height: 6px; diff --git a/apps/web/tests/components/design-system-project.test.ts b/apps/web/tests/components/design-system-project.test.ts new file mode 100644 index 000000000..55663470d --- /dev/null +++ b/apps/web/tests/components/design-system-project.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { DesignSystemSummary, Project } from '../../src/types'; + +import { + isDesignSystemProject, + isPublishedDesignSystemProject, +} from '../../src/components/design-system-project'; + +function project(overrides: Partial = {}): Project { + return { + id: 'p1', + name: 'StackMe', + updatedAt: 1, + designSystemId: null, + ...overrides, + } as Project; +} + +function system(overrides: Partial = {}): DesignSystemSummary { + return { + id: 'user:stackme', + title: 'StackMe', + category: 'Custom', + summary: '', + swatches: [], + surface: 'web', + source: 'user', + status: 'draft', + isEditable: true, + ...overrides, + }; +} + +describe('isDesignSystemProject', () => { + it('matches projects imported from a design system', () => { + expect(isDesignSystemProject(project({ metadata: { kind: 'other', importedFrom: 'design-system' } }))).toBe(true); + expect(isDesignSystemProject(project({ metadata: { kind: 'prototype' } }))).toBe(false); + expect(isDesignSystemProject(project())).toBe(false); + }); +}); + +describe('isPublishedDesignSystemProject', () => { + const dsProject = project({ + metadata: { kind: 'other', importedFrom: 'design-system' }, + designSystemId: 'user:stackme', + }); + + it('is true when the backing design system is published, even if the run failed', () => { + expect(isPublishedDesignSystemProject(dsProject, [system({ status: 'published' })])).toBe(true); + }); + + it('is false while the design system is still a draft', () => { + expect(isPublishedDesignSystemProject(dsProject, [system({ status: 'draft' })])).toBe(false); + }); + + it('is false for non-design-system projects and when the system is missing', () => { + expect( + isPublishedDesignSystemProject(project({ metadata: { kind: 'prototype' } }), [ + system({ status: 'published' }), + ]), + ).toBe(false); + expect(isPublishedDesignSystemProject(dsProject, [])).toBe(false); + }); +});