feat(signage-manager): create and edit signage artwork with AI (PPT-2741) - #489
Open
camreeves wants to merge 22 commits into
Open
feat(signage-manager): create and edit signage artwork with AI (PPT-2741)#489camreeves wants to merge 22 commits into
camreeves wants to merge 22 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
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.
camreeves
force-pushed
the
feat/signage-ai
branch
from
September 1, 2026 01:32
903a4ee to
c61f0d3
Compare
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
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.
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.loadwill 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
#placeosbecause the app's own rule lives under that ID and a class-only override loses on specificity.Notes for review
ai.fn.tsis 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.