feat(docs/api): protocol-style two-pane API reference layout

Replace the markdown-rendered /docs/api page with a structured
LiveView modelled on the Tailwind UI Protocol template — fixed
left sidebar with grouped section nav, hero with metadata dl,
two-column rows where prose sits next to a sticky code sample,
and endpoint cards with color-coded HTTP method tags.
This commit is contained in:
Graham McIntire 2026-05-10 11:06:29 -05:00
parent ff92979b50
commit 91c9d98a99
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 1524 additions and 51 deletions

View file

@ -327,61 +327,449 @@ select.select {
text-decoration: underline;
}
/* API docs page readable prose. Override the project's monospace
default with a system sans stack for the body, keep mono for code
spans and pre blocks. Without this the bulleted prose wraps to
2-3 lines per item and is a chore to scan. */
.markdown-content.api-docs {
max-width: 64rem;
font-family:
/*
/docs/api Tailwind UI "Protocol"-style API reference layout.
Sidebar on the left, two-column body where prose sits next to a
sticky code sample. Themed via daisyUI base-* tokens for auto
light/dark.
*/
.api-docs-shell {
--api-prose-font:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 1rem;
--api-mono-font:
"Cascadia Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
display: grid;
grid-template-columns: 1fr;
gap: 0;
min-height: calc(100vh - 4rem);
margin: -3rem -1rem 0;
font-family: var(--api-prose-font);
color: var(--color-base-content);
}
@media (min-width: 1024px) {
.api-docs-shell {
grid-template-columns: 17rem minmax(0, 1fr);
margin: -3rem 0 0;
}
}
.api-docs-sidebar {
display: none;
border-right: 1px solid var(--color-base-200);
padding: 1.5rem 1.25rem 2rem 1.5rem;
background: color-mix(in oklab, var(--color-base-100) 65%, transparent);
}
@media (min-width: 1024px) {
.api-docs-sidebar {
display: block;
position: sticky;
top: 4rem;
height: calc(100vh - 4rem);
overflow-y: auto;
}
}
.api-docs-nav-group {
margin-top: 1.5rem;
}
.api-docs-nav-group:first-child { margin-top: 0; }
.api-docs-nav-group h3 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-base-content);
opacity: 0.7;
margin: 0 0 0.5rem;
}
.api-docs-nav-group ul {
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-base-200);
padding-left: 0;
margin: 0;
list-style: none;
}
.api-docs-nav-group li { margin: 0; }
.api-docs-nav-group a {
display: block;
padding: 0.3rem 0 0.3rem 0.875rem;
margin-left: -1px;
font-size: 0.85rem;
border-left: 1px solid transparent;
color: var(--color-base-content);
opacity: 0.7;
text-decoration: none;
transition: color 120ms, border-color 120ms, opacity 120ms;
}
.api-docs-nav-group a:hover {
opacity: 1;
color: var(--color-primary);
}
.api-docs-nav-group a:target,
.api-docs-nav-group a:focus-visible {
outline: none;
opacity: 1;
border-left-color: var(--color-primary);
color: var(--color-primary);
}
.api-docs-main {
min-width: 0;
padding: 2rem 1rem 4rem;
}
@media (min-width: 768px) {
.api-docs-main { padding: 2.5rem 2rem 5rem; }
}
.api-docs-prose {
max-width: 76rem;
margin: 0 auto;
line-height: 1.7;
}
.markdown-content.api-docs code,
.markdown-content.api-docs pre,
.markdown-content.api-docs pre code {
font-family: "Cascadia Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
.api-docs-prose h1 {
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.75rem;
scroll-margin-top: 6rem;
}
.markdown-content.api-docs code {
word-break: break-word;
font-size: 0.875em;
}
.markdown-content.api-docs h1 {
font-size: 2rem;
letter-spacing: -0.01em;
}
.markdown-content.api-docs h2 {
.api-docs-prose h2 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.01em;
margin-top: 2.25rem;
margin: 0 0 0.75rem;
scroll-margin-top: 6rem;
}
.api-docs-prose h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
}
.api-docs-prose h4 {
font-size: 0.95rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-base-content);
opacity: 0.75;
margin: 1.25rem 0 0.5rem;
}
.markdown-content.api-docs h3 {
.api-docs-prose p { margin: 0.75rem 0; }
.api-docs-prose ul {
list-style: disc;
padding-left: 1.25rem;
margin: 0.75rem 0;
}
.api-docs-prose ul li { margin: 0.35rem 0; }
.api-docs-prose code {
font-family: var(--api-mono-font);
font-size: 0.85em;
padding: 0.1em 0.35em;
border-radius: 0.3rem;
background: var(--color-base-200);
color: var(--color-base-content);
word-break: break-word;
}
.api-docs-prose table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0 1.25rem;
font-size: 0.9rem;
border: 1px solid var(--color-base-200);
border-radius: 0.5rem;
overflow: hidden;
}
.api-docs-prose th,
.api-docs-prose td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-base-200);
}
.api-docs-prose th {
background: var(--color-base-200);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.api-docs-prose tbody tr:last-child td { border-bottom: 0; }
.api-docs-link {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.api-docs-link:hover { text-decoration-thickness: 2px; }
/* ── Hero ─────────────────────────────────────────────────────────── */
.api-docs-hero {
border-bottom: 1px solid var(--color-base-200);
padding-bottom: 2rem;
margin-bottom: 2.5rem;
}
.api-docs-eyebrow {
font-family: var(--api-mono-font);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-primary);
margin: 0 0 0.5rem;
}
.api-docs-lead {
font-size: 1.1rem;
line-height: 1.6;
opacity: 0.85;
max-width: 52rem;
}
.api-docs-meta {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem 2rem;
margin: 1.5rem 0 0;
padding: 1rem 1.25rem;
border: 1px solid var(--color-base-200);
border-radius: 0.75rem;
background: color-mix(in oklab, var(--color-base-100) 60%, transparent);
}
@media (min-width: 768px) {
.api-docs-meta { grid-template-columns: max-content 1fr; column-gap: 1.5rem; }
.api-docs-meta > div { display: contents; }
}
.api-docs-meta dt {
font-weight: 600;
font-size: 0.85rem;
opacity: 0.75;
}
.api-docs-meta dd {
margin: 0;
font-size: 0.9rem;
}
/* ── Two-column row (guide sections) + endpoint blocks ────────────── */
.api-docs-row,
.api-docs-endpoint {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
scroll-margin-top: 6rem;
padding-block: 2rem;
border-top: 1px solid var(--color-base-200);
}
.api-docs-row:first-of-type,
.api-docs-endpoint:first-of-type { border-top: 0; padding-top: 1rem; }
@media (min-width: 1100px) {
.api-docs-row,
.api-docs-endpoint {
grid-template-columns: minmax(0, 1fr) minmax(0, 28rem);
gap: 2.5rem;
}
.api-docs-row-code,
.api-docs-endpoint-code {
position: sticky;
top: 5rem;
align-self: start;
}
}
.api-docs-row-prose,
.api-docs-endpoint-prose {
min-width: 0;
}
.api-docs-row-code,
.api-docs-endpoint-code {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.api-docs-section-heading {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 1.5rem 0 0.5rem;
}
.api-docs-divider {
border: 0;
height: 1px;
background: var(--color-base-200);
margin: 2.5rem 0 1rem;
}
.api-docs-callout {
border-left: 3px solid var(--color-primary);
background: color-mix(in oklab, var(--color-primary) 7%, transparent);
padding: 0.6rem 0.9rem;
border-radius: 0 0.4rem 0.4rem 0;
font-size: 0.9rem;
}
/* ── Endpoint header ───────────────────────────────────────────────── */
.api-docs-eyebrow-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.api-docs-eyebrow-dot {
width: 0.2rem;
height: 0.2rem;
border-radius: 9999px;
background: var(--color-base-300);
}
.api-docs-eyebrow-path {
font-family: var(--api-mono-font);
font-size: 0.78rem;
background: transparent;
padding: 0;
color: var(--color-base-content);
opacity: 0.7;
}
.api-docs-endpoint-title {
font-size: 1.2rem;
margin-top: 1.75rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.markdown-content.api-docs h4 {
font-size: 1rem;
/* ── HTTP method tags ──────────────────────────────────────────────── */
.api-docs-method {
display: inline-flex;
align-items: center;
font-family: var(--api-mono-font);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.05rem 0.45rem;
border-radius: 0.4rem;
border: 1px solid currentColor;
background: color-mix(in oklab, currentColor 10%, transparent);
}
.api-docs-method.is-get { color: oklch(72% 0.15 160); }
.api-docs-method.is-post { color: oklch(70% 0.13 230); }
.api-docs-method.is-put { color: oklch(78% 0.13 75); }
.api-docs-method.is-patch { color: oklch(78% 0.13 75); }
.api-docs-method.is-delete { color: oklch(70% 0.18 25); }
.api-docs-method.is-zinc { color: var(--color-base-content); opacity: 0.6; }
/* ── Code blocks ───────────────────────────────────────────────────── */
.api-docs-code {
margin: 0;
border: 1px solid color-mix(in oklab, var(--color-neutral) 50%, transparent);
border-radius: 0.7rem;
overflow: hidden;
background: var(--color-neutral);
color: var(--color-neutral-content);
}
.markdown-content.api-docs li {
margin: 0.4rem 0;
.api-docs-code-caption {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
background: color-mix(in oklab, var(--color-neutral) 80%, black 20%);
border-bottom: 1px solid color-mix(in oklab, var(--color-neutral-content) 15%, transparent);
font-size: 0.78rem;
}
.markdown-content.api-docs table {
font-size: 0.95em;
.api-docs-code-title {
color: var(--color-neutral-content);
font-weight: 600;
}
.markdown-content.api-docs th,
.markdown-content.api-docs td {
white-space: normal;
.api-docs-code-meta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.api-docs-code-meta code {
font-family: var(--api-mono-font);
font-size: 0.72rem;
background: transparent;
color: color-mix(in oklab, var(--color-neutral-content) 70%, transparent);
padding: 0;
}
.api-docs-pre {
margin: 0;
padding: 0.95rem 1rem;
font-family: var(--api-mono-font);
font-size: 0.78rem;
line-height: 1.55;
overflow-x: auto;
color: var(--color-neutral-content);
background: transparent;
}
.api-docs-pre code {
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
white-space: pre;
}
/* ── Properties list ──────────────────────────────────────────────── */
.api-docs-properties {
margin: 0.5rem 0 1rem;
border-top: 1px solid var(--color-base-200);
}
.api-docs-property {
padding: 0.65rem 0;
border-bottom: 1px solid var(--color-base-200);
}
.api-docs-property dt {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.15rem;
font-size: 0.85rem;
}
.api-docs-property dd {
margin: 0;
font-size: 0.9rem;
opacity: 0.85;
}
.api-docs-property-name {
font-family: var(--api-mono-font);
font-weight: 600;
background: transparent;
padding: 0;
color: var(--color-base-content);
}
.api-docs-property-type {
font-family: var(--api-mono-font);
font-size: 0.72rem;
color: var(--color-base-content);
opacity: 0.6;
}
.api-docs-property-required {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: oklch(70% 0.18 25);
}
/* Allow map interaction through non-content parts of Leaflet popups */

File diff suppressed because it is too large Load diff

View file

@ -6,21 +6,44 @@ defmodule MicrowavepropWeb.ApiDocsLiveTest do
test "renders the API reference at /docs/api", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/docs/api")
assert html =~ "Microwaveprop REST API"
assert html =~ "Microwaveprop API"
assert html =~ "/api/v1"
assert html =~ "POST /auth/tokens"
assert html =~ "/auth/tokens"
end
test "renders bulleted lists rather than literal asterisks", %{conn: conn} do
test "renders the protocol-style two-pane layout shell", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/docs/api")
assert html =~ "<ul>"
assert html =~ "<li>"
refute html =~ ~r/<p>\*\s+<strong>Base URL/
assert html =~ ~s(class="api-docs-shell")
assert html =~ ~s(class="api-docs-sidebar")
assert html =~ ~s(class="api-docs-main")
end
test "applies the .api-docs class so the page has a sane reading width", %{conn: conn} do
test "renders sidebar nav links to in-page sections", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/docs/api")
assert html =~ ~s(class="markdown-content api-docs")
assert html =~ ~s(href="#quickstart")
assert html =~ ~s(href="#authentication")
assert html =~ ~s(href="#contacts")
assert html =~ ~s(href="#scores")
end
test "renders endpoint cards with HTTP method tags", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/docs/api")
assert html =~ "api-docs-method"
assert html =~ "is-get"
assert html =~ "is-post"
assert html =~ "is-delete"
end
test "renders code samples without literal Elixir interpolation curly tokens", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/docs/api")
# The JSON sample inside the curl call must round-trip the literal
# braces — HEEx must not have eaten them as interpolation. HTML
# escapes the surrounding double-quotes to &quot;.
assert html =~ ~s(&quot;station1&quot;: &quot;W5XD&quot;)
assert html =~ ~s(&quot;type&quot;: &quot;about:blank&quot;)
end
end