From 787cfe91c98ed7d167b6fa7b0053bc2d909283fc Mon Sep 17 00:00:00 2001
From: Cameron Reeves
Date: Wed, 21 Oct 2026 12:22:00 +1100
Subject: [PATCH 1/2] fix(map-editor): accept dominant-baseline on AI-edited
text (PPT-2832)
GPT-6.1 sol adds dominant-baseline="middle" to every label it inserts.
The attribute was missing from the allowlist, so each such insert was
rejected even though its position was right. Testing on placeos-dev on
21 Oct lost 4 of 4 labels on both runs.
The value is limited to the SVG 2 keywords plus text-before-edge and
text-after-edge. The ops schema description lists it, and rest-api
carries the same file.
Co-Authored-By: Claude Opus 5.5
---
libs/map-editor/src/lib/constants.ts | 1 +
libs/map-editor/src/lib/ops-schema.json | 2 +-
libs/map-editor/src/lib/value-syntax.ts | 18 +++++++++++++++
libs/map-editor/src/tests/validate.spec.ts | 27 ++++++++++++++++++++++
4 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/libs/map-editor/src/lib/constants.ts b/libs/map-editor/src/lib/constants.ts
index c9f3ec518dd..848df150748 100644
--- a/libs/map-editor/src/lib/constants.ts
+++ b/libs/map-editor/src/lib/constants.ts
@@ -26,6 +26,7 @@ export const ATTR_ALLOW_LIST: ReadonlySet = new Set([
'font-family',
'font-weight',
'text-anchor',
+ 'dominant-baseline',
]);
/** Properties `set_style_rule` declarations may carry. */
diff --git a/libs/map-editor/src/lib/ops-schema.json b/libs/map-editor/src/lib/ops-schema.json
index 791efac7a35..bdddc75a93f 100644
--- a/libs/map-editor/src/lib/ops-schema.json
+++ b/libs/map-editor/src/lib/ops-schema.json
@@ -33,7 +33,7 @@
},
"name": {
"type": "string",
- "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor or data-*"
+ "description": "fill, stroke, stroke-width, stroke-dasharray, fill-opacity, stroke-opacity, opacity, class, visibility, rx, ry, font-size, font-family, font-weight, text-anchor, dominant-baseline or data-*"
},
"value": {
"type": ["string", "null"],
diff --git a/libs/map-editor/src/lib/value-syntax.ts b/libs/map-editor/src/lib/value-syntax.ts
index 55f8e820d3a..fdf8540ad01 100644
--- a/libs/map-editor/src/lib/value-syntax.ts
+++ b/libs/map-editor/src/lib/value-syntax.ts
@@ -30,6 +30,20 @@ const NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
const LENGTH =
/^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px|pt|pc|mm|cm|in|em|rem|ex|%)?$/;
const CLASS_TOKEN = /^-?[A-Za-z_][A-Za-z0-9_-]*$/;
+/** `dominant-baseline` keywords from SVG 2, plus the two SVG 1.1 edge keywords browsers still honour. */
+const DOMINANT_BASELINES = new Set([
+ 'auto',
+ 'alphabetic',
+ 'central',
+ 'hanging',
+ 'ideographic',
+ 'mathematical',
+ 'middle',
+ 'text-after-edge',
+ 'text-before-edge',
+ 'text-bottom',
+ 'text-top',
+]);
const UNSAFE_TEXT = /url\(|expression\(|behavior:|javascript:|<|>|[\r\n]/i;
function cssSupports(property: string, value: string): boolean | null {
@@ -141,6 +155,10 @@ export function attributeValueIssue(
return ['start', 'middle', 'end'].includes(value.trim())
? null
: `"${value}" is not a text anchor`;
+ case 'dominant-baseline':
+ return DOMINANT_BASELINES.has(value.trim())
+ ? null
+ : `"${value}" is not a dominant baseline`;
default:
if (name.startsWith('data-')) {
return /[<>]/.test(value)
diff --git a/libs/map-editor/src/tests/validate.spec.ts b/libs/map-editor/src/tests/validate.spec.ts
index f341fb0614f..98fcaca5b26 100644
--- a/libs/map-editor/src/tests/validate.spec.ts
+++ b/libs/map-editor/src/tests/validate.spec.ts
@@ -126,6 +126,33 @@ describe('validateProposal', () => {
}
});
+ it('accepts a dominant-baseline keyword on an inserted label and rejects any other value', async () => {
+ const label = (baseline: string): MapOp => ({
+ op: 'insert_shape',
+ why: '',
+ kind: 'text',
+ text: 'Sydney Room 1',
+ parent: { id: 'bg' },
+ attrs: [
+ { name: 'x', value: '50' },
+ { name: 'y', value: '50' },
+ { name: 'text-anchor', value: 'middle' },
+ { name: 'dominant-baseline', value: baseline },
+ ],
+ new_id: 'ai-label',
+ });
+ const doc = MapDocument.load(
+ ``,
+ { geometry },
+ );
+ const ok = await single(label('middle'), {}, doc);
+ expect(ok.codes).toEqual([]);
+ expect(ok.row.locked).toBe(false);
+ const bad = await single(label('centre'), {}, doc);
+ expect(bad.codes).toEqual(['bad_value']);
+ expect(bad.row.locked).toBe(true);
+ });
+
it('rejects a bad colour such as "#f28c28."', async () => {
const bad = await single({
op: 'set_attr',
From b688acd33a1b563f94b369fd7c43108b49e7b0a5 Mon Sep 17 00:00:00 2001
From: Cameron Reeves
Date: Wed, 21 Oct 2026 12:36:00 +1100
Subject: [PATCH 2/2] feat(map-editor): note the rejected changes the server
had the AI correct (PPT-2832)
rest-api now asks the model once more when it rejects some of an answer's
ops, and records the outcome as `repair` on the job. The proposal card
says how many rejected changes were corrected that way, so a reviewer
knows the proposal is the model's second attempt. Nothing is shown when
the second answer was not used.
Co-Authored-By: Claude Opus 5.5
---
.../src/lib/ai/ai-panel.component.ts | 22 +++++++++++++
libs/map-editor/src/lib/ai/strings.ts | 3 ++
libs/map-editor/src/lib/ai/types.ts | 12 +++++++
.../src/tests/ai-panel.component.spec.ts | 32 ++++++++++++++++++-
4 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/libs/map-editor/src/lib/ai/ai-panel.component.ts b/libs/map-editor/src/lib/ai/ai-panel.component.ts
index 384e7219179..43604705079 100644
--- a/libs/map-editor/src/lib/ai/ai-panel.component.ts
+++ b/libs/map-editor/src/lib/ai/ai-panel.component.ts
@@ -268,6 +268,16 @@ export function readableModel(model: string): string {
}}
}
+ @if (repairNote(turn)) {
+
+ {{
+ repairNote(turn)
+ }}
+
+ }
@if (
turn.proposal.questions
.length
@@ -2247,6 +2257,18 @@ export class MapEditorAiPanelComponent implements OnInit, OnDestroy {
}
}
+ /** How many rejected changes the server's repair round corrected, when it did. */
+ public repairNote(turn: ThreadTurn): string {
+ const thread = this.thread();
+ if (!thread || turn.kind !== 'proposal') return '';
+ const repair = turn.proposal.job.repair;
+ if (!repair?.used) return '';
+ const corrected = repair.rejected - (repair.remaining ?? 0);
+ return corrected > 0
+ ? format(thread.strings.repaired_note, { count: corrected })
+ : '';
+ }
+
public outcomeText(turn: ThreadTurn): string {
const thread = this.thread();
if (!thread || turn.kind !== 'proposal') return '';
diff --git a/libs/map-editor/src/lib/ai/strings.ts b/libs/map-editor/src/lib/ai/strings.ts
index b3e2acef8c5..e80c4cd6fe3 100644
--- a/libs/map-editor/src/lib/ai/strings.ts
+++ b/libs/map-editor/src/lib/ai/strings.ts
@@ -45,6 +45,8 @@ export interface MapEditorAiStrings {
questions_title: string;
/** Shown under a question from the model. */
questions_hint: string;
+ /** `{count}` changes the server rejected and the AI corrected when asked again. */
+ repaired_note: string;
keep: string;
undo: string;
reason_prompt: string;
@@ -189,6 +191,7 @@ export const DEFAULT_AI_STRINGS: MapEditorAiStrings = {
questions_title: 'The AI has a question',
questions_hint:
'Click the element on the map to select it, then answer below; the selection is sent with your reply.',
+ repaired_note: 'Rejected changes corrected automatically: {count}',
keep: 'Keep',
undo: 'Undo',
reason_prompt: 'What was wrong?',
diff --git a/libs/map-editor/src/lib/ai/types.ts b/libs/map-editor/src/lib/ai/types.ts
index 6022edf3c80..18ffa6da1cd 100644
--- a/libs/map-editor/src/lib/ai/types.ts
+++ b/libs/map-editor/src/lib/ai/types.ts
@@ -80,6 +80,16 @@ export interface MapAIUsageInfo {
attempts?: number | null;
}
+/** The server's repair round: one more call naming the ops it rejected and why. */
+export interface MapAIRepairInfo {
+ /** Ops the server rejected in the first answer. */
+ rejected: number;
+ /** Ops it rejected in the second answer; null when that answer was unusable. */
+ remaining: number | null;
+ /** Whether the second answer replaced the first. */
+ used: boolean;
+}
+
/** A map edit job as the editor sees it. */
export interface MapAIJob {
id: string;
@@ -101,6 +111,8 @@ export interface MapAIJob {
server_issues: Record;
questions: string[];
usage: MapAIUsageInfo | null;
+ /** Absent unless the server asked the model again about the ops it rejected. */
+ repair?: MapAIRepairInfo | null;
resolution: MapAIResolution | null;
error_kind: MapAIErrorKind | string | null;
error_message: string | null;
diff --git a/libs/map-editor/src/tests/ai-panel.component.spec.ts b/libs/map-editor/src/tests/ai-panel.component.spec.ts
index d2008f19248..1c8aa6b28a8 100644
--- a/libs/map-editor/src/tests/ai-panel.component.spec.ts
+++ b/libs/map-editor/src/tests/ai-panel.component.spec.ts
@@ -3,7 +3,7 @@ import {
MapEditorAiPanelComponent,
readableModel,
} from '../lib/ai/ai-panel.component';
-import { MapAIEditRequest } from '../lib/ai/types';
+import { MapAIEditRequest, MapAIJob } from '../lib/ai/types';
import { MapDocument } from '../lib/map-document';
import { MapOp } from '../lib/ops';
import {
@@ -158,6 +158,36 @@ describe('MapEditorAiPanelComponent', () => {
expect(markers).toEqual(['1:true 2:true', '1:true 2:false']);
});
+ it('notes the rejected changes the server had the AI correct, and only when it used them', async () => {
+ const proposal = async (repair: MapAIJob['repair']) => {
+ const sequence = jobSequence({
+ summary: 'Recoloured.',
+ ops: [recolour(doc, 'area-1.01-status', '#d1fae5')],
+ repair,
+ });
+ const fixture = await mount(
+ new FakeTransport({
+ edit: sequence.accepted,
+ polls: sequence.polls,
+ }),
+ );
+ await fixture.componentInstance.thread()!.submit('Recolour');
+ await settle(fixture);
+ const note = text(fixture, '[proposal-card] [repaired]');
+ TestBed.resetTestingModule();
+ doc = MapDocument.load(OFFICE);
+ session = new FakeSession(doc);
+ return note;
+ };
+ expect(await proposal({ rejected: 3, remaining: 1, used: true })).toBe(
+ 'Rejected changes corrected automatically: 2',
+ );
+ expect(await proposal({ rejected: 2, remaining: 2, used: false })).toBe(
+ '',
+ );
+ expect(await proposal(undefined)).toBe('');
+ });
+
it('renders nothing visible when the deployment has no map AI', async () => {
const fixture = await mount(
new FakeTransport({ capabilities: httpError(404, 'not found') }),