Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .claude/skills/pixellab-cli-assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ file rather than the command.
| A pose or state, any animation, a walk cycle, an attack, interpolation | `pixellab-cli-animation` | `pixellab-cli character state` |
| A change to art that exists, a masked redraw, cleanup | `pixellab-cli-editing` | `pixellab-cli edit` · `pixellab-cli inpaint` |
| Ground, terrain, tiles, props, objects | `pixellab-cli-scenes` | `pixellab-cli tiles` · `pixellab-cli object` |
| A UI panel, a pixel font | `pixellab-cli-interface` | `pixellab-cli ui` · `pixellab-cli font` |
| A UI panel, a pixel font | `pixellab-cli-interface` | `pixellab-cli ui new` · `pixellab-cli font` |

Everything below is this skill's own: the money, the credentials, where files land,
recovering a stopped run, and the free local tooling every category uses.
Expand Down
30 changes: 23 additions & 7 deletions .claude/skills/pixellab-cli-interface/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: pixellab-cli-interface
description: Make the parts of a game a player reads — a UI panel with `pixellab-cli ui` and a pixel font with a usable TTF via `pixellab-cli font`. Use it for a dialogue box, an inventory frame, a HUD, a health bar, buttons, menus, a title screen's lettering, or any interface asset.
description: Make the parts of a game a player reads — a UI panel with `pixellab-cli ui new` and a pixel font with a usable TTF via `pixellab-cli font`. Use it for a dialogue box, an inventory frame, a HUD, a health bar, buttons, menus, a title screen's lettering, or any interface asset.
---

What a player reads rather than what they move.
Expand All @@ -10,8 +10,10 @@ Read `pixellab-cli-assets` first for the spending rule and the credentials.
## A panel

```
pixellab-cli ui <description> --size --element --piece --concept --route
--palette --style --name --seed
pixellab-cli ui new <description> --size --element --piece --concept --route
--palette --style --name --seed
pixellab-cli ui list what the account holds. Free.
pixellab-cli ui show <ui_asset_id> write one panel to the workspace. Free.
```

**Pro pricing**, twenty to forty generations. `--size` is 192 to 688 per axis, and the
Expand All @@ -23,7 +25,7 @@ no coordinates: `button`, `icon_button`, `toolbar`, `tab`, `panel`, `window`,
`health_bar`, `avatar`, and the polygons `triangle`, `pentagon`, `hexagon`, `octagon`.

```bash
pixellab-cli ui "deep charcoal slate framed by cool iron, hearth-fire orange accents" \
pixellab-cli ui new "deep charcoal slate framed by cool iron, hearth-fire orange accents" \
--element panel --element health_bar --element button --element button
```

Expand All @@ -36,7 +38,7 @@ takes `x, y, r`, `polygon` takes `x, y, r, sides, phase`, and each needs a uniqu
and a `kind`. The shapes are validated server side.

```bash
pixellab-cli ui "wooden RPG panel with gold trim" \
pixellab-cli ui new "wooden RPG panel with gold trim" \
--piece '{"id":"frame","kind":"rounded_rect","x":0,"y":0,"w":512,"h":320,"radius":12}' \
--piece '{"id":"bar","kind":"rounded_rect","x":24,"y":24,"w":200,"h":20,"radius":6}'
```
Expand Down Expand Up @@ -64,8 +66,8 @@ a 48-pixel icon button, a 64-pixel HUD frame were previously impossible at any p
because nothing else on this surface made UI.

```bash
pixellab-cli ui "a rusted iron inventory slot" --size 48
pixellab-cli ui "a health bar in this style" --concept mockup.png
pixellab-cli ui new "a rusted iron inventory slot" --size 48
pixellab-cli ui new "a health bar in this style" --concept mockup.png
```

`--concept` steers *what the thing is*; `--style` steers *what it looks like*, and it
Expand Down Expand Up @@ -114,3 +116,17 @@ an engine and in a document.

`--glyph-px` is the glyph size: 8, 16, 32 or 64. Pick it from where the text will be read —
8 is a HUD label, 32 and 64 are a title screen.


## The account keeps every panel

A panel generated here is saved under the account, so the file on disk is a copy rather
than the only one. `ui list` names what is there — identifier, name, size, status and
the description it was made from — and `ui show <ui_asset_id>` writes one out again.
Both are free: the panel was paid for when it was made.

So a lost file is not thirty generations. Reach for `ui list` before generating a panel
that sounds like one already made, and `ui show` rather than `ui new` to get it back.

