Skip to content

Rebuild the page actions row: Copy as markdown and Open in Claude - #3330

Draft
enf0rc3 wants to merge 11 commits into
mainfrom
wl/open-in-llm
Draft

Rebuild the page actions row: Copy as markdown and Open in Claude#3330
enf0rc3 wants to merge 11 commits into
mainfrom
wl/open-in-llm

Conversation

@enf0rc3

@enf0rc3 enf0rc3 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Implements two designs from Documentation vision: Open in LLM and Copy to clipboard.

What changed

The "Use Octopus docs with AI" pill at the foot of the article is gone. Under the page header, next to Edit on GitHub, there are now two actions:

Copy as markdown — fetches this page's .md companion and puts it on the clipboard. Swaps to a check icon and "Copied" for two seconds, then reverts.

Open in Claude with a caret — the primary half opens Claude with a prompt pointing at the page's .md URL. The caret opens a menu with Open in Claude and Open in ChatGPT, as link-styled rows with a provider logo and an external-link icon.

Gemini is deliberately absent: gemini.google.com ignores prompt parameters, so the entry could only prefill by pointing at AI Studio, which is not where "Open in Gemini" says it is going.

Both actions are the Button component, so borders, radius, hover, pressed and focus states come from the design-system tokens. Button gains an as prop so the caret can be a <summary>; the split button's two halves collapse into one divider via a negative margin, which keeps each half's focus ring unclipped.

Three icons added under src/assets/icons/, exported from the Figma file and applied as CSS masks so they take --colorIconPrimary in a button and --colorTextLinkDefault in the menu.

Reusing the shared copy module

