Repository navigation
feat(concierge): edit level maps with AI (PPT-2832) - #570
Conversation
The PR Flow test matrix was built from affected_apps, so a change to a library ran its specs only after merge, in the BUILD workflow's affected_units matrix. Use affected_units for the PR matrix as well. The app and lib test targets accept the same command, and Nx ignores the --configuration flag the job passes when a target defines none, which is already the case for every app. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The map editor sanitises replacement and inserted markup with DOMPurify's svg and svgFilters profiles on top of its own deny-list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New library @placeos/map-editor (libs/map-editor), framework-free so it
runs in jsdom; Angular hosts wrap it later. This commit holds the
document model:
- MapDocument.load: strict DOMParser('image/svg+xml'); rejects parser
errors, DOCTYPE and entity declarations, non-SVG roots and non-numeric
viewBoxes (synthesised once from unitless width and height); sanitises
on load and records what was stripped; gives every element a session
anchor data-map-editor-anchor="eN"; keeps the XML prolog; serialises a
clean clone with the anchors removed
- canonical own-signatures and 8-character FNV-1a content hashes for the
collateral gate and stale-target detection
- GeometryProvider with a browser implementation (getBBox through the
screen CTM, the maths of the viewer's getElementBoundsInSvgSpace) and
an attribute implementation for a detached DOM, which is the spike's
svg_index.py ported to TypeScript
- StyleResolver, the visibility rules and the duplicate-id check lifted
from Alex Sorafumo's Concierge level-map-editor-modal.component.ts as
pure functions, with rule matches precomputed per tree
- the deny-list sanitiser and a DOMPurify wrapper for inserted fragments
(svg and svgFilters profiles, HTML parsing so camel-case names survive,
re-parsed as XML)
- the element index: resolved fill and stroke, labels, kind guesses,
protected flags, anonymous child counts, the style rules and the
spike's prompt-lines text shape
- ProtectedIdSet and the merge of host-supplied sources, which marks the
set incomplete rather than failing when a source errors
- vitest specs over the real maps in shared/assets/maps: load and reject
cases, round trips with equal signatures and stable anchors, the
sanitiser keeping style, text, tspan and marker on every map. test.svg
is excluded from the valid set and asserted as a rejection because it
declares no SVG namespace, which an <img> would not draw either
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The closed vocabulary shared by the editor tools and the AI, as TypeScript types and as ops-schema.json in the OpenAI strict dialect (every field required, optional fields nullable, additionalProperties false, one anyOf variant per op). A spec asserts the schema's op enum equals the TypeScript union, so the Crystal rendering has one source to match field for field. applyOps applies a batch through the DOM API only and leaves the document as it was for any op it rejects: - set_attr enforces the attribute allow-list and value syntax, and also sets the inline style for fill and stroke so the value wins over class rules (the modal's _setColourAttribute) - set_style_rule merges into the rule with the same selector or appends one, creating a <style> when the map has none - transform and duplicate convert root-space deltas into the parent's space through the inverse parent CTM, and say so when the geometry provider cannot supply it - duplicate strips descendant ids, continues an id pattern after the highest existing number, and defaults to ai-N ids - insert_shape carries the inverse parent transform so the numbers the model gave stay in root units - set_id rewrites url(#old) and href="#old" references - group keeps the targets' anchors; ungroup folds the group's transform into each child - replace_element parses the markup as one element inside an svg wrapper, sanitises it, keeps the target's id and anchor and gives the new descendants fresh anchors Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
validateProposal checks every op against ops-schema.json, resolves its targets and content hashes (the original hash is accepted for an element an earlier op in the batch changed), applies the attribute and property allow-lists, value syntax (colours through CSS.supports where it exists, a regex otherwise), the markup deny-list, the id rules and the protected ids: delete, rename and ungroup of a bound id are warnings that stay locked until the id is explicitly unlocked; hiding one or moving it off the canvas warns and unticks the row. The accepted ops are then dry-run on a clone, and the batch is blocked on a changed root viewBox, width, height, preserveAspectRatio or xmlns, a changed <style> without a set_style_rule op, an element-count delta the ops do not account for, growth over 60 elements or 25 percent of the bytes, any changed, removed or added element that no accepted op names (the collateral-change gate on per-element signatures) and a failed render through the RenderCheck port, whose browser implementation loads the serialised result into an Image the way the viewer rasterises maps. An op that sets a paint property on an element whose children paint themselves is warned as having no visible effect, which is what the spike saw on #walls; a style rule reports how many elements it affects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MapHistory keeps whole-root clones with their anchors, 50 deep with a 64 MB byte budget, dropping the oldest entries first and never emptying the stack; undo and redo swap the live root and the document re-indexes. The draft store keys an unsaved working copy by zone id and the SHA-256 of the source map, with an IndexedDB implementation for the browser and a Map-backed one for tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The @import and url( filters tested literal text, so an escaped `@\69 mport u\72l(http://evil/x.css)` passed cleanStyleText, the replacement-markup deny list and the load-time sanitiser, and a browser decodes the escapes once the map is mounted inline. Every CSS safety test now runs on the raw text and on its escape-decoded form, and at-rule statements are matched by their decoded name so an escaped import is dropped like a literal one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… attribute allow-list
The server writes `id` ("op-N") and `server_issue` onto every stored op,
so the op types carry both and name them as bookkeeping outside the vendor
schema. Replacement markup gets its own deny list on top of the load-time
one: a `<style>` in a replacement restyles or hides the whole map without
tripping the style invariant, and `<use>` is what DOMPurify strips anyway,
so both are refused up front. The attribute allow-list check is one
function shared by the applier and the validator, and it excludes the
editor's own `data-map-editor-*` state. `styleReferencesTo` counts the
url(#id) references in `<style>` text that a rename does not rewrite.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…atches, refuses editor attributes set_text on a <text> with <tspan> children drops the tspans, which the applier now reports as touched so the validator can account for them. upsertStyleRule only matched the tail of a selector, so a rule for `.cls-1` edited `.foo .cls-1`; it now requires the whole selector and keeps `!important` on the declarations it does not override. set_attr and insert_shape refuse `data-map-editor-*` names and attribute pairs without a string name instead of throwing on them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…enies style markup, warns on wrappers Stored ops carry `id` and `server_issue`; the schema check skips both and a server issue becomes a warning that leaves the row unticked. `$ref` members are checked against their `$defs` entry, so a declaration or attribute pair without a string name locks its row instead of throwing out of validateProposal. set_text declares the tspans it drops as removed so the element delta and the collateral gate pass on real maps. Replacement markup is scanned with the replacement deny list, and the batch checks refuse any <style> added without a set_style_rule op. replace_element that turns an id'd element into a <g> whose children paint themselves gets the group_fill contract warning, as a warning when the id is bound. Hiding a bound element (visibility, opacity 0, fill-opacity 0, by attribute or style rule) locks until the id is unlocked, as PLAN §3.1 asks. Deleting, ungrouping or replacing elements whose ids other elements reference warns about the dangling references, and a rename notes the url(#id) references in <style> text it leaves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bookable-id guess matched any id starting with a keyword, so the `tables-5` layer groups on level_1 came out as bookable. The keyword now has to be followed by a separator, which every real binding id has (table-01.004, park-101, area-1.01-status). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…list Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A MapEditorSession holds the live document, selection, hover, undo history, the per-version element index and the browser draft, and is the one path every change takes: apply, undo, redo or restore bump its version signal. The history gains redo labels and a drop for an apply that changed nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The canvas mounts the live map inside a shadow root with an overlay for hover, selection and drag outlines, zooms and pans a wrapper, selects by elementsFromPoint ranked smallest box first, and turns drags, nudges and Delete into ops through the session. The properties strip edits id, fill, stroke and text through ops and resets an element to how it loaded. The editor shell adds the toolbar and a projected right panel. set_attr now writes an inline paint style only where a rule or inline style would beat the attribute, and drops a style attribute it emptied. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ssion Discard puts the loaded root back with its anchors, a save rebases the baseline, and an element restored from a draft is matched to the baseline by id when its anchor is new. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new Map Management page beside Zone Management lists every level with a map, filterable by building and search, and opens the editor at map-management/:level_id. The editor page loads the level's SVG the way the old modal did, collects the ids bound to desks, rooms, parking bays, locker banks and points of interest, hosts the map editor with Concierge dialogs and toasts, offers to restore a browser draft, warns when the map changed elsewhere, and saves through an upload, updateZone and a map-history entry on the level. The sidebar entry sits behind the maps feature flag; the Open in Map Studio link follows app.map_studio_url. The lib's ok unions narrow by equality so the app's non-strict build compiles them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The contract of /api/engine/v2/maps/ai as TypeScript, the request functions shaped like ts-client's signage AI functions with an injectable transport, and a job watcher that long-polls with wait and since, retries transient failures with backoff and stops on abort. Also the session interface the AI panel codes against, and the server's context fixture for the specs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The context an edit request carries: named elements first, anonymous ones by area, subtrees for the selection and shortlist, style rules, protected and duplicate ids, shrunk to the byte budget with a record of what was dropped. The snapshot's size maths and selection outlines are pure; the rasteriser draws the result through an Image onto a canvas. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A finished job becomes rows in plain words with the validator's verdict, the server's doubts and dependency links; a pure reducer holds the thread's phases; the thread controller previews the accepted set on the live document as one undo step, re-applies it when rows are unticked, keeps or undoes with reason chips, carries the parent job into the next request and records the decision through resolve. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
map-editor-ai-panel: capabilities and quota at the top, the thread of requests and proposal cards, a composer with selection and snapshot chips, Cancel while running, Keep and Undo with reason chips, Before and After thumbnails, grouped rows above twenty, a live region and keyboard paths. The host hides it when capabilities answer 404. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MapEditorSession implements MapEditorSessionLike and takes SessionApplyOptions from the contract file instead of declaring its own, and the AI strings resolver is resolveAiStrings, so the lib's index can re-export the canvas and the AI modules side by side without an ambiguous name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The editor page projects the map AI panel into the editor's panel slot on the level's session, with the level id as the zone, an upload port that stores the map snapshot through UploadsService and answers with the upload id, and the panel's words looked up as MAP_EDITOR.AI.* locale keys (English text added). When the server answers that it has no map AI the page drops the panel; for any other reason the panel shows its own note. The flow stays: pick a map, type a prompt, see the new version on the map, Keep or Undo, re-edit, or Open in Map Studio. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a tagged picture Each context entry now carries its effective fill and the smallest room, desk or zone whose box holds it; small anonymous paths inside such a container are sent as kind glyph. Elements whose ids carry a number the instruction names join the shortlist, so their markup travels with the request. The snapshot prints the ids of the larger rooms and zones and of every named element at their top-left corner, and the picture is on by default. A question from the model is followed by a hint to select the element on the map before answering. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Glyph candidates (small anonymous paths inside a room, desk or zone with one parent and one fill) are grouped into rows of adjacent letters, and rows that stack into one label; only members of a label are sent as kind glyph. The context gains a labels list with each label's container, glyph anchors in reading order, box and fill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The editor shell reads the element count from the cached index before the canvas mounts the map, so the first index is built with no bounding boxes and stayed cached for the session; every AI request then carried an index without geometry. The session now rebuilds the index the first time it is asked for after the document's root is in the page's render tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The validator applies ops to an unrendered copy of the map; the page's geometry provider returns wrong boxes for elements that are not in the render tree, so every inserted text was warned off the canvas and left unticked. The trial is now measured with the attribute provider unless its root is in the page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A delete remembers where each element stood, so an insert_shape whose after names an element deleted earlier in the batch lands where that element was instead of being refused. A text of several lines becomes one <tspan> per line, stacked at the element's x, for insert_shape and set_text alike; the validator counts the lines in its element delta and notes the placement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iece Anonymous rendered siblings whose boxes lie on each other (a chair's shadow, seat and outline) share one piece id in the context, and a delete that takes some layers of a piece and leaves others gets a note naming what stays. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
MrYuion
left a comment
There was a problem hiding this comment.
I reviewed the full diff against develop at e41049b86f. I read the sanitiser, the operation applier, the validator, the session, history and draft store, the AI client, the Concierge page and service, and the CI and dependency changes. I confirmed the sanitiser, validator and draft findings with throwaway specs run through bunx nx test map-editor. The existing 265 map-editor tests pass. The rest I confirmed by reading the code and the ts-client source. I did not use a live backend or a real AI endpoint.
High
- Edits made while a save is in flight are marked as saved but are not uploaded.
map-editor-page.component.ts:349 - Undo on an AI proposal removes whatever history entry is on top, which is not always the proposal.
ai/thread.ts:581 - A
font-familyvalue inset_style_rulecan inject any CSS, and can hide a protected desk with no lock.value-syntax.ts:94 - The style cleaner keeps text it fails to clean. Three inputs with external URLs pass.
sanitise.ts:61
Medium
image-set()and presentation attributes with externalurl()are not checked.sanitise.ts:23- The raw map text reaches an
innerHTMLsink before the sanitiser.map-management.service.ts:140 - Server error messages never reach the person, and a busy 503 shows as not configured.
ai/api.ts:133 - A reset or page leave during submit does not stop the request, and the proposal is still applied.
ai/thread.ts:473 - After the first save, drafts go under a key that no later load reads.
session.ts:108 - Desks are not protected when
desks.use_assetsis on.protected-ids.ts:40 - Opening the editor can create asset categories and types.
protected-ids.ts:67 - The list page downloads and parses every map.
map-manager.component.ts:266 - The CI change breaks the test job on
custom/*-prodbranches, and no CI run has covered this branch.pull-request.yml:135 ungroupdrops the fill, opacity, class and style of the group.apply.ts:871- Retry uses a new idempotency key, so a lost response costs quota twice.
ai/thread.ts:452 - A context over the byte limit is still sent.
ai/thread.ts:433 - A draft store error shows a save failure after a save that worked.
map-editor-page.component.ts:350
Low
- A style selector that matches nothing in the map is accepted.
validate.ts:648 - The poll loop has no per-request timeout and no minimum delay.
ai/api.ts:262 - Retry sends the composer text, not the failed instruction.
ai/thread.ts:527 - Responses are cast without a check, and an empty capabilities body makes the panel throw.
ai/thread.ts:620 - A rejected edit clears redo, and held arrow keys fill the history.
session.ts:175 - The file name is lost when the name lookup is late or the history write failed.
map-management.service.ts:174 - The zone update sends cached name and tags.
map-management.service.ts:185 replace_elementcan leave a duplicate id.apply.ts:934id_patterncan take a protected id with no warning.validate.ts:783
Optional and questions
The applier does not enforce the replacement deny list by itself. Maps with a DOCTYPE cannot be opened. The old map modal is now unreachable. Some AI client functions have no caller. The settings doc gives a default that settings.ts does not define.
Tests
The specs cover the easy paths. The sanitiser spec only has a style attribute that ends in ;. The AI fakes use an error shape and a session that differ from the real ones, which hides findings 2 and 7. The page spec never calls save(), and nothing tests IndexedDbDraftStore, the draft key after a save, or a reset during submit.
Not verified
I did not load the external-reference cases in a real browser, so I cannot say which browsers fetch them. I did not run get-affected.js on a -prod ref. I ran only the nx test payments command that it would produce. The new dompurify dependency and its lockfile entries look correct.
Review written by Claude Opus 5.5 in T3 Code, through the Claude Code harness.
| this._loaded_map_id = saved.map_id; | ||
| this.level.set(this._manager.level(level.id) || level); | ||
| this.changed_elsewhere.set(false); | ||
| session.markSaved(); |
There was a problem hiding this comment.
Issue, high. save() reads markup at line 333 and then awaits the render check, the upload and the zone update. The canvas and the AI panel stay live for all of that time. markSaved() then takes the baseline from the document as it is now, not from the markup that was uploaded.
To reproduce: start an AI request, click Save, and let the proposal arrive during the upload. thread.ts:672 applies it. markSaved() then sets dirty to false and the next line deletes the draft. The server copy does not have the change and the page says it is saved. An arrow-key nudge during the upload does the same.
Fix: pass the uploaded markup to markSaved, set the baseline from it, and set dirty from doc.serialise() !== markup. Clear the draft only when the result is clean. Blocking edits while saving() is set also works.
| const state = this._state(); | ||
| const proposal = proposalOf(state); | ||
| if (!proposal) return; | ||
| if (state.phase.kind === 'applied') this._session.undo(); |
There was a problem hiding this comment.
Issue, high. undo() here and _retick() at line 692 call session.undo() and assume the AI preview is the top history entry. Nothing locks the canvas, the properties strip or the toolbar while a preview is on the map.
To reproduce: preview a proposal, nudge one element with an arrow key, then click Undo on the card. The nudge is reverted, the AI changes stay on the map, the card says it was undone and the server records rejected. Unticking a row instead reverts the nudge and then applies the proposal a second time on top of the first.
There is a second trigger. MapEditorSession.apply drops its history entry when no op applied, but _preview still moves to applied, so the next Undo removes an unrelated entry.
Fix: store session.version() after the preview apply and only undo when it has not moved. Otherwise mark the proposal as no longer current. Do not enter applied when result.applied is empty. FakeSession.apply in ai-fakes.ts never drops the entry, so the specs cannot see this.
| } | ||
|
|
||
| export function isValidFontFamily(value: string): boolean { | ||
| return !!value.trim() && !UNSAFE_TEXT.test(value); |
There was a problem hiding this comment.
Issue, high. font-family is the one allow-listed property with a free-text value, and this check lets ;, { and } through. set_style_rule writes the value into the stylesheet text as it is, so one value can close the rule and add any CSS.
I ran this operation through validateProposal on the OFFICE fixture with table-01.001 protected:
{ "op": "set_style_rule", "selector": ".room", "declarations": [
{ "name": "font-family", "value": "Arial;} #table-01\\.001{display:none} .x{cursor:image-set(\"https://evil.example/x.png\" 1x)" } ] }It came back with no error, no warning, locked: false and ticked by default. The style text became .room{fill:#fff;font-family:Arial;} #table-01\.001{display:none} .x{cursor:image-set("https://evil.example/x.png" 1x)}. That hides a protected desk without the protected_hidden lock and sets properties that are not on the allow list.
Fix: reject ;, {, }, \ and /* in the value here, or accept only a comma list of quoted names and identifiers. mergeDeclarations in apply.ts should also refuse a value that holds those characters, so the applier does not depend on this check.
| removed.push(match.trim()); | ||
| return ''; | ||
| }); | ||
| if (isUnsafeCssValue(text)) { |
There was a problem hiding this comment.
Issue, high. When DECLARATION or AT_STATEMENT does not match, the unsafe text stays. The function returns it and the caller keeps it. I confirmed three inputs that go through MapDocument.load unchanged with an empty report:
- A style attribute whose last declaration has no
;, which is the usual way to write one.style="fill:url(https://evil.example/p.svg#a)"survives.purifyMarkupkeeps it too.DECLARATIONneeds a;or a}after the value. - A comment that holds a
;..a{fill:/*;*/url(https://evil.example/p.svg#a)}survives, because the match ends inside the comment. - An
@importwhose URL holds a{.@import "https://evil.example/{a.css";survives, becauseAT_STATEMENTstops at the brace and there is nourl(for the second check to find.
No script runs from these. They make the browser fetch from another host, and case 3 loads a remote stylesheet. The viewer puts the raw map into document.body to measure it at map-viewer.class.ts:201, so the fetch can fire in every app that shows the map.
Fix: fail closed. After cleaning, strip comments, decode escapes and test again. If the text still has an external url( or an @import, drop the whole attribute or the whole style text. The spec only covers style="fill:url(http://evil/p);stroke:#000", which has the ;.
| } | ||
|
|
||
| const UNSAFE_CSS_VALUE = | ||
| /expression\s*\(|behavior\s*:|javascript\s*:|url\s*\(\s*(?!['"]?#)/i; |
There was a problem hiding this comment.
Issue, medium. This pattern is the only test for external references in CSS, and it only knows url(. Two forms pass every check:
image-set('https://evil.example/x.png' 1x)takes a plain string, so there is nourl(.isUnsafeCssValuereturns false andmarkupDenyListIssuereturns null.- Presentation attributes are not checked at all.
fill="url(https://evil.example/p.svg#a)"andfilter="url(https://evil.example/f.svg#f)"surviveMapDocument.loadandpurifyMarkup. Onlystyleand thehreffamily are tested.
A replace_element whose markup has both passed validateProposal with no issues and was ticked by default. Whether a browser fetches them depends on the property and the browser, and I did not test that in a real browser.
Fix: in sanitiseTree, test every attribute value for a non-fragment url(, not only style. Add image-set( and image( to the pattern, or allow only fragment references in any function.
| reason: `markup is over ${MAX_REPLACEMENT_MARKUP_BYTES / 1024} KB`, | ||
| }; | ||
| } | ||
| const purified = purifyMarkup(op.markup); |
There was a problem hiding this comment.
Optional. REPLACEMENT_DENIED_TAGS is enforced only in the validator. applyOps on its own accepts <g><style>body{visibility:hidden}</style></g> as replacement markup. The AI path always validates first, so this is not reachable today, but applyOps and session.apply are public. A check on the purified element here would keep the two in step.
| if (typeof text !== 'string' || !text.trim()) { | ||
| throw new MapDocumentError('empty', 'The map is empty.'); | ||
| } | ||
| if (DOCTYPE_OR_ENTITY.test(text)) { |
There was a problem hiding this comment.
Question. This rejects any text with <!DOCTYPE, and Illustrator writes one at the top of every "Save As SVG" file. The level list now only routes to this editor, so those maps cannot be opened in Concierge at all. Is that intended? Dropping the doctype and still refusing entity declarations would keep the safety.
Related: MapDocument.load strips scripts, animation and external references, and the first save writes that tree back. Nothing in the app reads doc.sanitise_report, so the person is not told the saved file differs from the source in more than their edit.
|
|
||
| public readonly editLevel = (level) => this._manager.editLevel(level); | ||
| public readonly editLevelMap = (level) => this._manager.editLevelMap(level); | ||
| public readonly editLevelMap = (level) => |
There was a problem hiding this comment.
Optional. After this change nothing calls LevelManagementService.editLevelMap, so level-map-editor-modal.component.ts, 1359 lines plus its spec, cannot be reached. Remove it in this PR or note when it goes.
| } | ||
|
|
||
| /** Recent map edit jobs, the caller's own unless `mine` is false. */ | ||
| export function queryMapAIJobs( |
There was a problem hiding this comment.
Optional. queryMapAIJobs and mapAIUsage have no caller in this PR, with their option and row types. MapAIAnnotation is in the same state, since markup.annotations is always empty. _baseline_signatures in session.ts is written and never read. I would cut these until a feature needs them.
| | `admin_group` | string | `"concierge_admins"` | Name of the user group that grants access to admin sections of the app. Members of `placeos_admin` and `placeos_support` are always allowed. | | ||
| | `delegated` | boolean | `false` | Set when your PlaceOS instance uses delegated calendar access. Hides event actions from the event details view. | | ||
| | `has_uploads` | boolean | `true` | Whether file upload support (S3/cloud storage) is available in the app. | | ||
| | `map_studio_url` | string | `"/map-studio/"` | Where the `Open in Map Studio` link on the Map Management page goes. | |
There was a problem hiding this comment.
Optional. settings.ts does not define map_studio_url. The fallback is in map-list.utilities.ts. By the rule in docs/settings/README.md the Default column is then a dash, with the fallback in the description.
This file also has about 180 reformatted table lines that are not part of the change. They make the diff hard to read.
Concierge map editor, direction B from the design review: the toolbar row becomes a floating tool rail, zoom cluster and mark hint over the canvas; the page header carries the save status; the AI panel opens on the composer, lists changes in Added, Changed and Removed sections with numbered badges and keeps Keep and Undo pinned; the canvas draws numbered category pins and outlines for the previewed proposal and a summary pill. Also carries the 14 Oct worktree fixes that were still uncommitted: Map Studio keeps the detailed source SVG through import, edit and export; plant-copy checkbox selections keep the last valid preview; bookable overlays default to a transparent fill; coloured and rectangle annotations; AI usage refresh after submission.
The context builder and the snapshot renderer each worked out a mark's extent from its path points, box corners and arrow tip. Both now use annotationPoints() from annotations.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review pass on this PR, pushed to the branch as one commit (ea6dc15..53b7d96). 53b7d96, one helper for the points an annotation covers. The context builder, which checks whether a mark lies outside the map, and the snapshot renderer, which grows the picture to fit off-map marks, each worked out a mark's extent from its path, box corners and arrow tip. Both now use Reviewed and left as is: annotations and their hit-testing; canvas expansion (root only, keeps the old bounds, four-times limit, accepted as a whole); translucent default fill for new booking overlays; piece association for CAD lines and punctuation glyphs; the quota refresh after submit; a blocked selection restoring the last valid preview; Map Studio keeping the source SVG (sanitised, rendered only via Found in live testing, not fixed here (https://gist.github.com/camreeves/f323d149ce0f83b245161ff6547e451d):
🤖 Generated with Claude Code |
develop restructured the Concierge sidebar and removed the app top bar on 14 Oct. The sidebar keeps develop's layout with the Map Management entry added after Zone Management, and the map manager page no longer imports the removed top bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge-readiness pass (Steve reported clashes). This PR is now mergeable with develop.
Checked on the merged tree: Concierge and Map Studio build (staging); Concierge (245 test files), Map Studio and map-editor tests pass; map-editor and Concierge lint pass. Merge after rest-api #453 is deployed, since the panel calls 🤖 Generated with Claude Code |
MrYuion
left a comment
There was a problem hiding this comment.
Reviewed SHA: 4bdf50bc0f. This is an incremental review of ea6dc15f26, 53b7d96ec9 and the merge 4bdf50bc0f. I ran the map-editor tests, and all 292 pass. I used one throwaway spec to check the preview pins. I did not use a live backend or a real AI endpoint.
Merge. The sidebar resolution and the removal of the app-topbar import are correct. The maps entry still needs maps in app.features, as docs/settings/concierge.md says. No other develop change touches the files of this PR.
Earlier findings. None of the 26 numbered findings from the 13 Oct review is fixed, and none of the optional items. The files for 3 to 7, 10 to 13, 19, 23 and 24 did not change after e41049b86f. The page, thread.ts, session.ts, apply.ts and validate.ts changed, but not in the code that those findings name. Finding 2 is now easier to hit. The new Undo button on the tool rail and Ctrl+Z both call session.undo() while a preview is on the map. The two comments from 15 Oct do not answer any of these findings, so I did not post them again.
New findings, inline:
- Medium. The preview shows no pin for a ticked removal, and shows a red dashed outline for an unticked removal.
map-canvas.component.ts:770 - Medium. In Map Studio, every pointermove during a drag parses, sanitises and serialises the full source SVG again.
canvas.component.ts:789 - Low. An arrow that points left or up adds a point that is not on the arrow, so the context and the snapshot can grow for no reason.
annotations.ts:27 - Optional. The floating save and Studio actions in
map-editor.component.tshave no host.
Tests. No spec covers previewSummary or _drawPreview. A spec with one delete row would catch finding 1.
Not verified. I did not profile finding 2 in a browser. I did not build Concierge or Map Studio again. The merge comment says that both builds pass.
Review written by Claude Opus 5.5 in T3 Code, through the Claude Code harness.
| const units_per_px = 1 / this.view().scale; | ||
| for (const marker of preview.markers) { | ||
| const box = this._unionBox(index, marker); | ||
| if (!box) continue; |
There was a problem hiding this comment.
Issue, medium. A ticked removal gets no pin and no outline. An unticked removal gets the red dashed outline.
row.anchors comes from the document before the preview is applied. _preview then applies the delete, so the anchor is no longer in session.index(). _unionBox returns null, and this continue skips the marker. When the person unticks the row, the element comes back into the index, and line 779 draws the dashed removed outline on an element that will stay.
I confirmed this with a throwaway spec on the OFFICE fixture. After a ticked delete of plant-1, the index had no entry for the marker anchor. An insert_shape into zones got the box of the full zones group for the same reason. That is the duplicate-pin problem from your live testing, through a second op.
Fix: in _preview, record the box of each marker from the index before session.apply. Use AppliedOp.created for added rows. Draw the dashed outline only when marker.applied is true. No spec covers previewSummary or _drawPreview now. One test with a delete row would catch this.
| ), | ||
| ); | ||
|
|
||
| public readonly sourceImage = computed(() => { |
There was a problem hiding this comment.
Issue, medium. This computed reads state.objects(). _applyDrag, resize and vertex drag call patchLocal on every pointermove. So each move parses the full source SVG, runs sanitiseTree on it, serialises it, URL-encodes it and gives the <image> a new data URL. The browser then decodes and rasterises the full map again. Line 780 leaves imported objects out of the overlay, so this image is the only feedback during the drag.
The cost grows with the size of the source file, and this flow is for detailed CAD exports. I did not profile it in a browser. But a parse and a serialise of a 5 MB SVG take much longer than a 16 ms frame.
Fix: parse and sanitise the source once per floor in a separate computed. During a drag, keep the last image and draw the moving objects as outlines in the overlay. Compose the image again on pointerup. floor-plan-2d.component.ts:362 has no drag, so it only needs the cached parse.
| const points: [number, number][] = [...(mark.points || [])]; | ||
| if (mark.x !== undefined && mark.y !== undefined) { | ||
| points.push([mark.x, mark.y]); | ||
| points.push([mark.x + (mark.width || 0), mark.y + (mark.height || 0)]); |
There was a problem hiding this comment.
Issue, low. For an arrow, x and y are the start point. width and height are the absolute deltas that _updateMark sets, and _normalisedMark does not change arrows. So when an arrow points left or up, this corner is not on the arrow.
Example: on a 1000 x 500 map, an arrow from (990, 490) to (800, 300) adds the point (1180, 680). buildContext then sets outside and sends the root, and snapshotMarkup grows the picture to include a point that nobody drew. This can make the model propose a canvas expansion.
Fix: add this corner only for ellipse and box. The start point and x2, y2 already cover an arrow, and points covers a path.
| </svg> | ||
| </button> | ||
| </div> | ||
| @if (show_save() || show_studio()) { |
There was a problem hiding this comment.
Optional. No host sets show_save or show_studio, and no host listens to save or open_studio. Concierge shows the save status, Save and Open Studio in its own header. You can remove this block, the two inputs, the two outputs, and the .actions, .action and .status styles. setAnnotationColour at line 761 also has no caller. The only user is the test at line 305 of canvas.component.spec.ts, which can go too.
|
Deployment failed for project frontend-templates with the following error: Learn More: https://vercel.com/placeos?upgradeToPro=build-rate-limit |
The AI panel mixed sizes, weights and colours and had uneven spacing. Restyle it to read like the Outlook add-in polish: proposals sit in cards on a light wash, with a status badge in each card header, the question and its hint in one callout, and errors in red callouts. - One type scale (16/14/12), muted text by colour instead of opacity - One spacing scale (4/8/12/16); every block sits on a 16px inset - Scrolling to a new proposal keeps the thread's top padding, so the card no longer butts against the header - Rows drop the visible verb that repeated the row text (still read out to screen readers) and notes that repeated each other; a question row is hidden when the same question is in the callout - Composer: New request beside the label, count and Send inside the field; Keep and Undo are 40px instead of 48px - Override Concierge's global label rule (full width, bold), which made the model picker bold and pushed New request off screen - Request bubble no longer starts with a stray space No behaviour change: every binding, event and test hook is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Ticking a change back on restores the changes a cascade switched off
with it; a change the person unticked themselves stays off.
- The change list keeps the feedback chips and Keep/Undo bar while a new
selection is re-checked, so clicking a checkbox no longer makes the
list jump.
- The preview settles when the map changes outside the panel: Discard or
the toolbar's undo marks it undone, an edit on top marks it kept.
Before, the panel kept offering Keep/Undo and a checkbox would undo the
wrong step.
- The map editor page only asks before leaving when there are unsaved
changes. The `hasUnsavedChanges() && $event.preventDefault()` binding
returned false when clean, and Angular treats false as preventDefault,
so every reload was blocked.
- The preview pill counts the changes on the map ("6 of 8 changes").
- More than eight selected or dragged elements get one outline instead of
one box each (a 41-piece plant drew a solid blob).
- Models show by name (Claude Opus 5.5, GPT-5.4 mini), Send names its
shortcut, and the placement note no longer shows internal anchor ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrYuion
left a comment
There was a problem hiding this comment.
Reviewed SHA: 44fa6a6657. This review covers 4bdf50bc0f..44fa6a6657, the commits 0dc4da43be and 44fa6a6657. The map-editor tests (296) pass. Lint passes for map-editor and Concierge. I confirmed both findings with a throwaway spec that I did not commit. I did not use a live backend or a real AI endpoint.
Earlier findings
- 13 Oct, finding 2 (Undo on a proposal removes the wrong history entry). This is mostly fixed.
mapChanged()now settles the card after an edit on top or a toolbar undo, so the card no longer offers Undo or checkboxes at those times. One trigger is still there. Whensession.applyapplies no op, it drops its history entry, but_previewstill moves toapplied. The card's Undo then removes an unrelated entry. - 15 Oct, findings 1 to 4 (pins for removals, the arrow corner, the floating actions with no host). These are not changed.
- The other 13 Oct findings name code that these commits did not change. The only page change is the
beforeunloadhandler, and that fix is correct.
New findings (both low, inline)
- An edit on top of the preview can be recorded as undone when the history drops old entries because of its byte budget.
thread.ts:607 - A toolbar undo of the preview drops the feedback chips that the person picked.
thread.ts:615
Not verified
I did not check the new panel styles in a browser.
Review done by Claude Opus 5.5 in Claude Code via T3 Code.
| if (phase.kind !== 'applied' || this._preview_version < 0) return; | ||
| if (this._session.version() === this._preview_version) return; | ||
| const undone = | ||
| (this._session.undoDepth?.() ?? this._preview_depth) < |
There was a problem hiding this comment.
Issue, low. The undo depth is not a reliable signal. When the history is over its byte budget, MapHistory._trim drops old entries. So an edit on top of the preview can make the depth go down.
Example: a large map, with the history at the 64 MB budget. The preview adds elements, so the next snapshot is larger than the oldest one. The person then nudges a desk. The push drops two old entries and the depth goes from N to N-1. mapChanged marks the proposal undone and sends rejected, but the AI changes are still on the map. I confirmed this with a throwaway spec on OFFICE, with the budget set a little above the history size after the preview.
Also, undoDepth is optional in MapEditorSessionLike. A session without it does not see a toolbar undo of the preview while there are other unsaved edits. The proposal is then marked kept and sent as applied.
Fix: track the history position, not the stack length. For example, keep a counter in the session that goes up on apply, restore, revert and redo, and down on undo. Trimming does not change it. Make the method required in the contract.
| if (this._state().phase.kind !== 'applied') return; | ||
| if (undone) { | ||
| await this._resolveQuietly(proposal, 'rejected'); | ||
| this.dispatch({ type: 'undone', proposal, reasons: [] }); |
There was a problem hiding this comment.
Issue, low. This path drops the feedback chips that the person picked. undo() sends reasons ?? state.reasons, but this path sends no reasons.
Example: preview a proposal, click the "wrong elements" chip, then press Ctrl+Z. The resolve body has decision: 'rejected' and no reasons, and the turn shows no chips. I confirmed this with a throwaway spec.
Fix: in the queued task, read this._state().reasons and pass it to _resolveQuietly and to the undone event.
…changes Ticking or unticking a change used to take the preview off the map, check the new selection, then put it back. For the length of the check the map was clean: the header read "No changes", the summary closed, and every pin and outline disappeared and came back. The thread now keeps a copy of the map from just before the preview, checks the new selection against that copy while the preview stays on, and swaps the old preview for the new one in a single step. The validating phase says the earlier preview is still on the map, so the panel keeps its markers until the new ones are ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… drawing tools Drawing a mark left the toolbar Undo greyed out, because it only looked at map history. The session now records where each mark falls in that history, so Undo and Ctrl+Z take back whichever came last, a mark or a map change, and the button title says when it will remove a mark. While a drawing tool is active the footer now says how to get back to selecting elements, and the mark counter reads "1 mark" rather than "1 marks". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Rows that add, copy or group elements now point at what they create. An added label used to be outlined by its parent layer, which drew a red box round the whole map. - A row that later changes depend on says so, e.g. "changes 5, 6 and 7 need this", so grouping a plant before copying it no longer looks unexplained. Grouping now counts as a change rather than an addition. - Text in the AI conversation can be selected and copied. Concierge turns selection off for every element, so the panel turns it back on for the thread, leaving buttons unselectable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open in Map Studio used to land on Studio's project list and, once the auth server's 24 hour login session had lapsed, on the login page, even though Concierge was still signed in. - Concierge hands its access token to the Studio tab it opens, and keeps answering while that tab is open so Studio can get a fresh one before its token expires. Studio only accepts a token from the window that opened it on the same host, and still falls back to the login page. - The editor page links to Studio's new #/level/<zone> route, which finds or adds the floorplan for that level and opens it. - Floorplans record the level map they were imported from or published to. When the level's map has changed since, Studio offers to import the current one instead of showing a stale draft. - A detailed level's floorplan can be larger than the API accepts as metadata (1.3 MB for the demo Level 1, refused with 413). That no longer reports the save as failed: the draft is kept in the browser and Studio says it was not stored in PlaceOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ement markup From the review of #570: - A style rule whose selector matches nothing in the map is now an error. The viewer mounts the raw map in the page to measure it, so a rule such as body{visibility:hidden} could reach the host page. - Any element a duplicate, insert or group creates is checked against the bound ids, so an id_pattern or a group's new_id can no longer take a bound id without a warning. - applyOps refuses the replacement tags the validator refuses, so the two agree for callers that apply without validating. - Replacing an element that has no id reserves the markup root's id, so a child cannot reuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of #570: - A ticked removal keeps its pin and outline where the element stood, and an unticked removal no longer draws the removed outline on an element that stays. - Whether a toolbar undo took the preview off is judged by a history position that trimming old entries does not move, and the session must provide it. - Undoing the preview from the toolbar keeps the feedback chips picked. - A preview that changed nothing is never undone, so Undo cannot remove an unrelated edit. - An edit that applies nothing no longer clears redo or takes a history slot. - Retry sends the instruction that failed, not whatever is in the box. - A poll that stalls past its wait, or answers without a job, counts as a failed poll and backs off. - A quota refresh without numbers is ignored, and a refused send refreshes the quota. - An arrow pointing up or left no longer adds a point nobody drew. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nter move Dragging, resizing or rotating an object rebuilt the whole source SVG as a new image on every pointermove, which is slow for detailed CAD exports. The image is now held while the pointer edit is under way, with the selection outline following the pointer, and composed again on release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…modal From the review of #570: - Saving a map sends only map_id, so a rename of the level made elsewhere is not overwritten with the name this page loaded. - The saved file is named after the current upload even when the page was opened by link and the name lookup had not finished. - The level list now opens Map Management, so the old level map editor modal, which nothing could reach, is removed with its spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Alex. I've worked through the findings from all three reviews. Head is now Fixed
Not changed
Also in this push
Checks
|
…roduction The AI panel's styles compile to 11.7 kB, over the 10 kB error budget, so the production build of both apps that include the map editor failed. The staging build develop uses has no budget, which is why CI stayed green. The warning stays at 6 kB; stagehand already uses a 16 kB error budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Map Management to Concierge with an SVG editor and AI edit previews. Staff can describe changes, review individual operations, undo the proposal and save the accepted map. Existing booking IDs are protected, inserted SVG is sanitised, and the original stays unchanged until Save.
The AI context includes room geometry, outlined labels, furniture layers and a map image. This review also fixes incomplete CAD chair pieces and missing alignment context for named text labels.
Validation: 265 map-editor tests, 25 focused Concierge tests, map-editor lint, TypeScript and Concierge development build pass after merging current develop. Five distinct live prompts covered recolouring, centred renames, chair duplication/rotation, complete furniture movement and overlay opacity across five bundled assets (four distinct layouts). Two failed visual comparisons were fixed and retried; final screenshots/DOM checks passed and browser consoles were clean.
Draft for review and further end-to-end testing. Requires the companion models/API changes. Visual correctness still needs preview review; these tests do not guarantee arbitrary SVGs or every model. No deployment included.
Companion drafts: Frontend #570, API #453, Models #334.