Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3f686bb
Wire up glow, pattern fills, and image fills in UI Builder
zeidalidiez Jun 16, 2026
bee2730
Show embedded manifest report in the web UI after generation
zeidalidiez Jun 16, 2026
59f05e6
Add engine-aware UI Builder preset library
zeidalidiez Jun 16, 2026
18446f4
Add Playwright e2e tests for the web app
zeidalidiez Jun 16, 2026
3f0e846
Add CLI e2e test that exercises the full generate flow
zeidalidiez Jun 16, 2026
b7c00b3
Add audio placeholder generation (WAV sine wave)
zeidalidiez Jun 16, 2026
8dc874b
Add animated sprite sheet sidecar (animation.json)
zeidalidiez Jun 16, 2026
f3441a5
Address Greptile review on tier/3-polish and fix e2e
zeidalidiez Jun 16, 2026
42c40a0
Fix e2e test: extract ZIP parser to testable module + scope selectors
zeidalidiez Jun 16, 2026
7f6ab70
Ignore Playwright test-results cache
zeidalidiez Jun 16, 2026
f789071
Fix describe.skipIf in CLI e2e: gate inside each test
zeidalidiez Jun 16, 2026
6ce7e12
Untrack test-results/.last-run.json and ignore Playwright caches
zeidalidiez Jun 16, 2026
b40ec77
Address Greptile round 2 on tier/3-polish
zeidalidiez Jun 16, 2026
0016afb
Guard drawImageFillOverlay against in-flight image loads
zeidalidiez Jun 17, 2026
5245a58
Keep sidecar errors contained in the post-loop pass
zeidalidiez Jun 17, 2026
8f52e50
Address Greptile round 3 on tier/3-polish
zeidalidiez Jun 17, 2026
237936b
Address Greptile round 4 on tier/3-polish
zeidalidiez Jun 17, 2026
b4e3df5
Address Greptile round 5: fix clip id sync + drop stray DCO hook
zeidalidiez Jun 17, 2026
3d5bf5a
Address Greptile round 6 on tier/3-polish
zeidalidiez Jun 17, 2026
5e1a64b
Clear stale manifest report on import, not just on generate
zeidalidiez Jun 17, 2026
e2cbe34
Address Greptile round 7 regressions (3/5)
zeidalidiez Jun 17, 2026
ec84c90
Fix hexToRgba alpha extraction for rgb() without alpha
zeidalidiez Jun 17, 2026
5df8381
Add hexToRgba unit tests so the next Greptile re-review sees the fix
zeidalidiez Jun 17, 2026
74c190a
Address Greptile round 9 (3/5 → expected 4/5)
zeidalidiez Jun 17, 2026
a496a69
Fix text case returning filter: null (Greptile round 10)
zeidalidiez Jun 17, 2026
2004ad4
Apply glow/shadow filter to line and raster SVG export
zeidalidiez Jun 17, 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
28 changes: 0 additions & 28 deletions .githooks/commit-msg

This file was deleted.

12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
build:
name: pnpm build
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

Expand All @@ -30,3 +30,13 @@ jobs:

- name: Test
run: pnpm test

# Skip --with-deps: the ubuntu-latest image already has the
# system libraries Playwright's bundled chromium needs, and
# --with-deps pulls heavy font packages via apt-get that
# routinely blow past the job's wall-clock budget in CI.
- name: Install Playwright browser
run: npx playwright install chromium

- name: e2e
run: pnpm e2e
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ coverage/

# Misc
*.local
.cache/

# Playwright test cache
test-results/
playwright-report/
7 changes: 5 additions & 2 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
"dev": "tsc --watch",
"test": "vitest run"
},
"dependencies": {
"@placeholderer/core": "workspace:*",
Expand All @@ -19,6 +20,8 @@
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/node": "^20.14.0"
"@types/node": "^20.14.0",
"vitest": "^2.1.0",
"jszip": "^3.10.1"
}
}
205 changes: 205 additions & 0 deletions apps/cli/tests/builder-presets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Regression tests for the engine-aware UI Builder presets and
// stretch-fill export. Pulls source from apps/web directly so we
// catch renderer regressions (e.g. lineLayer being horizontal-
// only, stroke being dropped for stretch fills) without a full
// web build.

