mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
98c03d8d2b
commit
9912fa899a
12 changed files with 1659 additions and 32 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
150
packages/contracts/tests/analytics-design-system-helpers.test.ts
Normal file
150
packages/contracts/tests/analytics-design-system-helpers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue