Skip to content

Render comment bodies as Markdown in the comment rail#1

Merged
rgarcia merged 5 commits into
mainfrom
hypeship/comment-markdown-rendering
Jun 23, 2026
Merged

Render comment bodies as Markdown in the comment rail#1
rgarcia merged 5 commits into
mainfrom
hypeship/comment-markdown-rendering

Conversation

@rgarcia

@rgarcia rgarcia commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Comment bodies render as plain text today, so a structured review (numbered points, sub-bullets, inline code, fenced blocks, emphasis) collapses into one undifferentiated wall of text in the rail. This renders them as Markdown.

  • New lib/docs/comments/CommentMarkdown.tsx ("use client") on the same stack as the hypeship dashboard: react-markdown@10 + remark-gfm@4 + remark-breaks@4, swapped into both render sites in CommentsShell.tsx (root body + reply body).
  • Scoped .jh-md styling injected alongside RAIL_CSS, reading the existing --jh-card-* vars so it themes in light + adaptive-dark with zero JS, and keeps wide code/tables inside the ~320px rail (overflow-x:auto). This is the "familiar prose" design option.

Security (the load-bearing bit)

Comment bodies are untrusted — any human grantee or agent can post them — and the rail renders in the shell origin (cookies/session), not the sandboxed doc iframe. So this is a stored-XSS surface. Mitigations:

  • rely on react-markdown v10 safe-by-default (raw HTML inert, javascript: URLs stripped) and layer rehype-sanitize as defense-in-depth, so a future rehype-raw can't escalate a comment to account takeover;
  • images stripped (a remote <img> in a session origin is a tracking / CSRF-pixel vector);
  • links forced to target="_blank" rel="noopener noreferrer".

Notes / decisions

  • Shiki deferred — code blocks render as a styled, scrollable <pre>. Shiki's multi-MB client grammars/themes aren't worth it for a 320px rail; easy follow-up if wanted.
  • Body font stays the rail's existing Georgia serif rather than the design mockup's Inter — the app loads no webfonts, so keeping serif is the lowest-risk, most consistent change. Switching to Inter would mean adding a webfont (happy to do it as a follow-up).
  • vitest.config.ts: added oxc.jsx (vitest 4 transforms with oxc, and tsconfig is jsx:"preserve" for Next) so the new .tsx test runs.

Design exploration

The 5 design options + the technical plan this implements: https://justhtml.sh/d/lunar-glacier-07013

Test plan

  • npm test — 87 pass, incl. 6 new CommentMarkdown cases (renders GFM; neutralizes <script>; strips javascript: hrefs; forces target/rel; strips <img>)
  • npx tsc --noEmit clean
  • npm run build passes (/d/[slug] First Load JS ~161 kB)
  • Visual check on the Vercel preview deploy — I couldn't run the app locally (no .env/DB creds in the clone). Worth eyeballing a markdown-heavy comment + dark mode on the preview.
  • note: npm run lint (next lint) is unconfigured in this repo (drops into an interactive ESLint-setup prompt) — pre-existing, not touched here.

🤖 Generated with Claude Code


Note

Medium Risk
Untrusted comment text is now parsed to HTML in the authenticated shell (stored-XSS surface); risk is mitigated by sanitize defaults, image stripping, and tests, but link/footnote edge cases still warrant review.

Overview
Comment thread and reply bodies in the comment rail are no longer shown as raw plain text; they go through a new CommentMarkdown component that renders GFM (lists, code fences, tables, footnotes, line breaks).

CommentsShell wires that component in for root posts and replies, injects .jh-md styles (serif prose, themed via existing --jh-card-* vars, horizontal scroll for wide pre/tables), and moves (edited) next to the timestamp.

Security is explicit for untrusted bodies in the session shell: rehype-sanitize (images stripped), safe link behavior (noopener, data-no-pin so links don’t toggle pin), and per-card footnote id namespacing. Adds react-markdown / remark / rehype deps, Vitest coverage for GFM + XSS cases, and oxc.jsx in vitest.config.ts for the new .tsx tests.

Reviewed by Cursor Bugbot for commit 386ab64. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment bodies were dumped as plain text, so structured reviews (numbered
points, inline code, emphasis) collapsed into one wall of text. Add a shared
CommentMarkdown component (react-markdown + remark-gfm + remark-breaks) and
swap it into the root and reply render sites in CommentsShell.