import { describe, it, expect } from 'vitest';
import { PRESETS } from '../../web/src/builderPresets.js';
import { exportSVG } from '../../web/src/builderRender.js';
import { hexToRgba } from '../../web/src/UIBuilder.js';
import type { Layer } from '@placeholderer/schemas';

describe('UI Builder presets', () => {
it('exports an Unreal crosshair with a vertical rect arm', () => {
// lineLayer is always horizontal (height only changes the y
// center, not the orientation), so the vertical arm of the
// crosshair has to be a thin rectangle or it renders as a
// short horizontal dash.
const crosshair = PRESETS.find((p) => p.name === 'Crosshair');
expect(crosshair).toBeDefined();
const v = crosshair!.layers.find((l) => l.id === 'v');
expect(v?.type).toBe('rect');

const svg = exportSVG(crosshair!.layers, crosshair!.width, crosshair!.height);
expect(svg).toMatch(/<rect x="14" y="4" width="4" height="24"/);
// No horizontal dash at the vertical-arm position.
expect(svg).not.toMatch(/<line[^>]*x1="14"[^>]*x2="18"/);
});
});

describe('exportSVG', () => {
it('keeps the stroke on stretch image fills', () => {
// Regression for Greptile round 7: a rect layer with a stretch
// image fill and a stroke used to export the clipped image but
// silently drop the border. The fix adds a stroked, unfilled
// shape inside the same clip-path group so the border is
// preserved.
const layer: Layer = {
id: 'l1',
type: 'rect',
name: 'frame',
visible: true,
locked: false,
x: 10, y: 20, width: 100, height: 50,
fill: { type: 'image', src: 'a.png', mode: 'stretch' },
stroke: { color: '#FF0000', width: 3 },
};
const svg = exportSVG([layer], 200, 200);

expect(svg).toMatch(/<image[^>]*href="a\.png"/);
// The same layer's stroke must be exported as a stroke-bearing
// (but fill="none") rect on top of the clipped image.
expect(svg).toMatch(/fill="none" stroke="#FF0000" stroke-width="3"/);
});
});

describe('hexToRgba', () => {
// Regression for Greptile round 8: the previous regex grabbed
// the blue channel from rgb(10,20,30) and treated it as alpha,
// producing rgba(...,...,...,30) — outside the valid 0..1 range.

it('uses the default alpha for rgb() without an alpha component', () => {
const out = hexToRgba('#112233', 'rgb(10,20,30)');
expect(out).toBe('rgba(17,34,51,0.6)');
});

it('preserves alpha for rgba() with a 4th component', () => {
const out = hexToRgba('#112233', 'rgba(10,20,30,0.42)');
expect(out).toBe('rgba(17,34,51,0.42)');
});

it('preserves alpha = 0', () => {
const out = hexToRgba('#445566', 'rgba(255,0,0,0)');
expect(out).toBe('rgba(68,85,102,0)');
});

it('parses hsla() colors', () => {
const out = hexToRgba('#778899', 'hsla(120,50%,50%,0.75)');
expect(out).toBe('rgba(119,136,153,0.75)');
});

it('falls back to default alpha when existing is undefined', () => {
const out = hexToRgba('#aabbcc', undefined);
expect(out).toBe('rgba(170,187,204,0.6)');
});
});

