Skip to content

feat(signage-manager): create and edit signage artwork with AI (PPT-2741) - #489

Open
camreeves wants to merge 22 commits into
developfrom
feat/signage-ai
Open

feat(signage-manager): create and edit signage artwork with AI (PPT-2741)#489
camreeves wants to merge 22 commits into
developfrom
feat/signage-ai

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

Part of the AI signage feature (PPT-2741). This is the part people actually use. It self-gates, so it can merge whenever: signageAICapabilities() failing resolves to a disabled default and the AI controls simply do not appear against an older backend.

What this adds

Two things in the signage manager.

A branding page. The organisation's colours, typeface and logo, stored in zone metadata. Logos come in a light and a dark variant, and you can generate one from the other rather than making both by hand: it converts to HSL and inverts lightness while keeping hue and saturation, which holds a brand colour better than a straight invert. You can still upload both yourself. The page is read only unless the account has sys_admin.

An AI editor. Full screen, preview on the left, settings on the right, candidates along a rail underneath. You describe what you want and get several options back. You can attach reference images and name them in the brief. You can also edit an image that already exists rather than starting from a blank prompt.

Text goes on as draggable blocks, not fixed anchors, because anchor positioning could never quite put the words where you wanted them. Blocks take newlines so a block can be a paragraph. Each block can override the brand colour and typeface, defaulting to the brand kit. Fonts load from Google Fonts on demand, awaiting the stylesheet before asking for the face, since document.fonts.load will otherwise resolve against an unparsed sheet and the canvas quietly draws in the fallback.

There is a switch for whether a poster wears the branding at all.

Also in here

Two spacing fixes you asked for that are not AI related: equal gutters around the playlists panel, and one consistent height across the media toolbar. The toolbar fix has to match on #placeos because the app's own rule lives under that ID and a class-only override loses on specificity.

Notes for review

ai.fn.ts is local rather than imported from ts-client. The equivalent ts-client functions are in PlaceOS/ts-client#30 but nothing here depends on them, so the two can land in either order.

One gap worth naming rather than hiding: this branch adds 23 files and no unit specs, in an app that has 90 spec files for 87 sources. The behaviour was verified by driving the UI in a browser rather than by tests. I would rather add them than have that pass silently, so say the word and I will.

Testing

All 15 affected projects pass their unit tests and all 14 affected apps build, verified after rebasing onto current develop.

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
frontend-templates Ignored Ignored Preview Sep 1, 2026 1:33am UTC

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Creeves and others added 22 commits September 1, 2026 11:28
Adds an AI image service, a multi state modal and a canvas layer editor.

The modal runs compose, generating, choose and layer. Refine sends the
chosen image back with a follow up instruction and keeps the whole chain on
a rail, so any earlier version is one click away.

The words and the logo are drawn in the browser over the artwork, at its
native size, rather than asked of the model: no image model spells reliably
at small sizes, and a logo the model drew is the part of a poster a
trademark claim lands on. The prompt asks for a clear area for both.

The poll loop lives in the service rather than the modal so a job survives
the dialog being closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found driving the feature in a browser against a local stack:

- showMetadata takes the field name as a string and unwraps it itself, so
  the brand kit was never read and the palette and logo never appeared.
- The quota line interpolated {count} rather than {{ count }}.
- The layer put its controls beside the canvas at the lg breakpoint, which
  the modal is wide enough to trigger, leaving the preview tiny. Stacked.
- Saving set the shell's loading flag first, and the shell swaps its
  projected content for a spinner, destroying the canvas mid save. The
  composited image is now taken before that flag is set.
- The list painted an empty tile because it drew before the thumbnail was
  readable; the modal now waits for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jobs live in a closure so the long poll behaves as it does against a real
