+ {/* allow-popups (+ escape-sandbox): the overlay opens the doc's links in a new
+ tab rather than navigating this frame, which X-Frame-Options sites refuse. */}
diff --git a/app/d/[slug]/raw/route.ts b/app/d/[slug]/raw/route.ts
index 048478c..ace92f2 100644
--- a/app/d/[slug]/raw/route.ts
+++ b/app/d/[slug]/raw/route.ts
@@ -15,10 +15,13 @@ type Ctx = { params: Promise<{ slug: string }> };
// can never execute same-origin with our auth/session surface (birthday.md "The
// one security decision that matters").
//
-// Content-Security-Policy: sandbox allow-scripts allow-downloads → the response
-// is treated as a unique opaque origin; scripts run and user-initiated file
-// downloads work, but the document cannot read our cookies / tokens or reach
-// our origin's storage.
+// Content-Security-Policy: sandbox allow-scripts allow-downloads allow-popups
+// allow-popups-to-escape-sandbox → the response is treated as a unique opaque
+// origin; scripts run, user-initiated file downloads work, and the doc's links
+// can open in a new tab (the overlay routes them there — navigating the frame
+// itself breaks on every X-Frame-Options site), but the document cannot read
+// our cookies / tokens or reach our origin's storage. The popup escapes the
+// sandbox so the linked site loads as a normal page, never as our origin.
// X-Content-Type-Options: nosniff → no MIME sniffing.
//
// Directly linkable for zero-chrome viewing; same token rules as /d/:slug.
@@ -98,7 +101,8 @@ export async function GET(req: Request, ctx: Ctx): Promise
{
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
- "Content-Security-Policy": "sandbox allow-scripts allow-downloads",
+ "Content-Security-Policy":
+ "sandbox allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox",
"X-Content-Type-Options": "nosniff",
// Never cache private content at shared caches; tokens are capability URLs.
"Cache-Control": "private, no-store",
diff --git a/lib/docs/overlay.ts b/lib/docs/overlay.ts
index 4615122..4775eca 100644
--- a/lib/docs/overlay.ts
+++ b/lib/docs/overlay.ts
@@ -629,6 +629,8 @@ export const OVERLAY_SCRIPT = String.raw`
span.addEventListener("mouseleave", function(){ setHover(null); send({type:"jh:hlHoverOut"}); });
span.addEventListener("click", function(ev){
ev.stopPropagation();
+ // A click the link interceptor claimed is navigation, not an annotation click.
+ if (ev === linkClick) return;
onSegClick(seg, ev);
});
}
@@ -709,6 +711,62 @@ export const OVERLAY_SCRIPT = String.raw`
send({type:"jh:focus", key:null, id:null, keys:[]});
}
+ // ---- links leave the frame, never navigate it ----
+ // The doc renders in an opaque-origin sandbox inside the shell's iframe, so a
+ // plain click navigates the FRAME, and any site sending
+ // X-Frame-Options / frame-ancestors (github.com, most of the web) then paints
+ // "refused to connect" where the doc was. The sandbox also can't reach the top
+ // window, and target="_blank" alone is a blocked popup — so we open the tab
+ // ourselves from inside the user gesture (sandbox carries allow-popups +
+ // allow-popups-to-escape-sandbox so the target loads as a normal page).
+ // Same-document fragments (#section, the gutter section links) stay in-frame.
+ // The raw attribute, not a.href: on an SVG that property is an
+ // SVGAnimatedString and stringifies to garbage.
+ function externalHref(a){
+ var raw = a.getAttribute("href");
+ if (raw == null) raw = a.getAttribute("xlink:href");
+ if (raw == null) return null;
+ var u;
+ try { u = new URL(raw, document.baseURI); } catch(e){ return null; }
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
+ if (u.href.split("#")[0] === location.href.split("#")[0]) return null;
+ return u.href;
+ }
+ // Capture phase: a link inside a highlighted span would otherwise be swallowed
+ // by the segment's own click handler (it stopPropagation()s) and navigate. We
+ // never stop propagation ourselves — the doc may have its own click handlers on
+ // the link — so the claimed event is recorded instead, and the segment handler
+ // skips focusing for it.
+ var linkClick = null;
+ function openLink(ev){
+ if (ev.type === "auxclick" && ev.button !== 1) return; // middle-click only
+ var t = ev.target;
+ if (!t || !t.closest) return;
+ var a = t.closest("a");
+ if (!a) return;
+ // In edit mode a link is text to retype. The edit-mode handler already eats
+ // the click; this also covers middle-click, which would open a tab.
+ if (editing){ ev.preventDefault(); return; }
+ // Our own chrome can sit INSIDE an author link: a reaction chip is appended
+ // after the segment that ends its span, so a span ending mid-link puts the
+ // chip in the anchor, and the section anchor is a link of ours. Those clicks
+ // are ours — swallow the anchor's navigation (the chip handler stops
+ // propagation but not the default action), and open no tab.
+ if (fromOverlayChrome(ev) || t.closest("[data-jh-sec-anchor]")){ ev.preventDefault(); return; }
+ var href = externalHref(a);
+ if (!href) return;
+ ev.preventDefault();
+ linkClick = ev;
+ // A selection that survives the click is a comment/react gesture (a drag that
+ // ended in linked text), not navigation: keep the selection, open nothing.
+ // Same emptiness test as reportSelection.
+ var sel = window.getSelection();
+ if (sel && sel.rangeCount && !sel.isCollapsed && sel.toString().trim()) return;
+ window.open(href, "_blank", "noopener");
+ }
+ document.addEventListener("click", openLink, true);
+ document.addEventListener("auxclick", openLink, true);
+
// click-elsewhere (on non-highlight) clears focus + selection popovers
document.addEventListener("click", function(ev){
var t = ev.target;