Skip to content

Commit c8f4866

Browse files
authored
Merge pull request #5093 from cardstack/cs-11318-broken-link-overlay-ui
Enhance broken/missing linked-card overlay UI (CS-11318)
2 parents 242159d + 2584ee2 commit c8f4866

5 files changed

Lines changed: 264 additions & 71 deletions

File tree

packages/base/card-api.gts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
15851585
@errorDoc={{broken.errorDoc}}
15861586
@state={{broken.kind}}
15871587
@format={{brokenLinkFormat @format defaultFormats.cardDef}}
1588+
@viewCard={{cardCrudFunctions.viewCard}}
15881589
...attributes
15891590
/>
15901591
{{else}}

packages/base/default-templates/broken-link-template.gts

Lines changed: 129 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { guidFor } from '@ember/object/internals';
55
import { htmlSafe } from '@ember/template';
66
import { modifier } from 'ember-modifier';
77
import LinkOffIcon from '@cardstack/boxel-icons/link-off';
8+
import { Button, CopyButton } from '@cardstack/boxel-ui/components';
89
import { cardTypeName } from '@cardstack/runtime-common';
910
import type { SerializedError } from '@cardstack/runtime-common';
11+
import type { ViewCardFn } from '../card-api';
1012

1113
type 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

2330
interface 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) ──────────────────────

packages/base/links-to-editor.gts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { hash } from '@ember/helper';
33
import { on } from '@ember/modifier';
44
import { restartableTask } from 'ember-concurrency';
55
import {
6+
CardCrudFunctionsConsumer,
67
DefaultFormatsProvider,
78
PermissionsConsumer,
89
getBoxComponent,
@@ -86,12 +87,15 @@ export class LinksToEditor extends GlimmerComponent<Signature> {
8687
fixed-dimension card slot, so the placeholder renders `embedded`
8788
(flow-sized) rather than `fitted` (which clamps to a badge
8889
footprint and would clip the URL here). }}
89-
<BrokenLinkTemplate
90-
@brokenUrl={{@brokenLink.reference}}
91-
@errorDoc={{@brokenLink.errorDoc}}
92-
@state={{@brokenLink.kind}}
93-
@format='embedded'
94-
/>
90+
<CardCrudFunctionsConsumer as |crud|>
91+
<BrokenLinkTemplate
92+
@brokenUrl={{@brokenLink.reference}}
93+
@errorDoc={{@brokenLink.errorDoc}}
94+
@state={{@brokenLink.kind}}
95+
@format='embedded'
96+
@viewCard={{crud.viewCard}}
97+
/>
98+
</CardCrudFunctionsConsumer>
9599
{{else if this.isEmpty}}
96100
{{#if permissions.canWrite}}
97101
<Button

0 commit comments

Comments
 (0)