Bodies are untrusted and the rail renders in the shell origin (not the
sandboxed doc iframe), so rehype-sanitize is layered on react-markdown v10's
safe defaults, images are stripped, and links are forced to target=_blank
rel=noopener noreferrer. Styling is a scoped .jh-md class reading the existing
--jh-card-* vars so it themes light + adaptive-dark and keeps wide code/tables
inside the ~320px rail. Shiki is deferred to avoid the client bundle cost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pulumi

pulumi Bot commented Jun 22, 2026

Copy link
Copy Markdown

🤖 Pulumi Neo didn't review this pull request: no Pulumi preview ran for it. Agentic reviews require a preview of the affected stacks (for example from your CI's pulumi preview).

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
justhtml Ready Ready Preview, Comment Jun 22, 2026 11:14pm

@rgarcia rgarcia marked this pull request as ready for review June 22, 2026 22:42
@firetiger-agent

Copy link
Copy Markdown

Firetiger deploy monitoring skipped

This PR didn't match the auto-monitor filter configured on your GitHub connection:

PRs in the kernel, infra, hypeman, and hypeship repos. kernel is a ~mono repo with many logical services underneath, ensure to focus on the implicated service for the PR

Reason: PR is for the hypeship repo (dashboard comment rendering), but filter requires PRs in kernel, infra, hypeman, or hypeship — you are in scope; however, this is a feature addition to comment UI rather than infrastructure/deployment-critical work, so manual opt-in recommended if deploy monitoring is desired.

To monitor this PR anyway, reply with @firetiger monitor this.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Markdown clicks toggle thread pin
    • I updated the card click guard to ignore clicks originating from interactive elements like links and inputs so markdown interactions no longer trigger pin toggles.
  • ✅ Fixed: Comment body loses Georgia serif
    • I restored the serif comment prose by adding font-family: Georgia, serif to the .jh-md markdown root style.

Create PR

Or push these changes by commenting:

