@@ -5,8 +5,10 @@ import { guidFor } from '@ember/object/internals';
55import { htmlSafe } from ' @ember/template' ;
66import { modifier } from ' ember-modifier' ;
77import LinkOffIcon from ' @cardstack/boxel-icons/link-off' ;
8+ import { Button , CopyButton } from ' @cardstack/boxel-ui/components' ;
89import { cardTypeName } from ' @cardstack/runtime-common' ;
910import type { SerializedError } from ' @cardstack/runtime-common' ;
11+ import type { ViewCardFn } from ' ../card-api' ;
1012
1113type TipCorner = ' tl' | ' tr' | ' bl' | ' br' ;
1214
@@ -18,6 +20,11 @@ export interface BrokenLinkTemplateArgs {
1820 errorDoc: SerializedError ;
1921 state: BrokenLinkState ;
2022 format: BrokenLinkFormat ;
23+ // Threaded from the field component's CardCrudFunctions. When present, the
24+ // overlay offers an "Open anyway" affordance that navigates to the broken
25+ // reference (a stack visit in interact mode, a code-editor jump in code
26+ // mode — whatever the host's viewCard does for the current submode).
27+ viewCard? : ViewCardFn ;
2128}
2229
2330interface NormalizedAdditionalError {
@@ -27,19 +34,19 @@ interface NormalizedAdditionalError {
2734 stack? : string ;
2835}
2936
30- // Only http(s) URLs are safe to drop into an <a href> — `javascript:` and
31- // `data:` URLs in an anchor execute on click. The brokenUrl flows from
32- // trusted card-serialization data, but a corrupted realm could still ship
33- // a non-http reference; we fall back to plain text in that case.
34- function isSafeHttpUrl(url : string ): boolean {
35- if (typeof url !== ' string' || url .length === 0 ) {
36- return false ;
37- }
37+ // Only http(s) references are navigable. The brokenUrl is a card reference
38+ // from trusted serialization, but a corrupted realm could ship a non-http
39+ // value; "Open anyway" forwards it into viewCard, so reject other protocols
40+ // (`javascript:`, `data:`, …) — the same reasoning that keeps the URL display
41+ // plain text rather than a link.
42+ function parseHttpUrl(url : string ): URL | null {
3843 try {
3944 let parsed = new URL (url );
40- return parsed .protocol === ' http:' || parsed .protocol === ' https:' ;
45+ return parsed .protocol === ' http:' || parsed .protocol === ' https:'
46+ ? parsed
47+ : null ;
4148 } catch {
42- return false ;
49+ return null ;
4350 }
4451}
4552
@@ -151,10 +158,24 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
151158 return ! this .isNotFound && this .errorMessage .length > 0 ;
152159 }
153160
154- private get urlIsSafe(): boolean {
155- return isSafeHttpUrl (this .args .brokenUrl );
161+ // "Open anyway" navigates to the broken reference even though it failed to
162+ // load — the host's viewCard decides the destination per submode (a stack
163+ // visit in interact, a code-editor jump in code). Hidden when no viewCard is
164+ // wired (e.g. a context that can't navigate) or the reference isn't a
165+ // navigable http(s) URL.
166+ private get canOpen(): boolean {
167+ return !! this .args .viewCard && parseHttpUrl (this .args .brokenUrl ) !== null ;
156168 }
157169
170+ private openAnyway = () => {
171+ let { viewCard, brokenUrl } = this .args ;
172+ let url = parseHttpUrl (brokenUrl );
173+ if (! viewCard || ! url ) {
174+ return ;
175+ }
176+ viewCard (url );
177+ };
178+
158179 private get additionalErrorsLabel(): string {
159180 let n = this .additionalErrors .length ;
160181 return ` ${n } additional error${n === 1 ? ' ' : ' s' } ` ;
@@ -235,7 +256,8 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
235256 return ;
236257 }
237258 // The card boundary is the overlay's containing block.
238- let cardEl = (overlay .offsetParent as HTMLElement ) ?? document .documentElement ;
259+ let cardEl =
260+ (overlay .offsetParent as HTMLElement ) ?? document .documentElement ;
239261 let card = cardEl .getBoundingClientRect ();
240262 let o = overlay .getBoundingClientRect ();
241263 let t = trigger .getBoundingClientRect ();
@@ -267,7 +289,18 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
267289 : roomRight >= w + edge
268290 ? false
269291 : roomLeft >= roomRight ;
270- this .tipCorner = ` ${above ? ' b' : ' t' }${extendLeft ? ' r' : ' l' } ` as TipCorner ;
292+ this .tipCorner =
293+ ` ${above ? ' b' : ' t' }${extendLeft ? ' r' : ' l' } ` as TipCorner ;
294+
295+ // Clamp the panel to the room available on the side it opens so a tall error
296+ // scrolls inside the card instead of spilling past its boundary. 600px is
297+ // the design ceiling; a small floor keeps the panel usable and never
298+ // collapses it to nothing when the card is too short to fit it — it may then
299+ // overflow, the accepted fallback for very small cards.
300+ let roomChosen = above ? roomAbove : roomBelow ;
301+ let maxH = Math .min (600 , Math .max (roomChosen - gap - edge , 96 ));
302+ overlay .style .setProperty (' --bl-max-h' , ` ${maxH }px ` );
303+ overlay .style .setProperty (' --bl-min-h' , ` ${Math .min (155 , maxH )}px ` );
271304 };
272305
273306 // Re-measure the corner if the layout shifts while the overlay is open.
@@ -350,21 +383,24 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
350383 data-test-broken-link-overlay-close
351384 >×</label >
352385 </div >
353- {{#if this . urlIsSafe }}
354- <a
355- class =' overlay-url'
356- href ={{@ brokenUrl }}
357- target =' _blank'
358- rel =' noopener noreferrer'
359- data-test-broken-link-url
360- >{{@ brokenUrl }} </a >
361- {{else }}
362- {{! Unsafe protocol — render as text so a click cannot execute. }}
386+ {{! The reference is informational only, never a clickable link. A
387+ copy affordance to its left puts the URL on the clipboard (same
388+ control the AI assistant uses for code blocks). }}
389+ <div class =' overlay-url-row' >
390+ <CopyButton
391+ class =' overlay-url-copy'
392+ @ textToCopy ={{@ brokenUrl }}
393+ @ variant =' text-only'
394+ @ width =' 14'
395+ @ height =' 14'
396+ @ tooltipText =' Copy link'
397+ data-test-broken-link-copy
398+ />
363399 <span
364400 class =' overlay-url'
365401 data-test-broken-link-url
366402 >{{@ brokenUrl }} </span >
367- {{/ if }}
403+ </ div >
368404 </div >
369405
370406 <div class =' overlay-panel' >
@@ -420,9 +456,9 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
420456 class =' additional-item'
421457 data-test-broken-link-additional-error ={{i }}
422458 >
423- <span
424- class = ' additional-badge '
425- > {{# if err.status }}{{err.status }} {{/if }}{{err.message }} </span >
459+ <span class = ' additional-badge ' > {{# if
460+ err.status
461+ }}{{err.status }} {{/if }}{{err.message }} </span >
426462 {{#if err.stack }}
427463 <pre class =' additional-stack' >{{err.stack }} </pre >
428464 {{/if }}
@@ -436,6 +472,22 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
436472 </div >
437473 {{/if }}
438474 </div >
475+
476+ {{! Pinned below the scroller so it stays reachable however long the
477+ diagnostics get. Navigates to the broken reference via the threaded
478+ viewCard; the host resolves the destination for the current
479+ submode. }}
480+ {{#if this . canOpen }}
481+ <div class =' overlay-footer' >
482+ <Button
483+ class =' open-anyway'
484+ @ kind =' secondary'
485+ @ size =' small'
486+ {{on ' click' this . openAnyway}}
487+ data-test-broken-link-open-anyway
488+ >Open anyway</Button >
489+ </div >
490+ {{/if }}
439491 </div >
440492
441493 {{! The tip: a solid-white right-triangle sitting ON TOP of the overlay's
@@ -498,7 +550,8 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
498550 border : 1px solid var (--boxel-border-color );
499551 border-radius : var (--boxel-border-radius );
500552 background-color : var (--boxel-light-100 );
501- background-image : linear-gradient (
553+ background-image :
554+ linear-gradient (
502555 to top right ,
503556 transparent calc (50% - 0.5px ),
504557 var (--boxel-border-color ) calc (50% - 0.5px ),
@@ -515,11 +568,17 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
515568 overflow : hidden ;
516569 }
517570 .broken-link-template.atom .box {
518- min-height : 1.6em ;
519- padding : 0 var (--boxel-sp-5xs );
520- gap : var (--boxel-sp-5xs );
571+ min-height : 28px ;
572+ padding : 0 var (--boxel-sp-2xs );
573+ /* 10px between the type text and the caution triangle (per design); the
574+ label's own padding is zeroed below so this gap is measured from the
575+ text edge, not the chip's padding box. */
576+ gap : 10px ;
521577 border-radius : var (--boxel-border-radius-sm );
522578 }
579+ .broken-link-template.atom .label {
580+ padding : 0 ;
581+ }
523582
524583 .label {
525584 display : inline-flex ;
@@ -586,9 +645,16 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
586645 .overlay {
587646 display : none ;
588647 position : absolute ;
589- width : 17rem ;
590- max-width : min (20rem , 100% );
591- max-height : 18rem ;
648+ /* Fixed platter footprint per design (350 × 155–600). The tip anchors
649+ to the overlay's own corner, so it tracks these dimensions without
650+ any change to its geometry. */
651+ width : 350px ;
652+ /* Floor/ceiling defaults; the geometry pass narrows --bl-max-h to the
653+ room available inside the card so a tall panel scrolls rather than
654+ clipping, and drops --bl-min-h in lockstep so the floor never forces
655+ an overflow. */
656+ min-height : var (--bl-min-h , 155px );
657+ max-height : var (--bl-max-h , 600px );
592658 /* Placement is chosen on open (the `tip-{corner}` class on the root,
593659 set by a geometry measurement): the overlay extends into the open
594660 space and the tip sits on the corner facing the trigger. Anchored to
@@ -645,22 +711,44 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{
645711 background-color : var (--boxel-100 );
646712 color : var (--boxel-dark );
647713 }
714+ .overlay-url-row {
715+ display : flex ;
716+ align-items : center ;
717+ gap : var (--boxel-sp-5xs );
718+ }
719+ .overlay-url-copy {
720+ flex : none ;
721+ --boxel-icon-button-width : 1.125rem ;
722+ --boxel-icon-button-height : 1.125rem ;
723+ color : var (--boxel-400 );
724+ }
725+ .overlay-url-copy :hover {
726+ color : var (--boxel-dark );
727+ }
648728 .overlay-url {
649- display : block ;
729+ flex : 1 1 auto ;
730+ min-width : 0 ;
650731 font-family : var (--boxel-monospace-font-family , monospace );
651732 font-size : 0.8em ;
733+ line-height : 1.125rem ;
652734 word-break : break-all ;
653735 color : var (--boxel-500 );
654- text-decoration : none ;
655- }
656- .overlay-url :hover {
657- text-decoration : underline ;
658736 }
659737 .overlay-panel {
660738 flex : 1 1 auto ;
661739 min-height : 0 ;
662740 overflow : auto ;
663- padding : 0 var (--boxel-sp-xs ) var (--boxel-sp-xs );
741+ padding : 0 var (--boxel-sp-xxs );
742+ }
743+
744+ /* Pinned action row — sits outside the scroller so the primary CTA stays
745+ visible however tall the diagnostics get. Even vertical padding gives
746+ the button a balanced top/bottom margin. */
747+ .overlay-footer {
748+ flex : none ;
749+ display : flex ;
750+ justify-content : flex-end ;
751+ padding : var (--boxel-sp-xs );
664752 }
665753
666754 /* ── Overlay panel: status badge (not-found) ──────────────────────
0 commit comments