From f8297eeb065fc9697583cd30c3b05f7af2028374 Mon Sep 17 00:00:00 2001 From: Patrick A <141967+neogenix@users.noreply.github.com> Date: Tue, 19 May 2026 13:21:28 -0400 Subject: [PATCH 01/13] fix(web): preserve project title casing on design files page Add text-transform: capitalize to .app-project-title .title so lowercase-stored project names display with capitalized first letters in the design files page header. Red spec: apps/web/tests/styles/project-title-casing.test.ts Red on origin/main, green on this branch. --- apps/web/src/index.css | 1 + .../tests/styles/project-title-casing.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 apps/web/tests/styles/project-title-casing.test.ts diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3e593744bb..189e2858bb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1215,6 +1215,7 @@ code { text-overflow: ellipsis; flex: 0 1 auto; min-width: 0; + text-transform: capitalize; } .app-project-title .meta { color: var(--text-muted); diff --git a/apps/web/tests/styles/project-title-casing.test.ts b/apps/web/tests/styles/project-title-casing.test.ts new file mode 100644 index 0000000000..56288ffa91 --- /dev/null +++ b/apps/web/tests/styles/project-title-casing.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const indexCss = readFileSync(new URL('../../src/index.css', import.meta.url), 'utf8'); + +function cssBlock(selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(indexCss); + if (!match) throw new Error(`Missing CSS block for ${selector}`); + return match[1] ?? ''; +} + +function ruleValue(block: string, property: string): string { + const match = new RegExp(`(?:^|;)\\s*${property}:\\s*([^;]+);`).exec(block); + if (!match) throw new Error(`Missing CSS property ${property}`); + return match[1]!.trim(); +} + +describe('project title casing', () => { + it('applies text-transform: capitalize so lowercase-stored names display correctly', () => { + const block = cssBlock('.app-project-title .title'); + expect(ruleValue(block, 'text-transform')).toBe('capitalize'); + }); +}); From a11687fa59d263d14626416ce7e96ddc6a5e4791 Mon Sep 17 00:00:00 2001 From: Patrick A <141967+neogenix@users.noreply.github.com> Date: Tue, 19 May 2026 13:46:11 -0400 Subject: [PATCH 02/13] test(web): behavior-level coverage for project title capitalization Adds a jsdom render test that mounts ProjectView with a lowercase project name ('acme studio'), injects the .app-project-title CSS rules from index.css into the document head, and asserts that getComputedStyle on the data-testid='project-title' element returns text-transform: capitalize. Red/green verified: removing the text-transform rule produces AssertionError: expected 'none' to be 'capitalize' Restoring the rule makes the test pass. --- .../ProjectView.title-casing.test.tsx | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 apps/web/tests/components/ProjectView.title-casing.test.tsx diff --git a/apps/web/tests/components/ProjectView.title-casing.test.tsx b/apps/web/tests/components/ProjectView.title-casing.test.tsx new file mode 100644 index 0000000000..d84a14bcfd --- /dev/null +++ b/apps/web/tests/components/ProjectView.title-casing.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +// Behavioral coverage for the project title text-transform rule. +// Verifies that the CSS rule `text-transform: capitalize` is applied to the +// rendered `.app-project-title .title` element, not just present in the +// source file. Uses stylesheet injection into the jsdom document so that +// `getComputedStyle` resolves the rule against the real rendered DOM node. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanup, render } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectView } from '../../src/components/ProjectView'; +import type { + AgentInfo, + AppConfig, + DesignSystemSummary, + Project, + SkillSummary, +} from '../../src/types'; + +vi.mock('../../src/i18n', () => ({ + useT: () => (key: string) => key, +})); + +vi.mock('../../src/router', () => ({ + navigate: vi.fn(), +})); + +vi.mock('../../src/providers/anthropic', () => ({ + streamMessage: vi.fn(), +})); + +vi.mock('../../src/providers/daemon', () => ({ + fetchChatRunStatus: vi.fn(), + listActiveChatRuns: vi.fn().mockResolvedValue([]), + reattachDaemonRun: vi.fn(), + streamViaDaemon: vi.fn(), +})); + +vi.mock('../../src/providers/project-events', () => ({ + useProjectFileEvents: vi.fn(), +})); + +vi.mock('../../src/providers/registry', async () => { + const actual = await vi.importActual( + '../../src/providers/registry', + ); + return { + ...actual, + deletePreviewComment: vi.fn(), + fetchDesignSystem: vi.fn(), + fetchLiveArtifacts: vi.fn().mockResolvedValue([]), + fetchPreviewComments: vi.fn(), + fetchProjectFiles: vi.fn().mockResolvedValue([]), + fetchSkill: vi.fn(), + getTemplate: vi.fn(), + patchPreviewCommentStatus: vi.fn(), + upsertPreviewComment: vi.fn(), + writeProjectTextFile: vi.fn(), + }; +}); + +vi.mock('../../src/state/projects', async () => { + const actual = await vi.importActual( + '../../src/state/projects', + ); + return { + ...actual, + createConversation: vi.fn().mockResolvedValue(null), + listConversations: vi.fn().mockResolvedValue([]), + listMessages: vi.fn().mockResolvedValue([]), + loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }), + patchConversation: vi.fn(), + patchProject: vi.fn(), + saveMessage: vi.fn(), + saveTabs: vi.fn(), + }; +}); + +vi.mock('../../src/components/AppChromeHeader', () => ({ + AppChromeHeader: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../src/components/AvatarMenu', () => ({ + AvatarMenu: () => null, +})); + +vi.mock('../../src/components/FileWorkspace', () => ({ + FileWorkspace: () =>
, +})); + +vi.mock('../../src/components/Loading', () => ({ + CenteredLoader: () =>
, +})); + +vi.mock('../../src/components/ChatPane', () => ({ + ChatPane: () =>
, +})); + +const config: AppConfig = { + mode: 'api', + apiKey: '', + baseUrl: '', + model: '', + agentId: null, + skillId: null, + designSystemId: null, +}; + +const project: Project = { + id: 'project-1', + name: 'acme studio', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 1, +}; + +function renderProjectView() { + return render( + , + ); +} + +// Extract only the rules relevant to the project-title selector block so +// that the injected stylesheet stays small and does not pull in thousands +// of lines of unrelated CSS that jsdom would parse at test cost. +function extractProjectTitleRules(css: string): string { + const rules: string[] = []; + const re = /([^{}]*\.app-project-title[^{}]*)\{([^}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(css)) !== null) { + rules.push(`${m[1]}{${m[2]}}`); + } + return rules.join('\n'); +} + +describe('project title casing — rendered DOM', () => { + let styleEl: HTMLStyleElement; + + beforeEach(() => { + const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8'); + styleEl = document.createElement('style'); + styleEl.setAttribute('data-testid', 'project-title-rules'); + styleEl.textContent = extractProjectTitleRules(css); + document.head.appendChild(styleEl); + }); + + afterEach(() => { + styleEl.remove(); + cleanup(); + }); + + it('applies text-transform: capitalize to the rendered .app-project-title .title element', () => { + const { getByTestId } = renderProjectView(); + + // The element with data-testid="project-title" is the span carrying both + // the `title` and `editable` classes, nested inside `.app-project-title`. + const titleEl = getByTestId('project-title'); + + // Verify the DOM structure matches the selector before asserting the style. + expect(titleEl.closest('.app-project-title')).not.toBeNull(); + expect(titleEl.classList.contains('title')).toBe(true); + + // jsdom resolves