A panel still being generated reports how far along it is and writes nothing; run
`ui show` again when it is done.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ pixellab-cli tiles terrain --lower grass --upper stone
pixellab-cli tiles platform --material "stone bricks"
pixellab-cli edit knight.png -p "give him a blue cape"
pixellab-cli inpaint knight.png --mask mask.png -p "a horned helmet"
pixellab-cli ui "wooden RPG panel with gold trim"
pixellab-cli ui new "wooden RPG panel with gold trim"
pixellab-cli ui list # every panel the account holds. Free.
pixellab-cli ui show <ui_asset_id> # write one out again. Free.
pixellab-cli font "warm orange arcade font" --bold
pixellab-cli art boxart "a knight at dawn over a burning keep"
pixellab-cli image flip walk-east-*.png
Expand Down
3 changes: 3 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,6 @@ over this file answers with the example above as well as with the notes. -->
- n-0053 2026-09-20 #gotcha @src/pixellab_cli/commands/character.py — characters/animations names a v3 animation custom- plus the action's first thirty characters, truncated mid-word, which is how a motion is matched to one the character already has
- n-0054 2026-09-20 #gotcha @src/pixellab_cli/commands/character.py — animation_group_id is extra_forbidden on characters/animations and only the site's unprefixed animate-with-text-v3/character/background takes it, which refuses the API key
- n-0055 2026-09-20 #ceiling @src/pixellab_cli/subject.py — a motion is matched to one the character already has by the name the call would carry, so a bare action on one call and --name on the next are two motions and neither the refusal nor the join reporting fires
- n-0056 2026-09-20 #gotcha @src/pixellab_cli/pixellab.py — ui-assets/{id} returns image_url and no base64, so a completed job with no bytes is fetched from its address in _await rather than in _decode_images
- n-0057 2026-09-20 #gotcha @src/pixellab_cli/run.py — approve gates on the estimate, not the route, so a reported zero passes and an absent estimate still asks
- n-0058 2026-09-20 #gotcha @src/pixellab_cli/pixellab.py — a path parameter is refused in _split_path when it holds a separator or a dot segment, so no route has to remember the check
2 changes: 1 addition & 1 deletion docs/wiki/pages/ui-assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Both take a `color_palette` **in words** — `"brown and gold"` — where most o
takes a palette as a `color_image`. Worth knowing before encoding a PNG nobody wanted.

That is about the palette only; both routes do take an image for guidance.
`create-ui-asset` has a `style_image`, which `pixellab-cli ui --style` already sends, and
`create-ui-asset` has a `style_image`, which `pixellab-cli ui new --style` already sends, and
`generate-ui-v2` has a `concept_image` for steering a single element's design.

## The panel route describes a shape, not just a subject
Expand Down
14 changes: 14 additions & 0 deletions specs/asset-workspace/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,17 @@ of one character are gathered by the motion they animate, which is the name the
carried or the action it was made from, and the gathered animation holds every
direction they covered and every file they wrote. An atlas built from one animation is
then built from every direction of it, which is what `export atlas` is given.

## Agreement is for a call that can cost something

`approve` gated every call that reached the runner, including the ones whose price is
known to be zero — reading a spritesheet, or writing out a UI panel the account already
holds. R6.1 asks for agreement on *a call that can cost money*, so gating a free
download was never what it said; it made `character sheet` ask for `--yes` to fetch a
file, and the tests never saw it because the suite sets the assume-yes variable for
every run.

A free route is one whose estimate is present, reports zero, and is not a guess —
`Cost(generations=0.0, source="reported")`, which is how the library routes already
declare themselves. An absent estimate still gates: not knowing a price is not the same
as knowing it is nothing, and it is the case the gate exists for.
5 changes: 3 additions & 2 deletions specs/asset-workspace/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: feat/one-animation-many-directions
branch: feat/the-ui-panel-you-paid-for
delivery: in-review
pr: 78
pr: 79
---

# Asset workspace — requirements
Expand Down Expand Up @@ -61,6 +61,7 @@ A file nobody can account for is worth less than the same file with a manifest.
- **R6.2** Where the person's agreement has not been stated, the asset workspace shall send no request and shall write no ledger entry, because neither can have happened.
- **R6.3** Where there is nobody present to state the agreement, the asset workspace shall take it from a named environment variable instead.
- **R6.4** While a call is a dry run, the asset workspace shall require no agreement, because nothing is sent.
- **R6.5** (ADDED) Where a route is known to cost nothing, the asset workspace shall require no agreement, because no charge can begin.

## R7 · The subject as an entity

Expand Down
4 changes: 4 additions & 0 deletions specs/asset-workspace/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@
- [x] 7.1 (Unit) Gather one character's animation runs for a motion into one — R7.5
_Reason the provider starts a new animation per call_

## 8 · Agreement is for a call that can cost something

