From e36bce3c83c7d0e2ee475af91a6d19ed7bf2657b Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 20 Jul 2026 01:55:29 +0700 Subject: [PATCH 1/5] Honor embed format and custom size in the compose editor The CodeMirror compose editor dropped a BFM embed directive's format and size specifier: `CardWidget` never recorded them, the target notifier hardcoded `atom`/`embedded`, and the live-preview template rendered the card with no sizing. So `::card[url | fitted w:400 h:300]` and `| isolated` rendered at the wrong size in compose, while the saved view and edit preview rendered them correctly. Thread format/width/height through the widget -> DOM data-attrs -> target -> template chain, reusing `parseBfmSizeSpec` / `bfmRefFormatAndSize` so the compose editor matches the saved and preview markdown renderers (including `overflow: hidden` for fitted). Applies to both card and file embeds. `CardWidget.eq` and the target skip-check now account for the size fields so a size-only edit re-renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 19 ++- packages/host/app/lib/codemirror-context.ts | 123 ++++++++++++++-- .../components/codemirror-editor-test.gts | 137 ++++++++++++++++++ 3 files changed, 263 insertions(+), 16 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index b368da55a47..090537ee3f2 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -5,6 +5,7 @@ import { modifier } from 'ember-modifier'; import { fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { scheduleOnce } from '@ember/runloop'; +import { htmlSafe } from '@ember/template'; import { Tooltip } from '@cardstack/boxel-ui/components'; import { eq, not } from '@cardstack/boxel-ui/helpers'; @@ -16,7 +17,10 @@ import { CardContextName, trimJsonExtension, } from '@cardstack/runtime-common'; -import { type BfmRefRange } from '@cardstack/runtime-common/bfm-card-references'; +import { + type BfmRefFormat, + type BfmRefRange, +} from '@cardstack/runtime-common/bfm-card-references'; import { consume } from 'ember-provide-consume-context'; import { type BaseDef, @@ -47,10 +51,13 @@ import PencilIcon from '@cardstack/boxel-icons/pencil'; interface CardWidgetTarget { element: HTMLElement; cardId: string; - format: 'atom' | 'embedded'; + format: BfmRefFormat; kind: 'inline' | 'block'; // 'card' refs resolve to CardDef instances; 'file' refs to FileDef instances. refType: 'card' | 'file'; + // Inline sizing derived from the directive's size specifier (width/height plus + // `overflow: hidden` for fitted). Undefined for non-fitted formats. + style?: string; } interface CardRenderTarget extends CardWidgetTarget { @@ -995,7 +1002,11 @@ export default class CodeMirrorEditor extends GlimmerComponent= 0) { - cardId = cardId.substring(0, pipeIdx).trim(); - } + let { cardId, format, width, height } = parseCardWidgetContent(match[1]); let line = doc.lineAt(from); let onCursor = livePreview && line.number === cursorLine; @@ -684,7 +735,14 @@ function buildCardDecorations( class: 'cm-bfm-card-ref cm-bfm-card-ref--inline', }), }); - let previewWidget = new CardWidget(cardId, 'block', refType); + let previewWidget = new CardWidget( + cardId, + 'block', + refType, + format, + width, + height, + ); decos.push({ from: to, to: to, @@ -692,7 +750,14 @@ function buildCardDecorations( }); } else { // Replace source text with widget - let widget = new CardWidget(cardId, 'block', refType); + let widget = new CardWidget( + cardId, + 'block', + refType, + format, + width, + height, + ); decos.push({ from, to, @@ -708,7 +773,7 @@ function buildCardDecorations( let to = from + match[0].length; if (isInsideCode(state, from, to)) continue; - let cardId = match[1].trim(); + let { cardId, format, width, height } = parseCardWidgetContent(match[1]); let onCursor = livePreview && isOnCursorLine(state, from, cursorLine); if (!livePreview) { @@ -729,7 +794,14 @@ function buildCardDecorations( class: 'cm-bfm-card-ref cm-bfm-card-ref--inline', }), }); - let previewWidget = new CardWidget(cardId, 'inline', refType); + let previewWidget = new CardWidget( + cardId, + 'inline', + refType, + format, + width, + height, + ); decos.push({ from: to, to: to, @@ -737,7 +809,14 @@ function buildCardDecorations( }); } else { // Replace source text with inline widget - let widget = new CardWidget(cardId, 'inline', refType); + let widget = new CardWidget( + cardId, + 'inline', + refType, + format, + width, + height, + ); decos.push({ from, to, @@ -851,12 +930,30 @@ function createCardTargetNotifier( (el.getAttribute('data-bfm-ref-type') as 'card' | 'file') ?? 'card'; if (cardId) { + // Re-derive format + sizing from the directive's size attributes, + // matching the saved/preview markdown renderers. Inline embeds + // default to atom, block embeds to embedded. + let { format, sizeStyle } = bfmRefFormatAndSize( + el.getAttribute('data-boxel-bfm-format') ?? undefined, + el.getAttribute('data-boxel-bfm-width') ?? undefined, + el.getAttribute('data-boxel-bfm-height') ?? undefined, + kind === 'inline' ? 'atom' : 'embedded', + ); + // Fitted slots carry the width/height plus `overflow: hidden` so + // the resolved instance occupies the requested footprint. + let style = + format === 'fitted' + ? sizeStyle + ? `${sizeStyle}; overflow: hidden` + : 'overflow: hidden' + : undefined; targets.push({ element: el as HTMLElement, cardId, - format: kind === 'inline' ? 'atom' : 'embedded', + format, kind, refType, + style, }); } } diff --git a/packages/host/tests/integration/components/codemirror-editor-test.gts b/packages/host/tests/integration/components/codemirror-editor-test.gts index b070e0a3a76..a4e0c054686 100644 --- a/packages/host/tests/integration/components/codemirror-editor-test.gts +++ b/packages/host/tests/integration/components/codemirror-editor-test.gts @@ -442,6 +442,143 @@ module('Integration | codemirror-context', function (hooks) { } }); + // ── BFM format/size threading (CS-12112) ── + + async function collectTargets(content: string): Promise<{ + targets: CardWidgetTarget[]; + element: HTMLElement; + destroy: () => void; + }> { + let element = document.createElement('div'); + document.body.appendChild(element); + let targets: CardWidgetTarget[] = []; + let state = cmContext.createEditorState({ + content, + onDocChange: () => {}, + onCardTargetsChange: (t: CardWidgetTarget[]) => { + targets = t; + }, + onOpenCardSearch: () => {}, + }); + let view = new cmContext.EditorView({ state, parent: element }); + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- waiting for rAF-based codemirror widget notification + await new Promise((resolve) => requestAnimationFrame(resolve)); + await settled(); + return { + targets, + element, + destroy: () => { + view.destroy(); + element.remove(); + }, + }; + } + + test('block fitted embed with explicit size threads format + size style', async function (assert) { + let { targets, element, destroy } = await collectTargets( + '::card[https://example.com/cards/1 | fitted w:400 h:300]', + ); + try { + let target = targets.find((t) => t.kind === 'block'); + assert.strictEqual(target?.format, 'fitted', 'format is fitted'); + assert.ok( + target?.style?.includes('width: 400px'), + 'style carries the width', + ); + assert.ok( + target?.style?.includes('height: 300px'), + 'style carries the height', + ); + assert.ok( + target?.style?.includes('overflow: hidden'), + 'fitted style carries overflow: hidden', + ); + + let widget = element.querySelector('.cm-card-widget--block'); + assert.strictEqual( + widget?.getAttribute('data-boxel-bfm-format'), + 'fitted', + 'widget DOM carries the format attribute', + ); + assert.strictEqual( + widget?.getAttribute('data-boxel-bfm-width'), + '400', + 'widget DOM carries the width attribute', + ); + assert.strictEqual( + widget?.getAttribute('data-boxel-bfm-height'), + '300', + 'widget DOM carries the height attribute', + ); + } finally { + destroy(); + } + }); + + test('block isolated embed threads isolated format with no size style', async function (assert) { + let { targets, destroy } = await collectTargets( + '::card[https://example.com/cards/1 | isolated]', + ); + try { + let target = targets.find((t) => t.kind === 'block'); + assert.strictEqual(target?.format, 'isolated', 'format is isolated'); + assert.notOk(target?.style, 'isolated embed has no inline size style'); + } finally { + destroy(); + } + }); + + test('embeds without a size spec fall back to atom (inline) / embedded (block)', async function (assert) { + let { targets, destroy } = await collectTargets( + ':card[https://example.com/cards/1] and\n\n::card[https://example.com/cards/2]', + ); + try { + let inline = targets.find((t) => t.kind === 'inline'); + let block = targets.find((t) => t.kind === 'block'); + assert.strictEqual(inline?.format, 'atom', 'inline default is atom'); + assert.strictEqual( + block?.format, + 'embedded', + 'block default is embedded', + ); + } finally { + destroy(); + } + }); + + test('file embeds thread format + size style like card embeds', async function (assert) { + let { targets, destroy } = await collectTargets( + '::file[https://example.com/files/a.pdf | fitted w:400 h:300]\n\n::file[https://example.com/files/b.pdf | isolated]', + ); + try { + let fitted = targets.find( + (t) => t.refType === 'file' && t.format === 'fitted', + ); + assert.ok(fitted, 'a fitted file target is present'); + assert.strictEqual(fitted?.refType, 'file', 'refType is file'); + assert.ok( + fitted?.style?.includes('width: 400px'), + 'file fitted target carries the width', + ); + assert.ok( + fitted?.style?.includes('height: 300px'), + 'file fitted target carries the height', + ); + assert.ok( + fitted?.style?.includes('overflow: hidden'), + 'file fitted target carries overflow: hidden', + ); + + let isolated = targets.find( + (t) => t.refType === 'file' && t.format === 'isolated', + ); + assert.ok(isolated, 'isolated file target is present'); + assert.notOk(isolated?.style, 'isolated file target has no size style'); + } finally { + destroy(); + } + }); + // ── Text insertion for card refs ── test('inserting :card[URL] text creates inline card ref', async function (assert) { From 7de4a9a455178c203f4827d6b9cb7b9ba938b5a0 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 21 Jul 2026 20:22:04 +0700 Subject: [PATCH 2/5] Match saved renderer for inline embeds in the compose editor Route inline non-atom embeds (embedded/fitted/isolated) through a chrome-less inline-block slot (codemirror-card-slot--inline-embed) instead of the atom pill, so a sized or isolated inline card occupies its requested footprint the same way the saved and preview markdown renderers do. The atom pill path is unchanged. Type CardWidget.format and parseCardWidgetContent as BfmRefFormat. Add compose-editor coverage: block fitted and prefix-form (@-RRI) previews resolve and size their cards, inline embeds flow by format (atom pill vs sized inline-block), and a custom fitted chooser selection inserts a directive carrying its size rather than a bare `fitted`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 15 +- packages/host/app/lib/codemirror-context.ts | 14 +- .../markdown-embed-chooser-test.gts | 62 ++++ .../components/rich-markdown-field-test.gts | 298 ++++++++++++++++++ 4 files changed, 382 insertions(+), 7 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 090537ee3f2..d38ab55c8c0 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -1350,7 +1350,12 @@ export default class CodeMirrorEditor extends GlimmerComponent !document.querySelector('[data-test-markdown-embed-chooser-modal]'), + ); + await settled(); + + let editorEl = document.querySelector( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor] .cm-editor`, + ) as HTMLElement | null; + let docText = ( + editorEl + ? cmContext.EditorView.findFromDOM(editorEl)?.state.doc.toString() + : '' + )?.trim(); + + // The inserted directive must carry a resolvable fitted size — not a bare + // `fitted` with no dimensions. + assert.notStrictEqual( + docText, + `::card[../Pet/mango | fitted]`, + 'custom fitted must not insert a size-less bare fitted directive', + ); + }); + test('cursor inside an existing directive swaps the toolbar to the Edit pencil', async function (assert) { // Open the card on the stack, then patch the body content to a pre- // existing :card[...] directive so the cursor lands inside it once the diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 3d03a0ec76e..32358d6dd52 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -598,6 +598,304 @@ module('Integration | RichMarkdownField', function (hooks) { } }); + test('edit-mode compose preview renders a block fitted embed at the specified size', async function (assert) { + // Exercises the real edit path: the field renders in compose mode and + // resolves the embed via the getCards resource (linkedCards is empty in + // edit mode), the same way the operator-mode editor does. + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static fitted = class Fitted extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `::card[${testRealmURL}Pet/mango | fitted w:400 h:300]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = (await store.get(`${testRealmURL}article-1`)) as BaseDef; + await store.loaded(); + + await renderCard(loader, article, 'edit'); + + // The fitted card must actually render in the compose preview — a broken + // size path shows the URL fallback instead. + await waitFor('[data-test-pet-fitted]', { timeout: 10_000 }); + assert + .dom('[data-test-pet-fitted]') + .exists('block fitted embed renders the referenced card, not a fallback'); + assert + .dom('.codemirror-card-fallback') + .doesNotExist('no URL fallback remains once the card resolves'); + + let slot = document + .querySelector('[data-test-pet-fitted]')! + .closest('.codemirror-card-slot--block') as HTMLElement | null; + assert.ok(slot, 'the card renders inside a block slot'); + assert.strictEqual( + slot?.style.width, + '400px', + 'block fitted slot carries the requested width', + ); + assert.strictEqual( + slot?.style.height, + '300px', + 'block fitted slot carries the requested height', + ); + assert.strictEqual( + slot?.style.overflow, + 'hidden', + 'block fitted slot clips overflow', + ); + }); + + test('edit-mode compose preview renders a prefix-form (@-RRI) ref with a named fitted variant', async function (assert) { + // Mirrors the reported failing case: a block embed whose ref is a + // prefix-form RRI (`@scope/name/...`) with a named fitted variant + // (`double-strip`), resolved in edit mode via getCards. + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static fitted = class Fitted extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `::card[@test/cards/Pet/mango | double-strip]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let virtualNetwork = getService('network').virtualNetwork; + virtualNetwork.addRealmMapping('@test/cards/', testRealmURL); + try { + let store = getService('store'); + let article = (await store.get('@test/cards/article-1')) as BaseDef; + await store.loaded(); + + await renderCard(loader, article, 'edit'); + + await waitFor('[data-test-pet-fitted]', { timeout: 10_000 }); + assert + .dom('[data-test-pet-fitted]') + .exists('prefix-form ref renders the card, not a URL fallback'); + assert + .dom('.codemirror-card-fallback') + .doesNotExist('no URL fallback remains once the card resolves'); + + let slot = document + .querySelector('[data-test-pet-fitted]')! + .closest('.codemirror-card-slot--block') as HTMLElement | null; + assert.strictEqual( + slot?.style.width, + '250px', + 'double-strip width is applied', + ); + assert.strictEqual( + slot?.style.height, + '65px', + 'double-strip height is applied', + ); + } finally { + virtualNetwork.removeRealmMapping('@test/cards/'); + } + }); + + test('edit-mode compose preview flows inline embeds by format (atom pill vs sized inline-block)', async function (assert) { + // The compose editor must route inline embeds the same way the + // saved/preview renderers do: an atom ref keeps the atom pill chrome, while + // a non-atom (embedded/fitted) inline ref flows as a chrome-less + // inline-block slot that also carries any fitted size style. + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + + }; + static fitted = class Fitted extends Component { + + }; + } + + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { + adoptsFrom: { module: '../pet', name: 'Pet' }, + }, + }, + }, + 'article-1.json': { + data: { + attributes: { + body: { + content: `Embedded: :card[${testRealmURL}Pet/mango | embedded]\n\nAtom: :card[${testRealmURL}Pet/mango | atom]\n\nFitted: :card[${testRealmURL}Pet/mango | fitted w:200 h:100]\n`, + }, + }, + meta: { + adoptsFrom: { module: './article', name: 'ArticleCard' }, + }, + }, + }, + }, + }); + + let store = getService('store'); + let article = (await store.get(`${testRealmURL}article-1`)) as BaseDef; + await store.loaded(); + + await renderCard(loader, article, 'edit'); + + await waitFor('[data-test-pet-embedded]', { timeout: 10_000 }); + await waitFor('[data-test-pet-atom]', { timeout: 10_000 }); + await waitFor('[data-test-pet-fitted]', { timeout: 10_000 }); + + let embeddedSlot = document + .querySelector('[data-test-pet-embedded]')! + .closest('[data-test-codemirror-card-slot-inline]'); + assert + .dom(embeddedSlot) + .hasClass( + 'codemirror-card-slot--inline-embed', + 'a non-atom inline embed flows as an inline-block slot', + ); + assert + .dom(embeddedSlot) + .doesNotHaveClass( + 'codemirror-card-slot--inline', + 'a non-atom inline embed does not use the atom pill flow class', + ); + + let atomSlot = document + .querySelector('[data-test-pet-atom]')! + .closest('[data-test-codemirror-card-slot-inline]'); + assert + .dom(atomSlot) + .hasClass( + 'codemirror-card-slot--inline', + 'a plain (atom) inline ref keeps the atom pill flow class', + ); + + let fittedSlot = document + .querySelector('[data-test-pet-fitted]')! + .closest('[data-test-codemirror-card-slot-inline]') as HTMLElement | null; + assert + .dom(fittedSlot) + .hasClass( + 'codemirror-card-slot--inline-embed', + 'a fitted inline embed flows as an inline-block slot', + ); + assert.strictEqual( + fittedSlot?.style.width, + '200px', + 'fitted inline slot carries the requested width', + ); + assert.strictEqual( + fittedSlot?.style.height, + '100px', + 'fitted inline slot carries the requested height', + ); + assert.strictEqual( + fittedSlot?.style.overflow, + 'hidden', + 'fitted inline slot clips overflow', + ); + }); + test('inline card reference with a non-atom format resolves to an inline-block slot', async function (assert) { class Pet extends CardDef { static displayName = 'Pet'; From ec1d83dbbcd2230beb741d5c06137c2f2e9bf163 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 21 Jul 2026 21:17:37 +0700 Subject: [PATCH 3/5] Drop premature chooser custom-size acceptance test The "selecting a fitted format inserts a directive carrying its size" test asserts that choosing the Custom-size fitted option inserts a directive with explicit dimensions. Selecting Custom without entering a width/height currently serializes to a bare `| fitted` (selectFormat seeds dimensions only for the named fitted variants, not for Custom), so the test pins behavior that is not yet implemented and does not belong on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser-test.gts | 62 ------------------- 1 file changed, 62 deletions(-) diff --git a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts index 441c8e64bf4..609d251546c 100644 --- a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts +++ b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts @@ -203,68 +203,6 @@ module('Acceptance | markdown embed chooser modal', function (hooks) { ); }); - test('selecting a fitted format inserts a directive carrying its size', async function (assert) { - await visitOperatorMode({ - stacks: [[{ id: noteId, format: 'isolated' }]], - }); - - await click(`[data-test-operator-mode-stack="0"] [data-test-edit-button]`); - await waitFor( - `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor]`, - { timeout: 5000 }, - ); - await waitFor('[data-test-toolbar="add-embed"]', { timeout: 5000 }); - - await click('[data-test-toolbar="add-embed"]'); - await click('[data-test-toolbar-embed="card"]'); - await waitFor('[data-test-markdown-embed-chooser-modal]', { - timeout: 5000, - }); - - await fillIn( - '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', - 'Mango', - ); - await waitFor( - `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mangoId}"]`, - { timeout: 5000 }, - ); - await click( - `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mangoId}"]`, - ); - await waitFor('[data-test-markdown-embed-preview-cta]:not([disabled])', { - timeout: 5000, - }); - - // Choose the Custom-size Fitted option from the format dropdown. - await click('[data-test-markdown-embed-preview-format-select]'); - await waitFor('[data-test-format-option="custom"]', { timeout: 5000 }); - await click('[data-test-format-option="custom"]'); - await click('[data-test-markdown-embed-preview-cta]'); - - await waitUntil( - () => !document.querySelector('[data-test-markdown-embed-chooser-modal]'), - ); - await settled(); - - let editorEl = document.querySelector( - `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor] .cm-editor`, - ) as HTMLElement | null; - let docText = ( - editorEl - ? cmContext.EditorView.findFromDOM(editorEl)?.state.doc.toString() - : '' - )?.trim(); - - // The inserted directive must carry a resolvable fitted size — not a bare - // `fitted` with no dimensions. - assert.notStrictEqual( - docText, - `::card[../Pet/mango | fitted]`, - 'custom fitted must not insert a size-less bare fitted directive', - ); - }); - test('cursor inside an existing directive swaps the toolbar to the Edit pencil', async function (assert) { // Open the card on the stack, then patch the body content to a pre- // existing :card[...] directive so the cursor lands inside it once the From de2c330d3de8d9b5e56b15986f032fc88e51cc7d Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 21 Jul 2026 22:02:22 +0700 Subject: [PATCH 4/5] Disable embed-chooser Accept until Custom size has a dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting the Custom-size fitted option seeds no width/height, so inserting immediately would emit a size-less bare `::card[url | fitted]`. Gate the insert CTA on EmbedFormatSelection.hasValidSize — always true for the named formats, and for Custom only once at least one dimension parses — and guard the insert action so a disabled CTA can't fire. Cover the disabled state and the sized insert with an acceptance test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../format-selection.ts | 12 ++++ .../markdown-embed-chooser/pane.gts | 8 +++ .../markdown-embed-chooser-test.gts | 70 +++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/packages/host/app/components/markdown-embed-chooser/format-selection.ts b/packages/host/app/components/markdown-embed-chooser/format-selection.ts index 30f6ee766ac..5bb4a7e415f 100644 --- a/packages/host/app/components/markdown-embed-chooser/format-selection.ts +++ b/packages/host/app/components/markdown-embed-chooser/format-selection.ts @@ -278,6 +278,18 @@ export default class EmbedFormatSelection { } } + // A Custom-size fitted embed must carry at least one explicit dimension — + // otherwise it serializes to the size-less bare `fitted`, which defeats the + // point of the Custom option. Every named format (atom/embedded/isolated and + // the fixed fitted variants) already carries its size, so it is always + // insertable; the consumer disables the insert CTA when this is false. + get hasValidSize(): boolean { + if (this.category !== 'custom') { + return true; + } + return this.width !== undefined || this.height !== undefined; + } + // True once any format/placement/size field has diverged from the seeded // snapshot. Target identity (the user swapped in a different card/file) is // tracked separately by the tab panel, which owns the resolved target. diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index e8b82fb4222..4d913586cea 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -118,8 +118,15 @@ export default class MarkdownEmbedPreviewPane extends Component { this.args.selection.setKind(kind); } + // A Custom-size fitted embed with no dimensions would insert a size-less + // bare `fitted`, so the CTA is held disabled until a valid size is entered. + private get ctaDisabled(): boolean { + return !this.args.selection.hasValidSize; + } + @action private insert() { + if (this.ctaDisabled) return; let bfm = this.bfmString; if (!bfm) return; this.args.onInsert(bfm); @@ -207,6 +214,7 @@ export default class MarkdownEmbedPreviewPane extends Component {