Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
26dd0c7
chore: remove cruft left over after single-agent refactor
fluid-chey Apr 21, 2026
8c494ed
docs: update to reflect single-agent architecture and Fluid DesignOS …
fluid-chey Apr 21, 2026
4ea9a15
chore: update harness config and brand-intelligence skill for DB-back…
fluid-chey Apr 21, 2026
2e0a07c
refactor: strip legacy MCP tools and dead types from canvas
fluid-chey Apr 21, 2026
460df2c
docs: add UX improvements implementation plan
fluid-chey Apr 21, 2026
3099c45
chore(test): shared infra — Anthropic mock, SSE fixtures, HTML fixtur…
fluid-chey Apr 22, 2026
5979bdf
refactor(tools): picocolors for terminal output
fluid-chey Apr 22, 2026
4a3af5c
refactor(tools): yargs for CLI arg parsing
fluid-chey Apr 22, 2026
c380bd1
refactor: zod for schema validation
fluid-chey Apr 22, 2026
7e0b8ba
refactor(tools): parse5 for HTML validators
fluid-chey Apr 22, 2026
9cd1f28
refactor(server): AbortController for agent cancellation
fluid-chey Apr 22, 2026
4828240
refactor(client): @microsoft/fetch-event-source for SSE
fluid-chey Apr 22, 2026
c6e58db
refactor(client): @tanstack/react-query for useAssets
fluid-chey Apr 22, 2026
c65006b
refactor(client): @radix-ui/react-dialog for modals
fluid-chey Apr 22, 2026
1b6b5d1
ci: matrix, build job, playwright webServer bootstrap
fluid-chey Apr 22, 2026
2848c0f
chore: add eslint + prettier, auto-format, and fix lint errors
fluid-chey Apr 22, 2026
5b4edae
Merge branch 'chore/library-migration'
fluid-chey Apr 22, 2026
95c05e8
Merge library migration + tooling work from local main
fluid-chey Apr 22, 2026
a8847e0
test(ci): fix consistently-failing unit and tools jobs
fluid-chey Apr 22, 2026
3d1f8ab
test(tools): use readdirSync (Node 20-compatible) instead of globSync
fluid-chey Apr 22, 2026
16a1e5a
refactor: simplify pass — dedupe, dead code, hot-path perf
fluid-chey Apr 22, 2026
b4b7859
test(tools): fix CLI tests under GitHub Actions env
fluid-chey Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
unit:
name: Canvas unit tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['20', '22']
defaults:
run:
working-directory: canvas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- run: npm ci
- name: Install tools/ deps (canvas tests shell out to tools/*.cjs)
working-directory: tools
run: npm install
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('canvas/package-lock.json') }}
- name: Install Playwright chromium (needed by render-engine tests)
run: npx playwright install --with-deps chromium
- run: npm test -- --run

build:
name: Canvas typecheck + build
runs-on: ubuntu-latest
defaults:
run:
working-directory: canvas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- run: npm ci
- run: npm run build

tools:
name: CLI tools tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: tools
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install tools deps
run: npm install
- run: npm test

e2e:
name: Playwright e2e
runs-on: ubuntu-latest
defaults:
run:
working-directory: canvas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('canvas/package-lock.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
# webServer config in playwright.config.ts boots `npm run dev` and
# waits for :5174 before running specs.
run: npx playwright test --reporter=line
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.planning/
canvas/node_modules/
tools/node_modules/
canvas/dist/
canvas/.pid
.fluid/
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Fluid Creative OS — Agent Instructions
# Fluid DesignOS — Agent Instructions

All project context, architecture, conventions, and rules are in **AGENTS.md** (shared across all AI tools).

Expand Down
11 changes: 11 additions & 0 deletions canvas/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
dist
node_modules
coverage
.vite
playwright-report
test-results
*.db
*.db-shm
*.db-wal
package-lock.json
seed-data.json
12 changes: 12 additions & 0 deletions canvas/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"jsxSingleQuote": false
}
2 changes: 1 addition & 1 deletion canvas/e2e/archetypes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const ARCHETYPE_SLUGS = [
type ArchetypeSlug = typeof ARCHETYPE_SLUGS[number];

// Expected interactive field counts (non-divider fields) per archetype
const EXPECTED_FIELD_COUNTS: Record<ArchetypeSlug, number> = {
const _EXPECTED_FIELD_COUNTS: Record<ArchetypeSlug, number> = {
'hero-stat': 9, // eyebrow, headline, body-copy, 3x stat num+label
'hero-stat-split': 8, // photo, eyebrow, headline, body-copy, 2x stat num+label
'photo-bg-overlay': 3, // photo, headline, subtext
Expand Down
2 changes: 1 addition & 1 deletion canvas/e2e/styles-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ test.describe('Styles Page UI', () => {
}).toPass({ timeout: 5000 });
});

test('saving brand CSS persists via API', async ({ page, request }) => {
test('saving brand CSS persists via API', async ({ page: _page, request }) => {
// Use the API directly to verify save works — more reliable than UI flash
const testCss = `/* playwright-test-${Date.now()} */`;

Expand Down
4 changes: 2 additions & 2 deletions canvas/e2e/ux-improvements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ test.describe('Creations Tab Navigation', () => {
// Should be back at dashboard level (not inside campaign)
// Verify by checking breadcrumb does NOT show a campaign name segment
const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]');
const segments = breadcrumb.locator('span').filter({ hasText: /[A-Za-z]/ });
const _segments = breadcrumb.locator('span').filter({ hasText: /[A-Za-z]/ });

// The creations tab should be active
const borderBottom = await crTab.evaluate(
Expand Down Expand Up @@ -261,7 +261,7 @@ test.describe('Asset Preview Rendering', () => {
// Check that no bare "File" spans exist in preview containers
// (They might still exist for truly unknown file types)
const fileFallbacks = page.locator('span:text-is("File")');
const fileCount = await fileFallbacks.count();
const _fileCount = await fileFallbacks.count();

// If there are fonts, there should be previews and no "File" fallback for them
if (fontCount > 0) {
Expand Down
97 changes: 97 additions & 0 deletions canvas/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import prettier from 'eslint-config-prettier';
import globals from 'globals';

export default tseslint.config(
{
ignores: [
'dist/**',
'node_modules/**',
'coverage/**',
'.vite/**',
'playwright-report/**',
'test-results/**',
'**/*.cjs',
'**/*.js',
'!eslint.config.js',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.browser, ...globals.es2022 },
parserOptions: { ecmaFeatures: { jsx: true } },
},
settings: { react: { version: 'detect' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/no-unescaped-entities': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// react-hooks v7 introduces stricter experimental rules (React Compiler era).
// Surface them as warnings so the team migrates incrementally rather than blocking CI.
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/component-hook-factories': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/unsupported-syntax': 'warn',
'react-hooks/error-boundaries': 'warn',
'react-hooks/set-state-in-render': 'warn',
'react-hooks/static-components': 'warn',
'react-hooks/config': 'warn',
'react-hooks/globals': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'prefer-const': 'error',
'no-var': 'error',
},
},
{
files: ['mcp/**/*.ts', 'scripts/**/*.ts', '*.config.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.node, ...globals.es2022 },
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'prefer-const': 'error',
'no-var': 'error',
},
},
{
files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}', 'e2e/**/*.ts', 'tests/**/*.ts'],
languageOptions: { globals: { ...globals.node, ...globals.browser } },
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
prettier,
);
69 changes: 53 additions & 16 deletions canvas/mcp/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ describe('push_asset (db-api layer)', () => {
});
});

// ─── read_annotations — returns annotations by iterationId ───────────────
// ─── getAnnotations — returns annotations by iterationId ───────────────

describe('read_annotations (db-api layer)', () => {
describe('getAnnotations (db-api layer)', () => {
it('returns empty array when no annotations exist for an iteration', async () => {
const { slide } = scaffoldCampaignHierarchy();
const iteration = dbApi.createIteration({
Expand Down Expand Up @@ -177,9 +177,9 @@ describe('read_annotations (db-api layer)', () => {
});
});

// ─── read_statuses — returns status map for slide's iterations ────────────
// ─── iteration status map — returns status per iteration for a slide ─────

describe('read_statuses (db-api layer)', () => {
describe('getIterations status map (db-api layer)', () => {
it('returns status map keyed by iterationId', async () => {
const { slide } = scaffoldCampaignHierarchy();

Expand Down Expand Up @@ -216,29 +216,66 @@ describe('read_statuses (db-api layer)', () => {
});
});

// ─── read_history — returns full iteration chain ──────────────────────────
// ─── iteration history — returns full iteration chain for a slide ────────

describe('read_history (db-api layer)', () => {
describe('getIterations history chain (db-api layer)', () => {
it('returns all iterations for a slide in order', async () => {
const { slide } = scaffoldCampaignHierarchy();

dbApi.createIteration({ slideId: slide.id, iterationIndex: 0, htmlPath: 'r0.html', source: 'ai' });
dbApi.createIteration({ slideId: slide.id, iterationIndex: 1, htmlPath: 'r1.html', source: 'ai' });
dbApi.createIteration({ slideId: slide.id, iterationIndex: 2, htmlPath: 'r2.html', source: 'ai' });
dbApi.createIteration({
slideId: slide.id,
iterationIndex: 0,
htmlPath: 'r0.html',
source: 'ai',
});
dbApi.createIteration({
slideId: slide.id,
iterationIndex: 1,
htmlPath: 'r1.html',
source: 'ai',
});
dbApi.createIteration({
slideId: slide.id,
iterationIndex: 2,
htmlPath: 'r2.html',
source: 'ai',
});

const iterations = dbApi.getIterations(slide.id);
expect(iterations).toHaveLength(3);
expect(iterations.map(i => i.iterationIndex)).toEqual([0, 1, 2]);
expect(iterations.map((i) => i.iterationIndex)).toEqual([0, 1, 2]);
});

it('can retrieve annotations for each iteration in the chain', async () => {
const { slide } = scaffoldCampaignHierarchy();

const iter0 = dbApi.createIteration({ slideId: slide.id, iterationIndex: 0, htmlPath: 'r0.html', source: 'ai' });
const iter1 = dbApi.createIteration({ slideId: slide.id, iterationIndex: 1, htmlPath: 'r1.html', source: 'ai' });
const iter0 = dbApi.createIteration({
slideId: slide.id,
iterationIndex: 0,
htmlPath: 'r0.html',
source: 'ai',
});
const iter1 = dbApi.createIteration({
slideId: slide.id,
iterationIndex: 1,
htmlPath: 'r1.html',
source: 'ai',
});

dbApi.createAnnotation({ iterationId: iter0.id, type: 'pin', author: 'human', text: 'Too dark', x: 10, y: 10 });
dbApi.createAnnotation({ iterationId: iter1.id, type: 'sidebar', author: 'human', text: 'Perfect' });
dbApi.createAnnotation({
iterationId: iter0.id,
type: 'pin',
author: 'human',
text: 'Too dark',
x: 10,
y: 10,
});
dbApi.createAnnotation({
iterationId: iter1.id,
type: 'sidebar',
author: 'human',
text: 'Perfect',
});

const ann0 = dbApi.getAnnotations(iter0.id);
const ann1 = dbApi.getAnnotations(iter1.id);
Expand All @@ -259,15 +296,15 @@ describe('push_asset backward compatibility', () => {
sessionId: '20260310-143022',
variationId: 'v1',
html: '<html></html>',
})
}),
).toThrow(/DEPRECATED/);

expect(() =>
handleLegacyPushAsset({
sessionId: '20260310-143022',
variationId: 'v1',
html: '<html></html>',
})
}),
).toThrow(/sessionId/);
});
});
Loading
Loading