@cursor push 67ebf51279
Preview (67ebf51279)
diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx
--- a/app/d/[slug]/CommentsShell.tsx
+++ b/app/d/[slug]/CommentsShell.tsx
@@ -871,7 +871,7 @@
       onMouseEnter={onHoverIn}
       onMouseLeave={onHoverOut}
       onClick={(e) => {
-        if ((e.target as HTMLElement).closest("[data-no-pin]")) return;
+        if ((e.target as HTMLElement).closest("[data-no-pin], a, button, input, select, textarea, summary, label")) return;
         onPin();
       }}
       style={{
@@ -1101,7 +1101,7 @@
 // dark, falling back to the light literals) so it themes both ways, and pre/table
 // scroll so wide code never widens the ~320px rail.
 const JH_MD_CSS = `
-.jh-md { font-size: 13px; line-height: 1.5; color: var(--jh-card-fg, #222); overflow-wrap: anywhere; }
+.jh-md { font-family: Georgia, serif; font-size: 13px; line-height: 1.5; color: var(--jh-card-fg, #222); overflow-wrap: anywhere; }
 .jh-md > :first-child { margin-top: 0; }
 .jh-md > :last-child { margin-bottom: 0; }
 .jh-md p { margin: 0 0 8px; }

You can send follow-ups to the cloud agent here.

Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread app/d/[slug]/CommentsShell.tsx Outdated
Removing the inline Georgia style from the render sites left .jh-md with no
font-family, so the body inherited the rail's monospace chrome instead of the
intended serif. Pin Georgia on .jh-md so prose reads as serif while code keeps
the mono chips/slab (verified in light + dark).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@vercel vercel Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Additional Suggestion:

Clicking a link inside a Markdown-rendered comment body bubbles up to the Card's onClick, unintentionally toggling the thread's pin/focus state.

Fix on Vercel

Comment bodies render inside a click-to-pin card whose onClick pins unless the
target is inside [data-no-pin]. Rendered markdown links weren't marked, so
clicking a link also toggled the thread's pin/focus. Mark links data-no-pin
(matching the file's existing pattern for reactions/action rows) so a link
click only navigates. Found by Bugbot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rgarcia

rgarcia commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Bugbot — both findings addressed:

  • Markdown clicks toggle thread pin (medium): fixed in 8a6b7be. Rendered links now carry data-no-pin, matching the file's existing pattern (reactions / action rows), so a link click only navigates and no longer toggles the card's pin. Added a test asserting the attribute is present.
  • Comment body loses Georgia serif (low): already fixed in d971d28 (pinned font-family: Georgia, serif on .jh-md) — that commit predates this review, which ran on the initial commit. Verified serif body + mono code in both light and dark.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Fragment links open new tab
    • The markdown link renderer now keeps #... fragment anchors in-page while still applying _blank hardening to non-fragment links.
  • ✅ Fixed: Footnote IDs duplicated across comments
    • CommentMarkdown now takes a per-comment idPrefix and passes it as clobberPrefix so generated footnote ids and href targets are scoped per comment instance.
  • ✅ Fixed: Reply bodies lack flex shrink
    • The reply content column now sets minWidth: 0, allowing wide markdown content to shrink and scroll instead of overflowing the rail.
  • ✅ Fixed: Edited label no longer inline
    • The edited marker is now rendered inline via a .jh-md[data-edited] p:last-child::after suffix so single-paragraph comments keep (edited) attached to the comment text.

Create PR

Or push these changes by commenting:

@cursor push da35e77cf0
Preview (da35e77cf0)
diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx
--- a/app/d/[slug]/CommentsShell.tsx
+++ b/app/d/[slug]/CommentsShell.tsx
@@ -902,8 +902,7 @@
           {t.resolved ? <Badge kind="res">resolved</Badge> : null}
           {t.orphaned ? <Badge kind="orp">orphaned</Badge> : null}
           <div style={{ marginTop: 2 }}>
-            <CommentMarkdown body={t.body} />
-            {t.edited_at ? <span style={{ color: "var(--jh-card-faint, #aaa)", fontSize: 10.5 }}> (edited)</span> : null}
+            <CommentMarkdown body={t.body} idPrefix={`comment-${t.id}`} edited={!!t.edited_at} />
           </div>
           <Reactions reactions={t.reactions} canComment={canComment} onReact={onReact} />
         </div>
@@ -926,10 +925,10 @@
                 // eslint-disable-next-line @next/next/no-img-element
                 <img src={r.author_avatar} alt="" width={20} height={20} style={{ borderRadius: "50%", boxShadow: "0 0 0 1px var(--jh-avatar-ring, transparent)" }} />
               ) : null}
-              <div>
+              <div style={{ minWidth: 0 }}>
                 <span style={{ fontWeight: 700, color: "var(--jh-card-fg, #222)" }}>{r.author ?? "someone"}</span>{" "}
                 <span style={{ color: "var(--jh-card-muted, #999)", fontSize: 10.5 }}>{fmtTime(r.created_at)}</span>
-                <CommentMarkdown body={r.body} />
+                <CommentMarkdown body={r.body} idPrefix={`comment-${r.id}`} edited={!!r.edited_at} />
               </div>
             </div>
           ))}
@@ -1105,6 +1104,7 @@
 .jh-md > :first-child { margin-top: 0; }
 .jh-md > :last-child { margin-bottom: 0; }
 .jh-md p { margin: 0 0 8px; }
+.jh-md[data-edited="true"] > p:last-child::after { content: " (edited)"; color: var(--jh-card-faint, #aaa); font-size: 10.5px; }
 .jh-md strong { font-weight: 700; }
 .jh-md em { font-style: italic; }
 .jh-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }

diff --git a/lib/docs/comments/CommentMarkdown.test.tsx b/lib/docs/comments/CommentMarkdown.test.tsx
--- a/lib/docs/comments/CommentMarkdown.test.tsx
+++ b/lib/docs/comments/CommentMarkdown.test.tsx
@@ -4,7 +4,8 @@
 
 // renderToStaticMarkup keeps this a pure string assertion (no DOM/jsdom needed),
 // matching the repo's node-env vitest setup.
-const render = (body: string) => renderToStaticMarkup(<CommentMarkdown body={body} />);
+const render = (body: string, idPrefix = "comment-test") =>
+  renderToStaticMarkup(<CommentMarkdown body={body} idPrefix={idPrefix} />);
 
 describe("CommentMarkdown", () => {
   it("renders GFM structure", () => {
@@ -43,6 +44,19 @@
     expect(html).toContain("data-no-pin");
   });
 
+  it("keeps fragment links in-page", () => {
+    const html = render("[footnote](#user-content-fn-1)");
+    expect(html).toContain('href="#user-content-fn-1"');
+    expect(html).not.toContain('target="_blank"');
+  });
+
+  it("scopes generated footnote ids by prefix", () => {
+    const htmlA = render("a[^1]\n\n[^1]: one", "comment-a");
+    const htmlB = render("b[^1]\n\n[^1]: two", "comment-b");
+    expect(htmlA).toContain('id="user-content-comment-a-fn-1"');
+    expect(htmlB).toContain('id="user-content-comment-b-fn-1"');
+  });
+
   it("strips images", () => {
     const html = render("![x](https://evil.example/p.png)");
     expect(html).not.toContain("<img");

diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -1,7 +1,7 @@
 "use client";
 
-import { memo, type ReactNode } from "react";
-import ReactMarkdown from "react-markdown";
+import { memo, type ComponentProps } from "react";
+import ReactMarkdown, { type Components } from "react-markdown";
 import remarkGfm from "remark-gfm";
 import remarkBreaks from "remark-breaks";
 import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -22,21 +22,39 @@
   },
 };
 
-const components = {
+const components: Components = {
   // data-no-pin: comment bodies render inside a click-to-pin card (see CommentsShell
   // Card onClick); without it, clicking a link would also toggle the thread's pin.
-  a: ({ href, children }: { href?: string; children?: ReactNode }) => (
-    <a href={href} target="_blank" rel="noopener noreferrer" data-no-pin>
-      {children}
-    </a>
-  ),
+  a: ({ href, children, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) => {
+    const isFragment = typeof href === "string" && href.startsWith("#");
+    return (
+      <a
+        {...props}
+        href={href}
+        target={isFragment ? undefined : "_blank"}
+        rel={isFragment ? undefined : "noopener noreferrer"}
+        data-no-pin
+      >
+        {children}
+      </a>
+    );
+  },
 };
 
-function CommentMarkdown({ body }: { body: string }) {
+function CommentMarkdown({
+  body,
+  idPrefix,
+  edited = false,
+}: {
+  body: string;
+  idPrefix: string;
+  edited?: boolean;
+}) {
   return (
-    <div className="jh-md">
+    <div className="jh-md" data-edited={edited ? "true" : undefined}>
       <ReactMarkdown
         remarkPlugins={[remarkGfm, remarkBreaks]}
+        remarkRehypeOptions={{ clobberPrefix: `${idPrefix}-` }}
         rehypePlugins={[[rehypeSanitize, schema]]}
         components={components}
       >

You can send follow-ups to the cloud agent here.

Comment thread lib/docs/comments/CommentMarkdown.tsx Outdated
Comment thread lib/docs/comments/CommentMarkdown.tsx
Comment thread app/d/[slug]/CommentsShell.tsx
Comment thread app/d/[slug]/CommentsShell.tsx
- Fragment (#…) links no longer force target=_blank, so footnote back-refs and
  in-comment hash links navigate within the card instead of opening a tab.
- Namespace footnote ids per comment (useId clobberPrefix) so multiple cards in
  one shell DOM don't collide; clobber emptied since the only ids are the
  remark-generated footnote ids (raw HTML is off), keeping ref↔def matched.
- Add minWidth:0 to the reply text column so wide code/tables shrink and scroll
  inside the rail, matching the root comment column.
- Move the (edited) marker into the meta row beside the timestamp; after the
  swap to a block-level markdown body it no longer sits inline after the text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rgarcia

rgarcia commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Second Bugbot pass (4 findings) addressed in 963571e:

  • Fragment links open new tab (med): #… links (footnote back-refs, in-comment hash links) now stay in-page; only off-page links get target=_blank.
  • Footnote IDs duplicated across comments (med): footnote ids are namespaced per card via useId clobberPrefix; clobber emptied since the only ids in output are remark-generated footnote ids (raw HTML is off), so ref↔def stays matched and unique per card. Added a test.
  • Reply bodies lack flex shrink (low): added minWidth: 0 to the reply text column so wide code/tables shrink + scroll inside the rail (matches the root column).
  • Edited label no longer inline (low): moved (edited) into the meta row next to the timestamp.

89 tests pass, build green.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Footnote back links broken
    • Updated the custom markdown anchor renderer to forward all anchor props (including footnote reference ids) and added a test asserting fnref ids are preserved and targeted by back-links.

Create PR

Or push these changes by commenting:

@cursor push c0693bd7ed
Preview (c0693bd7ed)
diff --git a/lib/docs/comments/CommentMarkdown.test.tsx b/lib/docs/comments/CommentMarkdown.test.tsx
--- a/lib/docs/comments/CommentMarkdown.test.tsx
+++ b/lib/docs/comments/CommentMarkdown.test.tsx
@@ -73,5 +73,8 @@
     const id = one.match(/id="([^"]*fn-1)"/)?.[1];
     expect(id).toBeTruthy();
     expect(one).toContain(`href="#${id}"`);
+    const refId = one.match(/id="([^"]*fnref-1)"/)?.[1];
+    expect(refId).toBeTruthy();
+    expect(one).toContain(`href="#${refId}"`);
   });
 });

diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -1,6 +1,6 @@
 "use client";
 
-import { memo, useId, type ReactNode } from "react";
+import { memo, useId, type ComponentProps } from "react";
 import ReactMarkdown from "react-markdown";
 import remarkGfm from "remark-gfm";
 import remarkBreaks from "remark-breaks";
@@ -33,10 +33,15 @@
   // Card onClick); without it, clicking a link would also toggle the thread's pin.
   // Only off-page links open a new tab — in-page (#fragment) links, e.g. footnote
   // back-references, must navigate within the card rather than spawn a tab.
-  a: ({ href, children }: { href?: string; children?: ReactNode }) => {
+  a: ({ href, children, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) => {
     const inPage = href?.startsWith("#") ?? false;
     return (
-      <a href={href} data-no-pin {...(inPage ? {} : { target: "_blank", rel: "noopener noreferrer" })}>
+      <a
+        {...props}
+        href={href}
+        data-no-pin
+        {...(inPage ? {} : { target: "_blank", rel: "noopener noreferrer" })}
+      >
         {children}
       </a>
     );

You can send follow-ups to the cloud agent here.

Comment thread lib/docs/comments/CommentMarkdown.tsx
The link override only forwarded href/children, dropping the id on footnote
reference anchors, so the ↩ return link had no target. Forward the rest of the
sanitized attributes (id, aria-*, data-footnote-*) instead. Also strip the
useId prefix to ASCII — React 19's useId returns non-alphanumeric delimiters
(«…»), which were leaking into the generated ids/hrefs. Footnotes now jump
forward and back, with ids unique per card. Found by Bugbot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Task checkbox clicks toggle pin
    • Added a markdown li renderer that tags GFM task-list rows with data-no-pin, so checkbox and row clicks no longer bubble into the card pin toggle handler.

Create PR

Or push these changes by commenting:

@cursor push d0b9ded9ee
Preview (d0b9ded9ee)
diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -43,6 +43,16 @@
       </a>
     );
   },
+  // Task-list rows (remark-gfm) can contain disabled checkboxes; clicks often land on
+  // the row instead of the input, so mark the whole row as no-pin.
+  li: ({ node, className, children, ...rest }: ComponentPropsWithoutRef<"li"> & ExtraProps) => {
+    const isTaskItem = className?.includes("task-list-item") ?? false;
+    return (
+      <li {...rest} className={className} {...(isTaskItem ? { "data-no-pin": true } : {})}>
+        {children}
+      </li>
+    );
+  },
 };
 
 function CommentMarkdown({ body }: { body: string }) {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 386ab64. Configure here.

</a>
);
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Task checkbox clicks toggle pin

Medium Severity

GFM task lists can render checkbox controls inside comment bodies, but only markdown links get data-no-pin. Clicks on those controls (or through disabled checkboxes onto the list row) bubble to the card’s click-to-pin handler and toggle pin/unpin instead of behaving like a checklist interaction.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 386ab64. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified this isn't reachable in practice, so leaving it as-is.

GFM task checkboxes render disabled (confirmed in the sanitized output: <input type="checkbox" disabled>), and disabled form controls don't dispatch click events — so a click on the checkbox never bubbles to the card's pin handler. Confirmed in a real browser by replicating the exact closest('[data-no-pin]') handler:

  • click the disabled checkbox → 0 pin toggles
  • click the row text → 1 toggle — but that's the existing body-click-to-pin behavior that applies to all comment text and predates this PR (plain-text bodies behaved identically).

The only genuinely new interactive element markdown introduces is links, which already carry data-no-pin. Happy to add data-no-pin to the checkbox as defense-in-depth if task lists ever become interactive, but as rendered (read-only/disabled) it can't toggle pin.

@rgarcia rgarcia merged commit e5378ef into main Jun 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant