Skip to content

feat(concierge): edit level maps with AI (PPT-2832) - #570

Merged
camreeves merged 51 commits into
developfrom
PPT-2832-ai-map-review
Oct 19, 2026
Merged

camreeves merged 51 commits into
developfrom
PPT-2832-ai-map-review

Conversation

@camreeves

@camreeves camreeves commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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.

camreeves and others added 30 commits October 12, 2026 19:35
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>
@vercel

vercel Bot commented Oct 13, 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 Oct 19, 2026 4:17am UTC

@MrYuion MrYuion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

  1. Edits made while a save is in flight are marked as saved but are not uploaded. map-editor-page.component.ts:349
  2. Undo on an AI proposal removes whatever history entry is on top, which is not always the proposal. ai/thread.ts:581
  3. A font-family value in set_style_rule can inject any CSS, and can hide a protected desk with no lock. value-syntax.ts:94
  4. The style cleaner keeps text it fails to clean. Three inputs with external URLs pass. sanitise.ts:61

Medium

  1. image-set() and presentation attributes with external url() are not checked. sanitise.ts:23
  2. The raw map text reaches an innerHTML sink before the sanitiser. map-management.service.ts:140
  3. Server error messages never reach the person, and a busy 503 shows as not configured. ai/api.ts:133
  4. A reset or page leave during submit does not stop the request, and the proposal is still applied. ai/thread.ts:473
  5. After the first save, drafts go under a key that no later load reads. session.ts:108
  6. Desks are not protected when desks.use_assets is on. protected-ids.ts:40
  7. Opening the editor can create asset categories and types. protected-ids.ts:67
  8. The list page downloads and parses every map. map-manager.component.ts:266
  9. The CI change breaks the test job on custom/*-prod branches, and no CI run has covered this branch. pull-request.yml:135
  10. ungroup drops the fill, opacity, class and style of the group. apply.ts:871
  11. Retry uses a new idempotency key, so a lost response costs quota twice. ai/thread.ts:452
  12. A context over the byte limit is still sent. ai/thread.ts:433
  13. A draft store error shows a save failure after a save that worked. map-editor-page.component.ts:350

Low

  1. A style selector that matches nothing in the map is accepted. validate.ts:648
  2. The poll loop has no per-request timeout and no minimum delay. ai/api.ts:262
  3. Retry sends the composer text, not the failed instruction. ai/thread.ts:527
  4. Responses are cast without a check, and an empty capabilities body makes the panel throw. ai/thread.ts:620
  5. A rejected edit clears redo, and held arrow keys fill the history. session.ts:175
  6. The file name is lost when the name lookup is late or the history write failed. map-management.service.ts:174
  7. The zone update sends cached name and tags. map-management.service.ts:185
  8. replace_element can leave a duplicate id. apply.ts:934
  9. id_pattern can 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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread libs/map-editor/src/lib/ai/thread.ts Outdated
const state = this._state();
const proposal = proposalOf(state);
if (!proposal) return;
if (state.phase.kind === 'applied') this._session.undo();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. 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. purifyMarkup keeps it too. DECLARATION needs a ; or a } after the value.
  2. A comment that holds a ;. .a{fill:/*;*/url(https://evil.example/p.svg#a)} survives, because the match ends inside the comment.
  3. An @import whose URL holds a {. @import "https://evil.example/{a.css"; survives, because AT_STATEMENT stops at the brace and there is no url( 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 no url(. isUnsafeCssValue returns false and markupDenyListIssue returns null.
  • Presentation attributes are not checked at all. fill="url(https://evil.example/p.svg#a)" and filter="url(https://evil.example/f.svg#f)" survive MapDocument.load and purifyMarkup. Only style and the href family 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread docs/settings/concierge.md Outdated
| `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. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

camreeves and others added 2 commits October 15, 2026 13:52
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>
@camreeves

Copy link
Copy Markdown
Contributor Author

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 annotationPoints() in ai/annotations.ts. Behaviour is unchanged. map-editor tests (292) and lint pass.

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 <img>). The map AI client stays separate from signage-manager's image-gen client. It's a host-agnostic library with an injectable transport, and the signage code lives in the app.

Found in live testing, not fixed here (https://gist.github.com/camreeves/f323d149ce0f83b245161ff6547e451d):

  • A replacement label's preview outline is sized to its parent group, so it spans half the map. Overlay only, never saved.
  • Pins for duplicates sit on the source element, and many changes in one room stack their pins on one spot.
  • Deleting desks on some maps leaves chair stroke outlines behind, because they aren't grouped into the chair's piece.
  • buildProposal validates the full proposal and then the ticked subset, so every checkbox toggle runs validation twice, render check included.

🤖 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>
@camreeves

Copy link
Copy Markdown
Contributor Author

Merge-readiness pass (Steve reported clashes). This PR is now mergeable with develop.

  • 4bdf50b, merged develop (25 commits).
    • Sidebar conflict. Alex's 14 Oct redesign of app-sidebar.component.ts conflicted with the Map Management entry added here on 12 Oct. Resolved with develop's layout and the maps entry after Zone Management, with icon map.
    • Hidden build break. develop deleted app-topbar.component.ts, but map-manager.component.ts still imported it. That didn't show as a conflict, yet the build failed. The import and <app-topbar /> are removed, matching what develop did on every other page.

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 /api/engine/v2/maps/ai and hides itself until that API answers.

🤖 Generated with Claude Code

@MrYuion MrYuion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. 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
  2. Medium. In Map Studio, every pointermove during a drag parses, sanitises and serialises the full source SVG again. canvas.component.ts:789
  3. 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
  4. Optional. The floating save and Studio actions in map-editor.component.ts have 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@vercel

vercel Bot commented Oct 16, 2026

Copy link
Copy Markdown

Deployment failed for project frontend-templates with the following error:

Resource is limited - try again in 1 day (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/placeos?upgradeToPro=build-rate-limit

camreeves and others added 2 commits October 16, 2026 15:00
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 MrYuion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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. When session.apply applies no op, it drops its history entry, but _preview still moves to applied. 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 beforeunload handler, and that fix is correct.

New findings (both low, inline)

  1. 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
  2. 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.

Comment thread libs/map-editor/src/lib/ai/thread.ts Outdated
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) <

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread libs/map-editor/src/lib/ai/thread.ts Outdated
if (this._state().phase.kind !== 'applied') return;
if (undone) {
await this._resolveQuietly(proposal, 'rejected');
this.dispatch({ type: 'undone', proposal, reasons: [] });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

camreeves and others added 9 commits October 19, 2026 12:40
…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>
@camreeves

Copy link
Copy Markdown
Contributor Author

Thanks Alex. I've worked through the findings from all three reviews. Head is now 62c5325074, and develop is merged in.

Fixed

  • Removal pins (15 Oct, medium). Each row now records where its elements stood before the preview went on. A ticked removal keeps its pin and dashed outline there. An unticked removal shows only its pin, so the "removed" outline no longer sits on an element that stays. (c475a5738d)
  • Map Studio drag (15 Oct, medium). The composed source image is held while a drag, resize, rotate or vertex drag is under way, and composed again on release. In the browser, a drag with 6 pointer moves rebuilt the image once. (2594f7f818)
  • Arrow corner (15 Oct). The far corner is only added for boxes and ellipses.
  • Undo depth and the byte budget (18 Oct). The thread now compares a history position that goes up on push and redo and down on undo and drop. Trimming old entries doesn't move it. historyPosition() is required on the session contract now, so a host can't leave it out.
  • Chips on toolbar undo (18 Oct). The queued task reads the picked reasons and sends them with the rejection. Checked in the browser: the job was stored as rejected with ["too_much"].
  • Undo after an apply that changed nothing (13 Oct finding 2). The thread tracks whether the preview actually changed the map, and never undoes when it didn't.
  • History push before the result (13 Oct). session.apply captures the snapshot first and only commits it once an op applies, so redo survives a rejected edit.
  • Retry (13 Oct). Retry sends the last instruction you sent, not the composer text.
  • Polling (13 Oct). Each poll is raced against wait + 10 seconds, and a 2xx body without id, state and version counts as a failed poll, so backoff applies to both.
  • Quota (13 Oct). A refresh whose quota is missing is ignored, and a 429 on send refreshes the quota.
  • Style rules matching nothing (13 Oct). This is now an error (no_match), so body{visibility:hidden} is refused. I didn't require a class or id. PPT-2850 (sanitise the map before the viewer mounts it) is the wider fix for rules leaking into the host page. (a1148001c0)
  • Bound ids from id_pattern and group (13 Oct). After the trial apply, every element a duplicate, insert or group creates is checked against the bound ids.
  • REPLACEMENT_DENIED_TAGS in applyOps (13 Oct). Enforced on the purified markup as well, so the two agree.
  • Anonymous target id (13 Oct). The markup root's id is added to existing before the descendants are checked.
  • Saving sends the cached level (13 Oct). The save sends only id and map_id, and the cast through unknown is gone. (fcd30b8b8c)
  • File name when opened by link (13 Oct). The save awaits the name lookup before it builds the file.
  • Old level map modal (13 Oct). Removed with its spec and the service method.
  • Docs (13 Oct). map_studio_url now has a dash in the Default column with the fallback in the description. The reformatted table lines are gone, because I resolved the conflict on develop's version of the page.

Not changed

  • The unused show_save/show_studio inputs, the outputs, setAnnotationColour, and the unused API exports and types. They do no harm. I'd rather take them out in a follow-up than rework the editor template just before merge.
  • Merging consecutive arrow-key nudges into one undo entry.
  • A history write that failed on the last save. The file name still falls back to the level slug in that case. Reading the name from the upload record would fix it if the uploads API returned it.
  • <!DOCTYPE (question). Not intended as a product rule. It's how the loader refuses entity declarations. Illustrator's doctype often carries its own entity block (ns_svg and friends), so dropping the doctype safely needs those handled too. I'd keep that as its own change.

Also in this push

  • Checkboxes. Ticking a change now updates only that change's marker, with no flash of the other markers or the map.
  • Marks and undo. Undo takes back marks and map changes in the order they were made.
  • AI change rows. Rows that add elements point at what they create, and a row says when later changes depend on it.
  • Text selection. Text in the AI panel can be selected.
  • Map Studio from Concierge. Open in Map Studio opens the level being edited, signed in with the Concierge session through a token handed to the window it opens, and offers to import the level's current map when the Studio draft was made from an older one.

Checks

  • map-editor 313, map-studio 111, Concierge 1,381 and common 419 tests pass, and every other affected project's tests pass. Stagehand fails locally on develop too, because of my local packages.
  • tsc and lint are clean, and prettier is clean on the files this changes.
  • Concierge and Map Studio build in production mode. The production build of both failed on the AI panel's 11.7 kB of styles, against a 10 kB error budget, so I raised that budget to 16 kB for these two apps. Stagehand already uses that, and develop's staging build has no budget, which is why nothing flagged it.
  • I checked these in the browser against the local stack:
    • checkbox markers
    • removal pins
    • toolbar undo with chips
    • Open in Map Studio, signed in and on the level
    • Studio drag

…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>
@camreeves
camreeves marked this pull request as ready for review October 19, 2026 04:32
@camreeves
camreeves merged commit 871df3c into develop Oct 19, 2026
2 checks passed
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