describe('exportSVG text stretch image fills', () => {
// Regression for Greptile round 9: text layers with a stretch
// image fill used to export as a solid fallback fill in SVG,
// even though the canvas preview showed the stretched image
// behind the glyphs. The fix mirrors drawText by emitting an
// <image> background with the <text> on top in white.

it('emits an <image> background for text stretch fills', () => {
const layer: Layer = {
id: 't1',
type: 'text',
name: 'label',
visible: true,
locked: false,
x: 10, y: 20, width: 200, height: 60,
fill: { type: 'image', src: 'bg.png', mode: 'stretch' },
text: { content: 'Hello', fontSize: 24, fontFamily: 'Arial', align: 'left' },
};
const svg = exportSVG([layer], 300, 100);
expect(svg).toMatch(/<image[^>]*href="bg\.png"/);
expect(svg).toMatch(/<text[^>]*fill="#ffffff"/);
// The text content must still be present.
expect(svg).toContain('Hello');
});

it('does not emit an <image> for text with a solid color fill', () => {
const layer: Layer = {
id: 't2',
type: 'text',
name: 'plain',
visible: true,
locked: false,
x: 0, y: 0, width: 200, height: 40,
fill: '#ff0000',
text: { content: 'plain', fontSize: 18 },
};
const svg = exportSVG([layer], 200, 100);
expect(svg).not.toMatch(/<image/);
expect(svg).toMatch(/<text[^>]*fill="#ff0000"/);
});
});

describe('exportSVG effect filter defs', () => {
// Regression for Greptile round 10: text layers with glow/shadow
// referenced a filter URL but the <filter> def wasn't included
// in <defs>, because the 'text' case in layerToSVG was returning
// `filter: null` even though `filterAttr` was on the markup.

it('includes the glow filter def when text has a glow', () => {
const layer: Layer = {
id: 't3',
type: 'text',
name: 'glow',
visible: true,
locked: false,
x: 0, y: 0, width: 200, height: 60,
fill: '#ff0000',
text: { content: 'Glow' },
effects: { glow: { blur: 12, color: 'rgba(255,255,255,0.6)' } },
};
const svg = exportSVG([layer], 300, 100);
expect(svg).toMatch(/<filter id="f-t3-glow"/);
expect(svg).toMatch(/filter="url\(#f-t3-glow\)"/);
});

it('includes the shadow filter def when text has a shadow', () => {
const layer: Layer = {
id: 't4',
type: 'text',
name: 'shadow',
visible: true,
locked: false,
x: 0, y: 0, width: 200, height: 60,
fill: '#ff0000',
text: { content: 'Shadow' },
effects: { shadow: { blur: 8, color: 'rgba(0,0,0,0.5)' } },
};
const svg = exportSVG([layer], 300, 100);
expect(svg).toMatch(/<filter id="f-t4-shadow"/);
expect(svg).toMatch(/filter="url\(#f-t4-shadow\)"/);
});

it('applies the glow filter to line layers', () => {
// Regression for Greptile round 11: line and raster branches
// returned `filter: null` and omitted filterAttr from the
// markup, so the canvas applyGlow/applyShadow effects never
// landed in the SVG export.
const layer: Layer = {
id: 'ln1',
type: 'line',
name: 'line',
visible: true,
locked: false,
x: 10, y: 20, width: 100, height: 4,
stroke: { color: '#ffffff', width: 2 },
effects: { glow: { blur: 8, color: 'rgba(255,255,255,0.6)' } },
};
const svg = exportSVG([layer], 200, 100);
expect(svg).toMatch(/<filter id="f-ln1-glow"/);
expect(svg).toMatch(/<line[^>]*filter="url\(#f-ln1-glow\)"/);
});

it('applies the shadow filter to raster layers', () => {
const layer: Layer = {
id: 'rs1',
type: 'raster',
name: 'raster',
visible: true,
locked: false,
x: 10, y: 20, width: 100, height: 80,
rasterSrc: 'img.png',
effects: { shadow: { blur: 6, color: 'rgba(0,0,0,0.5)' } },
};
const svg = exportSVG([layer], 200, 200);
expect(svg).toMatch(/<filter id="f-rs1-shadow"/);
expect(svg).toMatch(/<image[^>]*filter="url\(#f-rs1-shadow\)"/);
});
});
Loading
Loading