From 5bdd2a170ccaae55c9a44d79172743b45974ef77 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Jul 2026 10:18:19 +0700 Subject: [PATCH 01/10] Render the broken-ref visual in the markdown-embed chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `:card[...]` / `:file[...]` reference that no longer resolves (deleted, moved, no permission) showed the same empty "search & preview" placeholder as a not-yet-picked slot: the chooser collapsed "still loading" and "resolution failed" into one undefined target. The chooser preview pane and the Edit modal now render the rich broken-ref visual that broken linksTo / linksToMany field values already use — the placeholder box with the type/file label plus the reveal overlay carrying the URL, copy button, and error diagnostics. To make that component reachable from host with a plain static import (rather than a runtime module load), relocate `BrokenLinkTemplate` from base to `@cardstack/boxel-ui`, the shared component package both base and host already consume. Its three outside dependencies are decoupled so boxel-ui takes on no dependency on runtime-common (which would invert the existing runtime-common -> boxel-ui edge into a cycle): - `cardTypeName` -> a caller-supplied `@typeName` arg (base card sites pass the card type name; the file chooser passes the filename). Falls back to 'Card'. - `SerializedError` -> a local `BrokenLinkErrorDoc` interface listing only the fields the overlay reads (SerializedError is a structural superset). - base `ViewCardFn` -> a local `BrokenLinkViewFn`. The five base call sites switch to the boxel-ui import and pass `@typeName={{cardTypeName reference}}`, reproducing the label the component previously computed internally — no visual change to the shipped linksTo UI. In the chooser, tab-panel threads a broken state (url / state / type name / error doc) through the pane into MarkdownEmbedPreview, which renders BrokenLinkTemplate in place of the embed. The Edit modal's existing Remove / Replace tile is the fix/remove affordance; there is no "Open anyway" here. The inline document render (rendered-markdown.gts) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/card-api.gts | 12 +- packages/base/links-to-editor.gts | 9 +- packages/base/links-to-many-component.gts | 12 +- packages/boxel-ui/addon/src/components.ts | 13 ++ .../src/components/broken-link/index.gts} | 140 +++++++++++------- .../components/broken-link-test.gts | 121 +++++++++++++++ .../markdown-embed-chooser/pane.gts | 26 +++- .../markdown-embed-chooser/preview/index.gts | 63 ++++++-- .../markdown-embed-chooser/tab-panel.gts | 70 ++++++++- .../components/broken-link-template-test.gts | 13 +- .../markdown-embed-chooser-modal-test.gts | 61 ++++++++ .../markdown-embed-preview-pane-test.gts | 41 +++++ .../markdown-embed-preview-test.gts | 85 +++++++++++ .../tests/integration/realm-indexing-test.gts | 2 - 14 files changed, 579 insertions(+), 89 deletions(-) rename packages/{base/default-templates/broken-link-template.gts => boxel-ui/addon/src/components/broken-link/index.gts} (92%) create mode 100644 packages/boxel-ui/test-app/tests/integration/components/broken-link-test.gts diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index a97633dcdc9..c4f6fceb596 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2,7 +2,12 @@ import Modifier from 'ember-modifier'; import GlimmerComponent from '@glimmer/component'; import { isEqual } from 'lodash-es'; import { WatchedArray, rawArrayValues } from './watched-array'; -import { BoxelInput, CopyButton } from '@cardstack/boxel-ui/components'; +import { + BoxelInput, + BrokenLinkTemplate, + CopyButton, + type BrokenLinkFormat, +} from '@cardstack/boxel-ui/components'; import { markdownEscape, type MenuItemOptions, @@ -97,6 +102,7 @@ import { type RealmResourceIdentifier, type VirtualNetwork, isDirectIndexedFieldKey, + cardTypeName, } from '@cardstack/runtime-common'; import { captureQueryFieldSeedData, @@ -113,9 +119,6 @@ import DefaultCardDefTemplate from './default-templates/isolated-and-edit'; import DefaultAtomViewTemplate from './default-templates/atom'; import DefaultHeadTemplate from './default-templates/head'; import MissingTemplate from './default-templates/missing-template'; -import BrokenLinkTemplate, { - type BrokenLinkFormat, -} from './default-templates/broken-link-template'; import FieldDefEditTemplate from './default-templates/field-edit'; import MarkdownTemplate from './default-templates/markdown'; import DefaultMarkdownFallbackTemplate from './default-templates/markdown-fallback'; @@ -1603,6 +1606,7 @@ class LinksTo implements Field { @errorDoc={{broken.errorDoc}} @state={{broken.kind}} @format={{brokenLinkFormat @format defaultFormats.cardDef}} + @typeName={{cardTypeName broken.reference}} @viewCard={{cardCrudFunctions.viewCard}} ...attributes /> diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index 867a85871b1..e376d69e386 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -28,11 +28,15 @@ import { Loader, type ResolvedCodeRef, isCardInstance, + cardTypeName, } from '@cardstack/runtime-common'; -import { Button, IconButton } from '@cardstack/boxel-ui/components'; +import { + BrokenLinkTemplate, + Button, + IconButton, +} from '@cardstack/boxel-ui/components'; import { IconMinusCircle } from '@cardstack/boxel-ui/icons'; import { consume } from 'ember-provide-consume-context'; -import BrokenLinkTemplate from './default-templates/broken-link-template'; import { type RelationshipState } from './field-support'; // A broken singular link surfaces as a terminal failure state from @@ -109,6 +113,7 @@ export class LinksToEditor extends GlimmerComponent { @errorDoc={{@brokenLink.errorDoc}} @state={{@brokenLink.kind}} @format='embedded' + @typeName={{cardTypeName @brokenLink.reference}} @viewCard={{crud.viewCard}} /> diff --git a/packages/base/links-to-many-component.gts b/packages/base/links-to-many-component.gts index bf45995b034..c340d08b370 100644 --- a/packages/base/links-to-many-component.gts +++ b/packages/base/links-to-many-component.gts @@ -16,7 +16,6 @@ import { isFileDef, brokenLinkFormat, } from './card-api'; -import BrokenLinkTemplate from './default-templates/broken-link-template'; import { getRelationshipMembershipState, type RelationshipState, @@ -29,7 +28,12 @@ import { PermissionsConsumer, getBoxComponent, } from './field-component'; -import { Button, IconButton, Pill } from '@cardstack/boxel-ui/components'; +import { + BrokenLinkTemplate, + Button, + IconButton, + Pill, +} from '@cardstack/boxel-ui/components'; import { restartableTask } from 'ember-concurrency'; import { chooseCard, @@ -46,6 +50,7 @@ import { uuidv4, CardCrudFunctionsContextName, CardErrorJSONAPI, + cardTypeName, } from '@cardstack/runtime-common'; import { IconMinusCircle, @@ -303,6 +308,7 @@ class LinksToManyStandardEditor extends GlimmerComponent @@ -458,6 +464,7 @@ class LinksToManyCompactEditor extends GlimmerComponent @@ -715,6 +722,7 @@ export function getLinksToManyComponent({ effectiveFormat effectiveFormat }} + @typeName={{cardTypeName broken.reference}} @viewCard={{crud.viewCard}} data-test-plural-view-item={{i}} /> diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index 75a1914ede4..cb1cb04edc2 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -3,6 +3,13 @@ import AddButton from './components/add-button/index.gts'; import Alert from './components/alert/index.gts'; import Avatar from './components/avatar/index.gts'; import BasicFitted from './components/basic-fitted/index.gts'; +import BrokenLink, { + type BrokenLinkErrorDoc, + type BrokenLinkFormat, + type BrokenLinkState, + type BrokenLinkTemplateArgs, + type BrokenLinkViewFn, +} from './components/broken-link/index.gts'; import Button from './components/button/index.gts'; import BoxelButton from './components/button/index.gts'; import CardContainer from './components/card-container/index.gts'; @@ -95,6 +102,11 @@ import ViewSelector, { } from './components/view-selector/index.gts'; export { + type BrokenLinkErrorDoc, + type BrokenLinkFormat, + type BrokenLinkState, + type BrokenLinkTemplateArgs, + type BrokenLinkViewFn, type Filter, type FittedCardLayout, type FittedCardTitleTag, @@ -126,6 +138,7 @@ export { BoxelMultiSelectBasic, BoxelSelect, BoxelTag, + BrokenLink as BrokenLinkTemplate, Button, CardContainer, CardHeader, diff --git a/packages/base/default-templates/broken-link-template.gts b/packages/boxel-ui/addon/src/components/broken-link/index.gts similarity index 92% rename from packages/base/default-templates/broken-link-template.gts rename to packages/boxel-ui/addon/src/components/broken-link/index.gts index bf27bcb0ae6..c2ba6dbfb00 100644 --- a/packages/base/default-templates/broken-link-template.gts +++ b/packages/boxel-ui/addon/src/components/broken-link/index.gts @@ -1,37 +1,64 @@ -import GlimmerComponent from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; +import LinkOffIcon from '@cardstack/boxel-icons/link-off'; +import type { TOC } from '@ember/component/template-only'; import { on } from '@ember/modifier'; import { guidFor } from '@ember/object/internals'; import { htmlSafe } from '@ember/template'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { modifier } from 'ember-modifier'; -import LinkOffIcon from '@cardstack/boxel-icons/link-off'; -import { Button, CopyButton } from '@cardstack/boxel-ui/components'; -import { cardTypeName } from '@cardstack/runtime-common'; -import type { SerializedError } from '@cardstack/runtime-common'; -import type { ViewCardFn } from '../card-api'; + +import Button from '../button/index.gts'; +import CopyButton from '../copy-button/index.gts'; type TipCorner = 'tl' | 'tr' | 'bl' | 'br'; export type BrokenLinkState = 'error' | 'not-found'; export type BrokenLinkFormat = 'isolated' | 'fitted' | 'embedded' | 'atom'; +// The failure payload the overlay reads. Kept local so boxel-ui carries no +// dependency on runtime-common (which would invert the existing +// runtime-common → boxel-ui edge into a cycle). It lists only the fields the +// template renders; runtime-common's `SerializedError` is a structural +// superset, so base callers can pass one unchanged. +export interface BrokenLinkErrorDoc { + additionalErrors?: Array<{ + message?: string; + stack?: string; + status?: number; + title?: string; + }> | null; + message?: string; + stack?: string; + status?: number; + title?: string; +} + +// Navigates to the broken reference for "Open anyway". Local so boxel-ui needn't +// import base's `ViewCardFn`; base's wider `crud.viewCard` (its first param +// accepts `URL`) stays assignable to this. +export type BrokenLinkViewFn = (url: URL) => void; + export interface BrokenLinkTemplateArgs { brokenUrl: string; - errorDoc: SerializedError; - state: BrokenLinkState; + errorDoc: BrokenLinkErrorDoc; format: BrokenLinkFormat; + state: BrokenLinkState; + // Human-readable label shown next to the link-off icon. Card sites pass the + // card type name; the BFM file chooser passes the filename. Falls back to + // 'Card' when omitted. + typeName?: string; // Threaded from the field component's CardCrudFunctions. When present, the // overlay offers an "Open anyway" affordance that navigates to the broken // reference (a stack visit in interact mode, a code-editor jump in code // mode — whatever the host's viewCard does for the current submode). - viewCard?: ViewCardFn; + viewCard?: BrokenLinkViewFn; } interface NormalizedAdditionalError { message: string; + stack?: string; status?: number; title?: string; - stack?: string; } // Only http(s) references are navigable. The brokenUrl is a card reference @@ -54,58 +81,56 @@ function parseHttpUrl(url: string): URL | null { // single-colour boxel icon can't express. Shared by the reveal trigger and the // overlay title; `@size` sets both svg dimensions so each caller can match its // context. -class WarningIcon extends GlimmerComponent<{ - Element: SVGElement; +const WarningIcon: TOC<{ Args: { size: string }; -}> { - -} + Element: SVGElement; +}> = ; export default class BrokenLinkTemplate extends GlimmerComponent<{ - Element: HTMLDivElement; Args: BrokenLinkTemplateArgs; + Element: HTMLDivElement; }> { // The placeholder box is identical for every failure — what went wrong only // surfaces inside the reveal overlay. `typeName` is the human-readable label - // shown next to the link-off icon, derived from the reference URL. + // shown next to the link-off icon, supplied by the caller. private get typeName(): string { - return cardTypeName(this.args.brokenUrl); + return this.args.typeName ?? 'Card'; } private get isNotFound() { @@ -245,6 +270,11 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ } let root = input.closest('.broken-link-template') as HTMLElement | null; if (root) { + // The overlay is revealed by pure CSS (`:checked ~ .overlay`), not an + // Ember render, so `afterRender` can't observe it. We genuinely need a + // post-paint callback to measure the laid-out overlay before picking the + // tip corner — the sanctioned use for rAF per the rule's own escape hatch. + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state requestAnimationFrame(() => this.updateTipCorner(root)); } }; diff --git a/packages/boxel-ui/test-app/tests/integration/components/broken-link-test.gts b/packages/boxel-ui/test-app/tests/integration/components/broken-link-test.gts new file mode 100644 index 00000000000..62f6ccdb366 --- /dev/null +++ b/packages/boxel-ui/test-app/tests/integration/components/broken-link-test.gts @@ -0,0 +1,121 @@ +import { click, render } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'test-app/tests/helpers'; +import { + BrokenLinkTemplate, + type BrokenLinkErrorDoc, +} from '@cardstack/boxel-ui/components'; + +const BROKEN_URL = 'https://example.com/realm/Author/exploded-card-id'; + +const notFoundDoc: BrokenLinkErrorDoc = { + status: 404, + title: 'Not Found', + message: `Could not find ${BROKEN_URL}`, + additionalErrors: null, +}; + +const errorDoc: BrokenLinkErrorDoc = { + status: 500, + title: 'Internal Server Error', + message: 'TypeError: Cannot read properties of undefined (reading "name")', + stack: 'Error: kaboom\n at Author.render (author.gts:42:7)', + additionalErrors: null, +}; + +module('Integration | Component | broken-link', function (hooks) { + setupRenderingTest(hooks); + + test('renders the placeholder box with the caller-supplied type name', async function (assert) { + await render( + , + ); + + assert.dom('[data-test-broken-link-template="embedded"]').exists(); + assert.dom('[data-test-broken-link-state="not-found"]').exists(); + assert.dom('[data-test-broken-link-type]').hasText('Author'); + // The failure reason lives only in the (hidden) overlay. + assert.dom('[data-test-broken-link-url]').isNotVisible(); + }); + + test('type name falls back to "Card" when none is supplied', async function (assert) { + await render( + , + ); + assert.dom('[data-test-broken-link-type]').hasText('Card'); + }); + + test('the reveal toggle opens the overlay with the URL and a copy button', async function (assert) { + await render( + , + ); + + assert.dom('[data-test-broken-link-overlay]').isNotVisible(); + await click('[data-test-broken-link-reveal]'); + assert.dom('[data-test-broken-link-overlay]').isVisible(); + assert.dom('[data-test-broken-link-url]').isVisible().hasText(BROKEN_URL); + assert.dom('[data-test-broken-link-copy]').exists(); + + await click('[data-test-broken-link-overlay-close]'); + assert.dom('[data-test-broken-link-overlay]').isNotVisible(); + }); + + test('the overlay headline distinguishes not-found from error', async function (assert) { + await render( + , + ); + assert + .dom('[data-test-broken-link-headline]') + .hasText('Linked card not found'); + + await render( + , + ); + assert + .dom('[data-test-broken-link-headline]') + .hasText('Linked card failed to load'); + assert + .dom('[data-test-broken-link-status]') + .includesText('500') + .includesText('Internal Server Error'); + }); +}); diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index 34f5a260578..409ab18d516 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -8,6 +8,10 @@ import { BoxelSelect, Button, } from '@cardstack/boxel-ui/components'; +import type { + BrokenLinkErrorDoc, + BrokenLinkState, +} from '@cardstack/boxel-ui/components'; import { IconX } from '@cardstack/boxel-ui/icons'; import { serializeBfmRef } from '@cardstack/runtime-common/bfm-card-references'; @@ -26,12 +30,20 @@ export { type OptionValue }; interface Signature { Element: HTMLElement; Args: { - // Resolved instance being previewed. Its `id` is the BFM ref URL. Always a - // real target — the parent (tab-panel) only mounts the pane once a row is - // picked and its instance resolves, rendering its own placeholder until then. - target: CardDef | FileDef; + // Resolved instance being previewed. Its `id` is the BFM ref URL. Absent + // when the picked/preloaded ref failed to resolve — the broken-ref args + // below then drive the preview instead. + target?: CardDef | FileDef; // Which BFM keyword to emit: `:card[...]` vs `:file[...]`. refType: 'card' | 'file'; + // Broken-ref state, set by the parent when the ref can't resolve. When + // `brokenUrl` is present (and `target` is not), the preview renders the + // broken-ref visual; the CTA still serializes `brokenUrl` so DONE/ACCEPT + // keep the ref. + brokenUrl?: string; + errorDoc?: BrokenLinkErrorDoc; + brokenState?: BrokenLinkState; + brokenTypeName?: string; // The shared format/placement/size selection. Owned by the modal and shared // across both tabs so the choice survives a tab switch; this pane is a pure // view over it plus the resolved target. @@ -59,7 +71,7 @@ export default class MarkdownEmbedPreviewPane extends Component { } private get bfmString(): string { - let url = this.args.target.id; + let url = this.args.target?.id ?? this.args.brokenUrl; if (!url) { return ''; } @@ -131,6 +143,10 @@ export default class MarkdownEmbedPreviewPane extends Component {
{ return this.args.format; } + // The broken template requires a state + error doc; default them so the arg + // types stay optional at this boundary (the parent only sets them alongside + // `brokenUrl`, but the correlation isn't expressible in the type). + private get brokenState(): BrokenLinkState { + return this.args.brokenState ?? 'error'; + } + + private get brokenErrorDoc(): BrokenLinkErrorDoc { + return this.args.errorDoc ?? {}; + } + // Fitted slots carry an inline width/height plus `overflow: hidden` so the // instance occupies the requested footprint — derived through the same helper // the live markdown renderer uses (`rendered-markdown.gts`). Inline embedded @@ -198,12 +224,22 @@ export default class MarkdownEmbedPreview extends Component { class='markdown-embed-preview-doc__word is-sm' aria-hidden='true' > - + {{#if @target}} + + {{else if @brokenUrl}} + + {{/if}}
- {{else}} + {{else if @target}} { @sizeStyle={{this.sizeStyle}} ...attributes /> + {{else if @brokenUrl}} + {{/if}} + +} diff --git a/packages/boxel-ui/addon/src/usage.ts b/packages/boxel-ui/addon/src/usage.ts index 998b4e9935c..d14fbfe824d 100644 --- a/packages/boxel-ui/addon/src/usage.ts +++ b/packages/boxel-ui/addon/src/usage.ts @@ -6,6 +6,7 @@ import AddButtonUsage from './components/add-button/usage.gts'; import AlertUsage from './components/alert/usage.gts'; import AvatarUsage from './components/avatar/usage.gts'; import BasicFittedUsage from './components/basic-fitted/usage.gts'; +import BrokenLinkUsage from './components/broken-link/usage.gts'; import ButtonUsage from './components/button/usage.gts'; import CardContainerUsage from './components/card-container/usage.gts'; import CardHeaderUsage from './components/card-header/usage.gts'; @@ -63,6 +64,7 @@ export const ALL_USAGE_COMPONENTS = [ ['Alert', AlertUsage], ['Avatar', AvatarUsage], ['BasicFitted', BasicFittedUsage], + ['BrokenLink', BrokenLinkUsage], ['Button', ButtonUsage], ['CardContainer', CardContainerUsage], ['CardHeader', CardHeaderUsage], From a9ec3c6184b8a6a151a4609c7754310a585d111e Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Jul 2026 14:57:12 +0700 Subject: [PATCH 03/10] Size the fitted broken-ref preview to the picked tile footprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fitted broken ref rendered at the template's 40px min-height because the chooser passed no dimensions — the box collapsed to a thin bar instead of the tile the real fitted embed previews. Thread the selected width/height onto the broken template (reusing the same bfmRefFormatAndSize the embed uses), so the fitted broken placeholder occupies the picked footprint. The size string omits `overflow: hidden` on purpose: the broken template's root must not clip, or the reveal overlay would be cut off — the inner box does its own clipping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser/preview/index.gts | 22 ++++++++++ .../markdown-embed-preview-test.gts | 44 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/host/app/components/markdown-embed-chooser/preview/index.gts b/packages/host/app/components/markdown-embed-chooser/preview/index.gts index 288cc4c0823..72c9963b0e0 100644 --- a/packages/host/app/components/markdown-embed-chooser/preview/index.gts +++ b/packages/host/app/components/markdown-embed-chooser/preview/index.gts @@ -170,6 +170,26 @@ export default class MarkdownEmbedPreview extends Component { return this.args.errorDoc ?? {}; } + // A fitted broken placeholder takes the same picked footprint as a real + // fitted embed, so the chooser previews the actual tile size rather than + // collapsing to the template's min-height. Unlike the embed's `sizeStyle` + // this omits `overflow: hidden`: the broken template's root must not clip, or + // the reveal overlay (which extends beyond the placeholder) would be cut off + // — the inner `.box` handles its own clipping. Non-fitted formats keep their + // intrinsic footprint. + private get brokenSizeStyle(): ReturnType | undefined { + if (this.args.format !== 'fitted') { + return undefined; + } + let { width, height } = this.args.sizeSpec ?? { format: 'fitted' }; + let { sizeStyle } = bfmRefFormatAndSize( + 'fitted', + width === undefined ? undefined : String(width), + height === undefined ? undefined : String(height), + ); + return sizeStyle ? htmlSafe(sizeStyle) : undefined; + } + // Fitted slots carry an inline width/height plus `overflow: hidden` so the // instance occupies the requested footprint — derived through the same helper // the live markdown renderer uses (`rendered-markdown.gts`). Inline embedded @@ -238,6 +258,7 @@ export default class MarkdownEmbedPreview extends Component { @errorDoc={{this.brokenErrorDoc}} @state={{this.brokenState}} @format={{@format}} + style={{this.brokenSizeStyle}} /> {{/if}} { @errorDoc={{this.brokenErrorDoc}} @state={{this.brokenState}} @format={{@format}} + style={{this.brokenSizeStyle}} ...attributes /> {{/if}} diff --git a/packages/host/tests/integration/components/markdown-embed-preview-test.gts b/packages/host/tests/integration/components/markdown-embed-preview-test.gts index 2890dfab8d0..fffea717e03 100644 --- a/packages/host/tests/integration/components/markdown-embed-preview-test.gts +++ b/packages/host/tests/integration/components/markdown-embed-preview-test.gts @@ -367,4 +367,48 @@ module('Integration | markdown-embed-preview', function (hooks) { ); assert.dom('[data-test-broken-link-type]').hasText('notes.md'); }); + + test('a fitted broken ref takes the picked tile footprint and does not clip its overlay', async function (assert) { + let brokenUrl = `${testRealmURL}Book/deleted`; + let errorDoc = { + status: 404, + title: 'Not Found', + message: `Could not find ${brokenUrl}`, + additionalErrors: null, + }; + // tall-tile = 150x275 + let tallTile: BfmSizeSpec = { format: 'fitted', width: 150, height: 275 }; + await render( + , + ); + let style = + document + .querySelector('[data-test-broken-link-template="fitted"]') + ?.getAttribute('style') ?? ''; + assert.ok( + style.includes('width: 150px'), + `fitted broken box takes the picked width (${style})`, + ); + assert.ok( + style.includes('height: 275px'), + `fitted broken box takes the picked height (${style})`, + ); + // The root must not clip, or the reveal overlay would be cut off — only the + // inner box clips its own content. + assert.notOk( + style.includes('overflow'), + 'the broken root does not set overflow, so the reveal overlay is not clipped', + ); + }); }); From 128d16bd46b4c21e02c4b860f795e8d30230952a Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Jul 2026 15:06:18 +0700 Subject: [PATCH 04/10] Show the current-target label as a realm-relative path when in-realm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In edit mode the tile above Replace / Remove falls back to the raw reference URL when there's no title to show (a broken ref, or a card without a title). When that reference lives in the realm the user currently has open, collapse it to its realm-relative path — a shorter, less noisy label. References outside the current realm keep their absolute URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser/tab-panel.gts | 36 +++++++++++++++++-- .../markdown-embed-chooser-modal-test.gts | 5 ++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts index 94468233fdb..a6ca1fe5189 100644 --- a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts +++ b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts @@ -16,6 +16,7 @@ import { eq } from '@cardstack/boxel-ui/helpers'; import { isCardErrorJSONAPI, + RealmPaths, type CardErrorJSONAPI, } from '@cardstack/runtime-common'; @@ -31,6 +32,7 @@ import type { MarkdownEmbedInitialTarget, MarkdownEmbedRefType, } from '@cardstack/host/services/markdown-embed-chooser'; +import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; import type StoreService from '@cardstack/host/services/store'; import type { CardDef, FileDef } from 'https://cardstack.com/base/card-api'; @@ -75,6 +77,8 @@ interface Signature { // Replace flips it back to the chooser so the user can swap in a new ref. export default class MarkdownEmbedChooserTabPanel extends Component { @service declare private store: StoreService; + @service + declare private operatorModeStateService: OperatorModeStateService; @tracked private selectedTarget: CardDef | FileDef | undefined; @tracked private selectedUrl: string | undefined; @@ -124,11 +128,39 @@ export default class MarkdownEmbedChooserTabPanel extends Component { private get currentTargetLabel(): string { let t = this.selectedTarget; - if (!t) return this.selectedUrl ?? ''; + if (!t) return this.toDisplayUrl(this.selectedUrl ?? ''); if (this.args.refType === 'file') { return fileNameFromUrl(t.id ?? this.selectedUrl ?? ''); } - return (t as CardDef).cardTitle ?? t.id ?? this.selectedUrl ?? ''; + return ( + (t as CardDef).cardTitle ?? + this.toDisplayUrl(t.id ?? this.selectedUrl ?? '') + ); + } + + // When the label falls back to showing a raw URL (a broken ref, or a card + // with no title), collapse it to a realm-relative path if it lives in the + // realm the user currently has open — a shorter, less noisy label than the + // absolute URL. References outside the current realm keep their full URL. + private toDisplayUrl(url: string): string { + if (!url) return url; + let realmURL: string | undefined; + try { + realmURL = this.operatorModeStateService.realmURL; + } catch { + realmURL = undefined; + } + if (!realmURL) return url; + try { + let paths = new RealmPaths(new URL(realmURL)); + let parsed = new URL(url); + if (paths.inRealm(parsed)) { + return paths.local(parsed); + } + } catch { + // Malformed URL or outside the realm — fall back to the absolute URL. + } + return url; } @action diff --git a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts index bc029b40097..1be83441f22 100644 --- a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts +++ b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts @@ -304,11 +304,14 @@ module('Integration | markdown-embed-chooser-modal', function (hooks) { '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-chooser-current]', ) .exists('the current-target tile renders for the broken preload'); + // The label falls back to the ref (no title to show). It renders either the + // full URL or, when the ref is in the currently-open realm, its realm- + // relative path — both end in the ref's path, so match on that. assert .dom( '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-chooser-current-label]', ) - .hasText(brokenUrl, 'the label falls back to the broken URL'); + .includesText('books/ghost', 'the label falls back to the broken ref'); assert .dom('[data-test-markdown-embed-chooser-remove]') .exists('Remove is available'); From 61da84f0111528f04e189824be02c0e3fbaf217d Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 9 Jul 2026 20:11:10 +0700 Subject: [PATCH 05/10] Name the broken-ref by card vs file in the reveal headline The broken-ref overlay headline is now driven by a `@noun` arg on BrokenLinkTemplate ("Linked card not found" vs "Linked file not found"), defaulting to 'card' so the linksTo/linksToMany field visuals are unchanged. The markdown-embed chooser threads 'file' for `:file[...]` refs so a broken file no longer reads as a card. Tighten the chooser label test to assert the exact realm-relative path for an in-realm ref, and add a case for an out-of-realm ref keeping its absolute URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/broken-link/index.gts | 13 +++++- .../src/components/broken-link/usage.gts | 10 +++++ .../components/broken-link-test.gts | 21 +++++++++ .../markdown-embed-chooser/pane.gts | 2 + .../markdown-embed-chooser/preview/index.gts | 3 ++ .../markdown-embed-chooser/tab-panel.gts | 8 ++++ .../markdown-embed-chooser-modal-test.gts | 45 +++++++++++++++++-- .../markdown-embed-preview-test.gts | 6 ++- 8 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/broken-link/index.gts b/packages/boxel-ui/addon/src/components/broken-link/index.gts index c2ba6dbfb00..9f2fb5304b3 100644 --- a/packages/boxel-ui/addon/src/components/broken-link/index.gts +++ b/packages/boxel-ui/addon/src/components/broken-link/index.gts @@ -42,6 +42,11 @@ export interface BrokenLinkTemplateArgs { brokenUrl: string; errorDoc: BrokenLinkErrorDoc; format: BrokenLinkFormat; + // The kind of reference, used only for the reveal-overlay headline + // ("Linked card not found" vs "Linked file not found"). `linksTo` field + // sites are always cards; the BFM chooser passes 'file' for `:file[...]` + // refs. Falls back to 'card' when omitted. + noun?: string; state: BrokenLinkState; // Human-readable label shown next to the link-off icon. Card sites pass the // card type name; the BFM file chooser passes the filename. Falls back to @@ -137,10 +142,14 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ return this.args.state === 'not-found'; } + private get noun(): string { + return this.args.noun ?? 'card'; + } + private get headline() { return this.isNotFound - ? 'Linked card not found' - : 'Linked card failed to load'; + ? `Linked ${this.noun} not found` + : `Linked ${this.noun} failed to load`; } private get statusLabel(): string { diff --git a/packages/boxel-ui/addon/src/components/broken-link/usage.gts b/packages/boxel-ui/addon/src/components/broken-link/usage.gts index 71edede1b87..9f13736a9a9 100644 --- a/packages/boxel-ui/addon/src/components/broken-link/usage.gts +++ b/packages/boxel-ui/addon/src/components/broken-link/usage.gts @@ -12,6 +12,7 @@ import BrokenLinkTemplate, { export default class BrokenLinkUsage extends Component { @tracked brokenUrl = 'https://example.com/realm/Author/exploded-card-id'; @tracked typeName = 'Author'; + @tracked noun = 'card'; @tracked state: BrokenLinkState = 'not-found'; @tracked format: BrokenLinkFormat = 'embedded'; @@ -54,6 +55,7 @@ export default class BrokenLinkUsage extends Component { + + + , + ); + assert + .dom('[data-test-broken-link-headline]') + .hasText( + 'Linked file not found', + 'the supplied noun drives the headline', + ); + }); }); diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index 409ab18d516..b9fc6c884b8 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -44,6 +44,7 @@ interface Signature { errorDoc?: BrokenLinkErrorDoc; brokenState?: BrokenLinkState; brokenTypeName?: string; + brokenNoun?: string; // The shared format/placement/size selection. Owned by the modal and shared // across both tabs so the choice survives a tab switch; this pane is a pure // view over it plus the resolved target. @@ -145,6 +146,7 @@ export default class MarkdownEmbedPreviewPane extends Component { @target={{@target}} @brokenUrl={{@brokenUrl}} @brokenTypeName={{@brokenTypeName}} + @brokenNoun={{@brokenNoun}} @errorDoc={{@errorDoc}} @brokenState={{@brokenState}} @format={{@selection.previewFormat}} diff --git a/packages/host/app/components/markdown-embed-chooser/preview/index.gts b/packages/host/app/components/markdown-embed-chooser/preview/index.gts index 72c9963b0e0..b83d5098998 100644 --- a/packages/host/app/components/markdown-embed-chooser/preview/index.gts +++ b/packages/host/app/components/markdown-embed-chooser/preview/index.gts @@ -128,6 +128,7 @@ interface Signature { errorDoc?: BrokenLinkErrorDoc; brokenState?: BrokenLinkState; brokenTypeName?: string; + brokenNoun?: string; // Render format. `fitted` consults `@sizeSpec` for its width/height; // atom/embedded/isolated ignore it. format: EmbedFormat; @@ -255,6 +256,7 @@ export default class MarkdownEmbedPreview extends Component { { { : cardTypeName(this.selectedUrl); } + // Drives the broken-ref overlay headline ("Linked file not found" vs the + // card wording) so a broken `:file[...]` ref doesn't read as a card. + private get brokenNoun(): string | undefined { + if (!this.selectedError) return undefined; + return this.args.refType === 'file' ? 'file' : 'card'; + } + @action private handleInsert(bfm: string) { let url = this.currentUrl; @@ -326,6 +333,7 @@ export default class MarkdownEmbedChooserTabPanel extends Component { @brokenUrl={{this.brokenUrl}} @brokenState={{this.brokenState}} @brokenTypeName={{this.brokenTypeName}} + @brokenNoun={{this.brokenNoun}} @errorDoc={{this.brokenErrorDoc}} /> {{else}} diff --git a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts index 1be83441f22..ecf78c92add 100644 --- a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts +++ b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts @@ -304,14 +304,17 @@ module('Integration | markdown-embed-chooser-modal', function (hooks) { '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-chooser-current]', ) .exists('the current-target tile renders for the broken preload'); - // The label falls back to the ref (no title to show). It renders either the - // full URL or, when the ref is in the currently-open realm, its realm- - // relative path — both end in the ref's path, so match on that. + // The label falls back to the ref (no title to show). The ref lives in the + // currently-open realm (testRealmURL), so it collapses to its realm- + // relative path rather than the absolute URL. assert .dom( '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-chooser-current-label]', ) - .includesText('books/ghost', 'the label falls back to the broken ref'); + .hasText( + 'books/ghost', + 'an in-realm broken ref labels as its realm-relative path', + ); assert .dom('[data-test-markdown-embed-chooser-remove]') .exists('Remove is available'); @@ -323,6 +326,40 @@ module('Integration | markdown-embed-chooser-modal', function (hooks) { await pending; }); + test('a broken ref outside the current realm keeps its full URL as the label', async function (assert) { + await render( + , + ); + + let svc = getService( + 'markdown-embed-chooser', + ) as MarkdownEmbedChooserService; + // A broken ref in the base realm — not the currently-open realm + // (testRealmURL), so the realm-relative collapse must not apply. + let brokenUrl = `${baseRealm.url}ghost-card`; + let pending = svc.editEmbed({ + refType: 'card', + url: brokenUrl, + sizeSpec: 'embedded', + }); + await waitFor( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-broken-link-template]', + { timeout: 5000 }, + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-chooser-current-label]', + ) + .hasText(brokenUrl, 'an out-of-realm broken ref keeps its absolute URL'); + + svc.resolve(undefined); + await pending; + }); + test('Remove resolves the deferred with { remove: true }', async function (assert) { await render( , ); @@ -106,7 +106,7 @@ module('Integration | Component | broken-link', function (hooks) { @errorDoc={{errorDoc}} @state='error' @format='embedded' - @typeName='Author' + @displayName='Author' /> , ); @@ -119,7 +119,7 @@ module('Integration | Component | broken-link', function (hooks) { .includesText('Internal Server Error'); }); - test('the headline noun defaults to "card" and honors an override', async function (assert) { + test('the headline itemType defaults to "card" and honors an override', async function (assert) { await render( , ); @@ -136,7 +136,24 @@ module('Integration | Component | broken-link', function (hooks) { .dom('[data-test-broken-link-headline]') .hasText( 'Linked file not found', - 'the supplied noun drives the headline', + 'the supplied itemType drives the headline', ); }); + + test('the label falls back to the capitalized itemType when no displayName is given', async function (assert) { + await render( + , + ); + assert + .dom('[data-test-broken-link-type]') + .hasText('File', 'the label capitalizes the itemType fallback'); + }); }); diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index 54d08c561cb..97206014c0b 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -10,6 +10,7 @@ import { } from '@cardstack/boxel-ui/components'; import type { BrokenLinkErrorDoc, + BrokenLinkItemType, BrokenLinkState, } from '@cardstack/boxel-ui/components'; import { IconX } from '@cardstack/boxel-ui/icons'; @@ -44,8 +45,8 @@ interface Signature { brokenUrl?: string; errorDoc?: BrokenLinkErrorDoc; brokenState?: BrokenLinkState; - brokenTypeName?: string; - brokenNoun?: string; + brokenDisplayName?: string; + brokenItemType?: BrokenLinkItemType; // The shared format/placement/size selection. Owned by the modal and shared // across both tabs so the choice survives a tab switch; this pane is a pure // view over it plus the resolved target. @@ -161,8 +162,8 @@ export default class MarkdownEmbedPreviewPane extends Component { = ; +interface BrokenEmbedSignature { + Element: HTMLElement; + Args: { + brokenUrl: string; + errorDoc: BrokenLinkErrorDoc; + state: BrokenLinkState; + displayName?: string; + itemType?: BrokenLinkItemType; + format: EmbedFormat; + kind: 'inline' | 'block'; + sizeStyle?: ReturnType; + }; +} + +// Placement wrapper for a broken ref, mirroring Embed's inline/block behavior so +// a broken inline ref (`:card[url | embedded]`) previews inline — the same +// placement it will serialize to — rather than collapsing to a block. Unlike +// Embed it adds no card-frame and never sets `overflow: hidden`: the broken +// template draws its own box, and its reveal overlay must stay free to extend +// beyond the placeholder footprint. It carries no `data-test-markdown-embed-preview` +// so the broken state stays distinguishable from a resolved embed. +const BrokenEmbed: TOC = ; + interface Signature { Element: HTMLElement; Args: { @@ -127,8 +206,8 @@ interface Signature { brokenUrl?: string; errorDoc?: BrokenLinkErrorDoc; brokenState?: BrokenLinkState; - brokenTypeName?: string; - brokenNoun?: string; + brokenDisplayName?: string; + brokenItemType?: BrokenLinkItemType; // Render format. `fitted` consults `@sizeSpec` for its width/height; // atom/embedded/isolated ignore it. format: EmbedFormat; @@ -168,7 +247,7 @@ export default class MarkdownEmbedPreview extends Component { } private get brokenErrorDoc(): BrokenLinkErrorDoc { - return this.args.errorDoc ?? {}; + return this.args.errorDoc ?? EMPTY_ERROR_DOC; } // A fitted broken placeholder takes the same picked footprint as a real @@ -253,14 +332,15 @@ export default class MarkdownEmbedPreview extends Component { @sizeStyle={{this.sizeStyle}} /> {{else if @brokenUrl}} - {{/if}} { ...attributes /> {{else if @brokenUrl}} - {{/if}} diff --git a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts index 9c8dd059f8a..507c26fb39c 100644 --- a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts +++ b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts @@ -10,6 +10,7 @@ import { restartableTask } from 'ember-concurrency'; import { BoxelButton } from '@cardstack/boxel-ui/components'; import type { BrokenLinkErrorDoc, + BrokenLinkItemType, BrokenLinkState, } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; @@ -221,7 +222,7 @@ export default class MarkdownEmbedChooserTabPanel extends Component { // Card refs label by type name; file refs label by filename — matching the // label the base `linksTo` broken visual derives for cards. - private get brokenTypeName(): string | undefined { + private get brokenDisplayName(): string | undefined { if (!this.selectedError || !this.selectedUrl) return undefined; return this.args.refType === 'file' ? fileNameFromUrl(this.selectedUrl) @@ -230,7 +231,7 @@ export default class MarkdownEmbedChooserTabPanel extends Component { // Drives the broken-ref overlay headline ("Linked file not found" vs the // card wording) so a broken `:file[...]` ref doesn't read as a card. - private get brokenNoun(): string | undefined { + private get brokenItemType(): BrokenLinkItemType | undefined { if (!this.selectedError) return undefined; return this.args.refType === 'file' ? 'file' : 'card'; } @@ -325,8 +326,8 @@ export default class MarkdownEmbedChooserTabPanel extends Component { @ctaLabelOverride={{this.ctaLabelOverride}} @brokenUrl={{this.brokenUrl}} @brokenState={{this.brokenState}} - @brokenTypeName={{this.brokenTypeName}} - @brokenNoun={{this.brokenNoun}} + @brokenDisplayName={{this.brokenDisplayName}} + @brokenItemType={{this.brokenItemType}} @errorDoc={{this.brokenErrorDoc}} /> {{else}} diff --git a/packages/host/tests/integration/components/broken-link-template-test.gts b/packages/host/tests/integration/components/broken-link-template-test.gts index 8106b64db5b..9d94d64c84c 100644 --- a/packages/host/tests/integration/components/broken-link-template-test.gts +++ b/packages/host/tests/integration/components/broken-link-template-test.gts @@ -47,9 +47,9 @@ type Scenario = { }; async function renderTemplate(scenario: Scenario) { - // The type-name label is now a caller-supplied prop; base card sites pass + // The display-name label is now a caller-supplied prop; base card sites pass // `cardTypeName(reference)`, so derive it the same way here. - let typeName = cardTypeName(scenario.brokenUrl); + let displayName = cardTypeName(scenario.brokenUrl); await render( , ); diff --git a/packages/host/tests/integration/components/codemirror-embed-toolbar-test.gts b/packages/host/tests/integration/components/codemirror-embed-toolbar-test.gts index 157548d39c5..9117fda95b1 100644 --- a/packages/host/tests/integration/components/codemirror-embed-toolbar-test.gts +++ b/packages/host/tests/integration/components/codemirror-embed-toolbar-test.gts @@ -106,12 +106,15 @@ async function loadCodeMirrorEditor() { async function renderEditorAndModal(opts: { CodeMirrorEditor: any; harness: ContentHarness; + // The editing document's own URL. When set, document-relative refs in the + // body (`../books/mango`) resolve against it — matching the live editor. + cardReferenceBaseUrl?: string; }) { // Static `, + ); + let wrapper = document.querySelector('.broken-embed'); + assert.ok(wrapper, 'the broken ref is wrapped for placement'); + assert.strictEqual( + wrapper?.tagName, + 'SPAN', + 'inline kind wraps the broken ref in a span (inline placement), not a block div', + ); + // A non-atom inline ref takes the inline-block placement class — the same + // treatment a resolved inline embed gets — so it flows within text instead + // of collapsing to a block. (Computed display can't be asserted here: the + // PreviewBox flex parent blockifies its items.) + assert.ok( + wrapper?.classList.contains('broken-embed--inline-embed'), + 'an inline embedded broken ref uses the inline-block placement class', + ); + assert + .dom('.broken-embed [data-test-broken-link-template="embedded"]') + .exists('the broken template renders inside the inline wrapper'); + }); }); From 87910378d47282e690beab07d3b9b6fb30240201 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 14 Jul 2026 23:51:04 +0700 Subject: [PATCH 08/10] Use @size on broken-link overlay close button Address Burcu's review: drive the overlay close ContextButton via @size='extra-small' instead of hard-coded @width/@height, and drop the now-redundant --boxel-icon-button-* size vars and the color override (default black) from .overlay-close. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../boxel-ui/addon/src/components/broken-link/index.gts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/broken-link/index.gts b/packages/boxel-ui/addon/src/components/broken-link/index.gts index def52b6a218..d712035b710 100644 --- a/packages/boxel-ui/addon/src/components/broken-link/index.gts +++ b/packages/boxel-ui/addon/src/components/broken-link/index.gts @@ -410,8 +410,7 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ @icon='close' @label='Close' @variant='ghost' - @width='13' - @height='13' + @size='extra-small' {{on 'click' this.close}} data-test-broken-link-overlay-close /> @@ -733,12 +732,6 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ .overlay-close { flex: none; margin: -2px -2px 0 0; - --boxel-icon-button-width: 1.25rem; - --boxel-icon-button-height: 1.25rem; - color: var(--boxel-400); - } - .overlay-close:hover { - color: var(--boxel-dark); } .overlay-url-row { display: flex; From 9910ccb2c0c0e3dde755e41d38044730f6144b14 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Wed, 15 Jul 2026 07:27:35 +0700 Subject: [PATCH 09/10] Fix capital word --- ...s-11678-broken-card-file-rendering-plan.md | 185 ++++++++++++++++++ docs/cs-11678-pr-feedback-plan.md | 184 +++++++++++++++++ .../format-selection.ts | 2 +- .../markdown-embed-chooser/pane.gts | 4 +- .../markdown-embed-chooser/tab-panel.gts | 4 +- .../markdown-embed-preview-pane-test.gts | 2 +- 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 docs/cs-11678-broken-card-file-rendering-plan.md create mode 100644 docs/cs-11678-pr-feedback-plan.md diff --git a/docs/cs-11678-broken-card-file-rendering-plan.md b/docs/cs-11678-broken-card-file-rendering-plan.md new file mode 100644 index 00000000000..05179c790e5 --- /dev/null +++ b/docs/cs-11678-broken-card-file-rendering-plan.md @@ -0,0 +1,185 @@ +# CS-11678 — Broken card / broken file rendering (Option D: move template to boxel-ui) + +## Context + +BFM refs (`:card[...]` / `:file[...]`) can point to a target that no longer +resolves (deleted, moved, no permission). Today the **chooser preview pane** and +the **Edit modal** can't express that: `tab-panel.gts`'s `loadTarget` collapses +"still loading / nothing picked" and "resolution failed" into the same +`selectedTarget = undefined`, so a broken ref just shows the _"Search for a … & +preview its format here"_ placeholder. The ticket wants these surfaces to render +the same rich broken-ref visual that broken `linksTo`/`linksToMany` field values +use — the reveal overlay with URL, copy button, and error diagnostics. + +That visual is `BrokenLinkTemplate`, which today lives in **base** +(`packages/base/default-templates/broken-link-template.gts`) and is only +reachable from host at runtime. Rather than pioneer a runtime component-render in +host, we relocate the component to its architecturally-correct home — +**`@cardstack/boxel-ui`**, the package both base and host already static-import +components from (the template itself imports `@cardstack/boxel-ui/components`). +Then every surface uses a plain static import. + +Confirmed scope decisions: + +1. **Full parity** with the `linksTo` broken visual (not the light inline box). +2. **Reuse one real component** — relocated to boxel-ui, imported statically. +3. **Affordance = treat the broken ref as a selected card/file** — the Edit + modal's existing `current`-mode **Remove / Replace** tile is the affordance. +4. **Leave the inline editor render (`rendered-markdown.gts`) as-is.** + +## Why boxel-ui (decision rationale, keep for reviewers) + +- boxel-ui is the shared-component package both base **and** host consume; the + template already imports `@cardstack/boxel-ui/components`. +- Its scoped CSS is compiled into the addon and shipped in the **host app + bundle**, so styles are globally present for both the host chooser render and + the base `linksTo` renders — **no runtime `.glimmer-scoped.css` injection**. +- The only outside deps are trivially removable (see below) → **no dependency + cycle** (boxel-ui must not depend on runtime-common; runtime-common→boxel-ui + already exists). +- Making `typeName` a prop is **required anyway**: base `linksTo` sites are always + cards (type-name label), but the BFM chooser must also label **files** by + filename. A caller-supplied `@typeName` serves both. + +Rejected alternatives: (A) keep in base + host `loader.import` at runtime — works, +but a novel host render pattern and subtler CSS story; (C) move to runtime-common +— Node-consumed/framework-light package, no component precedent, footgun. + +## Decouple the three outside imports (removes the cycle) + +In the relocated component: + +- `cardTypeName` (value, `runtime-common`) → **drop**; add `@typeName: string` + arg; `typeName` getter returns `this.args.typeName ?? 'Card'`. +- `SerializedError` (type, `runtime-common`) → local `interface +BrokenLinkErrorDoc` with only the fields read: `status?`, `title?`, + `message?`, `stack?`, `additionalErrors?: Array<{message?;title?;status?;stack?}> | null`. + (Base callers pass a `SerializedError`, structurally a superset — assignable.) +- `ViewCardFn` (type, base `../card-api`) → local `type BrokenLinkViewFn = (url: +URL) => void`. Base's `crud.viewCard` stays assignable (its wider first param + accepts `URL`). + +## Change set + +### 1. New boxel-ui component + +`packages/boxel-ui/addon/src/components/broken-link/index.gts` + +- Move the file content verbatim; swap the 3 imports as above; add the + `@typeName` arg + getter; keep `WarningIcon`, all CSS, overlay/anchor logic. +- Export `BrokenLinkFormat`, `BrokenLinkState`, and the arg types. + Register in `packages/boxel-ui/addon/src/components.ts`: + `import BrokenLink from './components/broken-link/index.gts';` and add to the + export block (alias `BrokenLinkTemplate` for a drop-in name; export the types). + +### 2. Base consumers — 5 call sites, behavior identical + +`card-api.gts:1601`, `links-to-editor.gts:107`, +`links-to-many-component.gts:301 / 456 / 710`: + +- Import `BrokenLinkTemplate` from `@cardstack/boxel-ui/components` (drop the + `./default-templates/broken-link-template` import). Move the `BrokenLinkFormat` + type import at `card-api.gts:117` to boxel-ui too. +- Add `@typeName={{cardTypeName }}` to each site (each already has the + reference URL; `cardTypeName` is invocable as a strict-mode helper). Value is + identical to what the component computed internally → **no visual change**. +- `brokenLinkFormat` helper (`card-api.gts:2236`) stays in base; its return type + now comes from boxel-ui. + +### 3. Delete the base template + +Remove `packages/base/default-templates/broken-link-template.gts`. + +### 4. Host chooser (the actual ticket) — thread the broken state + +`tab-panel.gts`: + +- Add `@tracked selectedError: CardErrorJSONAPI | undefined`. +- `loadTarget`: clear it up front; on `isCardErrorJSONAPI(result)` keep + `selectedUrl`, leave `selectedTarget` undefined, set `selectedError`. +- Right panel: mount the pane when `selectedTarget` **or** `selectedError`; keep + the empty placeholder only when neither. +- Derive & pass down: `brokenUrl` (= `selectedUrl`), `brokenState` + (`status === 404 ? 'not-found' : 'error'`), `errorDoc` (the `CardErrorJSONAPI`; + map `meta?.stack` → `stack` — structurally satisfies `BrokenLinkErrorDoc`), and + `brokenTypeName` (`refType === 'file' ? fileNameFromUrl(url) : cardTypeName(url)`). +- Edit-mode `current` tile already handles broken (label/url fall back to + `selectedUrl`; Remove/Replace already render). + +`pane.gts` (`MarkdownEmbedPreviewPane`): `@target` optional; add `@brokenUrl?`, +`@errorDoc?`, `@brokenState?`, `@brokenTypeName?`; `bfmString` uses +`target?.id ?? brokenUrl`; forward broken args to `MarkdownEmbedPreview`. +Format/size/CTA stay. + +`preview/index.gts` (`MarkdownEmbedPreview`): `@target` optional; add the broken +args; `import { BrokenLinkTemplate } from '@cardstack/boxel-ui/components'` +(static). Render: `@target` → existing ``; else if `@brokenUrl` → + +``` + +``` + +Format maps 1:1 (`atom|embedded|fitted|isolated`). No `@viewCard` (no "Open +anyway" here, per decision 3). Keep `@showSurroundingText` wrapping. + +## Critical files + +- New: `packages/boxel-ui/addon/src/components/broken-link/index.gts`; edit + `packages/boxel-ui/addon/src/components.ts`. +- Base: `packages/base/card-api.gts`, `packages/base/links-to-editor.gts`, + `packages/base/links-to-many-component.gts`; delete + `packages/base/default-templates/broken-link-template.gts`. +- Host: `packages/host/app/components/markdown-embed-chooser/{tab-panel,pane,preview/index}.gts`. +- Reuse: `cardTypeName`, `fileNameFromUrl`, `isCardErrorJSONAPI`, `CardErrorJSONAPI` + from `@cardstack/runtime-common` (subpaths). + +## Verification + +- **boxel-ui build**: `cd packages/boxel-ui/addon && pnpm start` (watch/rebuild). +- Add a boxel-ui **test-app** test for the moved component + (`packages/boxel-ui/test-app`, `ember test --path dist --filter "broken"`): + renders box + `@typeName`; reveal toggle opens the overlay with URL/copy; + `not-found` vs `error` variants. +- Update `packages/host/tests/integration/components/broken-link-template-test.gts` + → import from `@cardstack/boxel-ui/components`, pass `@typeName`. +- `linksto-broken-link-placeholder-test.gts` must still pass unchanged (same + typeName value) — this is the regression guard for the shipped `linksTo` UI. +- Add broken-state cases to the chooser suites: `markdown-embed-preview-test.gts` + (renders broken template for 404 + error; card vs file `typeName`), + `markdown-embed-preview-pane-test.gts` (pane surfaces broken; CTA serializes the + URL), `markdown-embed-chooser-modal-test.gts` (Edit-mode broken preload shows + broken preview + Remove/Replace, no empty placeholder; choose-mode broken row + shows broken preview). +- Host run: `pnpm exec ember test --path dist --filter "markdown-embed"` and + `--filter "broken-link"` (capture to a file per AGENTS.md). Rely on CI for full + suite. Lint: `pnpm lint` in `packages/host` and `packages/boxel-ui/addon`. +- Optional manual check via Chrome MCP at `https://localhost:4200/tests`. + +## Risk / blast radius + +Touches the **shipped** `linksTo`/`linksToMany` broken UI (import swap + one added +`@typeName` per call site) and deletes a base file. Every edit is mechanical and +the rendered result is identical (same `typeName` value, same component); the +existing `broken-link-template-test` + `linksto-broken-link-placeholder-test` +guard it. Watch for: strict-mode `cardTypeName` helper invocation in base +templates; the boxel-ui→base type import must be fully removed (no cycle); +`errorDoc` structural compatibility. + +## Out of scope + +- `rendered-markdown.gts` inline render (decision 4). +- Any "Open anyway"/navigation affordance inside the chooser. +- Behavioral changes to the `linksTo`/`linksToMany` broken UI (visual parity only). + +## Process (AGENTS.md Linear flow) + +- Ticket already assigned + In Progress. +- Refresh `docs/cs-11678-broken-card-file-rendering-plan.md` from this file + (the pre-plan-mode copy there predates the boxel-ui decision); delete it before + merge. +- Branch `cs-11678-broken-card-broken-file-rendering`; post plan as a Linear + comment; open a **draft** PR (`gh pr create --draft --base main`) via the + `FadhlanR` gh account. PR touches no `packages/boxel-cli/**`, so a plain + descriptive title (no conventional-commit prefix). diff --git a/docs/cs-11678-pr-feedback-plan.md b/docs/cs-11678-pr-feedback-plan.md new file mode 100644 index 00000000000..5d550545b0b --- /dev/null +++ b/docs/cs-11678-pr-feedback-plan.md @@ -0,0 +1,184 @@ +# CS-11678 — PR #5444 review-feedback plan + +Addresses all feedback on **PR #5444 "Render the broken-ref visual in the +markdown-embed chooser"**. Design reference: Zeplin _"DETAILED ERROR MESSAGE FOR +BROKEN/MISSING CARD V2"_ (saved at `.context/design/broken-card-v2.png` + +`broken-card-v2-spec.md`). + +Guiding principle for this round (per request): **reach for an existing +`--boxel-*` token before any literal value.** Literals are kept only where the +design specifies a fixed device pixel with no matching token (the 350 px panel +width, 155/600 px height envelope) or where a two-tone SVG can't express a single +theme colour. Each such exception is called out below. + +--- + +## Feedback inventory + +| # | Source | Location | Item | +| --- | ------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Burcu | `broken-link/index.gts:89` | Bake fill/stroke into the warning SVG, optimize, move to boxel-ui **raw-icons** | +| 2 | Burcu | `broken-link/usage.gts:15` | Rename `noun`→`itemType`, `typeName`→`displayName`; fall back to capitalized `itemType`; derive `itemType` from payload where possible | +| 3 | Burcu | review body | Match all CSS to the design; use **our context button** for the overlay × | +| 4 | Codex (P2) | `preview/index.gts:256` | Broken **inline** ref previews as a block; route it through the inline wrapper so placement matches serialization | +| 5 | Copilot | `preview/index.gts:172` | `brokenErrorDoc` returns a fresh `{}` each render — use a stable const | +| 6 | Copilot (low-conf) | `broken-link/index.gts:50` | `noun` typed `string`; narrow to a union — folded into #2 | + +--- + +## Item 1 — Warning icon → boxel-ui raw-icon + +**Today:** an inline `WarningIcon` TOC (`index.gts:89-128`) draws a two-tone +amber triangle + black `!`, triangle filled via `var(--boxel-warning-200,#ffba00)`. + +**Target:** a committed raw SVG with fill/stroke **baked in**, compiled through the +boxel-ui icon pipeline. + +- Author `packages/boxel-ui/addon/raw-icons/warning-triangle-filled.svg` (kebab + name; avoid the existing generic `warning.svg`). Two paths + circle, each with an + **explicit per-path `fill`/`stroke`** — SVGO drops a root `fill`, so colour must + live on each path (the `google-color.svg` precedent). +- Run `pnpm rebuild:icons` from `packages/boxel-ui/addon`; commit both the raw SVG + and the regenerated `src/icons/warning-triangle-filled.gts` + updated + `src/icons.gts`. +- Replace the inline `WarningIcon` usages (reveal trigger `@size='17'`, overlay + title `@size='16'`) with ``. +- Delete the inline `WarningIcon` TOC. + +**Colour decision (needs confirmation — see Open Decisions):** the design triangle +is `#ffa515`; the nearest token is `--boxel-warning-200` = `#ffba00`. Because a +two-tone icon can't be driven by the single `--icon-color` var and Burcu asked for +baked colour, the SVG will hardcode the triangle fill + `#1a1a1a` mark. This is the +one deliberate departure from "use a token". Proposed baked value: **`#ffa515`** +(design), unless we prefer to bake the existing token's `#ffba00` for brand +consistency. + +## Item 2 + 6 — Prop rename & type-narrowing + +Rename across the component and **every** call site: + +- `noun` → **`itemType`**, typed `'card' | 'file'` (was `string`) — resolves #6. +- `typeName` → **`displayName`** (stays optional `string`). +- Fallback: label + headline use `displayName ?? capitalize(itemType)` + (so a missing `displayName` yields "Card"/"File", replacing today's hardcoded + `'Card'`/`'card'` defaults). `itemType` defaults to `'card'`. +- `itemType` is already known at each call site (base `linksTo` = always `'card'`; + the chooser's `tab-panel` computes card-vs-file). Keep it caller-supplied — that + _is_ deriving it from the payload; no heuristic sniffing of `errorDoc`. + +**Files:** + +- `packages/boxel-ui/addon/src/components/broken-link/index.gts` — args, getters + (`itemType`, `displayName`, `headline`, `capitalize` helper), template. +- `packages/boxel-ui/addon/src/components/broken-link/usage.gts` — tracked props, + `Args.String` panels, `@options`. +- `packages/boxel-ui/addon/src/components.ts` — exported type names if any change. +- Base: `card-api.gts:1609`, `links-to-editor.gts:116`, + `links-to-many-component.gts:311,467,725` — pass `@displayName` + `@itemType='card'`. +- Host: `preview/index.gts` (`brokenTypeName`→`brokenDisplayName`, + `brokenNoun`→`brokenItemType` args at :130-131, :258-259, :295-296), + `pane.gts:47-48,164-165`, `tab-panel.gts:224-233,328-329`. +- Tests: `broken-link-template-test.gts`, `broken-link-test.gts` (boxel-ui + test-app), `markdown-embed-preview-test.gts`, and any snapshot referencing the + old arg names. + +## Item 3 — CSS conformance + context-button × + +### 3a. CSS mapping (design value → token to use) + +Most of the component already uses tokens. Concrete changes: + +| Element | Design | Action | +| ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- | +| Atom pill height | 28 px | already `min-height:28px` ✓ (design-fixed literal, keep) | +| Atom label↔icon gap | 10 px | already `gap:10px` ✓ (design-fixed, keep) | +| Atom pill radius | 5 px | switch `--boxel-border-radius-sm` (6px) → **`--boxel-border-radius-2xs`** (5px) | +| Section box/header radius | 5 px | `status-badge`/`diagnostics` use `--boxel-form-control-border-radius` (10px) → **`--boxel-border-radius-2xs`** (5px) | +| Section header bg | `#f4f4f4` | already `--boxel-light-100` ✓ (confirm value) | +| Section/panel hairline | `#d1d1d1` | already `--boxel-200` ✓ | +| Panel radius | 8 px | keep **`--boxel-border-radius`** (10px) — no 8px token; 2px, tokenised over literal | +| Panel width | 350 px | keep literal (design "FIXED PANEL WIDTH") ✓ | +| Panel min/max height | 155 / 600 px | keep `--bl-min-h`/`--bl-max-h` literals ✓ | +| Panel shadow | `0 4 10 /25%` | keep **`--boxel-deep-box-shadow`** ✓ | +| Title / body fonts | Plex Sans / Mono | already `--boxel-font-*` + `--boxel-monospace-font-family` ✓ | + +Net: only the two radius swaps to `--boxel-border-radius-2xs` are real edits; the +rest already conforms. Verify each `--boxel-light-100`/`--boxel-200` value against +the spec during implementation and adjust the token choice if a nearer one exists. + +### 3b. Overlay × → `ContextButton` + +**Architectural note (flag):** the reveal is a **pure-CSS disclosure** — the × +today is `` that toggles the checkbox with no JS. +`ContextButton` renders a real `