backend: queued first, then candidates landing one at a time. Images point
at media already in the mock library so the modal renders something real,
and a prompt containing trigger-moderation fails the job the way a vendor
refusal does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry point stays clickable while the modal is open, so a second click
opened a second dialog over a job already running. Only one at a time now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The modal said nothing about which model or provider was in use, so a domain
pointed at a test or gateway endpoint was indistinguishable from a model
doing badly. It now reads "Images made by <model>, via <provider>" under the
brief, which is normal for an AI feature and makes a misconfigured provider
obvious at a glance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The service could do it but nothing called it, so the only way in was
creating something new. Adds it to the media item menu, for uploaded stills
only, behind the same capability check as the create entry point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compose step in edit mode was a bare text box, so the instruction was
written without the picture in front of you. It now sits above the field,
under a Changing this image label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither toggle means anything on an edit. The words layer exists so a new
background can have a headline typed over it, and the logo space is asked of
a picture being composed; an edit keeps whatever the image already has, and
the edit prompt ignores both. They are hidden, and picking a candidate now
goes straight to saving rather than offering a headline to lay over a poster
that has its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An edit now comes back at the source's own dimensions, so there is nothing
to choose here and offering the choice implied it would be honoured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layer had one headline and one subheading, drawn as a single stack. A
poster usually wants a title, a date and a location, and they do not all
belong in the same corner.

Text is now a list of blocks. Each has its own size, colour, shading and one
of nine anchors; blocks sharing an anchor stack in order, so placement stays
predictable without building a drag surface. Sizes stay proportional to the
artwork so a block reads the same on any panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layer offered to place a logo and reserve space for it, but there was no
way for anyone to supply one: nothing in PlaceOS holds a customer logo, and
the only route in was writing zone metadata by hand.

The layer editor now takes an upload and keeps it in the brand kit, so it is
supplied once by whoever first wants it on a poster and used by everyone
afterwards. Without one it says so and offers to add it, rather than hiding
the feature or drawing a placeholder.

Metadata writes need only the metadata scope, so this does not need an
administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The brand kit shapes every generated poster but had no interface: colours,
font and logo could only be set by writing zone metadata by hand.

Adds a Branding section: organisation name, one to three brand colours, a
font, and the logo. It sits with the other signage sections rather than in
Backoffice, because the people who choose a house style are the people
making the posters, and metadata writes need only the metadata scope. The
entry hides itself where image generation is not configured, since nothing
else reads the kit yet.

Faces load from Google Fonts on demand rather than being bundled: a canvas
can only draw what the document has loaded, and the app already pulls Roboto
from there.

Two bugs found while testing the save: the API deep merges a PATCH, so a
colour taken out of the palette came back, and reading the palette by value
order shuffled the names on a round trip. The write replaces rather than
merges, having already merged against the loaded kit, and the palette is
read in a known order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ight

Alex asked for the modal to take the whole screen, with the preview as the
primary section and the settings in a sidebar. The small boxes in his sketch
become a candidate and version rail under the preview: every option of every
round, oldest first, so going back to one you liked is a click.

The wizard is gone with it. Compose and layer were separate states, which meant
typing a headline happened on a screen where the picture was a thumbnail. Now
the sidebar carries the brief, then the change box and the words and logo
controls, against a preview that stays put the whole time.

The layer component keeps only the canvas; its controls move to
ai-layer-controls beside it. Flattening on save is decided by whether anything
is actually drawn over the artwork rather than by which state the modal is in.
Two changes to the layer.

**Both logos.** A brand has one for light backgrounds and one for dark, and
only ever one turns up. The missing one is made from the one you have by
flipping lightness and keeping hue and saturation, so black ink goes white and
a brand colour stays recognisably itself. That is pixel work rather than a
model call on purpose: a logo is the one part of a poster that has to come back
identical, and a model asked to recolour a wordmark will quietly redraw the
letters. Either slot can still be replaced with a real file.

Which one gets drawn is read off the artwork under the logo, because the corner
is different on every generated poster and picking by hand each time is asking
the person to do the machine's job. There is an override for when it is wrong.

The uploaded file lands in the slot its own ink says it belongs to, so one
upload gives you both and neither has to be labelled by hand.

**Dragging.** Nine anchors never put a headline quite in the gap the artwork
left for it, which is the whole point of asking for a background with a clear
area. Blocks now carry a position, dragged on the image itself, with arrow keys
for whoever cannot use a mouse. The shade behind the text follows the block
rather than banding the full width.

Also fixes the Add a colour button, which was overflowing the branding page.
A block takes as many lines as you type. Line breaks you put in are kept,
including the empty ones, since a gap between two paragraphs is a decision
rather than stray whitespace; anything still too wide for the artwork wraps on
top of that. Each role got its own leading with it, because a headline wants
tight lines and a paragraph does not.

