Skip to content

Markdown embed chooser modal (CS-11675/76/77)#5346

Open
FadhlanR wants to merge 12 commits into
mainfrom
cs-11675-11676-11677-markdown-embed-chooser-modal
Open

Markdown embed chooser modal (CS-11675/76/77)#5346
FadhlanR wants to merge 12 commits into
mainfrom
cs-11675-11676-11677-markdown-embed-chooser-modal

Conversation

@FadhlanR

@FadhlanR FadhlanR commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

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 fitted WxH, or atom/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-common

  • bfm-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 a marked tokenizer 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/lib

  • Adds an Add-embed / Edit-embed toolbar to the markdown editor.
  • codemirror-context.ts tracks 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 + service

  • markdown-embed-chooser.ts service is the single entry point (chooseCardOrFile / editEmbed); it holds one in-flight request and resolves a Deferred with the picked { refType, url, bfm }, a { remove: true }, or undefined (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.
  • Edit mode — opening on an existing reference seeds the matching tab with the placed target and its size/placement and offers Replace / Remove.

Mini choosers — card-chooser/mini, file-chooser/mini, card-search

  • The left column of each tab reuses the compact MiniCardChooser / MiniFileChooser primitives (card search results vs. a workspace file tree).
  • Both choosers render their chrome and list rows at a shared 14px scale so toggling tabs doesn't jump in size, and the card chooser's search-bar focus outline is no longer clipped by the results list below it. (card-search/panel-content.gts + sheet-results.gts carry the matching mini-variant tweaks.)

Tests

  • Acceptance: markdown-embed-chooser-test.gts — end-to-end add/edit flow from the editor toolbar through the modal.
  • Integration: markdown-embed-chooser-modal-test.gts, codemirror-embed-toolbar-test.gts, codemirror-editor-test.gts.
  • Unit: 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

FadhlanR and others added 7 commits June 26, 2026 21:47
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>
@FadhlanR FadhlanR force-pushed the cs-11675-11676-11677-markdown-embed-chooser-modal branch from 288a7e6 to d3c567d Compare June 26, 2026 14:48
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 32m 4s ⏱️ + 2m 28s
3 344 tests ±0  3 329 ✅ +1  15 💤 ±0  0 ❌ ±0 
3 363 runs  ±0  3 348 ✅ +2  15 💤 ±0  0 ❌  - 1 

Results for commit 8328157. ± Comparison against earlier commit 047c8c0.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   9m 7s ⏱️ - 1m 1s
1 679 tests ±0  1 679 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 758 runs  ±0  1 758 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 8328157. ± Comparison against earlier commit 047c8c0.

FadhlanR and others added 2 commits June 27, 2026 09:06
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>
@FadhlanR FadhlanR marked this pull request as ready for review June 29, 2026 05:43
@FadhlanR FadhlanR requested a review from a team June 29, 2026 05:43

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +683 to +686
result = await editMarkdownEmbed({
refType: ref.refType as 'card' | 'file',
url: ref.url,
sizeSpec: ref.sizeSpec,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5eeditEmbed 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.

Comment on lines +125 to +127
async (url: string, refType: MarkdownEmbedRefType) => {
this.selectedUrl = url;
let result =

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5eloadTarget 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-context tracking 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.

Comment on lines +224 to +236
{{#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}}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot is wrong. That's not how Glimmer works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +273 to +280
private lastReportedDirty: boolean | undefined;
private reportDirty = () => {
let dirty = this.isDirty;
if (dirty !== this.lastReportedDirty) {
this.lastReportedDirty = dirty;
this.args.onDirtyChange?.(dirty);
}
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +297 to +300
/* The preview column reads as an off-white surface, distinct from the
white chooser column on the left. */
background-color: #fbf8f8;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5e — replaced #fbf8f8 with var(--boxel-100), the same token the inner preview viewport already uses.

Comment on lines +1102 to +1126
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'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +398 to +414
// 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +29 to +33
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'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5e — switched to {{cn}} for the conditional panel class.

Comment on lines +29 to +38
<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'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5e — switched the tab buttons to {{cn}}.

Comment on lines +737 to +740
let onUpdate = this.args.onUpdate;
if (onUpdate) {
onUpdate(view.state.doc.toString());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Comment on lines +757 to +760
let onUpdate = this.args.onUpdate;
if (onUpdate) {
onUpdate(view.state.doc.toString());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5e — same saveTimer cancel in _deleteRange; covered by a single-save test for delete.

Comment on lines 199 to 209
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
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 6ea0c5esameToolbarState 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.

@lukemelia

Copy link
Copy Markdown
Contributor

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 lukemelia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'll review again after the copilot comments are addressed

Comment on lines +366 to +380
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is a global the only/best way to accomplish this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
@FadhlanR

Copy link
Copy Markdown
Contributor Author

[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 — its lifted into a single EmbedFormatSelection` shared by both tabs, so the format/placement/size selection carries across a tab switch. Added an integration test that sets Embedded on the cards tab, switches to files, and asserts the file directive keeps the chosen format.

All Codex + Copilot threads above are addressed and replied to individually (one false positive — the @initialTarget.url one you already flagged — confirmed and the arg removed anyway). Ready for another look when you have a chance.

FadhlanR and others added 2 commits June 30, 2026 09:29
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>
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.

3 participants