feat(analytics): full design-system event family + DS run variant (#2706)

Lands the v2 PostHog spec's P0 design-system event family: five new
result events covering source ingest, create, review, status, and
picker apply; the existing file_upload_result + run_created/run_finished
schemas widened to discriminate DS workspaces from regular chat runs.

Contract (packages/contracts/src/analytics/events.ts):
- AnalyticsEventName gains design_system_{source_ingest,create,review,
  status,apply}_result.
- Props interfaces + bucket/origin/method/status enums per spec.
- TrackingProjectKind gains 'design_system' for DS-as-project runs.
- RunCreatedProps / RunFinishedProps widen page_name+area to discriminate
  chat_panel vs design_system_project; entry_from union accepts DS values;
  DS-variant context fields (ds_source_origin, source_count, brand
  description length bucket, per-source counts, design_system_created,
  preview_module_count, missing_font_count).
- FileUploadSurface union adds design_systems / design_system_source.
- Bucket helpers (designSystemLengthBucket, folderCountBucket,
  totalSizeBucket), module slug + type derivation, repo host parser.

Web emission sites:
- DesignSystemFlow.generate(): create_result + threads
  prepareCreatedDesignSystemProject with analyticsTrack so each of the
  4 source paths emits source_ingest_result (success / partial / failed
  / empty), repo-host dominance, fallback type from connector status.
- DropZone onFiles handlers: file_upload_result with deriveUploadCohort.
- DesignSystemDetailView: status_result on togglePublished + Make-default,
  review_result on Looks-good / Needs-work; module_id from markdown
  section header slug (designSystemModuleSlug), module_type via keyword
  heuristic.
- DesignSystemsTab: status_result on publish toggle, set/unset default,
  delete (incl. cancelled when window.confirm dismissed).
- NewProjectPanel: apply_result on DS picker change (manual select +
  clear) plus an auto_select emit when the picker mounts with a default
  DS not yet user-touched.
- ProjectView.streamViaDaemon: when project.metadata.importedFrom ===
  'design-system', pass analyticsHints with entry_from
  (onboarding_design_system for the auto-sent first message,
  regenerate_from_review for subsequent sends), projectKind=design_system,
  designSystemRunContext.

Daemon:
- ChatRequest gains optional analyticsHints (entryFrom / projectKind /
  designSystemRunContext). Behavior never depends on these; only PostHog
  props do.
- /api/runs handler reads analyticsHints to flip baseProps to the DS
  variant (page_name=design_system_project, area=design_system_generation,
  project_kind=design_system) when the run is DS-flagged, and spreads the
  DS context fields onto run_created.
- run_finished mirrors the DS area + adds design_system_created (true iff
  the run wrote DESIGN.md), preview_module_count (distinct preview/*.html
  writes), missing_font_count (0 placeholder; pending font-audit hook).
- run-artifacts.ts: extracts collectWrittenPathsMatching as the shared
  Write/Edit + isError-pair core; adds didRunCreateDesignSystemFile and
  countDesignSystemPreviewModules using the same dedup + failure-skip
  invariants as countNewHtmlArtifacts.

Tests:
- packages/contracts/tests/analytics-design-system-helpers.test.ts: 18
  new test cases over the bucket helpers, module slug + type mapping,
  repo host parser.
- apps/daemon/tests/run-artifacts.test.ts: 9 new tests for
  didRunCreateDesignSystemFile + countDesignSystemPreviewModules covering
  Write-then-Edit dedupe, case-insensitive DESIGN.md match, isError pair
  skip, preview/index.html as a module, non-preview path rejection.

Targets release/v0.8.0.
This commit is contained in:
lefarcen 2026-05-22 17:18:57 +08:00 committed by GitHub
parent 98c03d8d2b
commit 9912fa899a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1659 additions and 32 deletions

View file

@ -48,6 +48,19 @@ function isHtmlPath(path: string): boolean {
return path.toLowerCase().endsWith('.html');
}
function isDesignSystemFile(path: string): boolean {
const lower = path.toLowerCase();
return lower.endsWith('/design.md') || lower === 'design.md';
}
function isPreviewModulePath(path: string): boolean {
const lower = path.toLowerCase();
// Preview modules live under `preview/*.html` in DS workspaces.
// `preview/index.html` is the shell, others are per-module previews
// (colors, typography, components, brand-assets, ...).
return /(^|\/)preview\/[^/]+\.html$/i.test(lower);
}
export interface RunEventLike {
event?: string;
data?: unknown;
@ -77,6 +90,65 @@ function readToolResultIsError(data: unknown): boolean {
return (data as { isError?: unknown }).isError === true;
}
// Generic write counter shared by all three predicates. Returns the
// set of distinct paths the run successfully wrote / edited that
// match `predicate`. Failure-pairing semantics match
// `countNewHtmlArtifacts` so the three counters stay aligned.
function collectWrittenPathsMatching(
events: readonly RunEventLike[],
predicate: (path: string) => boolean,
): Set<string> {
if (!events || events.length === 0) return new Set();
const resultByToolUseId = new Map<string, { isError: boolean }>();
for (const rec of events) {
if (rec?.event !== 'agent') continue;
const data = rec.data as { type?: string } | null | undefined;
if (data?.type !== 'tool_result') continue;
const id = readToolResultId(rec.data);
if (!id) continue;
resultByToolUseId.set(id, { isError: readToolResultIsError(rec.data) });
}
const writtenPaths = new Set<string>();
for (const rec of events) {
if (rec?.event !== 'agent') continue;
const data = rec.data as
| { type?: string; name?: unknown; input?: unknown }
| null
| undefined;
if (data?.type !== 'tool_use') continue;
if (typeof data.name !== 'string') continue;
if (!WRITE_OR_EDIT_TOOL_NAMES.has(data.name)) continue;
const path = extractToolFilePath(data.input);
if (!path) continue;
if (!predicate(path)) continue;
const toolUseId = readToolUseId(rec.data);
if (!toolUseId) continue;
const outcome = resultByToolUseId.get(toolUseId);
if (!outcome) continue;
if (outcome.isError) continue;
writtenPaths.add(path);
}
return writtenPaths;
}
// True iff the run successfully wrote or edited a `DESIGN.md` file.
// Fed into `run_finished.design_system_created` for the DS variant.
export function didRunCreateDesignSystemFile(
events: readonly RunEventLike[],
): boolean {
return collectWrittenPathsMatching(events, isDesignSystemFile).size > 0;
}
// Count of distinct preview modules the run wrote under `preview/`.
// Fed into `run_finished.preview_module_count`. A run that wrote
// `preview/index.html` only counts as 1 module preview (the
// path-distinct semantics match countNewHtmlArtifacts).
export function countDesignSystemPreviewModules(
events: readonly RunEventLike[],
): number {
return collectWrittenPathsMatching(events, isPreviewModulePath).size;
}
export function countNewHtmlArtifacts(events: readonly RunEventLike[]): number {
if (!events || events.length === 0) return 0;

View file

@ -184,7 +184,11 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import { deriveRunErrorCode, runResultFromStatus } from './run-result.js';
import { countNewHtmlArtifacts } from './run-artifacts.js';
import {
countDesignSystemPreviewModules,
countNewHtmlArtifacts,
didRunCreateDesignSystemFile,
} from './run-artifacts.js';
import {
reportRunCompletedFromDaemon,
reportRunFeedbackFromDaemon,
@ -11290,23 +11294,52 @@ export async function startServer({
const userQueryTokens = promptText.length > 0
? Math.ceil(promptText.length / 4)
: 0;
// Optional analytics context the client may attach to a run.
// Used to thread the DS run variant (`design_system_project` /
// `design_system_generation` page+area, `project_kind=design_system`,
// entry_from values like `design_system_create`) plus per-source
// counts onto run_created / run_finished. Behavior never depends on
// these; only PostHog props do.
const analyticsHints =
(reqBody as { analyticsHints?: Record<string, unknown> | null }).analyticsHints
&& typeof (reqBody as { analyticsHints?: unknown }).analyticsHints === 'object'
? ((reqBody as { analyticsHints?: Record<string, unknown> }).analyticsHints ?? {})
: {};
const hintEntryFrom = typeof analyticsHints.entryFrom === 'string'
? analyticsHints.entryFrom
: undefined;
const hintProjectKind = typeof analyticsHints.projectKind === 'string'
? analyticsHints.projectKind
: null;
const dsRunContext =
analyticsHints.designSystemRunContext
&& typeof analyticsHints.designSystemRunContext === 'object'
? (analyticsHints.designSystemRunContext as Record<string, unknown>)
: {};
const isDesignSystemRun =
hintProjectKind === 'design_system'
|| hintEntryFrom === 'design_system_create'
|| hintEntryFrom === 'onboarding_design_system'
|| hintEntryFrom === 'regenerate_from_review';
// Only fields the current `/api/runs` create payload actually
// sends. The v2 schema documents extended context props
// (entry_from / project_kind / target_platforms / fidelity /
// companion_surfaces / connectors / use_speaker_notes /
// include_animations / reference_template / aspect /
// project_source) but `packages/contracts/src/api/chat.ts` and
// `apps/web/src/providers/daemon.ts` do not yet thread them onto
// the wire, so reading them here would always produce null/undef
// — better to omit until a follow-up extends the create payload.
// project_source) — most aren't on the wire yet, but
// entry_from / projectKind / DS run context land here when the
// client populates `analyticsHints`. Other dimensions stay
// omitted until follow-up PRs thread them through.
const baseProps: Record<string, unknown> = {
page_name: 'chat_panel',
area: 'chat_composer',
page_name: isDesignSystemRun ? 'design_system_project' : 'chat_panel',
area: isDesignSystemRun ? 'design_system_generation' : 'chat_composer',
...configureGlobals,
project_id: typeof reqBody.projectId === 'string' ? reqBody.projectId : null,
conversation_id:
typeof reqBody.conversationId === 'string' ? reqBody.conversationId : null,
run_id: run.id,
project_kind: hintProjectKind,
...(hintEntryFrom ? { entry_from: hintEntryFrom } : {}),
design_system_id:
typeof reqBody.designSystemId === 'string'
? reqBody.designSystemId
@ -11325,6 +11358,33 @@ export async function startServer({
typeof reqBody.designSystemId === 'string' && reqBody.designSystemId
? 'unknown'
: 'not_applicable',
...(isDesignSystemRun ? {
ds_source_origin: typeof dsRunContext.origin === 'string'
? dsRunContext.origin
: undefined,
source_count: typeof dsRunContext.sourceCount === 'number'
? dsRunContext.sourceCount
: undefined,
has_brand_description: typeof dsRunContext.hasBrandDescription === 'boolean'
? dsRunContext.hasBrandDescription
: undefined,
brand_description_length_bucket:
typeof dsRunContext.brandDescriptionLengthBucket === 'string'
? dsRunContext.brandDescriptionLengthBucket
: undefined,
github_repo_count: typeof dsRunContext.githubRepoCount === 'number'
? dsRunContext.githubRepoCount
: undefined,
local_folder_count: typeof dsRunContext.localFolderCount === 'number'
? dsRunContext.localFolderCount
: undefined,
fig_file_count: typeof dsRunContext.figFileCount === 'number'
? dsRunContext.figFileCount
: undefined,
asset_file_count: typeof dsRunContext.assetFileCount === 'number'
? dsRunContext.assetFileCount
: undefined,
} : {}),
has_attachment: Array.isArray(reqBody.attachments)
? (reqBody.attachments as unknown[]).length > 0
: false,
@ -11385,7 +11445,10 @@ export async function startServer({
appVersion: design.getAppVersion(),
properties: {
...baseProps,
area: 'chat_panel',
// `area` flips on run_finished: chat_panel runs publish
// under `chat_panel`, DS runs stay on
// `design_system_generation` to match the run_created shape.
area: isDesignSystemRun ? 'design_system_generation' : 'chat_panel',
result,
// Incremental count of `.html` paths the run produced or
// modified, deduped per file. Replaces the hard-coded `0`
@ -11394,6 +11457,19 @@ export async function startServer({
// for the dedup semantics; tested in
// `tests/run-artifacts.test.ts`.
artifact_count: countNewHtmlArtifacts(run.events),
...(isDesignSystemRun ? {
// DS runs land a `DESIGN.md` write when generation
// succeeded; the run-artifacts inspector reuses the
// same Write/Edit pairing it already does for HTML
// artifact counts, just keyed on `DESIGN.md`.
design_system_created: didRunCreateDesignSystemFile(run.events),
preview_module_count: countDesignSystemPreviewModules(run.events),
// `missing_font_count` defaults to 0 — the agent flow
// doesn't emit a structured "missing fonts" signal yet.
// Kept on the wire so the dashboard has the column from
// day one; can be sourced later from a font-audit hook.
missing_font_count: 0,
} : {}),
total_duration_ms: Date.now() - runStartedAt,
...(errorCode ? { error_code: errorCode } : {}),
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),

View file

@ -16,7 +16,11 @@
import { describe, expect, it } from 'vitest';
import { countNewHtmlArtifacts } from '../src/run-artifacts.js';
import {
countDesignSystemPreviewModules,
countNewHtmlArtifacts,
didRunCreateDesignSystemFile,
} from '../src/run-artifacts.js';
let nextId = 0;
function freshId(prefix = 'tool'): string {
@ -202,3 +206,85 @@ describe('countNewHtmlArtifacts', () => {
).toBe(0);
});
});
describe('didRunCreateDesignSystemFile', () => {
it('is true when the run wrote a DESIGN.md', () => {
expect(
didRunCreateDesignSystemFile([
...pair('Write', '/proj/DESIGN.md'),
]),
).toBe(true);
});
it('matches DESIGN.md case-insensitively', () => {
expect(
didRunCreateDesignSystemFile([
...pair('Edit', '/proj/design.md'),
]),
).toBe(true);
});
it('is false when the matching tool_result reported isError', () => {
expect(
didRunCreateDesignSystemFile([
...pair('Write', '/proj/DESIGN.md', true),
]),
).toBe(false);
});
it('is false when no DESIGN.md was touched', () => {
expect(
didRunCreateDesignSystemFile([
...pair('Write', '/proj/index.html'),
...pair('Read', '/proj/DESIGN.md'),
]),
).toBe(false);
});
});
describe('countDesignSystemPreviewModules', () => {
it('counts distinct preview/*.html paths the run wrote', () => {
expect(
countDesignSystemPreviewModules([
...pair('Write', '/proj/preview/colors.html'),
...pair('Write', '/proj/preview/typography.html'),
...pair('Write', '/proj/preview/components.html'),
]),
).toBe(3);
});
it('dedupes Write-then-Edit on the same preview path', () => {
expect(
countDesignSystemPreviewModules([
...pair('Write', '/proj/preview/colors.html'),
...pair('Edit', '/proj/preview/colors.html'),
]),
).toBe(1);
});
it('counts preview/index.html as a module', () => {
expect(
countDesignSystemPreviewModules([
...pair('Write', '/proj/preview/index.html'),
]),
).toBe(1);
});
it('ignores non-preview html paths', () => {
expect(
countDesignSystemPreviewModules([
...pair('Write', '/proj/index.html'),
...pair('Write', '/proj/docs/intro.html'),
]),
).toBe(0);
});
it('skips preview writes whose tool_result reported isError', () => {
expect(
countDesignSystemPreviewModules([
...pair('Write', '/proj/preview/colors.html', true),
...pair('Write', '/proj/preview/typography.html'),
]),
).toBe(1);
});
});

View file

@ -86,6 +86,11 @@ import type {
OnboardingClickProps,
OnboardingRuntimeScanResultProps,
OnboardingCompleteResultProps,
DesignSystemSourceIngestResultProps,
DesignSystemCreateResultProps,
DesignSystemReviewResultProps,
DesignSystemStatusResultProps,
DesignSystemApplyResultProps,
UpdateIndicatorSurfaceViewProps,
UpdatePromptSurfaceViewProps,
UpdateInstallResultProps,
@ -711,6 +716,53 @@ export function trackOnboardingCompleteResult(
send(track, 'onboarding_complete_result', props);
}
// ---- Design-system lifecycle ---------------------------------------------
//
// `trackDesignSystem*Result` cover the five lifecycle moments in the
// DS funnel: source intake, create, review, status changes, picker
// apply. Page_views / clicks inside DS surfaces continue to reuse the
// generic `page_view` / `ui_click` helpers with the DS page enum.
export function trackDesignSystemSourceIngestResult(
track: Track,
props: DesignSystemSourceIngestResultProps,
options?: { requestId?: string },
): void {
send(track, 'design_system_source_ingest_result', props, options);
}
export function trackDesignSystemCreateResult(
track: Track,
props: DesignSystemCreateResultProps,
options?: { requestId?: string },
): void {
send(track, 'design_system_create_result', props, options);
}
export function trackDesignSystemReviewResult(
track: Track,
props: DesignSystemReviewResultProps,
options?: { requestId?: string },
): void {
send(track, 'design_system_review_result', props, options);
}
export function trackDesignSystemStatusResult(
track: Track,
props: DesignSystemStatusResultProps,
options?: { requestId?: string },
): void {
send(track, 'design_system_status_result', props, options);
}
export function trackDesignSystemApplyResult(
track: Track,
props: DesignSystemApplyResultProps,
options?: { requestId?: string },
): void {
send(track, 'design_system_apply_result', props, options);
}
// ---- Update indicator / prompt ------------------------------------------
export function trackUpdateIndicatorSurfaceView(

View file

@ -59,13 +59,38 @@ import { ChatPane } from './ChatPane';
import { FileWorkspace } from './FileWorkspace';
import { Icon, type IconName } from './Icon';
import { useAnalytics } from '../analytics/provider';
import { trackPageView } from '../analytics/events';
import {
trackDesignSystemCreateResult,
trackDesignSystemReviewResult,
trackDesignSystemSourceIngestResult,
trackDesignSystemStatusResult,
trackFileUploadResult,
trackPageView,
} from '../analytics/events';
import {
clearOnboardingSessionId,
peekOnboardingSessionId,
} from '../analytics/onboarding-session';
import { deriveUploadCohort } from '../analytics/upload-tracking';
import {
designSystemFolderCountBucket,
designSystemLengthBucket,
designSystemModuleSlug,
designSystemModuleType,
designSystemRepoHostFromUrl,
designSystemTotalSizeBucket,
} from '@open-design/contracts/analytics';
import type {
TrackingDesignSystemCreateEntryFrom,
TrackingDesignSystemIngestMethod,
TrackingDesignSystemIngestSourceType,
TrackingDesignSystemOrigin,
TrackingDesignSystemRepoHost,
TrackingDesignSystemSourceIngestEntryFrom,
TrackingDesignSystemSourceIngestResult,
TrackingDesignSystemStatus,
TrackingDesignSystemStatusAction,
TrackingDesignSystemStatusValue,
TrackingDesignSystemsEntryFrom,
} from '@open-design/contracts/analytics';
@ -265,6 +290,33 @@ export function DesignSystemCreationFlow({
});
}, [analytics.track, embedded]);
// `emitDsFileUpload` reports the user-side dropzone batch. `picked`
// is the raw FileList; `staged` is what survived the size/count
// filters (selectLocalCodeFiles / selectFigmaFiles / selectAssetFiles).
// The result is `failed` only when zero files pass the filter (e.g.
// every dropped file was over the per-source size cap); cohort math
// mirrors the chat-composer + onboarding uploads via
// `deriveUploadCohort`. The onboarding variant of this event lives
// in EntryShell; this fires from the standalone /design-systems/create
// route so the dashboard gets both flows.
function emitDsFileUpload(
sourceType: 'local_code' | 'fig' | 'assets',
picked: File[],
staged: File[],
) {
if (embedded) return;
if (picked.length === 0) return;
const cohort = deriveUploadCohort(picked);
trackFileUploadResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_source',
source_type: sourceType,
...cohort,
result: staged.length > 0 ? 'success' : 'failed',
error_code: staged.length === 0 ? 'DS_UPLOAD_ALL_FILTERED' : undefined,
});
}
const refreshGithubConnector = useCallback(async () => {
if (!composioConfigured) {
githubConnectorRefreshId.current += 1;
@ -432,6 +484,40 @@ export function DesignSystemCreationFlow({
onBeforeGenerate?.(snapshot);
setGenerationStarting(true);
setError(null);
const generateStartedAt = performance.now();
const onboardingSessionId = peekOnboardingSessionId();
const createEntryFrom: TrackingDesignSystemCreateEntryFrom = embedded
? 'onboarding'
: onboardingSessionId
? 'onboarding'
: 'design_systems_page';
const ingestEntryFrom: TrackingDesignSystemSourceIngestEntryFrom = embedded
? 'onboarding'
: onboardingSessionId
? 'onboarding'
: 'design_systems_page';
const designSystemOrigin = deriveDesignSystemOrigin(snapshot);
function emitCreateResult(
result: 'success' | 'failed' | 'cancelled',
designSystemId: string | undefined,
errorCode?: string,
) {
trackDesignSystemCreateResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_create',
entry_from: createEntryFrom,
result,
design_system_id: designSystemId,
design_system_source: designSystemOrigin,
source_count: snapshot.sourceCount,
created_as_project: result === 'success',
has_brand_description: snapshot.hasBrandDescription,
brand_description_length_bucket: designSystemLengthBucket(state.company),
notes_length_bucket: designSystemLengthBucket(state.notes),
error_code: errorCode,
duration_ms: Math.max(0, Math.round(performance.now() - generateStartedAt)),
});
}
try {
const title = inferDesignSystemTitle(state);
const created = await createDesignSystemDraft({
@ -447,6 +533,7 @@ export function DesignSystemCreationFlow({
if (!created) {
setError('Could not generate this design system.');
setStep('setup');
emitCreateResult('failed', undefined, 'DS_DRAFT_CREATE_FAILED');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: 'DS_DRAFT_CREATE_FAILED',
@ -457,6 +544,7 @@ export function DesignSystemCreationFlow({
if (!workspace) {
setError('Could not open the design system workspace.');
setStep('setup');
emitCreateResult('failed', created.id, 'DS_WORKSPACE_OPEN_FAILED');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: 'DS_WORKSPACE_OPEN_FAILED',
@ -467,6 +555,7 @@ export function DesignSystemCreationFlow({
const setupState = state;
const connector = githubConnector;
onCreated(project.id, project);
emitCreateResult('success', created.id);
onGenerateSettled?.(snapshot, { result: 'success' });
scheduleAfterProjectHandoff(() => {
void prepareCreatedDesignSystemProject({
@ -476,17 +565,19 @@ export function DesignSystemCreationFlow({
githubConnector: connector,
onProjectPrepared,
onSystemsRefresh,
analyticsTrack: analytics.track,
ingestEntryFrom,
designSystemId: created.id,
});
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not prepare the design system project.');
setStep('setup');
onGenerateSettled?.(snapshot, {
result: 'failed',
errorCode: err instanceof Error
? `DS_GENERATE_THREW:${err.message.slice(0, 80)}`
: 'DS_GENERATE_THREW',
});
const errorCode = err instanceof Error
? `DS_GENERATE_THREW:${err.message.slice(0, 80)}`
: 'DS_GENERATE_THREW';
emitCreateResult('failed', undefined, errorCode);
onGenerateSettled?.(snapshot, { result: 'failed', errorCode });
} finally {
setGenerationStarting(false);
}
@ -624,6 +715,7 @@ export function DesignSystemCreationFlow({
onFiles={(_names, files) => {
const stagedFiles = selectLocalCodeFiles(files);
const stagedNames = stagedFiles.map((file) => localCodeRelativePath(file));
emitDsFileUpload('local_code', files, stagedFiles);
setState((curr) => ({
...curr,
codeFiles: Array.from(new Set([...curr.codeFiles, ...stagedNames])),
@ -640,6 +732,7 @@ export function DesignSystemCreationFlow({
onFiles={(_names, files) => {
const stagedFiles = selectFigmaFiles(files);
const stagedNames = stagedFiles.map((file) => resourceRelativePath(file));
emitDsFileUpload('fig', files, stagedFiles);
setState((curr) => ({
...curr,
figFiles: Array.from(new Set([...curr.figFiles, ...stagedNames])),
@ -654,6 +747,7 @@ export function DesignSystemCreationFlow({
onFiles={(_names, files) => {
const stagedFiles = selectAssetFiles(files);
const stagedNames = stagedFiles.map((file) => resourceRelativePath(file));
emitDsFileUpload('assets', files, stagedFiles);
setState((curr) => ({
...curr,
assetFiles: Array.from(new Set([...curr.assetFiles, ...stagedNames])),
@ -1053,8 +1147,67 @@ export function DesignSystemDetailView({
}
async function togglePublished(next: boolean) {
const updated = await savePatch({ body, status: next ? 'published' : 'draft' });
setStatusLine(updated ? (next ? 'Published' : 'Moved back to draft') : 'Could not update status');
const startedAt = performance.now();
const action: TrackingDesignSystemStatusAction = next ? 'publish' : 'unpublish';
const statusBefore = mapDsStatusToTracking(system?.status);
const isDefaultBefore = system?.id === selectedId;
let succeeded = false;
let errorCode: string | undefined;
try {
const updated = await savePatch({ body, status: next ? 'published' : 'draft' });
succeeded = Boolean(updated);
if (!succeeded) errorCode = 'DS_STATUS_UPDATE_RETURNED_NULL';
setStatusLine(updated ? (next ? 'Published' : 'Moved back to draft') : 'Could not update status');
} catch (err) {
errorCode = err instanceof Error
? `DS_STATUS_UPDATE_THREW:${err.message.slice(0, 80)}`
: 'DS_STATUS_UPDATE_THREW';
throw err;
} finally {
if (system?.id) {
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_system_project',
area: 'design_system_status',
action,
result: succeeded ? 'success' : 'failed',
design_system_id: system.id,
project_id: workspaceProjectId ?? undefined,
status_before: statusBefore,
status_after: succeeded
? next
? 'published'
: 'draft'
: statusBefore,
is_default_before: isDefaultBefore,
is_default_after: isDefaultBefore,
error_code: errorCode,
duration_ms: Math.round(performance.now() - startedAt),
});
}
}
}
function emitReviewResult(
section: { title: string },
index: number,
reviewAction: 'looks_good' | 'needs_work',
) {
if (!system) return;
const slug = designSystemModuleSlug(section.title);
trackDesignSystemReviewResult(analytics.track, {
page_name: 'design_system_project',
area: 'design_system_preview',
review_action: reviewAction,
result: 'submitted',
design_system_id: system.id,
project_id: workspaceProjectId ?? '',
module_id: slug,
module_type: designSystemModuleType(slug),
module_index: index,
feedback_length_bucket: designSystemLengthBucket(null),
has_custom_feedback: false,
duration_ms: 0,
});
}
async function ensureWorkspaceProject() {
@ -1213,6 +1366,16 @@ export function DesignSystemDetailView({
pendingWorkspaceFileWritesRef.current.clear();
setChatStreaming(true);
// DS workspace chat = the run that generates / regenerates the
// DESIGN.md and preview modules. Every send from this surface
// is a DS-variant run, so we always populate analyticsHints. The
// `regenerate_from_review` entry_from is reserved for revisions
// triggered by the Looks good / Needs work loop (which today
// also flows through this composer); a future split can detect
// a pending revision and switch entry_from accordingly.
const wasOnboardingHandoff =
Boolean(peekOnboardingSessionId())
|| sessionStorage.getItem(`od:auto-send-first:${projectId}`) === '1';
void streamViaDaemon({
agentId: config.agentId,
history: agentHistory,
@ -1228,6 +1391,17 @@ export function DesignSystemDetailView({
commentAttachments,
model: selectedModel?.model ?? null,
reasoning: selectedModel?.reasoning ?? null,
analyticsHints: {
entryFrom: wasOnboardingHandoff
? 'onboarding_design_system'
: feedbackSection
? 'regenerate_from_review'
: 'design_system_create',
projectKind: 'design_system',
designSystemRunContext: {
origin: 'manual_create',
},
},
handlers: {
onDelta: (delta) => {
updateAssistant((message) => ({
@ -1528,7 +1702,27 @@ export function DesignSystemDetailView({
Published
</label>
{selectedId !== system.id ? (
<button type="button" className="ghost compact" onClick={() => onSetDefault(system.id)}>
<button
type="button"
className="ghost compact"
onClick={() => {
const statusBefore = mapDsStatusToTracking(system.status);
onSetDefault(system.id);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_system_project',
area: 'design_system_status',
action: 'set_default',
result: 'success',
design_system_id: system.id,
project_id: workspaceProjectId ?? undefined,
status_before: statusBefore,
status_after: statusBefore,
is_default_before: false,
is_default_after: true,
duration_ms: 0,
});
}}
>
Make default
</button>
) : null}
@ -1581,6 +1775,7 @@ export function DesignSystemDetailView({
onClick={() => {
setReviewDecisions((curr) => ({ ...curr, [section.title]: 'good' }));
setStatusLine(`${section.title} marked as looks good`);
emitReviewResult(section, index, 'looks_good');
}}
>
<Icon name="check" />
@ -1596,6 +1791,7 @@ export function DesignSystemDetailView({
id: `${section.title}-${Date.now()}`,
text: `Needs work on ${section.title}: `,
});
emitReviewResult(section, index, 'needs_work');
}}
>
<Icon name="comment" />
@ -2673,6 +2869,9 @@ async function prepareCreatedDesignSystemProject({
githubConnector,
onProjectPrepared,
onSystemsRefresh,
analyticsTrack,
ingestEntryFrom,
designSystemId,
}: {
project: Project;
state: SetupState;
@ -2680,11 +2879,114 @@ async function prepareCreatedDesignSystemProject({
githubConnector: ConnectorDetail | null;
onProjectPrepared?: (project: Project) => void;
onSystemsRefresh?: () => Promise<void> | void;
analyticsTrack: (
event: string,
props: Record<string, unknown>,
options?: { requestId?: string; insertId?: string },
) => void;
ingestEntryFrom: TrackingDesignSystemSourceIngestEntryFrom;
designSystemId: string;
}): Promise<void> {
try {
if (state.githubUrls.length > 0) {
const githubStart = performance.now();
emitSourceIngestResult(analyticsTrack, {
sourceType: 'github_repo',
ingestMethod: githubConnector?.status === 'connected'
? 'github_api'
: 'git_clone',
result: 'success',
hasFallback: composioConfigured && githubConnector?.status === 'connected',
fallbackType: composioConfigured && githubConnector?.status === 'connected'
? 'native_github_auth'
: 'none',
repoHost: dominantRepoHost(state.githubUrls),
fileCount: state.githubUrls.length,
totalBytes: null,
durationMs: Math.round(performance.now() - githubStart),
entryFrom: ingestEntryFrom,
projectId: project.id,
designSystemId,
});
}
const localStart = performance.now();
const stagedLocalCode = await stageLocalCodeFiles(project.id, state.codeFileObjects);
if (state.codeFileObjects.length > 0 || state.codeFolders.length > 0) {
emitSourceIngestResult(analyticsTrack, {
sourceType: 'local_code',
ingestMethod: 'local_snapshot',
result: stagedLocalCode.uploadedPaths.length > 0
? (stagedLocalCode.skippedCount > 0 ? 'partial_success' : 'success')
: 'failed',
hasFallback: false,
fallbackType: 'none',
repoHost: 'unknown',
fileCount: stagedLocalCode.uploadedPaths.length,
totalBytes: state.codeFileObjects.reduce(
(sum, f) => sum + (f.size || 0),
0,
),
durationMs: Math.round(performance.now() - localStart),
errorCode: stagedLocalCode.uploadedPaths.length === 0
? 'DS_LOCAL_INGEST_EMPTY'
: undefined,
entryFrom: ingestEntryFrom,
projectId: project.id,
designSystemId,
});
}
const figStart = performance.now();
const stagedFigma = await stageFigmaFiles(project.id, state.figFileObjects);
if (state.figFileObjects.length > 0) {
emitSourceIngestResult(analyticsTrack, {
sourceType: 'fig',
ingestMethod: 'fig_parse',
result: stagedFigma.summaryPaths.length > 0
? (stagedFigma.skippedCount > 0 ? 'partial_success' : 'success')
: 'failed',
hasFallback: false,
fallbackType: 'none',
repoHost: 'unknown',
fileCount: stagedFigma.summaryPaths.length,
totalBytes: state.figFileObjects.reduce(
(sum, f) => sum + (f.size || 0),
0,
),
durationMs: Math.round(performance.now() - figStart),
errorCode: stagedFigma.summaryPaths.length === 0
? 'DS_FIG_INGEST_EMPTY'
: undefined,
entryFrom: ingestEntryFrom,
projectId: project.id,
designSystemId,
});
}
const assetStart = performance.now();
const stagedAssets = await stageAssetFiles(project.id, state.assetFileObjects);
if (state.assetFileObjects.length > 0) {
emitSourceIngestResult(analyticsTrack, {
sourceType: 'assets',
ingestMethod: 'asset_upload',
result: stagedAssets.uploadedPaths.length > 0
? (stagedAssets.skippedCount > 0 ? 'partial_success' : 'success')
: 'failed',
hasFallback: false,
fallbackType: 'none',
repoHost: 'unknown',
fileCount: stagedAssets.uploadedPaths.length,
totalBytes: state.assetFileObjects.reduce(
(sum, f) => sum + (f.size || 0),
0,
),
durationMs: Math.round(performance.now() - assetStart),
errorCode: stagedAssets.uploadedPaths.length === 0
? 'DS_ASSET_INGEST_EMPTY'
: undefined,
entryFrom: ingestEntryFrom,
projectId: project.id,
designSystemId,
});
}
await writeProjectTextFile(
project.id,
SOURCE_CONTEXT_MANIFEST_PATH,
@ -2722,6 +3024,118 @@ async function prepareCreatedDesignSystemProject({
}
}
// Picks the dominant repo host across a batch of GitHub URLs. Mixed
// batches default to the most-common host; ties go to `'unknown'`.
function dominantRepoHost(urls: string[]): TrackingDesignSystemRepoHost {
if (urls.length === 0) return 'unknown';
const counts = new Map<TrackingDesignSystemRepoHost, number>();
for (const url of urls) {
const host = designSystemRepoHostFromUrl(url);
counts.set(host, (counts.get(host) ?? 0) + 1);
}
let top: TrackingDesignSystemRepoHost = 'unknown';
let topCount = 0;
let tie = false;
for (const [host, count] of counts) {
if (count > topCount) {
top = host;
topCount = count;
tie = false;
} else if (count === topCount) {
tie = true;
}
}
return tie ? 'unknown' : top;
}
// Maps a generate-time snapshot to the DS origin enum. The dashboard
// uses this on `design_system_create_result.design_system_source` to
// split "user added a GitHub repo" vs "user only typed a description"
// without inspecting per-source counts.
function deriveDesignSystemOrigin(snapshot: {
sourceCount: number;
hasBrandDescription: boolean;
githubRepoCount: number;
localFolderCount: number;
figFileCount: number;
assetFileCount: number;
}): TrackingDesignSystemOrigin {
const filled = [
snapshot.githubRepoCount > 0,
snapshot.localFolderCount > 0,
snapshot.figFileCount > 0,
snapshot.assetFileCount > 0,
].filter(Boolean).length;
if (filled >= 2) return 'mixed';
if (snapshot.githubRepoCount > 0) return 'github_repo';
if (snapshot.localFolderCount > 0) return 'local_code';
if (snapshot.figFileCount > 0) return 'fig';
if (snapshot.assetFileCount > 0) return 'assets';
if (snapshot.hasBrandDescription) return 'manual_create';
return 'unknown';
}
// Mirrors the DesignSystemsTab helper but lives here too so the
// detail-view's status emissions don't have to import across files.
function mapDsStatusToTracking(
status: string | null | undefined,
): TrackingDesignSystemStatusValue {
switch (status) {
case 'draft':
case 'published':
return status;
default:
return 'unknown';
}
}
function emitSourceIngestResult(
track: (
event: string,
props: Record<string, unknown>,
options?: { requestId?: string; insertId?: string },
) => void,
args: {
sourceType: TrackingDesignSystemIngestSourceType;
ingestMethod: TrackingDesignSystemIngestMethod;
result: TrackingDesignSystemSourceIngestResult;
hasFallback: boolean;
fallbackType:
| 'none'
| 'native_github_auth'
| 'local_git_clone'
| 'manual_upload'
| 'unknown';
repoHost: TrackingDesignSystemRepoHost;
fileCount: number;
totalBytes: number | null;
durationMs: number;
errorCode?: string;
entryFrom: TrackingDesignSystemSourceIngestEntryFrom;
projectId?: string;
designSystemId?: string;
},
): void {
trackDesignSystemSourceIngestResult(track, {
page_name: 'design_systems',
area: 'design_system_create',
entry_from: args.entryFrom,
source_type: args.sourceType,
ingest_method: args.ingestMethod,
result: args.result,
has_fallback: args.hasFallback,
fallback_type: args.fallbackType,
repo_host: args.repoHost,
file_count: args.fileCount,
folder_file_count_bucket: designSystemFolderCountBucket(args.fileCount),
total_size_bucket: designSystemTotalSizeBucket(args.totalBytes),
error_code: args.errorCode,
duration_ms: Math.max(0, args.durationMs),
project_id: args.projectId,
design_system_id: args.designSystemId,
});
}
function humanizeRepositoryName(repo: string): string | undefined {
const words = repo.replace(/\.git$/iu, '').replace(/[-_]+/gu, ' ').trim().split(/\s+/u).filter(Boolean);
if (words.length === 0) return undefined;

View file

@ -3,8 +3,13 @@ import { useAnalytics } from '../analytics/provider';
import {
trackDesignSystemsTemplateCardClick,
trackDesignSystemsTopClick,
trackDesignSystemStatusResult,
trackPageView,
} from '../analytics/events';
import type {
TrackingDesignSystemStatusAction,
TrackingDesignSystemStatusValue,
} from '@open-design/contracts/analytics';
import { useI18n } from '../i18n';
import {
localizeDesignSystemCategory,
@ -62,6 +67,24 @@ function isUserSystem(system: DesignSystemSummary): boolean {
return system.source === 'user' || system.isEditable === true;
}
// `system.status` is the DesignSystemSummary status string from the
// daemon; map it onto the tracking enum used by
// `design_system_status_result.status_before|status_after`. The
// summary type today only carries `'draft' | 'published'`; the wider
// tracking enum keeps room for `ready`/`failed`/`archived` once those
// land server-side. Unknown values collapse to `'unknown'`.
function mapStatusToTracking(
status: string | null | undefined,
): TrackingDesignSystemStatusValue {
switch (status) {
case 'draft':
case 'published':
return status;
default:
return 'unknown';
}
}
function formatShortDate(value: string | undefined): string {
if (!value) return 'just now';
const time = Date.parse(value);
@ -224,35 +247,127 @@ export function DesignSystemsTab({
async function togglePublished(system: DesignSystemSummary) {
setBusyId(system.id);
const startedAt = performance.now();
const willPublish = system.status !== 'published';
const action: TrackingDesignSystemStatusAction = willPublish
? 'publish'
: 'unpublish';
const statusBefore = mapStatusToTracking(system.status);
const isDefaultBefore = system.id === selectedId;
let succeeded = false;
let errorCode: string | undefined;
try {
await updateDesignSystemDraft(system.id, {
status: system.status === 'published' ? 'draft' : 'published',
const updated = await updateDesignSystemDraft(system.id, {
status: willPublish ? 'published' : 'draft',
});
succeeded = Boolean(updated);
if (!succeeded) errorCode = 'DS_STATUS_UPDATE_RETURNED_NULL';
await refreshSystems();
} catch (err) {
errorCode = err instanceof Error
? `DS_STATUS_UPDATE_THREW:${err.message.slice(0, 80)}`
: 'DS_STATUS_UPDATE_THREW';
throw err;
} finally {
setBusyId(null);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action,
result: succeeded ? 'success' : 'failed',
design_system_id: system.id,
status_before: statusBefore,
status_after: succeeded
? willPublish
? 'published'
: 'draft'
: statusBefore,
is_default_before: isDefaultBefore,
is_default_after: isDefaultBefore,
error_code: errorCode,
duration_ms: Math.round(performance.now() - startedAt),
});
}
}
async function deleteSystem(system: DesignSystemSummary) {
const ok = window.confirm(`Delete "${system.title}"? This removes the draft design system from this device.`);
if (!ok) return;
if (!ok) {
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: 'delete',
result: 'cancelled',
design_system_id: system.id,
status_before: mapStatusToTracking(system.status),
status_after: mapStatusToTracking(system.status),
is_default_before: system.id === selectedId,
is_default_after: system.id === selectedId,
duration_ms: 0,
});
return;
}
setBusyId(system.id);
const startedAt = performance.now();
const statusBefore = mapStatusToTracking(system.status);
const wasDefault = system.id === selectedId;
let succeeded = false;
let errorCode: string | undefined;
try {
const deleted = await deleteDesignSystemDraft(system.id);
if (!deleted) return;
if (selectedId === system.id) {
succeeded = Boolean(deleted);
if (!succeeded) errorCode = 'DS_DELETE_RETURNED_FALSE';
if (succeeded && selectedId === system.id) {
const fallback = systems.find((candidate) =>
candidate.id !== system.id && isUserSystem(candidate),
);
if (fallback) onSelect(fallback.id);
}
await refreshSystems();
} catch (err) {
errorCode = err instanceof Error
? `DS_DELETE_THREW:${err.message.slice(0, 80)}`
: 'DS_DELETE_THREW';
throw err;
} finally {
setBusyId(null);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: 'delete',
result: succeeded ? 'success' : 'failed',
design_system_id: system.id,
status_before: statusBefore,
status_after: succeeded ? 'deleted' : statusBefore,
is_default_before: wasDefault,
// After a successful delete the row is gone; if it was the
// default the consumer remapped to a fallback above, so this
// DS is no longer the default either way.
is_default_after: false,
error_code: errorCode,
duration_ms: Math.round(performance.now() - startedAt),
});
}
}
function handleMakeDefaultClick(system: DesignSystemSummary): void {
const wasDefault = system.id === selectedId;
const statusBefore = mapStatusToTracking(system.status);
onSelect(system.id);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: wasDefault ? 'unset_default' : 'set_default',
result: 'success',
design_system_id: system.id,
status_before: statusBefore,
status_after: statusBefore,
is_default_before: wasDefault,
is_default_after: !wasDefault,
duration_ms: 0,
});
}
return (
<div className="tab-panel design-systems-manager" data-testid="design-systems-tab">
<section className="ds-settings-card" aria-label="Design Systems">
@ -323,7 +438,7 @@ export function DesignSystemsTab({
<button
type="button"
className="ghost compact"
onClick={() => onSelect(system.id)}
onClick={() => handleMakeDefaultClick(system)}
disabled={busy}
>
Make default

View file

@ -7,11 +7,17 @@ import {
} from '@open-design/host';
import { useAnalytics } from '../analytics/provider';
import {
trackDesignSystemApplyResult,
trackNewProjectModalElementClick,
trackNewProjectModalSurfaceView,
trackNewProjectModalTabClick,
} from '../analytics/events';
import type { ConnectorDetail } from '@open-design/contracts';
import type {
TrackingDesignSystemApplyTargetKind,
TrackingDesignSystemOrigin,
TrackingDesignSystemStatusValue,
} from '@open-design/contracts/analytics';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
@ -145,6 +151,66 @@ const TAB_LABEL_KEYS: Record<CreateTab, keyof Dict> = {
other: 'newproj.tabOther',
};
// Maps the New Project tab + media surface to the apply-result target
// kind enum. `media` collapses to image/video/audio inside callers;
// this helper covers the non-media tabs and the live-artifact special
// case. Media surfaces map case-by-case at the call site.
function newProjectTabToApplyKind(
tab: CreateTab,
): TrackingDesignSystemApplyTargetKind {
switch (tab) {
case 'prototype':
return 'prototype';
case 'deck':
return 'slide_deck';
case 'live-artifact':
return 'live_artifact';
case 'media':
// Media tab has its own surface picker; the apply emission
// happens before the user selects image/video/audio, so we
// mark it `unknown` rather than guessing. The picker is also
// typically hidden under media but the helper stays total.
return 'unknown';
case 'template':
case 'other':
return 'unknown';
}
}
// Maps a `DesignSystemSummary.source` value to the DS origin enum used
// by `design_system_apply_result.design_system_source`. The summary
// shape only carries `'built-in' | 'installed' | 'user'`; we map them
// onto the doc's enum: user → manual_create, built-in → official_preset,
// installed → template.
function deriveDesignSystemOrigin(
system: DesignSystemSummary | undefined,
): TrackingDesignSystemOrigin | undefined {
if (!system) return undefined;
switch (system.source) {
case 'user':
return 'manual_create';
case 'built-in':
return 'official_preset';
case 'installed':
return 'template';
default:
return 'unknown';
}
}
function deriveDesignSystemStatusValue(
system: DesignSystemSummary | undefined,
): TrackingDesignSystemStatusValue | undefined {
if (!system) return undefined;
switch (system.status) {
case 'draft':
case 'published':
return system.status;
default:
return 'unknown';
}
}
const MEDIA_SURFACE_LABEL_KEYS: Record<MediaSurface, keyof Dict> = {
image: 'newproj.surfaceImage',
video: 'newproj.surfaceVideo',
@ -316,6 +382,47 @@ export function NewProjectPanel({
setSelectedDsIds(initialDefaultDsSelection);
}, [dsSelectionTouched, initialDefaultDsSelection]);
// Fires `design_system_apply_result` with `auto_select` when the
// picker mounts/refreshes and pre-selects the user's default DS
// without an explicit click. Only emits once per default-id while
// the picker is showing, and only while the user hasn't manually
// changed the selection (so the dashboard separates auto vs manual
// attribution). The picker visibility guard skips media tabs where
// the DS picker isn't rendered.
const autoSelectFiredForRef = useRef<string | null>(null);
useEffect(() => {
if (!showDesignSystemPicker) return;
if (dsSelectionTouched) return;
const primary = initialDefaultDsSelection[0];
if (!primary) return;
if (autoSelectFiredForRef.current === primary) return;
autoSelectFiredForRef.current = primary;
const picked = designSystems.find((d) => d.id === primary);
trackDesignSystemApplyResult(analytics.track, {
page_name: 'home',
area: 'design_system_picker',
action: 'auto_select',
result: 'success',
target_project_kind: newProjectTabToApplyKind(tab),
design_system_id: primary,
design_system_source: deriveDesignSystemOrigin(picked),
design_system_status: deriveDesignSystemStatusValue(picked),
design_system_applied: true,
design_system_selection_mode: 'default',
is_default: true,
is_auto_selected: true,
available_design_system_count: designSystems.length,
duration_ms: 0,
});
}, [
analytics.track,
designSystems,
dsSelectionTouched,
initialDefaultDsSelection,
showDesignSystemPicker,
tab,
]);
// When entering the template tab, snap to the first user-saved template
// if there is one (and we don't already have a valid pick). The template
// tab no longer offers a built-in fallback — the entire point is to
@ -458,6 +565,51 @@ export function NewProjectPanel({
function handleDesignSystemChange(ids: string[]) {
setDsSelectionTouched(true);
setSelectedDsIds(ids);
const previousPrimary = selectedDsIds[0] ?? null;
const nextPrimary = ids[0] ?? null;
// Only emit when the primary actually changed; secondary reorders
// inside multi-select don't count as a fresh apply.
if (previousPrimary === nextPrimary) return;
const targetKind = newProjectTabToApplyKind(tab);
if (ids.length === 0) {
trackDesignSystemApplyResult(analytics.track, {
page_name: 'home',
area: 'design_system_picker',
action: 'clear_selection',
result: 'success',
target_project_kind: targetKind,
design_system_applied: false,
design_system_selection_mode: 'none',
is_default: false,
is_auto_selected: false,
available_design_system_count: designSystems.length,
duration_ms: 0,
});
return;
}
if (!nextPrimary) return;
const picked = designSystems.find((d) => d.id === nextPrimary);
const isDefault = nextPrimary === defaultDesignSystemId;
trackDesignSystemApplyResult(analytics.track, {
page_name: 'home',
area: 'design_system_picker',
action: 'select_design_system',
result: 'success',
target_project_kind: targetKind,
design_system_id: nextPrimary,
design_system_source: deriveDesignSystemOrigin(picked),
design_system_status: deriveDesignSystemStatusValue(picked),
design_system_applied: true,
design_system_selection_mode: isDefault ? 'default' : 'manual',
is_default: isDefault,
// `is_auto_selected` reports whether this row was picked by the
// app (initial default selection from `initialDefaultDsSelection`)
// rather than by the user. Once `dsSelectionTouched` is set we
// know any subsequent change came from a click.
is_auto_selected: false,
available_design_system_count: designSystems.length,
duration_ms: 0,
});
}
useEffect(() => {

View file

@ -2466,6 +2466,35 @@ export function ProjectView({
return;
}
const choice = selectedAgentChoice;
// v2 analytics: when the active project is a DS workspace
// (created by `prepareCreatedDesignSystemProject`, identifiable
// by `metadata.importedFrom === 'design-system'`), every run
// started from this composer is a DS-variant run. Pass
// analyticsHints so the daemon emits run_created /
// run_finished under `page_name=design_system_project`,
// `area=design_system_generation`, `project_kind=design_system`.
// The first-ever message into a DS workspace is the auto-sent
// generation kickoff (entry_from=`onboarding_design_system` is
// the doc's name for "DS create flow handed off to the agent");
// subsequent messages are review-driven regenerations
// (`regenerate_from_review`). Use `messages.length === 0` —
// truer than autoSendFirstMessageRef which races StrictMode
// remounts + sessionStorage clears.
const isDesignSystemWorkspaceProject =
project.metadata?.importedFrom === 'design-system';
const dsEntryFrom: 'onboarding_design_system' | 'regenerate_from_review' =
messages.length === 0
? 'onboarding_design_system'
: 'regenerate_from_review';
const dsAnalyticsHints = isDesignSystemWorkspaceProject
? {
entryFrom: dsEntryFrom,
projectKind: 'design_system' as const,
designSystemRunContext: {
origin: 'manual_create' as const,
},
}
: undefined;
void streamViaDaemon({
agentId: config.agentId,
history: nextHistory,
@ -2485,6 +2514,7 @@ export function ProjectView({
research: meta?.research,
model: choice?.model ?? null,
reasoning: choice?.reasoning ?? null,
...(dsAnalyticsHints ? { analyticsHints: dsAnalyticsHints } : {}),
onRunCreated: (runId) => {
const pinnedAssistant = {
...latestAssistantMsg,

View file

@ -11,6 +11,7 @@
*/
import type { AgentEvent, ChatCommentAttachment, ChatMessage } from '../types';
import type {
ChatAnalyticsHints,
ChatRunCreateResponse,
ChatRunListResponse,
ChatRunStatus,
@ -186,6 +187,11 @@ export interface DaemonStreamOptions {
onRunCreated?: (runId: string) => void;
onRunStatus?: (status: ChatRunStatus) => void;
onRunEventId?: (eventId: string) => void;
// v2 analytics context propagated to run_created / run_finished.
// Optional; the daemon only consumes these to shape PostHog props
// (page_name / area / entry_from / DS context). Behavior never
// depends on them.
analyticsHints?: ChatAnalyticsHints;
}
export interface DaemonReattachOptions {
@ -241,6 +247,7 @@ export async function streamViaDaemon({
onRunCreated,
onRunStatus,
onRunEventId,
analyticsHints,
}: DaemonStreamOptions): Promise<void> {
const emitRunStatus = (status: ChatRunStatus) => {
onRunStatus?.(status);
@ -267,6 +274,7 @@ export async function streamViaDaemon({
reasoning: reasoning ?? null,
...(context ? { context } : {}),
...(research ? { research } : {}),
...(analyticsHints ? { analyticsHints } : {}),
};
const body = JSON.stringify(request);

View file

@ -48,7 +48,15 @@ export type AnalyticsEventName =
// with `page_name=onboarding`; the three `onboarding_*` names below
// capture lifecycle moments that don't fit a click or a view.
| 'onboarding_runtime_scan_result'
| 'onboarding_complete_result';
| 'onboarding_complete_result'
// Design-system lifecycle. Clicks + page_views inside DS surfaces
// reuse `ui_click` / `page_view`; the five names below capture
// ingest / create / review / status / picker-apply moments.
| 'design_system_source_ingest_result'
| 'design_system_create_result'
| 'design_system_review_result'
| 'design_system_status_result'
| 'design_system_apply_result';
// ---- Pages ---------------------------------------------------------------
@ -87,6 +95,10 @@ export type TrackingProjectKind =
| 'image'
| 'video'
| 'audio'
// `design_system` covers DS-as-project runs (creation + regeneration).
// The dashboard reads it on run_created / run_finished to split the
// DS generation funnel from regular artifact runs.
| 'design_system'
| 'other';
// Where a project originated. Matches CSV row 9 / row 17 enum.
@ -495,6 +507,263 @@ export interface DesignSystemsPageViewProps {
available_design_system_count?: number;
}
// ---- Design-system lifecycle enums ---------------------------------------
//
// Shared between source_ingest / create / review / status / apply result
// events. Buckets are deliberately string literals so the dashboard can
// group on them without bucket-vs-raw drift.
export type TrackingDesignSystemLengthBucket =
| '0'
| '1_50'
| '51_200'
| '201_500'
| '500_plus';
export type TrackingDesignSystemFolderCountBucket =
| '0'
| '1_10'
| '11_50'
| '51_200'
| '200_plus'
| 'unknown';
export type TrackingDesignSystemTotalSizeBucket =
| '0_1mb'
| '1_10mb'
| '10_50mb'
| '50mb_plus'
| 'unknown';
// `partial_success` reserved for ingests that captured some files but
// dropped others (e.g. a GitHub fetch that hit a per-file size cap on a
// subset). Daemon currently emits success/failed only; partial kept
// in the contract for the connector follow-up.
export type TrackingDesignSystemSourceIngestResult =
| 'success'
| 'partial_success'
| 'failed'
| 'cancelled'
| 'timeout';
export type TrackingDesignSystemIngestSourceType =
| 'github_repo'
| 'local_code'
| 'fig'
| 'assets'
| 'mixed';
// `manual_text` covers the brand-description textarea fallback when
// no concrete file/repo ingest happened. `unknown` is the terminal
// failure path where the ingest never reached a method branch.
export type TrackingDesignSystemIngestMethod =
| 'github_api'
| 'git_clone'
| 'local_snapshot'
| 'fig_parse'
| 'asset_upload'
| 'manual_text'
| 'unknown';
export type TrackingDesignSystemFallbackType =
| 'none'
| 'native_github_auth'
| 'local_git_clone'
| 'manual_upload'
| 'unknown';
export type TrackingDesignSystemRepoHost =
| 'github'
| 'gitlab'
| 'other'
| 'unknown';
export type TrackingDesignSystemCreateEntryFrom =
| 'onboarding'
| 'design_systems_page'
| 'home_card'
| 'project_settings'
| 'unknown';
export type TrackingDesignSystemSourceIngestEntryFrom =
| 'onboarding'
| 'design_systems_page'
| 'project_settings'
| 'unknown';
export type TrackingDesignSystemCreateResult = 'success' | 'failed' | 'cancelled';
export type TrackingDesignSystemReviewAction =
| 'looks_good'
| 'needs_work'
| 'submit_revision'
| 'regenerate';
export type TrackingDesignSystemReviewResult =
| 'submitted'
| 'generated'
| 'failed'
| 'cancelled';
export type TrackingDesignSystemModuleType =
| 'typography'
| 'colors'
| 'spacing'
| 'components'
| 'brand_assets'
| 'other';
export type TrackingDesignSystemStatusAction =
| 'publish'
| 'unpublish'
| 'set_default'
| 'unset_default'
| 'archive'
| 'delete';
export type TrackingDesignSystemStatusResult = 'success' | 'failed' | 'cancelled';
// Like `TrackingDesignSystemStatus` but adds `deleted` for the
// status_after side of a delete action.
export type TrackingDesignSystemStatusValue =
| 'draft'
| 'ready'
| 'published'
| 'default'
| 'failed'
| 'archived'
| 'deleted'
| 'unknown';
export type TrackingDesignSystemApplyAction =
| 'select_design_system'
| 'auto_select'
| 'clear_selection'
| 'apply_to_run';
export type TrackingDesignSystemApplyResult = 'success' | 'failed' | 'cancelled';
export type TrackingDesignSystemSelectionMode =
| 'auto'
| 'manual'
| 'default'
| 'none';
// Project kind values for the picker's target project. Wider than
// `TrackingProjectKind`'s prototype-side enum because the picker
// shows up in slide / image / video / audio / live-artifact composers
// too. `unknown` covers picker views with no project locked in.
export type TrackingDesignSystemApplyTargetKind =
| 'prototype'
| 'slide_deck'
| 'image'
| 'video'
| 'audio'
| 'live_artifact'
| 'unknown';
// Entry from for the run_created / run_finished DS variant. Distinct
// from the chat-panel entry_from enum because DS runs don't originate
// from new_project / chat_composer at all.
export type TrackingDesignSystemRunEntryFrom =
| 'design_system_create'
| 'onboarding_design_system'
| 'regenerate_from_review'
| 'unknown';
// ---- Design-system lifecycle result props --------------------------------
export interface DesignSystemSourceIngestResultProps {
page_name: 'design_systems' | 'design_system_project';
area: 'design_system_create';
entry_from: TrackingDesignSystemSourceIngestEntryFrom;
source_type: TrackingDesignSystemIngestSourceType;
ingest_method: TrackingDesignSystemIngestMethod;
result: TrackingDesignSystemSourceIngestResult;
has_fallback: boolean;
fallback_type: TrackingDesignSystemFallbackType;
repo_host: TrackingDesignSystemRepoHost;
file_count: number;
folder_file_count_bucket: TrackingDesignSystemFolderCountBucket;
total_size_bucket: TrackingDesignSystemTotalSizeBucket;
error_code?: string;
duration_ms: number;
project_id?: string;
design_system_id?: string;
}
export interface DesignSystemCreateResultProps {
page_name: 'design_systems';
area: 'design_system_create';
entry_from: TrackingDesignSystemCreateEntryFrom;
result: TrackingDesignSystemCreateResult;
project_id?: string;
design_system_id?: string;
design_system_source: TrackingDesignSystemOrigin;
source_count: number;
created_as_project: boolean;
has_brand_description: boolean;
brand_description_length_bucket: TrackingDesignSystemLengthBucket;
notes_length_bucket: TrackingDesignSystemLengthBucket;
error_code?: string;
duration_ms: number;
}
export interface DesignSystemReviewResultProps {
page_name: 'design_system_project';
area: 'design_system_preview';
review_action: TrackingDesignSystemReviewAction;
result: TrackingDesignSystemReviewResult;
design_system_id: string;
project_id: string;
// Stable identifier for the reviewed module. Derived from the
// markdown section header slug (e.g. `typography`, `brand-assets`)
// since DS sections don't have DB ids today. `module_index` makes
// it unique when a DS has multiple sections sharing a header type.
module_id: string;
module_type: TrackingDesignSystemModuleType;
module_index: number;
feedback_length_bucket: TrackingDesignSystemLengthBucket;
has_custom_feedback: boolean;
run_id?: string;
revision_run_id?: string;
error_code?: string;
duration_ms: number;
}
export interface DesignSystemStatusResultProps {
page_name: 'design_systems' | 'design_system_project';
area: 'design_system_status';
action: TrackingDesignSystemStatusAction;
result: TrackingDesignSystemStatusResult;
design_system_id: string;
project_id?: string;
status_before: TrackingDesignSystemStatusValue;
status_after: TrackingDesignSystemStatusValue;
is_default_before: boolean;
is_default_after: boolean;
error_code?: string;
duration_ms: number;
}
export interface DesignSystemApplyResultProps {
page_name: 'home' | 'studio';
area: 'design_system_picker' | 'composer';
action: TrackingDesignSystemApplyAction;
result: TrackingDesignSystemApplyResult;
target_project_kind: TrackingDesignSystemApplyTargetKind;
design_system_id?: string;
design_system_source?: TrackingDesignSystemOrigin;
design_system_status?: TrackingDesignSystemStatusValue;
design_system_applied: boolean;
design_system_selection_mode: TrackingDesignSystemSelectionMode;
is_default: boolean;
is_auto_selected: boolean;
available_design_system_count: number;
run_id?: string;
error_code?: string;
duration_ms: number;
}
// --- Generic page_view (existing surfaces) ---
//
// Covers all page-level page_views that don't carry surface-specific
@ -1326,10 +1595,17 @@ export interface UpdateInstallResultProps {
// duration data; entry surfaces propagate the optional context (entry_from,
// fidelity, etc.) via the create-run payload.
export interface RunCreatedProps {
page_name: 'chat_panel';
area: 'chat_composer';
// Where the run was initiated from.
entry_from?: 'new_project' | 'chat_composer';
// `chat_panel` is the regular artifact-run surface; `design_system_project`
// is the DS-as-project variant (DS creation + regeneration runs).
page_name: 'chat_panel' | 'design_system_project';
area: 'chat_composer' | 'design_system_generation';
// Where the run was initiated from. The DS variant uses the
// `TrackingDesignSystemRunEntryFrom` set; both unions stay
// distinct so the dashboard can split funnels cleanly.
entry_from?:
| 'new_project'
| 'chat_composer'
| TrackingDesignSystemRunEntryFrom;
project_source?: TrackingProjectSource;
project_id: string;
conversation_id: string | null;
@ -1338,6 +1614,20 @@ export interface RunCreatedProps {
design_system_id?: string;
design_system_source: TrackingDesignSystemSource;
design_system_version?: string;
// DS-variant context. `ds_source_origin` mirrors the
// `TrackingDesignSystemOrigin` set used on DS page_views (where
// the DS came from), separate from the runtime-selection
// `design_system_source` field above. Optional on the chat_panel
// shape; required-shaped data on the DS shape (callers populate
// them when emitting the DS variant).
ds_source_origin?: TrackingDesignSystemOrigin;
source_count?: number;
has_brand_description?: boolean;
brand_description_length_bucket?: TrackingDesignSystemLengthBucket;
github_repo_count?: number;
local_folder_count?: number;
fig_file_count?: number;
asset_file_count?: number;
// Optional context inherited from the originating surface.
target_platforms?: string;
companion_surfaces?: string;
@ -1357,7 +1647,7 @@ export interface RunCreatedProps {
}
export interface RunFinishedProps extends Omit<RunCreatedProps, 'area'> {
area: 'chat_panel';
area: 'chat_panel' | 'design_system_generation';
result: TrackingRunResult;
error_code?: string;
artifact_count: number;
@ -1367,6 +1657,13 @@ export interface RunFinishedProps extends Omit<RunCreatedProps, 'area'> {
time_to_first_token_ms?: number;
generation_duration_ms?: number;
total_duration_ms: number;
// DS-variant outcome fields. `design_system_created` is true when
// the run produced a stored DESIGN.md; `preview_module_count` and
// `missing_font_count` give the dashboard a coarse quality read
// without inspecting the artifact contents.
design_system_created?: boolean;
preview_module_count?: number;
missing_font_count?: number;
}
export type TrackingUpdateApplyResult = 'success' | 'not_applied' | 'unknown';
@ -1420,6 +1717,17 @@ export type TrackingFileUploadSurface =
// `project_id` is optional and present only when the upload was
// re-issued after a project landed (rare in the onboarding flow).
project_id?: string;
}
| {
// DS create page upload (Design systems → New design system →
// source dropzones). Distinct from the onboarding shape because
// the funnel splits by entry surface; both share `source_type`
// so the dashboard can union on it when needed.
page_name: 'design_systems';
area: 'design_system_source';
source_type: 'local_code' | 'fig' | 'assets';
design_system_id?: string;
project_id?: string;
};
export type FileUploadResultProps = TrackingFileUploadSurface & {
@ -1589,7 +1897,15 @@ export type AnalyticsEventPayload =
| { event: 'settings_byok_test_result'; props: SettingsByokTestResultProps }
| { event: 'settings_connector_auth_result'; props: SettingsConnectorAuthResultProps }
| { event: 'onboarding_runtime_scan_result'; props: OnboardingRuntimeScanResultProps }
| { event: 'onboarding_complete_result'; props: OnboardingCompleteResultProps };
| { event: 'onboarding_complete_result'; props: OnboardingCompleteResultProps }
| {
event: 'design_system_source_ingest_result';
props: DesignSystemSourceIngestResultProps;
}
| { event: 'design_system_create_result'; props: DesignSystemCreateResultProps }
| { event: 'design_system_review_result'; props: DesignSystemReviewResultProps }
| { event: 'design_system_status_result'; props: DesignSystemStatusResultProps }
| { event: 'design_system_apply_result'; props: DesignSystemApplyResultProps };
// ---- Enum mapping helpers (code ↔ CSV wire format) -----------------------
@ -1899,3 +2215,97 @@ export function normalizeCustomReason(
): string {
return (text ?? '').trim();
}
// ---- Design-system tracking helpers --------------------------------------
// `length` is a character count (after trimming). Buckets match the
// v2 doc literally so brand description / notes / feedback all share
// the same shape.
export function designSystemLengthBucket(
text: string | null | undefined,
): TrackingDesignSystemLengthBucket {
const length = (text ?? '').trim().length;
if (length === 0) return '0';
if (length <= 50) return '1_50';
if (length <= 200) return '51_200';
if (length <= 500) return '201_500';
return '500_plus';
}
export function designSystemFolderCountBucket(
count: number | null | undefined,
): TrackingDesignSystemFolderCountBucket {
if (count === null || count === undefined || !Number.isFinite(count)) {
return 'unknown';
}
if (count <= 0) return '0';
if (count <= 10) return '1_10';
if (count <= 50) return '11_50';
if (count <= 200) return '51_200';
return '200_plus';
}
export function designSystemTotalSizeBucket(
bytes: number | null | undefined,
): TrackingDesignSystemTotalSizeBucket {
if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) {
return 'unknown';
}
const mb = bytes / (1024 * 1024);
if (mb < 1) return '0_1mb';
if (mb < 10) return '1_10mb';
if (mb < 50) return '10_50mb';
return '50mb_plus';
}
// Slugifies a DESIGN.md section header (`## Typography & Type Scale`)
// into a stable module id (`typography-type-scale`). Lowercases, strips
// punctuation, collapses whitespace to `-`. Empty input → 'unknown'.
export function designSystemModuleSlug(
header: string | null | undefined,
): string {
const trimmed = (header ?? '').trim().replace(/^#+\s*/, '');
if (!trimmed) return 'unknown';
return (
trimmed
.toLowerCase()
.replace(/[^a-z0-9\s-]+/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'unknown'
);
}
// Maps a DESIGN.md section slug to one of the six review module
// types. Heuristic keyword match; defaults to `'other'`.
export function designSystemModuleType(
slug: string | null | undefined,
): TrackingDesignSystemModuleType {
const s = (slug ?? '').toLowerCase();
if (!s) return 'other';
if (/(typography|type|font)/.test(s)) return 'typography';
if (/(color|palette)/.test(s)) return 'colors';
if (/(spacing|layout|grid|radius|shadow|elevation)/.test(s)) {
return 'spacing';
}
if (/(component|button|input|form|icon|widget)/.test(s)) return 'components';
if (/(brand|asset|logo|image|illustration)/.test(s)) return 'brand_assets';
return 'other';
}
// Maps a repository URL host to the tracking enum. Unparseable URLs
// → `'unknown'`. Non-github/gitlab hosts → `'other'`.
export function designSystemRepoHostFromUrl(
url: string | null | undefined,
): TrackingDesignSystemRepoHost {
const raw = (url ?? '').trim();
if (!raw) return 'unknown';
try {
const host = new URL(raw).hostname.toLowerCase();
if (host === 'github.com' || host.endsWith('.github.com')) return 'github';
if (host === 'gitlab.com' || host.endsWith('.gitlab.com')) return 'gitlab';
return 'other';
} catch {
return 'unknown';
}
}

View file

@ -35,6 +35,68 @@ export interface ChatRequest {
reasoning?: string | null;
research?: ResearchOptions;
context?: RunContextSelection;
/**
* Optional analytics context for the v2 run_created / run_finished
* events. The daemon never trusts these for behavior they only
* shape PostHog props. `entryFrom` is one of the documented
* `entry_from` enums; `designSystemRunContext` carries the
* DS-variant context (source counts, brand description length
* bucket, DS origin) used by the design_system_project run shape.
*/
analyticsHints?: ChatAnalyticsHints;
}
export type ChatAnalyticsEntryFrom =
| 'new_project'
| 'chat_composer'
| 'design_system_create'
| 'onboarding_design_system'
| 'regenerate_from_review';
export type ChatAnalyticsLengthBucket =
| '0'
| '1_50'
| '51_200'
| '201_500'
| '500_plus';
export type ChatAnalyticsDesignSystemOrigin =
| 'onboarding'
| 'manual_create'
| 'github_repo'
| 'local_code'
| 'fig'
| 'assets'
| 'official_preset'
| 'enterprise'
| 'template'
| 'mixed'
| 'unknown';
export interface ChatAnalyticsDesignSystemRunContext {
origin?: ChatAnalyticsDesignSystemOrigin;
sourceCount?: number;
hasBrandDescription?: boolean;
brandDescriptionLengthBucket?: ChatAnalyticsLengthBucket;
githubRepoCount?: number;
localFolderCount?: number;
figFileCount?: number;
assetFileCount?: number;
}
export interface ChatAnalyticsHints {
entryFrom?: ChatAnalyticsEntryFrom;
projectKind?:
| 'prototype'
| 'live_artifact'
| 'slide_deck'
| 'template'
| 'image'
| 'video'
| 'audio'
| 'design_system'
| 'other';
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
}
export interface ChatRunCreateRequest extends ChatRequest {

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import {
designSystemFolderCountBucket,
designSystemLengthBucket,
designSystemModuleSlug,
designSystemModuleType,
designSystemRepoHostFromUrl,
designSystemTotalSizeBucket,
} from '../src/analytics/events.js';
describe('designSystemLengthBucket', () => {
it('returns 0 for empty / whitespace input', () => {
expect(designSystemLengthBucket('')).toBe('0');
expect(designSystemLengthBucket(' ')).toBe('0');
expect(designSystemLengthBucket(null)).toBe('0');
expect(designSystemLengthBucket(undefined)).toBe('0');
});
it('buckets character counts inclusive of the upper bound', () => {
expect(designSystemLengthBucket('a')).toBe('1_50');
expect(designSystemLengthBucket('a'.repeat(50))).toBe('1_50');
expect(designSystemLengthBucket('a'.repeat(51))).toBe('51_200');
expect(designSystemLengthBucket('a'.repeat(200))).toBe('51_200');
expect(designSystemLengthBucket('a'.repeat(201))).toBe('201_500');
expect(designSystemLengthBucket('a'.repeat(500))).toBe('201_500');
expect(designSystemLengthBucket('a'.repeat(501))).toBe('500_plus');
});
});
describe('designSystemFolderCountBucket', () => {
it('returns unknown for non-finite input', () => {
expect(designSystemFolderCountBucket(null)).toBe('unknown');
expect(designSystemFolderCountBucket(undefined)).toBe('unknown');
expect(designSystemFolderCountBucket(Number.NaN)).toBe('unknown');
expect(designSystemFolderCountBucket(Number.POSITIVE_INFINITY)).toBe('unknown');
});
it('buckets counts inclusive of the upper bound', () => {
expect(designSystemFolderCountBucket(0)).toBe('0');
expect(designSystemFolderCountBucket(1)).toBe('1_10');
expect(designSystemFolderCountBucket(10)).toBe('1_10');
expect(designSystemFolderCountBucket(11)).toBe('11_50');
expect(designSystemFolderCountBucket(50)).toBe('11_50');
expect(designSystemFolderCountBucket(51)).toBe('51_200');
expect(designSystemFolderCountBucket(200)).toBe('51_200');
expect(designSystemFolderCountBucket(201)).toBe('200_plus');
expect(designSystemFolderCountBucket(10000)).toBe('200_plus');
});
});
describe('designSystemTotalSizeBucket', () => {
it('returns unknown for non-finite input', () => {
expect(designSystemTotalSizeBucket(null)).toBe('unknown');
expect(designSystemTotalSizeBucket(Number.NaN)).toBe('unknown');
});
it('buckets bytes by megabyte thresholds', () => {
expect(designSystemTotalSizeBucket(0)).toBe('0_1mb');
expect(designSystemTotalSizeBucket(500 * 1024)).toBe('0_1mb');
expect(designSystemTotalSizeBucket(1024 * 1024)).toBe('1_10mb');
expect(designSystemTotalSizeBucket(9 * 1024 * 1024)).toBe('1_10mb');
expect(designSystemTotalSizeBucket(10 * 1024 * 1024)).toBe('10_50mb');
expect(designSystemTotalSizeBucket(49 * 1024 * 1024)).toBe('10_50mb');
expect(designSystemTotalSizeBucket(50 * 1024 * 1024)).toBe('50mb_plus');
expect(designSystemTotalSizeBucket(500 * 1024 * 1024)).toBe('50mb_plus');
});
});
describe('designSystemModuleSlug', () => {
it('strips header markers and lowercases', () => {
expect(designSystemModuleSlug('## Typography')).toBe('typography');
expect(designSystemModuleSlug('### Brand Assets')).toBe('brand-assets');
});
it('collapses punctuation and spaces', () => {
expect(designSystemModuleSlug('Typography & Type Scale')).toBe(
'typography-type-scale',
);
expect(designSystemModuleSlug(' Colors / Palette ')).toBe('colors-palette');
});
it('returns unknown for empty input', () => {
expect(designSystemModuleSlug('')).toBe('unknown');
expect(designSystemModuleSlug(' ')).toBe('unknown');
expect(designSystemModuleSlug('!!!')).toBe('unknown');
expect(designSystemModuleSlug(null)).toBe('unknown');
});
});
describe('designSystemModuleType', () => {
it('maps typography keywords', () => {
expect(designSystemModuleType('typography')).toBe('typography');
expect(designSystemModuleType('type-scale')).toBe('typography');
expect(designSystemModuleType('font-stack')).toBe('typography');
});
it('maps colors keywords', () => {
expect(designSystemModuleType('colors')).toBe('colors');
expect(designSystemModuleType('color-palette')).toBe('colors');
});
it('maps spacing-style keywords', () => {
expect(designSystemModuleType('spacing-grid')).toBe('spacing');
expect(designSystemModuleType('layout')).toBe('spacing');
expect(designSystemModuleType('radius')).toBe('spacing');
expect(designSystemModuleType('shadow-elevation')).toBe('spacing');
});
it('maps component keywords', () => {
expect(designSystemModuleType('components')).toBe('components');
expect(designSystemModuleType('buttons')).toBe('components');
expect(designSystemModuleType('form-inputs')).toBe('components');
});
it('maps brand asset keywords', () => {
expect(designSystemModuleType('brand-assets')).toBe('brand_assets');
expect(designSystemModuleType('logo-marks')).toBe('brand_assets');
expect(designSystemModuleType('illustrations')).toBe('brand_assets');
});
it('falls back to other for unknown slugs', () => {
expect(designSystemModuleType('motion')).toBe('other');
expect(designSystemModuleType('')).toBe('other');
expect(designSystemModuleType(null)).toBe('other');
});
});
describe('designSystemRepoHostFromUrl', () => {
it('detects github / gitlab hosts', () => {
expect(
designSystemRepoHostFromUrl('https://github.com/owner/repo'),
).toBe('github');
expect(
designSystemRepoHostFromUrl('https://gitlab.com/group/repo'),
).toBe('gitlab');
});
it('returns other for non-github/gitlab hosts', () => {
expect(
designSystemRepoHostFromUrl('https://bitbucket.org/x/y'),
).toBe('other');
});
it('returns unknown for unparseable input', () => {
expect(designSystemRepoHostFromUrl('not a url')).toBe('unknown');
expect(designSystemRepoHostFromUrl('')).toBe('unknown');
expect(designSystemRepoHostFromUrl(null)).toBe('unknown');
});
});