Colour and face are now free, defaulting to the brand. The palette swatches
stay one click away and a colour well sits beside them, and the face list is
the same one the branding page offers, with the organisation's own face named
as the default rather than left as a blank.

Fixes a real bug that only showed up once faces could differ per block:
ensureBrandFont appended the stylesheet and asked for the face in the same
tick, so document.fonts.load resolved against a sheet that had not been parsed,
reported success, and the canvas drew in the fallback face. It now waits for
the sheet. The brand font was hitting this too on a cold load; it only looked
fine because the branding page had usually loaded the face first.
The panel carried an 8px margin while the media grid pads 16, so the space
above the cards, the space above the panel and the gap between them were three
different widths, the gap between them being the sum of the other two. The
panel now sits on the same 16 the header and the grid already use, and drops
its left margin so the gutter is the grid's own padding rather than two
spacings added together.
Three heights sat in that row: the view toggle 46, the search field 48, the
icon buttons 50. All were centred, so they lined up down the middle and nowhere
else, and every edge was 1 or 2px off its neighbour.

They are all 50 now. The toggle gets there on its own padding. The field needs
2px, given to its infix rather than by resizing an icon button that is used all
over the app; the rule has to sit under #placeos because the app's own rule for
that padding does.
…e brief

Pictures to work from, numbered so the words can point at them: "in the style
of image 1", "the person in image 2". They upload as they are picked, so the
number under each thumbnail is settled before the brief mentions it, and they
ride along on a refine too, so image 1 keeps meaning the same thing through a
chain.

They are the dialog's, so they go with it: removing one deletes it, and closing
deletes the rest. Not while a job is still running, since the server reads the
bytes when the vendor call starts and a job outlives this dialog by design;
those are left to the sweep, which the server now tags them for.
…ding

On by default, and it governs both halves: the prompt stops carrying the
organisation, its palette and its tone, and the words stop defaulting to the
brand face and offering the brand palette as swatches. A poster that is not in
the company's colours should not hand you its colours to type in either.

Hidden when the organisation has set no branding, since there is nothing to
switch off. The logo keeps its own controls: it is a separate decision, and one
that is already a toggle in two places.
The brand kit is one object for the whole domain, so a change here lands on
every screen every group runs. That is an administrator's call, and everyone
else gets to see what it is set to rather than being sent away from the page.

Follows the app's own split: the actions that write are hidden the way every
other permission-gated control in this app is hidden, and the fields that would
be pointless to fill in are disabled so nobody types into a form that has no
Save. A line at the top says why. The four writing handlers refuse on their own
as well, so a stale view or a console cannot get past the template.

The server is stricter than this page in one direction and looser in another:
it already refuses a plain user outright, and it accepts a support user or one
with a manage grant on the org zone. This is the UI policy asked for, not a
replacement for that check.
The API refuses a caller who is not support and names no group, and the browser
never sent one, so nobody outside PlaceOS could generate anything. Every image
in testing was made by the one local sys admin, which is why it went unnoticed.
`api_group_id` is public now and rides on generate, edit and refine.

Saving branding after a failed read wrote an empty kit over the real one and
took the logo upload ids with it. The read result is tracked, and a save is
refused unless the kit on screen is what the server holds.

The modal's poll loop kept ticking for the life of the page and wrote into a
destroyed component. The service polled a 404 or a 403 forever. Both stop now,
and `watch` cannot start a second loop over one job.

Job and source ids were written as media tags, and the library builds its
folders from tags, so every generated image left behind a folder of one.

Also: the Save button is reachable on a narrow screen, a failed artwork decode
no longer saves a blank PNG, rapid rail clicks cannot leave the preview and the
saved file disagreeing, the idempotency key is one per intent so it can
actually prevent a double spend, a non-admin cannot set the organisation logo
from the modal, the logo is measured from the file that is drawn, Tab steps
through text blocks with a visible selection, a brand colour has to be a hex
value, the quota line refreshes, and reference thumbnails read out their number.
…oss dialogs

Tab cycled through text blocks and never released, so a keyboard user was stuck
inside the canvas with no way back to the dialog. It now steps through the
blocks and hands the key back at either end.

The idempotency key lived on the modal, which is rebuilt on every open, so the
likeliest double spend of all was uncovered: close a slow generation, reopen,
ask for the same thing, pay twice. It lives on the service now.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants