uploads.sh docs page style
These pages teach a developer (or their agent) to do one thing with the uploads
CLI and then get out of the way. Each subject page is one .mdx file in the
docs content collection (apps/web/src/content/docs/), rendered through
DocsLayout by the apps/web/src/pages/docs/[...slug].astro route: you write
Markdown prose, fenced code blocks, and a small, fixed vocabulary of HTML
wrappers and components. The hub page (apps/web/src/pages/docs.astro) is still
a plain Astro page — it is a card index, not prose. The goal is a page that reads like reputable dev-tool docs (Stripe, Vercel, Wrangler):
one short orientation, then commands you can actually run, with prose used only as
connective tissue.
The failure mode to fight is the opposite: heavy paragraphs, an example for every variation, a copy button on every line, and setup buried below usage. That reads as "complicated" even when every sentence is individually fine — because the structure makes the reader work.
How to approach a page
- Read the page and identify the ONE thing it's for. Everything else is secondary and should be demoted or linked out, not given equal weight.
- Decide the page's job. A hub/landing page (
docs.astro) triages and links — it should not re-teach what a subject page already covers. A subject page walks through one workflow. Don't duplicate a worked example that lives on another page; link to it instead. - Order it: understand → set up → do. A short "what/why", then install or prerequisites, then the golden-path command. A reader should be able to run the first real command without scrolling back up to find setup.
- Pick one golden path. If there are two ways to do the thing, show the more universal one as the worked example and demote the other to a one-line pointer (see the pattern below). Never present two competing commands as parallel entry points — that fork is the single most common source of "this feels complicated."
- Apply the prose, copy, and inline-code rules below to what's left.
- Verify it renders. Reuse only the existing component classes, keep every
command/flag/link real (never invent one — check a sibling page or the CLI),
and confirm the page still compiles (
pnpm buildinapps/web).
Adding or editing a page
A new subject page is a new .mdx file in apps/web/src/content/docs/. The
filename is the URL slug (galleries.mdx → /docs/galleries); the left nav,
the prev/next chain, and the static path all derive from its frontmatter, so
there is no route, nav, or pagination edit to make.
---
title: Galleries # <title> and og/twitter title
description: … # meta description
heading: Galleries # the <h1>
tagline: … # one line under the <h1>
navLabel: Galleries # sidebar label (often shorter than heading)
navSlug: galleries # active-state key
navOrder: 2 # sidebar order AND the prev/next chain
toc: # optional "on this page" rail; omit it and no rail renders
- { id: what, label: What a gallery is }
---
navOrder is the whole ordering story: the sidebar lists entries in that order,
and the prev/next footer walks the same sequence, wrapping through the /docs
hub at both ends. Inserting a page means renumbering the ones after it.
MDX gotchas worth knowing before you write:
- Leave a blank line after an opening
<section …>tag and before its</section>so the prose inside is parsed as Markdown. - Use
<div class="note">, not<p class="note">: MDX wraps multi-line children in their own<p>, and a<p>inside a<p>is invalid. - Never let an inline component or
{expression}start a line inside a paragraph — MDX reparses it as a block and splits the sentence around it. This is why*.mdxis excluded fromoxfmt(see.oxfmtrc.json): a reflow would do exactly that. Wrap prose by hand.
Structure & voice
Intro: one sentence, then a capability list — not paragraphs
Lead with what the tool/feature does for the reader, verb-first, then (if scope needs conveying) a short bulleted capability list where each bullet links to the relevant page. This is the Stripe pattern: the copy reads like a spec sheet, not a pitch. No scene-setting ("In this guide…", "uploads.sh is a service that…"), no adjectives, no wind-up.
Prefer:
<p>Galleries collect related media behind one public link. With a gallery you can:</p>
<ul>
<li><a href="/docs/…">Group screenshots</a> into an ordered set.</li>
<li>Share the whole set with one URL.</li>
<li>Link the gallery to a PR or issue.</li>
</ul>
Avoid: a two-sentence throat-clear before the reader learns what the page is for, or a second explanatory paragraph after the first command (the reader wants to act, not read more).
The thing to cut is throat-clearing ("In this guide…", "uploads.sh is a service that…"), not substance. A concrete why — a real constraint the tool exists to solve, like "Agents take screenshots long before a pull request exists, and GitHub's own attachments only work inside GitHub" — earns its two or three sentences, because it tells the reader what problem they're actually solving. Lead with that when the page has one; keep it tight and get to the capability list.
One golden path; demote the rest to a pointer
Show the single most universal command as the worked example. A secondary command becomes a one-line pointer directly beneath it, linking to where it's covered in full:
<p class="pointer">
Already have a PR open? Use <code>uploads attach</code> instead —
<a href="/docs/attach-pull-request-images">see the walkthrough <span class="go">→</span></a
>.
</p>
Note the pointer names only the verb (uploads attach), not the full
multi-argument invocation — that lives on the linked page.
Prose diet
- Cut throat-clearing lead-ins. Every sentence should state a fact or give an instruction.
- One idea per sentence; split compound sentences glued with a comma or dash.
- Never fabricate terminal output. Reputable docs don't show scripted output.
Either show real, verifiable output (an
```ansiblock the reader can reproduce) or describe the effect in one sentence ("That uploads the file and prints a public URL."). Prefer the sentence. - Push troubleshooting and edge cases to the end, a
.note, or a linked page — not into the main reading path.
Code blocks
Fenced blocks are rendered by Expressive Code
(configured in apps/web/src/lib/expressive-code-options.mjs). The language tag
is the affordance — three conventions carry what the old hand-rolled .cmd and
.block markup used to:
| Fence | Renders as | Copy button |
|---|---|---|
```bash |
A command, with a $ prompt drawn in the gutter |
yes |
```ansi |
Terminal output in a terminal frame (titlebar chrome) | no |
```text |
A plain block — slash commands typed into an agent, snippets | yes |
```yaml / ```json |
A syntax-highlighted config example | yes |
Frames add hierarchy where a bare panel would read flat:
ansiblocks get terminal chrome automatically (configured inexpressive-code-options.mjs). Addtitle="uploads staged"to name the command that produced the output — use the command the surrounding prose already names, never an invented one. Untitled is fine for catalogs.- Config/file examples opt into an editor frame per-fence with
title=".uploads.yml" frame="code"— the title is the filename the reader will save. A paste-into-your-instructions snippet works the same way (```md title="AGENTS.md" frame="code") and keeps its copy button. - Commands stay bare panels (
$prompt is the affordance); don't wrap a one-liner in terminal chrome. - A command shown together with its output is ONE
ansiterminal block, not abashblock followed by anansiblock — the split reads as two unrelated artifacts. Write the command as a literal$ commandfirst line (the only place a literal$is allowed —ansihas no copy button, so nothing dirty gets copied) and title the frame with the command name:```ansi title="uploads staged".
The $ prompt is CSS, not source text, so the copy button still yields a clean
command. Never write the $ yourself. Comments (# …) inside a bash block are
stripped from the copied text automatically.
Config-file examples get syntax highlighting: a real .uploads.yml or JSON
snippet goes in a ```yaml / ```json fence, not a plain block.
Highlighting makes the keys and values pop out from explanatory comments.
Copy-affordance rule
A copyable block signals "paste this verbatim." Putting one on every line trains the eye to see many equally-weighted "do this" boxes when usually only one command matters. So:
- **Use a
```bashblock** for commands the reader genuinely pastes: the install line, the golden-path command, a longnpx …one-off. - Use inline
`code`for short, memorable, or illustrative commands —uploads login,uploads doctor, a barenpx, a flag, a filename. These are steps you read, not snippets you paste.
Rule of thumb: one copy target per action, not per line. A typical page has one to three copyable command blocks, not one per command mentioned.
A reference list of sibling commands is one block, not N copy rows. When a
section just enumerates related commands (a "here's the command surface" menu,
e.g. list / delete / usage / --help), a stack of copyable command blocks
reads as a wall of buttons. Put them in a single non-copyable ```ansi
block instead, with the # comment aligned:
```ansi
uploads list # see your files
uploads delete <key> # remove a file
uploads usage # storage used by your workspace
```
Reserve ```bash for the one or two commands in that section a reader
actually runs in sequence (an install line, a golden path) — not the whole
catalog.
Genuinely equivalent alternatives go in tabs, not stacked blocks. When one
action has two interchangeable entry points (the two ways to bake annotations
onto a capture, say), wrap them in <Tabs> / <TabItem> from
apps/web/src/components/docs/:
<Tabs syncKey="annotate-entry-point">
<TabItem label="screenshot --annotate">
```bash
uploads screenshot … --annotate ./callouts.json
```
Groups sharing a syncKey switch together across the page, and the choice is
remembered per browser. Tabs are for real alternatives only — never a sequence of
steps, and never an invented variant of a command.
Inline-code rule
Inline <code> is for short identifiers and single tokens — a command verb,
a flag, a package name, a path like /g/<id>. This is near-universal in good docs.
Never set a full, multi-argument command inline (e.g.
uploads attach ./before.png ./after.png mid-sentence). It forces the reader to
switch between prose cadence and monospace in one breath, wraps awkwardly across
lines, and makes a runnable command look like a passing mention. Instead:
- Put the full command in a
```bashblock, or - Reference only the verb inline (
uploads attach) and let the full form live in a block or on the linked page.
Watch inline-code density too: three or more inline-code spans crammed into one
paragraph reads as busy. Split them across sentences, or move asides (like an
npx alternative or a doctor check) into a .note.
Component vocabulary (DocsLayout)
Reuse these classes — don't invent new ones without a strong reason (note any new
class you add and why). Headings and their # anchors, the copy-button behavior,
the table of contents, and section dividers are all handled by DocsLayout
automatically; you don't wire them up per page.
| Element | Use for |
|---|---|
<section class="lead" id="…"> |
The first section (no top divider under the page title). |
<section id="…"> |
Every subsequent section; gets a top divider automatically. |
<h2> with an <a class="anchor" href="#id">#</a> inside |
Section heading. The anchor sits in the left gutter and appears on hover; the whole heading is click-to-anchor. Keep the markup pattern; the layout styles it. |
<h3> |
Sub-heading within a section (e.g. a card title, a labelled step). |
```bash fence |
A copyable command. The $ prompt is drawn by CSS — never type it. A trailing # comment is stripped from the copied text. |
```ansi fence |
Multi-line, non-copyable output or a reference command listing. Only for real, reproducible output. |
```text fence |
A copyable non-shell line — a slash command typed into an agent, an instructions-file snippet. |
<Tabs> / <TabItem label="…"> |
Two or more genuinely equivalent commands. syncKey links groups across the page. Not for sequences of steps. |
<div class="cards"> + <a class="card"> |
The "explore" grid of links to subject pages. Each card: <h3>Title <span class="go">→</span></h3> + one-sentence <p>. Trim to the highest-intent destinations rather than listing everything. |
<div class="note"> |
A muted aside — the place for asides, "more:" link lists, and edge cases pulled out of the main flow. |
<div class="pointer"> |
A demoted secondary-command line under the golden path (styled quieter than body text). Name only the verb inline; link to where it's covered in full. |
<div class="callout"> |
An accent-tinted contextual banner near the top of a page (e.g. "landed here from a bot comment?"). Use sparingly, for orientation the reader needs before the content. |
<GithubAppInstalledBanner /> |
The green post-install success banner, revealed by a ?setup_action=… query param (GitHub App page only). Don't add new ones without the matching reveal logic. |
<table> |
Reference/comparison data (e.g. plans, limits). The natural form for a reference page — prefer it over prose for anything grid-shaped. |
| (prev/next footer) | Generated from navOrder by the [...slug].astro route — don't hand-write one. |
<GhComment /> |
A styled mock GitHub comment (avatar + bubble), for showing what a posted comment looks like. Specialised — only where a page illustrates GitHub output. |
`code` |
Short inline identifiers (see the inline-code rule). |
<span class="go">→</span> |
The trailing arrow on card titles and pointers. |
Page-specific markup that doesn't generalize (a mock wireframe, a data table fed
from code) belongs in a small .astro component under
apps/web/src/components/docs/, imported by the MDX — not inlined as a slab of
HTML in the prose.
The canonical example of all of this working together is
apps/web/src/content/docs/attach-pull-request-images.mdx (fenced-block
conventions, tabs, an imported wireframe component) alongside
apps/web/src/pages/docs.astro (the hub page: intro + capability list,
install-first ordering, the golden-path-plus-pointer pattern, and a trimmed card
grid). Read one of them before reworking a subject page.
Hard constraints
- Don't invent facts. Every command, flag, URL, and capability must already
exist on the page, a sibling docs page, or the CLI. When unsure, check a sibling
page under
apps/web/src/content/docs/rather than guessing. - Reuse the vocabulary above. These are shared styles in
DocsLayout; a new class means new CSS and drift. Flag it explicitly if you truly need one. - Keep the page valid MDX so it compiles, and verify it renders in the
browser preview (the
webdev server) when you can — check the resting state is calm and scannable, not just that it builds.