- [x] 8.1 (Unit) Ask for no agreement where the route is known to cost nothing — R6.5
_Reason a free download asked for --yes, and the suite never saw it_
25 changes: 22 additions & 3 deletions specs/interface-portrait-and-font/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ci: wait
One module, `commands/interface.py`, three commands:

```
pixellab-cli ui "wooden RPG panel with gold trim" create-ui-asset (Pro) R1.1
pixellab-cli ui new "wooden RPG panel with gold trim" create-ui-asset (Pro) R1.1
pixellab-cli font "warm orange arcade font" --bold generate-font-pro (25) R2.1
pixellab-cli portrait knight.png portrait-character-pro (Pro) R3.1
```
Expand Down Expand Up @@ -56,8 +56,7 @@ route's floor and inside `generate-ui-v2`'s, which starts at 16.

## Choosing between them

`pixellab-cli ui` keeps making a panel, so nothing that works today changes. It moves to
the single-element route on either of the two signals that mean a panel was never
`pixellab-cli ui new` makes a panel by default. It moves to the single-element route on either of the two signals that mean a panel was never
possible or never intended (R1.3):

| Given | Route |
Expand All @@ -82,3 +81,23 @@ they wrote.

The last two are the ones worth writing down, because neither fails: without the refusal
the argument is simply absent from the body that gets sent.

## The panel you already paid for

`POST /create-ui-asset` saves what it makes: the account keeps every panel, and
`GET /v2/ui-assets` and `GET /v2/ui-assets/{ui_asset_id}` read them back, both free.
Nothing here called either, so a panel whose file was lost was a panel to buy again at
thirty generations — and until the fix in `specs/provider-core/` there was no file to
lose, because the address the job completes with was never fetched.

`ui` becomes a group to make room for them, the same shape `character` and `object`
already have: `ui new "<description>"` generates, `ui list` names what the account
holds, and `ui show <id>` reports one and writes its image. That renames the generating
form — `ui "<description>"` was the whole command before — which the skill and the
README are updated for in the same change.

`ui show` writes through the same runner every other command writes through, at no cost:
the download is a public CDN address, like a rotation URL, and the run is recorded with
the panel's identifier so the file can be traced back to the call that paid for it. A
panel still processing reports its progress and writes nothing, because `image_url` is
null until it is done.
9 changes: 6 additions & 3 deletions specs/interface-portrait-and-font/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: feat/foundation
delivery: merged
pr: 52
branch: feat/the-ui-panel-you-paid-for
delivery: in-review
pr: 79
---

# Interface, portrait and font — requirements
Expand All @@ -21,6 +21,9 @@ a feature of its own.
- **R1.2** The interface commands shall say that a panel is priced as a Pro Tools route before calling it.
- **R1.3** (ADDED) Where a concept image is given, or a size smaller than the panel route accepts, the interface commands shall generate one interface element rather than a panel, and shall say that it is priced as a Pro Tools route.
- **R1.4** (ADDED) The interface commands shall accept a concept image to steer the design of one element.
- **R1.5** (ADDED) The interface commands shall list the UI panels the account holds, reporting for each its identifier, its name, the description it was made from, its size and its status.
- **R1.6** (ADDED) When asked to show one UI panel, the interface commands shall report it and shall write its image to the workspace, without calling a paid route.
- **R1.7** (ADDED) While a UI panel is still being generated, the interface commands shall report that and shall write no image for it.

## R2 · Fonts

Expand Down
14 changes: 14 additions & 0 deletions specs/interface-portrait-and-font/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@

- [x] 4.1 (Unit) Report the route and the arguments under a dry run, sending nothing — R4.1
_Depends 1.1_

## 7 · The panel you already paid for

- [x] 7.1 (Unit) Add the UI asset list and detail routes to the catalogue — R1.5, R1.6
_Reason the UI library routes were never reached and a lost panel meant paying again_
- [x] 7.2 (Unit) Make ui a group with ui new, and list the account's panels — R1.5
_Depends 7.1_
_Reason the UI library routes were never reached and a lost panel meant paying again_
- [x] 7.3 (Unit) Show one panel, write it, and say when it is not ready — R1.6, R1.7
_Depends 7.1_
_Reason the UI library routes were never reached and a lost panel meant paying again_
- [x] 7.4 (Unit) Teach the skill and the README the ui group — R1.5, R1.6
_Depends 7.3_
_Reason the UI library routes were never reached and a lost panel meant paying again_
41 changes: 41 additions & 0 deletions specs/provider-core/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,44 @@ raise site added later would not remember.
the CDN, call `subscribe`, download the returned URLs to disk. The tool's own estimate
stands in for the usage fal does not report (R5.2). Its errors are translated into the
same hierarchy, so a caller handles one set.

## An image that arrives as an address