copy-button.js (from #3317) grows two things rather than the copy action reimplementing them:

  • showResult writes to .btn__label when there is one, and falls back to the tooltip otherwise. The design wants the result in the label; the two existing icon-only consumers are unchanged.
  • copyFetchedOnClick covers text that has to be fetched. It hands ClipboardItem a pending promise so the click's user activation survives the request — awaiting the fetch first and then writing loses it in Safari, which is the trap the module's own comment warns about.

The delegated listener, result swap and announcement are shared by both entry points. The two write strategies stay apart on purpose: the promise trick only earns its keep when there is an await to survive, and text read off the page has none, so it keeps the simpler and more widely supported writeText.

The button also locks its measured width before the label shortens, so "Open in Claude" beside it doesn't shuffle. Measured rather than hardcoded, so it holds for any translation.

The eligibility check both actions need moved to pageMarkdownUrl(), so neither component re-derives the slug.

The menu opens towards whichever side has room

The menu is wider than the control, so the side it can open towards depends on where the control has ended up: at the end of the row on one line, or at the start of its own once the row wraps. A width breakpoint cannot tell those apart, and the first attempt had them backwards — the menu forced a horizontal scrollbar between roughly 520 and 720 wide, and was cut off past the start edge below 480.

CSS anchor positioning with position-try-fallbacks: flip-inline asks the browser instead. position: fixed rather than absolute, because the containing block was the caret's <details> at 32px wide, which leaves nothing to measure the room against; the anchor keeps it tracking the button through a scroll. Browsers without anchor positioning keep a plain absolute rule, right-aligned, which is the orientation the unwrapped row needs.

Fixes a markdown emitter bug on Windows

globSync returns backslash-separated paths on Windows and llm-md-emitter.ts fed them into forward-slash logic in two places. Two lines of posix: true:

  • pathToSlug strips a trailing /index, so argo-cd\index kept its suffix and the page was written to dist/docs/argo-cd/index.md. Every request for /docs/argo-cd.md 404d, taking the page actions and llms.txt parity with it.
  • The shared content lookup keys off path.posix.join, which leaves a backslash mid-key and never matches the include paths in the source files. A page whose include failed to resolve was judged ineligible, so 1252 rather than 846 pages now get a .md companion on a Windows build.

Linux was unaffected, which is why this survived. It is in this PR because it is what the two new actions point at.

Testing

astro build from a clean dist: exit 0, 2673 pages, 1252 .md emitted.

tests/llm-endpoints.spec.ts: 15 passed, 0 failed. Includes a test that clicks the copy button and reads the clipboard back to confirm the page markdown actually arrives, and two that open the menu at 700px and 430px and assert it stays on screen with no horizontal scrollbar. Before the emitter fix, five of these failed.

Verified in a real browser in both themes: menu open and closed, the copy button through rest, copied and reverted, and the menu flipping side as the row wraps.

🤖 Generated with Claude Code

@team-marketing-branch-protections

Copy link
Copy Markdown

Pull request environment is available at https://stoctodocspr3330.z22.web.core.windows.net.

You can view the ephemeral environment status in Octopus Deploy.

This environment will be automatically deprovisioned when the pull request is closed, or after 7 days of inactivity.

@enf0rc3
enf0rc3 force-pushed the wl/open-in-llm branch 2 times, most recently from 3c7c692 to 4eb9610 Compare August 11, 2026 02:41
@enf0rc3 enf0rc3 changed the title Turn the AI dropdown into an Open in Claude split button Rebuild the page actions row: Copy as markdown and Open in Claude Aug 11, 2026
enf0rc3 and others added 4 commits August 11, 2026 14:42
The "Use Octopus docs with AI" pill offered three markdown actions. The
design replaces it with a split button: a primary "Open in Claude", and a
caret that opens a list of assistants. Each entry hands the assistant this
page's .md URL to read.

The markdown actions come back as a separate copy button once the shared
copy module lands.

Both halves reuse the .btn component, so the pair matches "Edit on GitHub"
beside it in the page actions row, and the whole control moves from the
foot of the article up under the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split button hand-wrote `class="btn btn--small"`. Astro compiles
Button.astro's `.btn` rules to `.btn[data-astro-cid-ekguhzzh]` and only
stamps that attribute on elements Button itself renders, so none of the
button styles reached this markup.

Reuse the component instead of its class names. Button gains an `as` prop
so a `<details>` disclosure trigger can be a real Button: a `<summary>` is
a list item that carries its own marker, and dragging over a trigger
selects its text rather than opening it, so both resets belong with the
rest of the button styles. `<summary>` has no native disabled state, so
`disabled` maps to `aria-disabled`, which the existing state rules already
key off.

Move the `.octo-llm` rules out of main.css into the component. Reaching
into Button from here needs `:global()`, because the parent's scope
attribute lands on the Button root but never on the icon spans inside it.
Keeping `.octo-llm` in front of the `:global()` leaves the compiled
selector scoped, so these rules cannot escape the component.

Verified against the built output: the emitted CSS is unchanged by the
pending move of button styles into an imported stylesheet, since that
changes where the rules are authored and not what they compile to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page actions row lost its copy action when the AI dropdown became a
split button, on the grounds that it would come back once the shared copy
module landed. It has, so this brings it back as its own button.

copy-button.js grows two things. A labelled button now shows its result in
its label rather than a tooltip, which is what the design asks for and what
an icon-only button cannot do. And copyFetchedOnClick covers text that has
to be fetched: it hands ClipboardItem a pending promise so the click's user
activation survives the request, which awaiting the fetch first would spend.

The eligibility check both page actions need moves to pageMarkdownUrl, so
neither component re-derives the slug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
globSync returns backslash-separated paths on Windows, and both call sites
feed them into forward-slash logic.

pathToSlug strips a trailing `/index`, so `argo-cd\index` kept its suffix and
the page was written to dist/docs/argo-cd/index.md. Every request for
/docs/argo-cd.md 404d, which took the page actions and llms.txt parity with
it.

The shared content lookup keys off path.posix.join, which leaves a backslash
mid-key and never matches the include paths written in the source files. A
page whose include failed to resolve was judged ineligible, so 1252 rather
than 846 pages now get a .md companion on a Windows build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enf0rc3 and others added 7 commits August 11, 2026 15:11
`unclipped` and `neighbour` were only in comments, and `hrefs` only a local
name, so none of them needs a dictionary entry. `neighbour` in particular
would have committed the repo to a British spelling that the dictionary does
not otherwise establish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu is wider than the control it hangs from, so the side it can open
towards depends on where the control has ended up: at the end of the row on
one line, or at the start of its own once the row wraps. A width breakpoint
cannot tell those apart, and the one here had them backwards, so the menu
forced a horizontal scrollbar between roughly 520 and 720 wide and was cut
off past the start edge below 480.

Anchor positioning asks the browser instead, and flip-inline covers the wrap.
Fixed rather than absolute, because the containing block was the caret's
`<details>` at 32px wide, which leaves the browser nothing to measure the
room against; the anchor keeps it tracking the button through a scroll.

Browsers without anchor positioning keep the plain absolute rule, now
right-aligned rather than left, which is the orientation the unwrapped row
needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both wrapped the same delegated listener, result swap and announcement around
one differing line. That scaffolding moves to onCopyClick, which takes a
function that starts the write.

The write strategies stay apart. Handing ClipboardItem a pending promise only
earns its keep when there is an await to survive; text already read off the
page has none, so it keeps writeText, which is the simpler call and the more
widely supported one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gemini's own web app ignores prompt parameters, so the entry had to point at
AI Studio to prefill at all, which is not where "Open in Gemini" says it is
going. Removes the entry, its label, its icon and the mask rule.

Two shorter labels no longer fill 16rem, so the list is 13rem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page sets border-box on `html` but the universal reset does not pass it
on, so `min-height` was measuring the content alone and the padding was
landing outside it. Rows came out at 60px against a design that says 48, and
the menu at 136px against 112.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rows go from 3rem to 2.25rem and their block padding from space6 to space4,
which takes the menu from 112px to 88px for two items. The design system has
no compact MenuList yet, so the height borrows the step the medium button
already uses rather than inventing a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SplitButton is a design system component in its own right, described there as
having the same anatomy and tokens as Button with the addition of a split
action that triggers a Menu, so it composes Button the way the design does
and takes the same icon, size and importance.

OpenInLlm keeps only what is about assistants: the prompt, the two entries and
their icons. The generic half moves with the component, including the Escape
and outside-click handling, which was never LLM-specific.

`max-width: fit-content` is new rather than moved. A flex or grid parent turns
the root's `inline-flex` into a block-level box, which stretched it to the full
column on the components page; `.btn` already guards against the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants