Markdown embed chooser modal (CS-11675/76/77)#5346
Conversation
A tabbed modal that wraps the existing mini choosers and the MarkdownEmbedPreviewPane from #5303. Both tabs stay mounted so each tab's search query, highlighted row, scroll position, and pane W×H survive a switch. Driven by a new `markdown-embed-chooser` service exposing `chooseCardOrFile({ defaultTab })` (edit-mode `editEmbed` follows in CS-11676). Pane teach: render with `@target` undefined so layout doesn't jump before a row is picked, CTA disabled until then. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When opened via `editEmbed({ url, refType, sizeSpec })` the matching tab
starts in `current` mode showing the placed target with Replace / Remove
buttons; the other tab still mounts its mini chooser per Zeplin 08B.
Replace flips the tab back to the chooser. Remove resolves the modal's
deferred with `{ remove: true }`. The pane gains edit-mode preload args
(initialFormat / initialWidth / initialHeight / initialKind) plus a
`ctaLabelOverride` and an `onDirtyChange` callback the parent uses to
flip the CTA between DONE and ACCEPT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `extractBfmRefRanges(markdown)` (runtime-common) returns one `[from, to]` per BFM directive, skipping code blocks; powers in-editor range tracking and in-place replacement. - `codemirror-context.ts` `SelectionInfo` gains a `currentRef` field; the doc-wide range scan re-runs on `docChanged` and the cursor is tested against the cached ranges on every selection update. - `codemirror-editor.gts` toolbar gets a new left-most slot: an Add-embed popover (Add a card / Add a file) when the cursor is outside any directive, an Edit pencil when it's inside. Add inserts the returned BFM at the cursor (block placement gets surrounding newlines); Edit replaces the matched range, Remove deletes it. - The combined chooser modal registers a global bridge `_CARDSTACK_MARKDOWN_EMBED_CHOOSER`; `chooseMarkdownEmbed` / `editMarkdownEmbed` in runtime-common dispatch to it — mirrors the `chooseCard` / `chooseFile` pattern so the base editor reaches the host modal without a direct import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end coverage of the toolbar → modal → editor round-trip: - Toolbar surfaces an Add-embed popover (Add a card / Add a file) when the cursor is outside a directive. - Picking a card from the popover lands `:card[url]` at the cursor. - Placing the cursor inside a directive swaps the toolbar to the Edit pencil; Remove on the edit modal deletes the directive in place. Loads CodeMirrorEditor through the loader (virtual-URL module) and injects it via precompileTemplate's scope so the integration test can mount the base-realm editor alongside the host-side modal in the same render pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives the full operator-mode flow: interact submode → open a Note card with a RichMarkdownField → switch to edit → click Add embed in the markdown toolbar → pick a card from the combined chooser modal → assert the editor's source now carries the inserted `:card[url]` directive. Second test asserts cursor-inside-directive swaps the toolbar to the Edit pencil and opens the modal on the current-target tile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- externals.ts: shim `@cardstack/runtime-common/bfm-card-references` as
an async module so the base-realm editor (and anything else that
reaches the subpath through the loader) resolves it via the virtual
network — the loader has no way to fetch it otherwise.
- codemirror-editor.gts: drop `disabled={{not this.toolbarEnabled}}`
from the Add-embed and Edit-embed buttons; both open modals that own
their own state, so editor focus isn't a prerequisite.
- pane.gts: new `initialTargetUrl` arg. Without it, in edit mode the
dirty check briefly flips true once the resolved target instance
lands (constructor records `undefined`, then `args.target?.id` changes
to the URL), making the CTA read ACCEPT before the user edits
anything. Tab-panel passes the URL through verbatim.
- Tests: switch from `editor.cmView.view` (not a public CM6 surface) to
`cmContext.EditorView.findFromDOM(editor)`; fix one unit-test
assertion where the block-ref form needed to sit at the start of a
line for the `^::` regex to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the card/file tab control from a full-width strip into a segmented pill control (tab-pills.gts) that sits at the top of each panel's left search column, directly above its search bar. Both tabpanels stay mounted so each tab keeps its search query, highlighted row, scroll position, and the pane's size/format selection across a switch. Harmonize the two mini choosers so toggling tabs no longer jumps in scale: both now render their chrome and list rows at the shared 14px (--boxel-font-sm) size — the file tree (was 12px), the card chooser baseline, and the mini "Search Results" summary (was 16px). Each list keeps its own natural weight. Give the card chooser's search header a small bottom inset so the bar's 2px focus outline is no longer painted over by the results list below it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
288a7e6 to
d3c567d
Compare
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 2h 32m 4s ⏱️ + 2m 28s Results for commit 8328157. ± Comparison against earlier commit 047c8c0. Realm Server Test Results 1 files ±0 1 suites ±0 9m 7s ⏱️ - 1m 1s Results for commit 8328157. ± Comparison against earlier commit 047c8c0. |
Narrow the embed-chooser resolution before reading `bfm`: a negated `&&` condition doesn't narrow the union, so split the `remove` check into its own block. And read a card's display title via `cardTitle` (the base CardDef `title` field was replaced by the cardInfo-derived `cardTitle`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 451ffcb1b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| result = await editMarkdownEmbed({ | ||
| refType: ref.refType as 'card' | 'file', | ||
| url: ref.url, | ||
| sizeSpec: ref.sizeSpec, |
There was a problem hiding this comment.
Preserve block placement when opening edit mode
When editing an existing size-less block directive such as ::card[url] or ::file[url], this request sends only refType, url, and an undefined sizeSpec. The pane then falls back to its default inline atom state, so clicking the edit modal's clean DONE CTA reserializes the reference as :card[url] and silently converts a block embed into an inline embed. Include the ref.kind in the initial target or seed block defaults when the current ref is a block directive.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — editEmbed now carries the directives kind, and the seed logic maps a size-less block to the format it actually renders as (embedded) in block placement, so ::card[url]` round-trips as a block embed instead of collapsing to an inline atom. Added a round-trip test.
| async (url: string, refType: MarkdownEmbedRefType) => { | ||
| this.selectedUrl = url; | ||
| let result = |
There was a problem hiding this comment.
Clear stale selected targets while loading a new pick
After one target has loaded, choosing a different card/file only updates selectedUrl while selectedTarget continues to point at the previous instance until store.get resolves. During that window the preview CTA remains enabled, and handleInsert prefers selectedTarget.id, so a quick Insert/Accept after selecting a new row can insert the old URL. Clear selectedTarget or disable the CTA before awaiting the new load.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — loadTarget now clears selectedTarget before awaiting the new pick, so the pane drops to its placeholder during the load window and the CTA cannot insert the previous target. Format state was lifted to a shared model, so the brief remount no longer resets the format.
There was a problem hiding this comment.
Pull request overview
This PR adds an end-to-end “Markdown embed chooser” workflow for the CodeMirror-based markdown editor: authors can insert or edit BFM embed directives (:card[...], ::file[...]) via a combined Cards/Files modal that also controls display format and sizing. It spans runtime-common (parsing + bridge), the base-realm editor UI, and host-side modal/service + supporting chooser components, with new acceptance/integration/unit tests.
Changes:
- Introduces a typed global bridge + range-extraction utility for BFM directives (
extractBfmRefRanges) to support cursor-over detection and in-place replacement. - Adds Add-embed / Edit-embed toolbar controls in the base CodeMirror editor, backed by host
codemirror-contexttracking of the directive under the caret. - Implements a host-side combined chooser modal + service (Cards/Files tabs, preview pane, edit/remove flows) and updates mini chooser components/styles to support the modal.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/bfm-card-references.ts | Adds markdown-embed chooser bridge types/functions and extractBfmRefRanges() for editor tooling. |
| packages/host/tests/unit/bfm-card-references-test.ts | Adds unit coverage for extractBfmRefRanges. |
| packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts | Integration tests for modal open/close, tab persistence, edit/remove/replace, insert flow. |
| packages/host/tests/integration/components/codemirror-embed-toolbar-test.gts | Integration tests for Add/Edit embed toolbar behavior and resulting editor changes. |
| packages/host/tests/integration/components/codemirror-editor-test.gts | Adds tests for currentRef selection tracking. |
| packages/host/tests/acceptance/markdown-embed-chooser-test.gts | Acceptance tests for the end-to-end operator-mode flow (toolbar → modal → insert/edit). |
| packages/host/app/services/markdown-embed-chooser.ts | New service that manages a single in-flight chooser request via a Deferred. |
| packages/host/app/lib/externals.ts | Adds shim for @cardstack/runtime-common/bfm-card-references for runtime module loading. |
| packages/host/app/lib/codemirror-context.ts | Tracks BFM directive under cursor (currentRef) using cached extractBfmRefRanges. |
| packages/host/app/components/operator-mode/container.gts | Mounts the new markdown-embed chooser modal in operator mode. |
| packages/host/app/components/markdown-embed-chooser/modal.gts | New combined chooser modal + global bridge registration. |
| packages/host/app/components/markdown-embed-chooser/tab-panel.gts | Implements each tab’s left chooser + right preview pane, including edit/current mode. |
| packages/host/app/components/markdown-embed-chooser/pane.gts | Preview pane UI, initial-state seeding, dirty detection, and directive serialization. |
| packages/host/app/components/markdown-embed-chooser/tabs.gts | Tab panel wrapper that keeps both panels mounted (state preservation). |
| packages/host/app/components/markdown-embed-chooser/tab-pills.gts | Segmented pill tab control (Cards/Files). |
| packages/host/app/components/file-chooser/mini/index.gts | Adds onHighlight to support preview-on-highlight semantics. |
| packages/host/app/components/card-search/sheet-results.gts | Hides global header in mini/recents state to match mini chooser design. |
| packages/host/app/components/card-search/panel-content.gts | Adjusts mini header font size for consistent 14px scale. |
| packages/host/app/components/card-chooser/mini/index.gts | Harmonizes mini chooser typography and fixes clipped focus outline. |
| packages/base/codemirror-editor.gts | Adds Add/Edit embed toolbar UI, insertion/replacement/deletion logic, and modal invocation via bridge. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {{#if this.selectedTarget}} | ||
| <MarkdownEmbedPreviewPane | ||
| @target={{this.selectedTarget}} | ||
| @refType={{@refType}} | ||
| @onInsert={{this.handleInsert}} | ||
| @initialFormat={{this.initialPaneFormat}} | ||
| @initialWidth={{this.initialPaneWidth}} | ||
| @initialHeight={{this.initialPaneHeight}} | ||
| @initialKind={{this.initialPaneKind}} | ||
| @initialTargetUrl={{@initialTarget.url}} | ||
| @ctaLabelOverride={{this.ctaLabelOverride}} | ||
| @onDirtyChange={{this.onPaneDirtyChange}} | ||
| /> |
There was a problem hiding this comment.
Copilot is wrong. That's not how Glimmer works.
There was a problem hiding this comment.
[Claude Code 🤖] Confirmed not a runtime error: a Glimmer template returns undefined when a path is read through an undefined value, so {{@initialTarget.url}} in choose mode just yields undefined (and the pane arg is typed initialTargetUrl?: string). That said, the format-state refactor in 6ea0c5e removes @initialTargetUrl from the pane altogether — target-change detection now lives in the tab panel via an explicit this.args.initialTarget?.url, so the implicit template safe-navigation is gone here anyway.
| private lastReportedDirty: boolean | undefined; | ||
| private reportDirty = () => { | ||
| let dirty = this.isDirty; | ||
| if (dirty !== this.lastReportedDirty) { | ||
| this.lastReportedDirty = dirty; | ||
| this.args.onDirtyChange?.(dirty); | ||
| } | ||
| }; |
There was a problem hiding this comment.
[Claude Code 🤖] Addressed in 6ea0c5e — the format-state refactor moves dirty detection onto a shared selection model as an isDirty getter, so reportDirty, lastReportedDirty, and the {{this.reportDirty}} line are removed entirely. No function is rendered and no side effect runs during render.
| /* The preview column reads as an off-white surface, distinct from the | ||
| white chooser column on the left. */ | ||
| background-color: #fbf8f8; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — replaced #fbf8f8 with var(--boxel-100), the same token the inner preview viewport already uses.
| aria-label='Add embed' | ||
| aria-haspopup='menu' | ||
| aria-expanded={{if this._embedPopoverOpen 'true' 'false'}} | ||
| {{on 'mousedown' this._preventFocusLoss}} | ||
| {{on 'click' this._toggleEmbedPopover}} | ||
| ><PlusIcon width='16' height='16' /></button> | ||
| {{#if this._embedPopoverOpen}} | ||
| <div | ||
| class='toolbar-embed-popover' | ||
| role='menu' | ||
| data-test-toolbar-embed-popover | ||
| > | ||
| <button | ||
| type='button' | ||
| role='menuitem' | ||
| class='toolbar-embed-popover__item' | ||
| data-test-toolbar-embed='card' | ||
| {{on 'mousedown' this._preventFocusLoss}} | ||
| {{on 'click' (fn this._openEmbedChooser 'card')}} | ||
| >Add a card</button> | ||
| <button | ||
| type='button' | ||
| role='menuitem' | ||
| class='toolbar-embed-popover__item' | ||
| data-test-toolbar-embed='file' |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — dropped the role=menu / role=menuitem / aria-haspopup=menu in favor of a plain disclosure (the trigger keeps aria-expanded), since this is just a two-button popover with no menu keyboard model.
| // Half-open byte range into the original markdown string: `markdown.slice( | ||
| // from, to)` reproduces the directive verbatim. Suitable for a CodeMirror | ||
| // dispatch that replaces or deletes the directive in place. | ||
| from: number; | ||
| to: number; | ||
| refType: string; | ||
| // Unresolved URL as written between `[` and `]` — callers resolve against | ||
| // a base URL when they need the canonical form. | ||
| url: string; | ||
| // Raw size specifier after `|` (e.g. `'embedded'`, `'tall-tile'`, | ||
| // `'w:400 h:200'`). Undefined when the directive has no `|` segment. | ||
| sizeSpec?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Locates every BFM reference site in `markdown` and returns its source-byte | ||
| * range, refType, URL, and size specifier (verbatim — no URL resolution). |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — reworded the comments to describe these as UTF-16 string offsets (the units match.index / String#slice / CodeMirror positions use), not byte ranges.
| id='markdown-embed-chooser-panel-cards' | ||
| aria-labelledby='markdown-embed-chooser-tab-cards' | ||
| class='markdown-embed-chooser-tabs__panel | ||
| {{unless (eq @activeTab "card") "is-hidden"}}' | ||
| data-test-markdown-embed-chooser-panel='card' |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — switched to {{cn}} for the conditional panel class.
| <button | ||
| type='button' | ||
| role='tab' | ||
| id='markdown-embed-chooser-tab-cards' | ||
| aria-controls='markdown-embed-chooser-panel-cards' | ||
| aria-selected={{if (eq @activeTab 'card') 'true' 'false'}} | ||
| tabindex={{if (eq @activeTab 'card') '0' '-1'}} | ||
| class='markdown-embed-chooser-tab-pills__tab | ||
| {{if (eq @activeTab "card") "is-active"}}' | ||
| data-test-markdown-embed-chooser-tab='card' |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — switched the tab buttons to {{cn}}.
| let onUpdate = this.args.onUpdate; | ||
| if (onUpdate) { | ||
| onUpdate(view.state.doc.toString()); | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — _replaceRange now clears the pending saveTimer before the immediate onUpdate, mirroring _insertBfm. Added a test asserting a single save per edit.
| let onUpdate = this.args.onUpdate; | ||
| if (onUpdate) { | ||
| onUpdate(view.state.doc.toString()); | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — same saveTimer cancel in _deleteRange; covered by a single-save test for delete.
| function sameToolbarState(a: SelectionInfo, b: SelectionInfo): boolean { | ||
| return ( | ||
| a.hasFocus === b.hasFocus && | ||
| a.formats.bold === b.formats.bold && | ||
| a.formats.italic === b.formats.italic && | ||
| a.formats.code === b.formats.code && | ||
| a.formats.strikethrough === b.formats.strikethrough && | ||
| a.formats.link === b.formats.link | ||
| a.formats.link === b.formats.link && | ||
| a.currentRef?.from === b.currentRef?.from && | ||
| a.currentRef?.to === b.currentRef?.to | ||
| ); |
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6ea0c5e — sameToolbarState now also compares currentRef.url / sizeSpec / kind, so an in-place edit (URL/spec change with the cursor unmoved) refreshes the ref the pencil acts on. Added a test that rewrites a URL in place without moving the cursor.
|
The screen recording shows the format reverting to atom when the user swtiches from card to file. I think the format should be sticky. |
lukemelia
left a comment
There was a problem hiding this comment.
I'll review again after the copilot comments are addressed
| const MARKDOWN_EMBED_CHOOSER_KEY = '_CARDSTACK_MARKDOWN_EMBED_CHOOSER'; | ||
|
|
||
| export async function chooseMarkdownEmbed( | ||
| opts: { defaultTab?: 'card' | 'file' } = {}, | ||
| ): Promise<MarkdownEmbedResolution> { | ||
| let here = globalThis as any; | ||
| let chooser: MarkdownEmbedChooser | undefined = | ||
| here[MARKDOWN_EMBED_CHOOSER_KEY]; | ||
| if (!chooser) { | ||
| throw new Error( | ||
| `no cardstack markdown-embed chooser is available in this environment`, | ||
| ); | ||
| } | ||
| return chooser.chooseCardOrFile(opts); | ||
| } |
There was a problem hiding this comment.
Is a global the only/best way to accomplish this?
There was a problem hiding this comment.
[Claude Code 🤖] The global mirrors the existing _CARDSTACK_CARD_CHOOSER / _CARDSTACK_FILE_CHOOSER pattern. codemirror-editor.gts lives in packages/base and is loaded at runtime in the base realm, so it cannot statically import a host service; the externals.ts shim only swaps static module imports, not a Promise-based runtime API. Registering the chooser on globalThis at modal mount (with a destructor that removes it) is the established way for base-realm code to reach a host modal. Happy to extract the three globals behind a small typed registry helper in runtime-common if you would prefer to DRY them up.
There was a problem hiding this comment.
Let's spin that off to a separate issue.
Lift the preview pane's format/placement/size state into a shared
EmbedFormatSelection model owned by the modal so the choice sticks when
switching between the Cards and Files tabs (Luke), and read its isDirty as a
getter — removing the render-phase {{this.reportDirty}} side effect (Copilot).
Also:
- Preserve placement when editing a size-less block directive by threading the
directive's kind through editEmbed; a `::card[url]` round-trips as a block
embed instead of collapsing to an inline atom (Codex).
- Clear the staged target at the start of each pick so a quick Insert during
the load window can't serialize the previously-resolved URL (Codex).
- Cancel the debounced save in _replaceRange/_deleteRange so an edit/delete
saves once, not twice (Copilot).
- Compare a directive's url/sizeSpec/kind in sameToolbarState so an in-place
edit refreshes the ref the pencil acts on (Copilot).
- Drop the unfulfilled ARIA menu roles from the add-embed popover in favor of a
plain disclosure (aria-expanded) (Copilot).
- Use the cn helper for conditional classes, replace a hardcoded color with a
token, and correct the "byte range" comments to UTF-16 string offsets
(Copilot).
Add coverage for tab-switch stickiness, size-less block round-trip, single
save on edit/delete, and in-place url refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
[Claude Code 🤖] On the format reverting to atom when switching from card to file: fixed in 6ea0c5e. The pane no longer owns its format state — it All Codex + Copilot threads above are addressed and replied to individually (one false positive — the |
The modal exposed the shared EmbedFormatSelection via an `@cached` getter, but
the model's constructor reads its own tracked fields to snapshot the initial
state — so the cached getter took a dependency on those fields and invalidated
itself the moment the user changed the format, handing back a fresh atom
instance and discarding the choice. This broke format stickiness in the app,
not just the tests.
Own the selection in the Tabs component instead, which lives inside the modal's
`{{#if request}}` block: it's created once per chooser invocation (seeded from
the edited directive) and torn down on close, giving both tab panels a stable
shared instance. Tabs yields it to each tab block.
Also fix the in-place-edit test to assert on the chooser request's target URL
rather than the rendered card title (the fixture cards render the same
placeholder title, so the label couldn't distinguish them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sanity assertion compared `harness.content` (updated only on the editor's 500ms-debounced onUpdate) right after dispatching the in-place edit, so it saw the pre-edit text. Read the live CodeMirror document instead; the real assertion (the chooser request targets the freshly-typed URL) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overview
Lets an author embed a card or file reference into a markdown field directly from the CodeMirror editor, choosing the target and its display size/format through a single combined chooser. Covers Linear CS-11675, CS-11676, and CS-11677.
A markdown embed reference is written as a link with an optional size/format specifier after a
|(e.g. the target URL plus a fittedWxH, oratom/embedded/isolated). The editor renders these inline, and the toolbar lets you add a new one at the cursor or edit the one under it.Changes by area
Embed-reference parsing —
runtime-commonbfm-card-references.ts— parses/serializes the size-format spec (BfmSizeSpec:atom | fitted | isolated | embedded, with optional width/height), resolves reference URLs against the doc's base, and provides amarkedtokenizer extension. Code fences and inline code spans are stripped before extraction so references inside code aren't matched.Editor integration —
base/codemirror-editor.gts+host/app/libcodemirror-context.tstracks the embed reference under the caret so the toolbar knows whether to offer Add (no ref at cursor) or Edit (ref at cursor), and which target/spec to seed.Chooser modal + service —
host/app/components/markdown-embed-chooser+ servicemarkdown-embed-chooser.tsservice is the single entry point (chooseCardOrFile/editEmbed); it holds one in-flight request and resolves aDeferredwith the picked{ refType, url, bfm }, a{ remove: true }, orundefined(cancel). A new request cancels any open one.modal.gts— the modal shell, mounted once in the operator-mode container and driven by the service via a global bridge (mirrors the existing card/file chooser pattern).tabs.gts/tab-pills.gts/tab-panel.gts— a two-tab (Cards / Files) layout. Tabs are a segmented pill control above each tab's search bar; both panels stay mounted so each keeps its search query, highlighted row, scroll position, and the pane's size/format selection across a switch.pane.gts— the right-hand preview pane: shows the selected target and lets the user choose the display format and dimensions, emitting the BFM spec on insert.Mini choosers —
card-chooser/mini,file-chooser/mini,card-searchMiniCardChooser/MiniFileChooserprimitives (card search results vs. a workspace file tree).card-search/panel-content.gts+sheet-results.gtscarry the matchingmini-variant tweaks.)Tests
markdown-embed-chooser-test.gts— end-to-end add/edit flow from the editor toolbar through the modal.markdown-embed-chooser-modal-test.gts,codemirror-embed-toolbar-test.gts,codemirror-editor-test.gts.bfm-card-references-test.ts.The chooser font-scale harmonization and focus-outline fix were verified live in the browser via the component freestyle page.
Screen Recording
Screen.Recording.2026-06-26.at.21.40.26.mov
🤖 Generated with Claude Code