`_decode_images` reads `image` and `images`, which is every base64 field the routes
here return — and `POST /create-ui-asset` returns neither. Its job completes into
`GET /v2/ui-assets/{ui_asset_id}`, whose payload carries `image_url`, a public CDN
address, and no bytes at all:

```json
{"id": "43d70268-…", "status": "completed", "size": {"width": 192, "height": 192},
"image_url": "https://backblaze.pixellab.ai/file/…/full.png?v=1789936808"}
```

So a panel charged at thirty generations completed, wrote its manifest, and wrote no
image — the command exited `0` and the asset was never collected. The tests did not
catch it because they replied to the poll with `{"status": "completed", "images": […]}`,
a shape that route has never returned.

The fix is where the job is awaited rather than in the decoder: fetching needs the
client, and `_decode_images` is a function over a payload. When a completed job yields
no bytes, `image_url` is downloaded through the same unauthenticated `download` the
rotation URLs already go through. Only `image_url`, and only because a real payload was
seen holding it — the same rule the decoder states for `quantized_images`.

## What a value reaching the address may be

A path parameter is interpolated into the route's path, and the request that carries it
carries the bearer token — so a separator or a dot segment in one is a URL of somebody
else's choosing, reached with the caller's credential. `collect` already refused it for
a job id and `character state` for a pose, each at its own call site; a third caller
with the same shape is the point at which the check belongs at the one place every path
parameter passes through instead. `_split_path` refuses it for every route, present and
future, before anything is sent (R3.6).

The address a completed job hands back is the provider's, and R4.9 now fetches it with
nobody asking. How much of it is read is therefore not the provider's decision either:
`_fetch` refuses a body past a ceiling far above any asset this tool makes, by the
declared length where there is one and by what arrived where there is not (R3.7).
Nothing narrower than a size limit is attempted — PixelLab serves assets from more than
one host and a host list would fail closed on the day they add another, which for a
tool whose whole job is to collect what was paid for is the more expensive failure.
9 changes: 6 additions & 3 deletions specs/provider-core/requirements.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
autonomy: auto
ci: wait
branch: fix/paid-call-defects
delivery: merged
pr: 13
branch: feat/the-ui-panel-you-paid-for
delivery: in-review
pr: 79
---

# Provider core — requirements
Expand Down Expand Up @@ -47,6 +47,8 @@ looks like have to be decided once.
- **R3.3** If a caller supplies a parameter the route does not accept, a value outside an enumerated parameter's set, or a size the route rejects, then the provider core shall fail before the request is sent, naming the parameter and the values or bounds that are allowed.
- **R3.4** The route table shall be derived from the vendored provider schemas under `reference/`, and a route whose shape contradicts those schemas shall be reported by the suite rather than at call time.
- **R3.5** (ADDED) Where a route requires an image to match one of its size parameters, if the image given does not match it, then the provider core shall fail before the request is sent, naming both sizes.
- **R3.6** (ADDED) If a value that becomes part of a route's address carries a path separator or a dot segment, then the provider core shall refuse the call before anything is sent, because the request that would carry it carries the account's credential.
- **R3.7** (ADDED) If a download named by a provider's response is larger than the ceiling this tool holds for a generated asset, then the provider core shall refuse it rather than read it.

## R4 · Background jobs

Expand All @@ -58,6 +60,7 @@ looks like have to be decided once.
- **R4.6** (MODIFIED) Where a collected job is written, the provider core shall record no second call, and shall settle the call the job belongs to with what the job reports it cost.
- **R4.8** (ADDED) If polling stops before a job resolves, then the provider core shall record that call as still running and shall record no cost for it, because a call that has not finished has not reported one.
- **R4.7** (MODIFIED) Where a response declares how many images it holds and fewer were found, the provider core shall take them from a field it has seen carry them, and shall leave the count alone otherwise.
- **R4.9** (ADDED) Where a completed job reports the address of the image it made rather than its bytes, the provider core shall fetch that address and return the image, because the call has been charged and its result is otherwise never collected.

## R5 · Results and cost

Expand Down
9 changes: 9 additions & 0 deletions specs/provider-core/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,12 @@
_Depends 1.2_
- [x] 4.2 (Unit) Translate fal failures into the shared exception hierarchy, and return the tool's own estimate marked as an estimate — R2.5, R5.2
_Depends 4.1_

## 7 · An image that arrives as an address
- [x] 7.1 (TDD) Download a completed job's image_url when it returns no bytes — R4.9
_Reason a UI panel charged at 30 generations wrote no image_
- [x] 7.2 (TDD) Refuse a path parameter carrying a separator or a dot segment — R3.6
_Reason security review found ui show sending the id unvalidated_
- [x] 7.3 (TDD) Refuse a download past the ceiling for a generated asset — R3.7
_Reason the address is now fetched unasked_

Loading
Loading