From 9ee2c1994c3687dc0f7a0f5b8eb6b6a4eaf783cc Mon Sep 17 00:00:00 2001 From: Tom Huang <1043269994@qq.com> Date: Sat, 2 May 2026 23:19:00 +0800 Subject: [PATCH] feat(web): add brand design systems, card thumbnails, and DESIGN.md side-by-side preview (#289) * feat(web): add brand design systems, card thumbnails, and DESIGN.md side-by-side preview - Add 7 new brand design systems (arc, canva, discord, duolingo, github, huggingface, openai) - Show live showcase HTML thumbnails on Design Systems cards - Add toggleable DESIGN.md side panel in preview modal with syntax-highlighted spec view - Make preview iframe responsive: render at fixed design viewport and scale to fit so opening the side panel never reflows showcases into broken breakpoints - Add floating collapse/expand handles on the sidebar boundary for direct hide/show Co-authored-by: Cursor * fix(web): guard ResizeObserver and re-fire sidebar lazy-load on content swap - Guard `new ResizeObserver(...)` in PreviewModal so the modal mounts in jsdom (the existing preview-modal-fullscreen test was failing with `ReferenceError: ResizeObserver is not defined`) and in older embedded WebViews. Fall back to a window resize listener when the constructor is unavailable. - Add a `contentKey` hint to PreviewSidebar so the lazy-load `onToggle` callback re-fires when the underlying side-panel source swaps while the sidebar stays open. Wire `system.id` through from DesignSystemPreviewModal so swapping design systems with the spec panel open primes a fresh DESIGN.md fetch instead of leaving it stuck. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(web/i18n): add missing ds/preview keys to hu locale The Validate workspace check failed after main's hu.ts landed without the four i18n keys introduced by this PR (ds.specToggle, ds.specLoading, preview.showSidebar, preview.hideSidebar). Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) --------- Co-authored-by: Cursor --- apps/web/src/components/DesignSpecView.tsx | 94 ++++++ .../components/DesignSystemPreviewModal.tsx | 37 ++- apps/web/src/components/DesignSystemsTab.tsx | 211 +++++++++--- apps/web/src/components/PreviewModal.tsx | 174 +++++++++- apps/web/src/i18n/content.ts | 7 + apps/web/src/i18n/locales/de.ts | 4 + apps/web/src/i18n/locales/en.ts | 4 + apps/web/src/i18n/locales/es-ES.ts | 4 + apps/web/src/i18n/locales/fa.ts | 4 + apps/web/src/i18n/locales/hu.ts | 4 + apps/web/src/i18n/locales/ja.ts | 4 + apps/web/src/i18n/locales/ko.ts | 4 + apps/web/src/i18n/locales/pl.ts | 4 + apps/web/src/i18n/locales/pt-BR.ts | 4 + apps/web/src/i18n/locales/ru.ts | 4 + apps/web/src/i18n/locales/tr.ts | 4 + apps/web/src/i18n/locales/zh-CN.ts | 4 + apps/web/src/i18n/locales/zh-TW.ts | 4 + apps/web/src/i18n/types.ts | 4 + apps/web/src/index.css | 310 +++++++++++++++++- design-systems/arc/DESIGN.md | 152 +++++++++ design-systems/canva/DESIGN.md | 157 +++++++++ design-systems/discord/DESIGN.md | 162 +++++++++ design-systems/duolingo/DESIGN.md | 154 +++++++++ design-systems/github/DESIGN.md | 155 +++++++++ design-systems/huggingface/DESIGN.md | 149 +++++++++ design-systems/openai/DESIGN.md | 140 ++++++++ 27 files changed, 1883 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/components/DesignSpecView.tsx create mode 100644 design-systems/arc/DESIGN.md create mode 100644 design-systems/canva/DESIGN.md create mode 100644 design-systems/discord/DESIGN.md create mode 100644 design-systems/duolingo/DESIGN.md create mode 100644 design-systems/github/DESIGN.md create mode 100644 design-systems/huggingface/DESIGN.md create mode 100644 design-systems/openai/DESIGN.md diff --git a/apps/web/src/components/DesignSpecView.tsx b/apps/web/src/components/DesignSpecView.tsx new file mode 100644 index 000000000..35000b1eb --- /dev/null +++ b/apps/web/src/components/DesignSpecView.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; + +interface Props { + source: string | null | undefined; + loading?: boolean; + loadingLabel: string; +} + +// Render a DESIGN.md as a lightly syntax-coloured monospace source view — +// the right-hand panel of the preview modal, mirroring the layout used by +// styles.refero.design where the rendered showcase sits next to the spec +// text. Highlights are CSS-class only; no innerHTML for untrusted text. +export function DesignSpecView({ source, loading, loadingLabel }: Props) { + const lines = useMemo(() => (source ? source.split(/\r?\n/) : []), [source]); + + if (loading || source === undefined || source === null) { + return
{loadingLabel}
; + } + + return ( +
+      
+        {lines.map((line, idx) => (
+          
+            {renderInline(line)}
+            {'\n'}
+          
+        ))}
+      
+    
+ ); +} + +function classifyLine(line: string): string { + if (/^#{1,6}\s+/.test(line)) { + const hashes = /^(#+)\s/.exec(line)?.[1]?.length ?? 1; + return `is-h${Math.min(hashes, 4)}`; + } + if (/^>\s/.test(line)) return 'is-quote'; + if (/^[-*+]\s/.test(line.trimStart())) return 'is-list'; + if (/^\|.*\|\s*$/.test(line)) return 'is-table'; + if (/^\s*```/.test(line)) return 'is-fence'; + if (/^\s*$/.test(line)) return 'is-blank'; + return ''; +} + +const TOKEN_RE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|#[0-9a-fA-F]{3,8}\b)/g; + +function renderInline(line: string) { + if (!line) return null; + const out: (string | JSX.Element)[] = []; + let last = 0; + let key = 0; + for (const match of line.matchAll(TOKEN_RE)) { + const start = match.index ?? 0; + if (start > last) out.push(line.slice(last, start)); + const token = match[0]; + if (token.startsWith('**')) { + out.push( + + {token.slice(2, -2)} + , + ); + } else if (token.startsWith('*')) { + out.push( + + {token.slice(1, -1)} + , + ); + } else if (token.startsWith('`')) { + out.push( + + {token.slice(1, -1)} + , + ); + } else if (token.startsWith('#')) { + out.push( + + + {token} + , + ); + } else { + out.push(token); + } + last = start + token.length; + } + if (last < line.length) out.push(line.slice(last)); + return out; +} diff --git a/apps/web/src/components/DesignSystemPreviewModal.tsx b/apps/web/src/components/DesignSystemPreviewModal.tsx index f9dc4f239..93f13743f 100644 --- a/apps/web/src/components/DesignSystemPreviewModal.tsx +++ b/apps/web/src/components/DesignSystemPreviewModal.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; import { useT } from '../i18n'; import { + fetchDesignSystem, fetchDesignSystemPreview, fetchDesignSystemShowcase, } from '../providers/registry'; import type { DesignSystemSummary } from '../types'; +import { DesignSpecView } from './DesignSpecView'; import { PreviewModal } from './PreviewModal'; interface Props { @@ -14,11 +16,14 @@ interface Props { // Two-tab DS preview: a complete Showcase webpage rendered from the system's // tokens, and the original Tokens view (palette / typography / components + -// rendered DESIGN.md prose). +// rendered DESIGN.md prose). A toggleable side panel surfaces the raw +// DESIGN.md so users can compare spec to render at the same time, mirroring +// the styles.refero.design layout. export function DesignSystemPreviewModal({ system, onClose }: Props) { const t = useT(); const [showcaseHtml, setShowcaseHtml] = useState(undefined); const [tokensHtml, setTokensHtml] = useState(undefined); + const [specBody, setSpecBody] = useState(undefined); // Lazy-load each view on first reveal. Both endpoints are cheap, but this // keeps the network panel quiet when the user only opens one tab. @@ -36,10 +41,24 @@ export function DesignSystemPreviewModal({ system, onClose }: Props) { [system.id, showcaseHtml, tokensHtml], ); - // If the system swaps under us (rare but possible), wipe both caches. + // Fetch DESIGN.md the first time the side panel opens. Once we have it we + // never re-fetch unless the underlying system swaps. + const handleSidebarToggle = useCallback( + (open: boolean) => { + if (!open || specBody !== undefined) return; + setSpecBody(null); + void fetchDesignSystem(system.id).then((detail) => + setSpecBody(detail?.body ?? null), + ); + }, + [system.id, specBody], + ); + + // If the system swaps under us (rare but possible), wipe all caches. useEffect(() => { setShowcaseHtml(undefined); setTokensHtml(undefined); + setSpecBody(undefined); }, [system.id]); return ( @@ -54,6 +73,20 @@ export function DesignSystemPreviewModal({ system, onClose }: Props) { onView={handleView} exportTitleFor={(viewId) => `${system.title} — ${viewId}`} onClose={onClose} + sidebar={{ + label: t('ds.specToggle'), + defaultOpen: true, + onToggle: handleSidebarToggle, + // Re-fire onToggle when the system swaps under us so the new + // DESIGN.md fetch starts even if the sidebar never closed. + contentKey: system.id, + content: ( + + ), + }} /> ); } diff --git a/apps/web/src/components/DesignSystemsTab.tsx b/apps/web/src/components/DesignSystemsTab.tsx index 30a161fd4..6493d5dd1 100644 --- a/apps/web/src/components/DesignSystemsTab.tsx +++ b/apps/web/src/components/DesignSystemsTab.tsx @@ -1,9 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../i18n'; import { localizeDesignSystemCategory, localizeDesignSystemSummary, } from '../i18n/content'; +import { fetchDesignSystemShowcase } from '../providers/registry'; +import { buildSrcdoc } from '../runtime/srcdoc'; import type { DesignSystemSummary, Surface } from '../types'; interface Props { @@ -45,6 +47,10 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P const [filter, setFilter] = useState(''); const [surfaceFilter, setSurfaceFilter] = useState('all'); const [category, setCategory] = useState('All'); + // Cache fetched showcase HTML across re-renders so cards never re-flicker + // when the user filters / scrolls back. null = "in flight"; undefined = + // "not yet requested". Mirrors the pattern used by ExamplesTab. + const [thumbs, setThumbs] = useState>({}); const surfaceScoped = useMemo( () => surfaceFilter === 'all' ? systems : systems.filter((s) => surfaceOf(s) === surfaceFilter), @@ -93,6 +99,16 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P return localizeDesignSystemCategory(locale, c); }; + function loadThumb(id: string) { + setThumbs((prev) => { + if (prev[id] !== undefined) return prev; + void fetchDesignSystemShowcase(id).then((html) => { + setThumbs((p) => ({ ...p, [id]: html })); + }); + return { ...prev, [id]: null }; + }); + } + return (
@@ -135,55 +151,154 @@ export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: P {filtered.length === 0 ? (
{t('ds.emptyNoMatch')}
) : ( -
- {filtered.map((s) => { - const active = s.id === selectedId; - return ( -
onSelect(s.id)} - > -
-
- {s.title} - {active ? ( - - {t('ds.badgeDefault')} - - ) : null} -
-
- {localizeDesignSystemSummary(locale, s)} -
-
- {s.swatches && s.swatches.length > 0 ? ( -
- {s.swatches.map((c, i) => ( - - ))} -
- ) : null} - -
- ); - })} +
+ {filtered.map((s) => ( + loadThumb(s.id)} + onSelect={() => onSelect(s.id)} + onPreview={() => onPreview(s.id)} + /> + ))}
)}
); } + +interface CardProps { + system: DesignSystemSummary; + active: boolean; + thumbHtml: string | null | undefined; + onIntersect: () => void; + onSelect: () => void; + onPreview: () => void; +} + +function DesignSystemCard({ + system, + active, + thumbHtml, + onIntersect, + onSelect, + onPreview, +}: CardProps) { + const { locale, t } = useI18n(); + const ref = useRef(null); + + // Lazy-load the showcase iframe only when the card scrolls into the + // viewport. With ~120 design systems we can't afford to mount every + // iframe up front — even with `loading="lazy"`, srcDoc iframes ignore + // the native lazy hint, so we gate via IntersectionObserver. + useEffect(() => { + if (thumbHtml !== undefined) return; + const node = ref.current; + if (!node || typeof IntersectionObserver === 'undefined') { + onIntersect(); + return; + } + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onIntersect(); + observer.disconnect(); + break; + } + } + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [thumbHtml, onIntersect]); + + const localizedSummary = localizeDesignSystemSummary(locale, system); + const categoryLabel = localizeDesignSystemCategory( + locale, + system.category || 'Uncategorized', + ); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }} + > +
{ + e.stopPropagation(); + onPreview(); + }} + title={t('ds.previewTitle')} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onPreview(); + } + }} + > + {thumbHtml ? ( +