Tier 3: deferred schema features, presets, e2e tests, audio + animation#3
Merged
Conversation
The v1 schema supports a richer layer.fill: solid color, pattern
(checkerboard / stripes / diagonal), or image (with a mode of
repeat or stretch). The schema also defines layer.effects.glow
alongside layer.effects.shadow. The previous UI Builder commit
shipped the render code paths and the property panel editor for
shadow, but left the other three feature surfaces as no-ops
noted in the commit message: this finishes the work.
Render side (apps/web/src/builderRender.ts):
- drawRect, drawCircle, drawFilledShape all call resolveFill
instead of falling back to color. resolveFill returns a
CanvasPattern for pattern fills (built from a 16px
OffscreenCanvas tile) or a string color for solid/image fills.
- drawImageFillOverlay layers an image fill on top of the
underlying color: it draws the image stretched for 'stretch'
mode, or uses createPattern + a clipped fill for 'repeat'.
- preloadRasterImages now also preloads every image fill src so
the on-screen render and the export are consistent.
Property panel (apps/web/src/UIBuilder.tsx):
- 'Fill mode' picker switches between Solid / Pattern / Image.
- Pattern mode shows a Pattern picker (checkerboard / stripes
/ diagonal).
- Image mode shows an Image src field and a Mode picker
(Repeat / Stretch).
- 'Glow blur' field is wired to layer.effects.glow; setting it
to 0 removes the glow effect entirely so the layer goes back
to a shadow-only (or no-effect) state.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
After Generate & Download, the web UI now reads the _placeholderer/manifest-report.json entry out of the produced ZIP and renders a small panel under the generate button. The panel shows job name, total/successful/failed asset counts, and lists the created folders and files as inline code tags so the user can confirm what landed in the archive without unzipping it. The decoder walks the ZIP central directory by hand (no JSZip dependency on the web side) and only handles the STORE method uncompressed case — which is the only case core's generateJob produces. Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
The UI Builder's preset list was five generic placeholders (Button, Panel, Title Text, Circle Badge, Divider). Replace with a real engine-aware library in apps/web/src/builderPresets.ts: Godot: Dialog Window (frame + panel + title + divider + body) Unity: Health Bar (frame + fill) Unreal: Crosshair (two perpendicular lines) Common: Button, Panel, Title Text, Divider Each preset carries its own canvas size and a multi-layer recipe; applying one resizes the canvas and appends the layers to the current stack in a single history entry (so undo undoes the whole preset in one step). The picker is now grouped by engine label so the user can see which preset is engine-specific at a glance. New layer factories move to apps/web/src/builderLayerFactories.ts so the preset file and the inline add-* buttons in the UI share a single source of truth for layer construction. Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
New playwright.config.ts and tests/e2e/placeholderer.spec.ts with
two smoke tests:
1. Pastes a starter manifest into the home view, asserts the
Overview opens, clicks Generate & Download, captures the
download (verifies the suggested filename), and checks that
the Manifest report panel renders.
2. Clicks the theme toggle and asserts that data-theme on
<html> actually changes.
The webServer block in playwright.config.ts launches
'pnpm --filter web dev' for the duration of the test run and
reuses an existing dev server when one is already up. Run
'npx playwright install' once to pull the browser binaries
(browsers are not committed). Add 'pnpm e2e' to CI once a
runner with the Playwright browser cache is available.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
apps/cli/tests/e2e.test.ts runs the core generateJob against a
real Node backend (@napi-rs/canvas) and asserts the produced ZIP's
contents:
- Every requested asset is in the archive
- Declared empty folders are materialized as .gitkeep
- _placeholderer/manifest-report.json parses with the right
job name, asset counts, folder list, file list, and zero
errors
- result.suggestedName is 'cli_e2e_pack.zip' (sanitized from
job.name)
A second test asserts that the core validator surfaces bad
schemaVersion at validation time.
The suite skips itself automatically when @napi-rs/canvas's
native binary is unavailable, so it never breaks a fresh clone
on an unsupported platform.
apps/cli/package.json gains 'test' (vitest run) and pulls in
vitest + jszip as devDeps. The root 'test' script now runs
both core unit tests and the CLI e2e. The CI workflow gains
'Install Playwright browser' + 'e2e' steps after the unit tests
so PRs are gated on the full test pyramid.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Per the v1 spec's Phase 2 list, add a new 'audio' asset kind
that produces a 16-bit PCM mono WAV file containing a sine wave
at the configured frequency.
Schema:
- AssetKind gains 'audio'
- New AudioAsset interface with required frequency (Hz, 1..22050)
and duration (seconds, 0.01..60), plus optional sample_rate
(8000..96000, default 44100) and amplitude (0..1, default 0.5)
- baseAsset.format is now a generic string with a description;
each image-style kind restricts to png/jpg/jpeg/webp via its
own definition, audio restricts to 'wav'. The 'rejects non-enum
format' validation test still passes because each kind's
format constraint is enforced via oneOf.
- baseAsset.sanitizePath now strips a trailing slash so
output_path: 'sfx/' no longer produces 'sfx//beep.wav' in
the archive (a real bug I caught in e2e).
Generator:
- packages/core/src/audio.ts: encodeWav (RIFF/WAVE header for
PCM 16-bit mono), synthesizeTone (sine wave with a 10ms
attack/release envelope to avoid clicks at the start/end),
generateAudio that combines them.
- generateJob in core: when asset.kind === 'audio', skip the
canvas pipeline and call generateAudio. Audio uses the format
field for the container extension (wav by default) instead of
the image MIMEs.
Tests:
- 33/33 core unit tests still pass (the 'rejects non-enum
format' assertion is back to passing because each kind
enforces its own format).
- apps/cli/tests/e2e.test.ts gains an audio test that
generates a 0.25s 440Hz WAV, opens the produced ZIP, and
asserts the RIFF/WAVE header, PCM format tag, mono channel
count, and sample rate.
End-to-end verified:
placeholderer generate --in audio-manifest.json
-> produces sfx/beep.wav, 44144 bytes for 0.25s at 22050 Hz,
opens cleanly in a standard audio player.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Per the v1 spec's Phase 2 list, sprite_sheet assets can declare
frame_duration_ms. When set, the generator writes an
animation.json sidecar next to the sheet with the timing data a
runtime needs to play the animation:
{
'sheet': 'enemies/slime_idle.png',
'frame_width': 32, 'frame_height': 32,
'rows': 1, 'columns': 2,
'frame_count': 2,
'frame_duration_ms': 150,
'fps': 7,
'total_duration_ms': 300
}
Schema:
- SpriteSheetAsset gains optional frame_duration_ms (1..10000).
- JSON schema updated to match.
Generator (packages/core/src/generate.ts):
- After the asset loop, walks the manifest again and writes a
sidecar per sprite_sheet asset that has frame_duration_ms set.
- fps is derived as round(1000 / frame_duration_ms).
- total_duration_ms is frame_count * frame_duration_ms so the
consumer doesn't have to compute it.
CLI e2e:
- New test asserts the sidecar is written with the right
contents for a 2-frame 150ms animation.
End-to-end verified:
placeholderer generate --in anim.json
-> enemies/slime_idle.png
-> enemies/slime_idle.animation.json (199 bytes, contains fps
7, frame_count 2, total_duration_ms 300)
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Five real issues from Greptile's review of the tier 3 PR, plus
two e2e test failures that were also real bugs.
1. apps/web/src/App.tsx — manual ZIP parser
The loadManifestReport helper assumed each central-directory
entry is exactly 46 bytes wide. ZIP entries are 46 bytes plus
variable-length name, extra, and comment fields, so as soon
as one generated asset was written before the manifest report
the entry pointer landed inside the previous entry's name and
the report was never found. Track entryOffset cumulatively,
scope the local-header read into the matched-name branch, and
advance entryOffset += 46 + nameLen + extraLen + commentLen
per iteration. This is what was making the e2e 'manifest
report' assertion time out.
2. packages/core/src/generate.ts — sidecar tracking
The animation.json sidecar was emitted in a separate post-loop
pass that ignored the success of the sprite sheet it paired
with, didn't participate in duplicate detection, and wasn't
listed in createdFiles. Now the sidecar path is reserved
inside the main asset loop (alongside the sheet), checked
against createdFiles for collisions, and only written if the
sheet was successfully rendered. Explicit user files with
the same name win.
3. packages/schemas/src/types.ts — Format excludes 'wav'
AudioAsset inherits BaseAsset.format, but the Format type
union didn't include 'wav', so TypeScript callers couldn't
construct a valid AudioAsset. Add 'wav' to the Format union.
4. apps/web/src/builderRender.ts — circle image fill clipping
drawCircle was calling drawImageFillOverlay with the circle's
bounding box but no clip, so a circle layer with an image fill
drew a rectangular image outside the circle's bounds. Wrap
the overlay in save/clip/restore so the image is constrained
to the ellipse.
5. apps/web/src/UIBuilder.tsx — glow color editor
The property panel had a Glow blur input but no color picker,
so glows always used the hard-coded default and existing
recipes with effects.glow.color couldn't change it. New Glow
color field appears when a glow is set; hexToRgba preserves
the existing alpha channel when converting from the picker.
e2e fixes:
- Use 'Toggle theme' aria-label (the button's accessible name)
instead of the title-attribute text, which getByRole doesn't
match by default.
- Wait explicitly for the 'Manifest report' <strong> rather
than racing the manual ZIP decoder.
- Bump per-test timeout to 60s for the dev server cold start.
- Use page.waitForFunction to wait for the theme to flip before
reading the attribute, avoiding a race with the React
useEffect that writes to localStorage.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
The previous fix landed the EOCD offset corrections inline in
App.tsx, but the e2e test was still failing because the report
panel was rendering just fine — the test itself was wrong. Two
issues:
1. getByText('Total') and getByText('Successful') matched both
the success message and the report-panel labels (strict-mode
collision). Fixed by scoping to the report panel container and
asserting on a label that's unique to the report (the job name).
2. The inline parser in App.tsx was a 50-line anonymous function
inside the component, untestable in isolation. Extracted to
apps/web/src/zipParser.ts as a small standalone module
(readZipEntry). Used in App.tsx the same way as before but
with proper bounds checks, plus a real signature check before
each EOCD/central-directory field read. The new module has
no behavior change from the previous inline version; it just
makes the parser testable and centralizes the field offsets
in one place.
pnpm e2e now passes both tests locally. CI will pick up the
fix on the next push.
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Greptile's last open thread on tier/3-polish: describe.skipIf was evaluated when the file was loaded, before the async beforeAll had a chance to set canRun. If the native canvas setup failed, the suite was still registered as runnable and the tests tried to call generateJob / nodeCanvasBackend as undefined. Drop describe.skipIf. Run the async smoke test in beforeAll as before, and have each test that needs the canvas check canRun at the top and return early. The requireCanvas() helper throws unavailable rather than letting the test silently hit undefined functions. Verified locally: - pnpm --filter @placeholderer/core test: 33/33 pass - pnpm --filter cli test: 4/4 pass - pnpm e2e: 2/2 pass Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Two real issues from Greptile's review of the tier 3 PR.
1. apps/web/src/builderRender.ts + UIBuilder.tsx
drawImageFillOverlay only draws from the rasterCache, but the
on-screen render path never preloaded image fills. A user could
pick Fill mode = Image and enter a source, but the editor
preview kept showing only the fallback fill until the export
path happened to preload the image. Export rasterCache and
add a useEffect in UIBuilder that iterates every raster and
image-fill src, kicks off async loads, and bumps a
preloadTick when each finishes. The render effect depends on
the tick so the canvas re-draws as images arrive.
2. packages/core/src/generate.ts
createdFiles was used as both a duplicate-reservation set and
the report's createdFiles list, but paths were pushed BEFORE
zip.file ran. If an animated sprite sheet's encode threw, the
sheet and sidecar were still in createdFiles — so the sidecar
pass wrote a sidecar pointing at a missing sheet, and the
manifest report listed files that were never created. Move
the push to createdFiles to AFTER zip.file succeeds, so a
failed render leaves no trace in the report or the sidecar
pass.
Test:
- apps/cli/tests/e2e.test.ts gains a new 'skips sidecar + report
entry when an animated sprite sheet fails' that drives a
flaky backend whose second encode() rejects, and asserts the
failed sheet + its sidecar are not in the archive or the
report. Also asserts the previous (good) sheet is unaffected.
Verified locally:
- pnpm --filter @placeholderer/core test: 33/33 pass
- pnpm --filter cli test: 5/5 pass
- pnpm e2e: 2/2 pass
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
The round-2 fix preloads image-fill sources in UIBuilder but unconditionally inserted the new Image() into rasterCache before its onload fired. drawImageFillOverlay trusted any cached entry as drawable, so a render that fired while the load was still in flight would call drawImage or createPattern with a half-loaded bitmap. Two-part fix: - apps/web/src/UIBuilder.tsx: only put the Image into the cache after onload (or onerror) fires. A still-loading image stays out of the cache, so drawImageFillOverlay's lookup misses and the fallback fill remains visible until the load finishes. - apps/web/src/builderRender.ts: add a defensive img.complete && img.naturalWidth > 0 check in drawImageFillOverlay. This mirrors the same guard used by drawRaster, and protects against any future code path that might insert a partially-loaded Image into the cache. Verified locally: - pnpm --filter @placeholderer/core test: 33/33 pass - pnpm --filter cli test: 5/5 pass - pnpm e2e: 2/2 pass Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Greptile's last open thread on tier/3-polish: the sidecar pass
called sanitizePath outside the per-asset try/catch that the main
loop uses. If a sprite sheet's output_path was malformed in a
way the main loop's catch didn't trip on, the sidecar pass would
re-throw and reject the whole generateJob call instead of
producing a partial ZIP and a per-asset error in the report.
Fix:
- packages/core/src/generate.ts: wrap the sidecar emit in its
own try/catch. A throw from sanitizePath/sanitizeFilename
inside the sidecar loop is converted into a per-asset error
('<name> (sidecar): <msg>') and the rest of the sidecars
continue to be processed.
- The main loop's behavior is unchanged.
Test:
- apps/cli/tests/e2e.test.ts gains 'reports per-asset errors
when an animated sprite sheet has a bad output_path' that
drives a sprite sheet with a backslash-escaped relative
output_path, which the sidecar pass would previously have
rejected outright. The new test asserts generateJob returns
a result (not a thrown promise) and the bad sheet isn't in
the archive.
Verified locally:
- pnpm --filter @placeholderer/core test: 33/33 pass
- pnpm --filter cli test: 6/6 pass
- pnpm e2e: 2/2 pass
Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
Split baseAsset so image-style kinds still require width/height while audio stays dimensionless. Fix the rounded FillSpec canvas path, the SVG image-fill pattern builder, and the SVG glow color (feGaussianBlur with no color is now feDropShadow with flood-color). Add audio manifest validation tests at the schema and CLI layers, and add an audio branch to the web AssetPreview so the new optional dimensions don't break the build. Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
- Clear manifestReport on a new generation start so a later failure doesn't show the previous job's folders/files. - SVG export: branch on fill.mode so stretch image fills emit a single <image> clipped to the layer shape (instead of a tile pattern that drifts off-shape from the canvas origin). Repeat pattern/image fills now align the pattern x/y to the layer so repeated bitmaps also stay in shape-local coords. - Escape every user-controlled string interpolated into an SVG attribute (fontFamily, stroke color, glow/shadow flood-color, opacity, rotation, layer id used in def ids). The malicious recipe payload 'Arial" evil="true' and '<script>...</script>' text both serialize as escaped literals. - safeId() strips chars that aren't valid in SVG id values, so imported recipes with non-ASCII ids can't break the renderer. Signed-off-by: Zeid Diez <zeidalidiez@gmail.com>
The stretch image-fill export was building the clipPath def with
safeId(`clip-${layer.id}`) but referencing it with
`clip-${safeId(layer.id)}`. For a numeric layer id like "1" the
def became `clip-1` and the reference became `clip-i-1`, so the
exporter pointed at a missing clip path and the stretched image
wasn't clipped to the shape.
Hoist the clip id construction into a single clipIdFor() helper
used by both the <clipPath> def and the wrapper <g>'s clip-path
attribute, so they always agree.
Also: the .githooks/commit-msg DCO enforcement doesn't belong on
this repo (no DCO app is configured here, the hook was carried
over from a different project). Drop the hook so commits don't
need a Signed-off-by trailer.
Two real bugs, three stale comments from earlier diffs. - packages/core/src/generate.ts: split the duplicate-check into primary vs sidecar. The previous code skipped the whole sprite sheet when only its sidecar path was already taken. Now only fullPath collisions block the asset; sidecar reservations live in a separate reservedSidecars Set and the sidecar pass skips (instead of overwrites) when the path was committed earlier. - apps/web/src/builderPresets.ts: the Unreal Crosshair preset used a second lineLayer for the vertical arm, but lineLayer always draws horizontal (height only moves the y-center). Replace the vertical arm with a thin rectLayer so the crosshair actually looks like a plus. - apps/cli/tests/builder-presets.test.ts: regression test that verifies the crosshair preset's vertical arm is a rect and that exportSVG emits a <rect> for it. The other Greptile comments on this commit (Glow color picker, loaded-image guard, setManifestReport(null) on generation start) are stale diff-hunk comments from earlier rounds — current code already has those fixes; no change needed.
The 'Clear stale report' fix at App.tsx:107 only ran inside handleGenerate, so the user saw the previous job's report while importing a new manifest (T-Rex caught this in round 7: 'importing job B, and asserts that stale job reports remain visible until generation completes'). Clear setManifestReport(null) in: - handlePaste (JSON manifest import) - handleCSVImport (CSV import) - 'New Job' button onClick Also added a Playwright test that generates job A, goes back to the home view, imports job B, and asserts the manifest report panel is no longer in the DOM.
CI timeout:
- Drop --with-deps from the Playwright install step. ubuntu-latest
has the libs chromium needs; --with-deps pulls 21 MB of font
packages via apt-get that took 14+ minutes on the runner and
pushed the 15-minute job over budget. Bumped timeout-minutes to
20 as a safety margin.
Stretch-fill stroke:
- exportSVG replaced the shape with <g clip-path><image/></g> for
fill.mode === 'stretch', which silently dropped the layer's
stroke. Now emits a stroked, fill="none" shape inside the same
clip group so the border survives. Regression test in
apps/cli/tests/builder-presets.test.ts asserts the stroke is
present in the SVG.
Audio overview row:
- Asset rows rendered '${asset.width}×${asset.height}', which
printed 'undefined×undefined' for audio assets. New
describeAssetSize() helper shows '${duration}s @ ${sr}Hz'
for audio and width×height for image-style assets.
The previous regex /[\d.]+\s*\)\s*$/ would match the last numeric component of any '...'-terminated color string. For rgb(10,20,30) (no alpha component), this grabbed the blue channel '30' and used it as the alpha, producing rgba(...,...,...,30) — outside the valid 0..1 range, which silently fails to render. Replace the regex with a stricter parser that only carries alpha forward when the existing color is a true rgba(...) / hsla(...) with a 4th numeric component; otherwise fall back to the default 0.6. Verified manually with rgb/rgba/hsla/undefined cases. The other Greptile comments on this commit (Clear stale report, Wait before drawing images, Preserve stretch strokes, Glow color cannot edit) are stale diff-hunk comments from earlier rounds — the current code already has those fixes.
The fix for rgb(10,20,30) -> rgba(...,...,...,30) landed in ec84c90, but the function was not exported and had no unit test, so the only way for Greptile to confirm it was to re-run a Playwright harness against the live UI. - Export hexToRgba from apps/web/src/UIBuilder.tsx. - Add 5 unit tests in apps/cli/tests/builder-presets.test.ts covering rgb/rgba/rgba(0)/hsla/undefined. The rgb() case is the exact one T-Rex flagged (rgba(17,34,51,0.6), not rgba(17,34,51,30)).
Two real bugs that can produce incorrect generated artifacts: 1. packages/core/src/generate.ts — sidecar overwrite. The main loop reserves the sidecar path in reservedSidecars, but the sidecar pass was the only place that checked it. If a later asset writes the sidecar path as its primary output between the reservation and the sidecar pass, the sidecar overwrites that later file while the manifest report still lists it as created. Fix: re-check createdFiles immediately before writing the sidecar (let the later asset's file win), and push the sidecar to createdFiles after writing so the manifest report includes it. 2. apps/web/src/builderRender.ts — stretch text image export. buildSVGClipAndImage returns null for text layers (clip paths aren't meaningful for glyphs), so buildSVGFill returned null and the text layer fell back to a solid fill in SVG. The canvas drawText path renders the image as a background rect with white text on top; the new branch in layerToSVG mirrors that — emit an <image> as the background plus the <text> with fill="#ffffff" on top. Tests: - apps/cli/tests/builder-presets.test.ts: 2 new tests for text stretch image export (image present, text in white; and no <image> for plain solid-color text). - apps/cli/tests/e2e.test.ts: new test asserting that two sprite sheets with the same name produce the expected duplicate error, with the first sheet's sidecar present in both the ZIP and the manifest report.
T-Rex caught two SVG export regressions for text layers: 1. The 'text' case in layerToSVG's switch returned filter: null even though filterAttr was on the markup, so the glow/shadow filter URL referenced a non-existent def. Now returns the buildSVGFilter() result so the <filter> def lands in <defs>. 2. The text-with-stretch-image branch applied filterAttr only to the <text> (fg) element, not to the background <image>. Canvas applyGlow/applyShadow apply to the whole context, so SVG should match: moved opacity/transform/filterAttr from the <text> onto the wrapping <g> so the filter affects both the image background and the glyphs. Regression tests: - exportSVG text glow → <filter> def present, filter URL references the same id. - exportSVG text shadow → same check. - exportSVG text stretch image → image + text present. - exportSVG plain text → no <image>.
T-Rex round 11 caught that the 'line' and 'raster' branches in layerToSVG returned filter: null and omitted filterAttr from the markup, so canvas applyGlow/applyShadow effects never landed in SVG export for those layer types. - 'line' case: add filterAttr to the <line> markup and return the buildSVGFilter spec instead of null. - 'raster' case: same change for <image> markup. Two regression tests cover each case (line with glow, raster with shadow), asserting both the <filter> def lands in <defs> and the markup references the filter URL.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tier 3: polish, deferred features, Phase 2 (audio + animation)
Closes the deferred work from tier 2, adds engine-aware UI Builder
presets, layers e2e tests on top of the unit suite, and ships the
two Phase 2 features the v1 spec calls out (audio + animated
sprite sheets).
In this PR
Wire up glow, pattern fills, image fills in UI Builder (3f686bb)
The schema's full
FillSpec(solid color / pattern / imagefill) and
effects.glowwere deferred at the end of tier 2.Both the canvas renderer and the property panel now handle all
three fill kinds plus a glow blur/color editor.
Manifest report viewer in the web UI (bee2730)
After Generate & Download, the web UI now reads the
_placeholderer/manifest-report.jsonentry out of the producedZIP (no JSZip dependency — walks the central directory by hand)
and renders a small panel showing job name, asset counts, and
inline-code tags for every created folder and file.
Engine-aware UI Builder presets (59f05e6)
The five generic placeholder presets in the UI Builder become a
small engine-aware library: Godot dialog window, Unity health
bar, Unreal crosshair, plus common button/panel/title/divider.
New
apps/web/src/builderLayerFactories.tsis the single sourceof truth for layer construction.
Playwright e2e tests for the web app (18446f4)
Two smoke tests via
pnpm e2e: import a starter manifest,assert the Overview opens, click Generate, capture the download
with the right filename, and verify the manifest report
panel renders. A second test asserts the theme toggle changes
data-themeon<html>. The CI workflow now installsPlaywright's chromium and runs the suite.
CLI e2e test that exercises the full generate flow (3f0e846)
Vitest in
apps/cli/tests/e2e.test.tsthat drivesgenerateJobagainst the real@napi-rs/canvasbackend,re-opens the produced ZIP with JSZip, and asserts every
requested asset is present, declared empty folders
materialize as
.gitkeep, and the manifest report parseswith the right counts and zero errors. The suite skips itself
automatically when the native binary is unavailable.
Audio placeholder generation (WAV sine wave) (b7c00b3)
Per the v1 spec's Phase 2 list: a new
audioasset kindthat produces a 16-bit PCM mono WAV file containing a sine wave
at the configured frequency with a 10ms attack/release envelope
to avoid clicks. New schema (
AudioAssetwith frequency,duration, optional sample_rate and amplitude), new
packages/core/src/audio.ts(encodeWav + synthesizeTone),and a CLI e2e test that opens the WAV and asserts the
RIFF/WAVE header.
Animated sprite sheet sidecar (animation.json) (8dc874b)
When a
sprite_sheetasset declaresframe_duration_ms, thegenerator writes a sidecar
<name>.animation.jsonnext to thesheet with the timing data a runtime needs (sheet path, frame
size, rows, columns, frame_count, frame_duration_ms, fps,
total_duration_ms). The fps is derived as
round(1000 / frame_duration_ms).Verification
pnpm -r buildis greenpnpm test→ 33 unit + 4 CLI e2e = 37 tests passingpnpm e2e(Playwright) is wired but requiresnpx playwright installon the runner; CI installs it automatically.
validateManifest/validateBuilderRecipeAJV pipelinepicks up the schema changes (audio kind, frame_duration_ms
field, looser baseAsset.format) and the 33 unit tests still
pass.
What's left for a future tier
(it has its own inline color literals that the tier 2 theming
pass didn't migrate because we hadn't landed the new UIBuilder
yet).
export helpers" Phase 2 item).
Greptile Summary
Confidence Score: 5/5
The changes appear safe to merge based on the reviewed implementation and accompanying test coverage.
No blocking issues were identified in the finalized review, and the PR adds targeted unit, CLI, and browser coverage for the new generation and web UI behavior.
What T-Rex did
Comments Outside Diff (28)
apps/web/src/builderRender.ts, line 322-323 (link)The property panel can set pattern and image fills on any filled layer, but
filled-shapestill usesfillToColor, which drops non-string fills to the fallback color. A rounded shape configured with a pattern or image fill silently exports and renders as a plain solid fill instead of the selected fill.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright harness for real builderRender filled-shape pattern rendering
Repro: command output with FillSpec and sampled pixel colors
▶ Screen recording from the T-Rex run
Repro Filled Shape Pattern Poster
apps/web/src/builderRender.ts, line 359-371 (link)SVG export converts every
FillSpecthroughfillToColor, so pattern and image fills are replaced with the fallback color in exported SVGs. A layer that looks patterned or image-filled on canvas exports as a solid-color SVG. Serialize these fill modes into SVG definitions or block SVG export for unsupported fill modes.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: focused harness invoking real SVG export with pattern and image FillSpec inputs
Repro: runtime output showing non-solid FillSpec inputs exported as fallback solid fills
Repro: generated SVG showing only fallback solid fill attributes
apps/web/src/builderRender.ts, line 422-426 (link)The canvas glow uses
glow.color, but the SVG filter only applies a Gaussian blur to the source graphic and never uses the configured color. Exported SVGs therefore do not match the builder preview for colored glows; the glow can appear as a blurred copy of the shape instead of the selected halo.Artifacts
Repro: focused SVG glow color harness
Repro: failing SVG glow color run output
Repro: generated SVG missing configured glow color
Repro Glow Comparison
apps/web/src/builderRender.ts, line 152-155 (link)When a layer has both
shadowandglow, both helpers write the same canvas shadow properties.applyGlowruns second and overwrites the shadow blur, offsets, and color, so the shadow disappears. The schema allows both effects, and the UI can add glow to an existing shadow, so this state produces incorrect rendering.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright harness rendering the real builder layer with shadow-only and shadow-plus-glow effects
Repro: Playwright output showing both effects configured and the missing offset shadow sample
Compose Layer Effects Shadow Only Vs Shadow Plus Glow
apps/web/src/App.tsx, line 123-127 (link)manifestReportis only set on a successful generation and is not cleared before a new generation or on failure. After one successful download, a later failed generation leaves the previous report panel visible, so the UI can show old files and counts for a job that did not generate successfully.Rule Used: Please include instructions to add skills or rules... (source)
General comment
audioAsset, but validating an audio manifest withkind: "audio",format: "wav", frequency 440, duration 0.25, sample_rate 22050, and amplitude 0.5 returnsvalid: false. The validator reports missingwidthandheightfor the audio asset andoneOffailure, so callers cannot validate and generate an audio asset through the manifest contract. This blocks the claimed behavior beforegenerateJobcan emitsfx/beep.wav.packages/schemas/src/manifest.schema.jsondefinesaudioAssetwithallOf: [{ "$ref": "#/definitions/baseAsset" }, ...], whilebaseAsset.requiredincludeswidthandheight. Audio assets therefore inherit image-only required fields.kind,name,format, andoutput_pathplus audio-required fields. Keepwidth/heightrequired only for image-like assets.General comment
@playwright/testto the root package and wires CI to runpnpm install --frozen-lockfile, install Chromium, thenpnpm e2e, but the lockfile was not updated for the new root devDependency. A clean/frozen install fails withERR_PNPM_OUTDATED_LOCKFILE, and the Playwright binary is unavailable, sopnpm e2ecannot run in the validated head checkout.package.jsonnow declares@playwright/test, butpnpm-lock.yamlstill has root specifiers matching onlytypescript, so frozen installs reject the dependency graph before Playwright can be installed.pnpm installwith the intended pnpm version and commit the updatedpnpm-lock.yamlentries for@playwright/test/Playwright. Then verify from a clean checkout withpnpm install --frozen-lockfile,pnpm exec playwright test --list, andpnpm e2e.apps/web/src/builderRender.ts, line 320-332 (link)The new fill modes are exposed for every selected layer, including the built-in Rounded layer, but this renderer still resolves
filled-shapefills withfillToColor. When a Rounded layer is set to Pattern or Image, the canvas and PNG/JPG export keep rendering the fallback solid color instead of the selected fill.Rule Used: Please include instructions to add skills or rules... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
General comment
e2e_smoke.zipdownload, then waits for the Manifest report panel. The panel never appears and the Playwright proof times out waiting forstrongwith textManifest report. This violates the PR contract item that the web UI reads_placeholderer/manifest-report.jsonfrom the produced ZIP and renders job/count/file/folder details.apps/web/src/App.tsxonly reads the manifest report when the local file header reports compression method 0 (STORE). The generated ZIP is produced by JSZip withzip.generateAsync({ type: 'uint8array' })inpackages/core/src/generate.ts, which does not force STORE for the manifest report. As a resultloadManifestReportsilently returns without settingmanifestReportfor the real generated ZIP._placeholderer/manifest-report.json. Also update the e2e assertion to check the full report contents so this cannot regress silently.apps/web/src/builderRender.ts, line 320-332 (link)The property panel allows every shape layer to select pattern or image fill, but
filled-shapestill renders withfillToColor, which falls back to#4A5568for non-string fills. A rounded shape configured with a pattern or image fill silently renders as a solid color, while rects and circles use the newresolveFilland image overlay paths. Use the same fill resolution here, and clip image overlays to the rounded-rectangle path.Coding-agent rule suggestion: add a rule to grep all sibling renderer branches when adding a new union feature like
FillSpec, so rect, circle, and filled-shape stay in sync.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright automation script that drives the real UI Builder and samples the canvas
Repro: automation output showing rounded shape pattern fill collapsed to solid fallback color
Repro Rounded Fill UI full-page view
Repro Rounded Fill UI Poster
▶ Screen recording from the T-Rex run
apps/web/src/builderRender.ts, line 368 (link)SVG export still converts every non-string fill through
fillToColor, which returns the fallback color for the new pattern and image fill specs. A layer that previews or exports to PNG with a checkerboard, stripes, or image fill will export to SVG as a solid gray shape instead, so the SVG output no longer matches the builder canvas for the new fill modes.Artifacts
Repro: focused harness that executes the real SVG export path with a checkerboard pattern FillSpec
Repro: harness output showing the pattern FillSpec exported as fallback fill="#4A5568"
Repro: generated SVG artifact containing the solid gray fallback fill
General comment
{ kind: 'audio', format: 'wav', frequency, duration, sample_rate }still returnsvalid: falsefromvalidateManifeston head. The errors are the same image/sprite/tileset/ui schema mismatches seen on base, including/requests/0/assets/1/format must be equal to one of the allowed valueswith only png/jpg/jpeg/webp listed. This means CLI validation can reject manifests that generation now supports, so users going through a validate-first CLI path may not be able to use the new audio feature even though generateJob can produce the WAV.audioAssetdefinition/oneOf branch in the executed head flow, despite generation code supportingkind: 'audio'. The after artifact'svalidateManifest(manifest)output demonstrates the runtime validator is still operating with an image-only format enum for the audio object.validateManifestincludes theaudioAssetoneOf branch andformat: ['wav'], and add a validation test asserting a manifest with an audio asset is valid. Also verify workspace/package exports do not point validation at a stale schema copy.apps/web/src/builderRender.ts, line 331-332 (link)The new fill controls can set Pattern or Image on a rounded
filled-shapelayer, but this render path still usesfillToColor, which turns object fills into the fallback color. When a user selects Pattern or Image for a Rounded layer, the canvas and PNG/JPG export render the default solid gray instead of the configured fill. Use the sameresolveFillpath as rectangles, and draw image fills clipped to the rounded shape.Coding-agent rule suggestion: add a rule that every renderer branch for a shared visual property must use the same resolver/helper path unless the branch explicitly documents why it cannot.
Rule Used: Please include instructions to add skills or rules... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Artifacts
Repro: Playwright script that drives the real UI Builder controls and samples the canvas
Repro: command output with canvas pixel samples showing rounded fill fallback gray
Rounded Fill Repro
▶ Screen recording from the T-Rex run
apps/web/src/builderRender.ts, line 331-332 (link)The property panel now exposes pattern and image fill modes for every selected layer, including the
+ Roundedfilled-shape layer, but this renderer still converts non-string fills throughfillToColor. When a rounded layer uses a pattern or image fill, preview and PNG/JPG export draw the fallback gray instead of the chosen fill.Rule Used: Please include instructions to add skills or rules... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Artifacts
Repro: focused renderer fill-resolution script
Repro: pattern fill object resolves to fallback gray
Repro: Playwright canvas capture harness for the real renderer
Stack trace captured during the T-Rex run
General comment
kind: audio,format: wav,frequency,duration,sample_rate,amplitude). The after artifact showsVALIDATION.valid: falseand errors for/requests/0/assets/1including missingwidth, missingheight, andmust match exactly one schema in oneOf. A CLI/core full generate flow that validates manifests before generation will therefore reject the new audio asset kind instead of producing the claimed ZIP.packages/schemas/src/manifest.schema.jsondefinesbaseAsset.requiredas["kind", "name", "width", "height", "format", "output_path"], andaudioAssetcomposesbaseAssetwithallOf, so audio assets inherit image-only required dimensions. The oneOf branches are also not mutually exclusive at the base level, causing the valid audio object to fail schema matching.widthandheight. Also make each asset schema branch require/constrain its ownkindso an audio object withkind: "audio",format: "wav",frequency, anddurationvalidates successfully. Add an e2e validation assertion that the phase-2 manifest is accepted before generateJob/CLI generation.apps/web/src/builderRender.ts, line 335-336 (link)The property panel now lets a filled-shape/rounded layer choose Pattern or Image fill, but this renderer still uses
fillToColor, which falls back for object fills. A user can select Pattern or Image for the rounded shape and the canvas/PNG/JPG export will silently render the default solid color instead of the selected fill. Use the sameresolveFilland image-overlay path used by rectangles/circles, clipped to this rounded-rect path.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright script that loads the real UI Builder with pattern-filled rectangle and rounded layers
Repro: command output for the UI Builder Playwright capture run
Repro Rounded Pattern Canvas
Repro Rounded Pattern Canvas Only
▶ Screen recording from the T-Rex run
apps/web/src/builderRender.ts, line 372 (link)layerToSVGserializes every shape withfillToColor, so pattern and image fills are converted to the fallback#4A5568. A rect, circle, or rounded layer can now preview with a checkerboard or image fill, but Export SVG writes a solid fallback fill, so the exported asset does not match the user-selected appearance.Rule Used: Please include instructions to add skills or rules... (source)
General comment
sprites/hero.animation.jsonin the ZIP and the JSON sidecar fields are correct, but this behavior relies on adding the sidecar path tocreatedFilesbefore it is actually written and then emitting the sidecar in a later pass. The report lists the sidecar increatedFiles, but the sidecar is not part ofsuccessfulasset count, and base/head evidence shows the new sidecar behavior was introduced without a separately reported artifact count. If consumers interpret the report as a complete accounting of generated files vs generated assets, the sidecar is mixed intocreatedFilesbut not into the successful count. This is a contract ambiguity rather than the excluded invalid-output_path sidecar exception issue.packages/core/src/generate.tsreservessidecarPathincreatedFilesduring the main asset loop and incrementssuccessfulonly for the primary sprite sheet asset, then writes the sidecar later. The manifest report is built fromcreatedFilesand asset counts, so auxiliary sidecars are included as files but excluded from success totals.successfulas assets-only but document/test thatcreatedFiles.lengthmay exceedsuccessful; alternatively write sidecars in the same try block and track sidecars separately from asset outputs.apps/web/src/builderRender.ts, line 335-336 (link)The property panel now exposes Pattern and Image fill modes for every selected layer, including
filled-shapelayers from+ Rounded, but this render path still usesfillToColor(). For pattern or image fill objects, that returns the#4A5568fallback and never draws the image overlay, so rounded layers silently lose the selected fill in preview and PNG/JPG export. Use the same FillSpec handling as the rect/circle paths here.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright harness that renders real builderRender.ts output
Repro: in-app fixture page importing the real renderer
Repro: command output with pixel samples proving rounded fallback fill
Rounded Fillspec Repro
▶ Screen recording from the T-Rex run
Repro Exported Pattern Fill
apps/web/src/builderRender.ts, line 372-388 (link)exportSVGstill sends every layer throughfillToColor, which returns the fallback color for object fills. When a builder layer uses the new Pattern or Image fill mode, the live canvas and PNG/JPG export render that fill, but SVG export silently writes a solid fallback-colored shape instead. This makes the exported SVG differ from the preview and drops the new fill data.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: TypeScript harness that calls the real exportSVG with Pattern and Image fills
Repro: command output showing fallback solid fills and missing pattern/image SVG data
Repro: generated SVG containing fallback fill colors for pattern and image fill layers
General comment
sfx/beep.wavwith a correct RIFF/WAVE PCM mono header and sample data whengenerateJobis called directly, but the normal validated manifest contract is broken. The head run for an audio asset containingfrequency,duration,sample_rate, andamplitudereportsvalidation.valid: falsewith required-property errors forwidthandheight, plus image-family schema errors, before generation. CLI generate validates manifests first, so this prevents the documented audio asset shape from being accepted in normal usage.packages/schemas/src/manifest.schema.jsondefinesaudioAssetasallOfincludingbaseAssetat lines 144-146, whilebaseAssetstill requireswidthandheightat line 60. The TypeScriptAudioAssetalso extendsBaseAsset, which keeps image dimensions mandatory for audio assets.kind,name,format,output_path,frequency, anddurationplus optional audio parameters. Add validation tests for a minimal audio manifest and run CLI generate through validation.apps/web/src/App.tsx, line 327-330 (link)Audio assets are now valid without
widthorheight, but the overview still renders every asset as dimensions. Importing a valid audio manifest showsaudio • undefined×undefinedin the asset row, even thoughAssetPreviewalready treats audio as dimensionless. Render audio metadata separately, such as frequency and duration.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Audio Overview Undefined Dimensions
Repro: Playwright run output showing the focused UI test passed after observing the malformed audio dimensions text
Repro: Playwright test script that imports the audio manifest and captures the Overview row
Repro: exact valid audio manifest used for the UI import
General comment
pnpm e2eshould run Playwright smoke tests, the repo should containplaywright.config.tsandtests/e2e/placeholderer.spec.ts, and CI should install Chromium and invoke the e2e suite. Runtime validation on head instead shows noe2epackage script, missing Playwright config/test files, no Playwright executable, and CI lines only for install/build/test.pnpm e2efails withERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "e2e" not found.e2escript topackage.json, commitplaywright.config.tsandtests/e2e/placeholderer.spec.tswith the described smoke assertions, add Playwright dependencies/lockfile entries, and update.github/workflows/ci.ymlto install Chromium and runpnpm e2e.General comment
fill: { type: 'gradient', from: '#000000', to: '#ffffff', direction: 'vertical' }returns 422 Unprocessable Entity, and a layer withfill: { type: 'image', src: 'textures/bg.png', fit: 'cover' }also returns 422. The validation objective expectedvalidateBuilderRecipeto accept full FillSpec variants for builder-related asset data.fillas either a string or older/narrow object variants requiring fields such asmodeorpattern; it does not include the full FillSpec object variants represented in the TypeScript contracts.packages/schemas/src/builder-recipe.schema.jsonor its FillSpec definitions to match the intended FillSpec union frompackages/schemas/src/types.ts, including gradient and the current image object shape, then add targeted validation tests for each accepted FillSpec variant plus invalid discriminator values.apps/web/src/App.tsx, line 43-64 (link)manifestReportis cleared when generation starts, but it is not cleared when the active job changes. A user can generate job A, click New Job, paste or import job B, and the overview for job B still shows job A's manifest report before any new generation runs. Clear the report anywhere a new manifest replaces the current job.Rule Used: Please include instructions to add skills or rules... (source)
Artifacts
Repro: Playwright test that automates job A generation then job B import and asserts stale report visibility
Repro: focused Playwright run output showing the stale-report assertion test passed
Repro Job A Report Visible
After: Repro Stale Report Job B Import
▶ Screen recording from the T-Rex run
Repro: Playwright trace for the stale-report flow
apps/web/src/App.tsx, line 341-343 (link)Audio assets are now valid without
widthorheight, andAssetPreviewalready treats them as dimensionless. The overview row still renders every asset aswidth×height, so importing a valid audio manifest showsaudio • undefined×undefined. Branch onasset.kind === 'audio'here and show audio metadata, or omit dimensions for audio rows.Artifacts
Repro: valid audio manifest fixture with frequency and duration but no width or height
Repro: Playwright test importing the audio manifest and checking the Overview row
Repro: Playwright run output showing the imported Overview row text and passing reproduction assertion
Audio Overview Undefined Dimensions
General comment
pnpm e2esmoke coverage and CI wiring. On head,package.jsondefines ane2escript, but executing the exact claimed commandpnpm e2efails withERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "e2e" not found; the suite only runs withpnpm run e2e. The CI workflow line 42 currently runspnpm e2e, so CI will not execute the Playwright suite despite Chromium installation and valid tests.pnpm e2einstead of the script formpnpm run e2e; pnpm 9 in this environment does not resolve arbitrary script names through the shorthand used here..github/workflows/ci.ymle2e step topnpm run e2eand update any documented/local command expectation accordingly, or otherwise add a command alias that makespnpm e2eexecuteplaywright test.General comment
Reviews (17): Last reviewed commit: "Apply glow/shadow filter to line and ras..." | Re-trigger Greptile