diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 116407a..7a19269 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "docs/**"
+ - "build_docs.py"
- ".github/workflows/docs.yml"
workflow_dispatch:
@@ -25,13 +26,30 @@ jobs:
steps:
- uses: actions/checkout@v6
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install docs build dependencies
+ run: pip install markdown
+
+ # Previously this workflow uploaded ./docs/ raw, which published the markdown
+ # sources as downloads and the Sphinx sources as dead files, while the API
+ # reference was never built at all. build_docs.py renders each guide into a
+ # styled page and wires them together. See audit A-13.
+ - name: Build documentation site
+ run: python build_docs.py
+
- name: Configure Pages
uses: actions/configure-pages@v6
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
- path: ./docs/
+ path: ./docs/_site/
deploy:
name: Deploy to GitHub Pages
diff --git a/.gitignore b/.gitignore
index 38cdd99..787a9e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -190,3 +190,6 @@ CLAUDE.md
# Plottle — generated test artifacts (should never be committed)
tests/*.png
tests/*.json
+
+# Generated documentation site (build_docs.py)
+docs/_site/
diff --git a/README.md b/README.md
index c91699c..7078311 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Plottle provides a unified interface for scientific data work:
- **Data I/O** — 18 file formats (CSV, Excel, TSV, JSON, Parquet, NumPy, Pickle, JCAMP-DX, HDF5, NetCDF, SPC, ASC, mzML/mzXML, and more)
- **Mathematical analysis** — statistics, curve fitting, signal processing, peak analysis, hypothesis testing, optimization, linear algebra
-- **Multi-library plotting** — 26 plot types across Matplotlib (static), Seaborn (statistical), and Plotly (interactive)
+- **Multi-library plotting** — 27 plot types across Matplotlib (static), Seaborn (statistical), and Plotly (interactive)
- **14-page Streamlit GUI** — exploratory data analysis without writing code
- **CLI** — batch processing and scripted workflows
- **Plugin system** — drop `plugin_*.py` into `plugins/` for custom plot types and tools
@@ -76,7 +76,7 @@ without installing, use `python -m plottle.cli` instead.
| --- | --- |
| **Home** | Dashboard overview and help tabs |
| **1 — Data Upload** | Upload files in 18 formats; preview shape, column types, and summary statistics; batch folder import |
-| **2 — Quick Plot** | 26 plot types with live style controls, annotation panel, and Convert to Plotly toggle |
+| **2 — Quick Plot** | 27 plot types with live style controls, annotation panel, and Convert to Plotly toggle |
| **3 — Analysis Tools** | 8 tabs: Statistics, Distribution, Curve Fit, Optimization, Linear Algebra, Signal Processing, Peak Analysis, Statistical Tests |
| **4 — Multi-Plot Dashboard** | Up to 4×4 grid layouts with axis sharing and combined PNG/PDF export |
| **5 — Advanced Plotting** | Seaborn statistical plots and Plotly interactive charts with HTML export |
@@ -140,7 +140,7 @@ save_figure(fig, 'spectrum.png', dpi=300)
| --- | --- |
| `plottle.io` | `load_data()` / `save_data()` — auto-detects format from extension |
| `plottle.math` | 25 functions — statistics, curve fitting, hypothesis tests, optimization, linear algebra |
-| `plottle.plotting` | 26 plot types; Matplotlib → `(fig, ax, info)`, Plotly → `(fig, info)` |
+| `plottle.plotting` | 27 plot types; Matplotlib → `(fig, ax, info)`, Plotly → `(fig, info)` |
| `plottle.signal` | 16 functions — smoothing, filtering, FFT, derivatives, baseline correction, interpolation |
| `plottle.peaks` | 5 functions — find, integrate, FWHM, fit (Gaussian/Lorentzian/Voigt/pseudo-Voigt) |
| `plottle.data_tools` | 12 non-destructive DataFrame operations |
diff --git a/build_docs.py b/build_docs.py
new file mode 100644
index 0000000..95d7968
--- /dev/null
+++ b/build_docs.py
@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+"""Build the static documentation site into ``docs/_site/``.
+
+Plottle's docs used to contain two disconnected systems: a full Sphinx setup that
+CI never built, and a hand-written ``docs/index.html`` that linked to none of the
+markdown guides. The published site was therefore a single marketing page, with
+seven markdown files and an entire API reference deployed but unreachable except
+by guessing URLs — and ``.md`` served raw downloads rather than renders
+(audit A-13).
+
+Sphinx was dropped in favour of this script, which renders each markdown guide
+into a page styled to match ``index.html`` and wires them together with a shared
+nav. Run it locally to preview, or let ``.github/workflows/docs.yml`` run it.
+
+Usage
+-----
+ pip install markdown
+ python build_docs.py
+ # then open docs/_site/index.html
+
+Output
+------
+``docs/_site/`` — ``index.html`` (copied verbatim), one HTML page per guide, and
+``docs.css``. The directory is gitignored and is what the Pages workflow uploads.
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List
+
+_ROOT = Path(__file__).resolve().parent
+_DOCS = _ROOT / "docs"
+_SITE = _DOCS / "_site"
+
+REPO_URL = "https://github.com/The-Schultz-Lab/plottle"
+
+
+@dataclass(frozen=True)
+class Page:
+ """One markdown guide and where it lands in the built site."""
+
+ source: Path
+ slug: str
+ title: str
+ blurb: str
+
+ @property
+ def output_name(self) -> str:
+ return f"{self.slug}.html"
+
+
+# Ordered — this is also the nav order and the order of the cards on the landing
+# page. `docs/archive/` is deliberately excluded: it is superseded internal notes,
+# not user documentation.
+PAGES: List[Page] = [
+ Page(
+ _DOCS / "getting_started.md",
+ "getting-started",
+ "Getting Started",
+ "Install Plottle, load your first dataset, and make your first plot.",
+ ),
+ Page(
+ _DOCS / "tutorials" / "gui_guide.md",
+ "gui-guide",
+ "GUI Guide",
+ "A tour of all 14 pages of the Streamlit interface.",
+ ),
+ Page(
+ _DOCS / "tutorials" / "cli_guide.md",
+ "cli-guide",
+ "CLI Guide",
+ "The five `plottle` subcommands, with worked examples.",
+ ),
+ Page(
+ _DOCS / "cheatsheet.md",
+ "cheatsheet",
+ "Cheatsheet",
+ "Quick reference for the Python API — every module, at a glance.",
+ ),
+ Page(
+ _DOCS / "bug-reports.md",
+ "bug-reports",
+ "Reporting Bugs",
+ "What to include so a problem can be reproduced and fixed.",
+ ),
+ Page(
+ _DOCS / "feature_requests.md",
+ "feature-requests",
+ "Feature Requests",
+ "How to propose new functionality.",
+ ),
+]
+
+_CSS = """\
+/* Documentation pages — palette matched to index.html. */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-size: 16px; scroll-behavior: smooth; }
+body {
+ font-family: Inter, 'Segoe UI', -apple-system, BlinkMacSystemFont,
+ 'Helvetica Neue', Arial, sans-serif;
+ background: #ffffff; color: #334155; line-height: 1.7;
+ -webkit-font-smoothing: antialiased;
+}
+img, svg { display: block; max-width: 100%; }
+
+.nav {
+ position: sticky; top: 0; z-index: 100;
+ background: rgba(15, 23, 42, 0.92);
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
+ border-bottom: 1px solid rgba(255,255,255,0.06);
+}
+.nav__inner {
+ max-width: 1160px; margin: 0 auto; padding: 0.85rem 1.5rem;
+ display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
+}
+.nav__logo {
+ font-weight: 700; font-size: 1.0625rem; color: #f8fafc;
+ text-decoration: none; letter-spacing: -0.01em;
+}
+.nav__link {
+ color: #94a3b8; text-decoration: none; font-size: 0.9375rem;
+ transition: color 0.15s ease;
+}
+.nav__link:hover, .nav__link[aria-current="page"] { color: #f8fafc; }
+.nav__link[aria-current="page"] { font-weight: 600; }
+.nav__spacer { margin-left: auto; }
+.btn--ghost {
+ color: #e2e8f0; text-decoration: none; font-size: 0.875rem;
+ padding: 0.4rem 0.85rem; border: 1px solid rgba(203,213,225,0.22);
+ border-radius: 7px; transition: background 0.15s ease;
+}
+.btn--ghost:hover { background: rgba(255,255,255,0.07); color: #f8fafc; }
+
+.wrap { max-width: 820px; margin: 0 auto; padding: 3rem 1.5rem 5rem; }
+.crumb { font-size: 0.875rem; color: #64748b; margin-bottom: 1.5rem; }
+.crumb a { color: #8B1A2B; text-decoration: none; }
+.crumb a:hover { text-decoration: underline; }
+
+.wrap h1 {
+ font-size: clamp(1.9rem, 4vw, 2.4rem); font-weight: 700; color: #0f172a;
+ letter-spacing: -0.02em; line-height: 1.2; margin-bottom: 1.25rem;
+}
+.wrap h2 {
+ font-size: 1.5rem; font-weight: 700; color: #0f172a;
+ margin: 2.75rem 0 0.85rem; padding-bottom: 0.4rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+.wrap h3 { font-size: 1.15rem; font-weight: 650; color: #0f172a; margin: 2rem 0 0.6rem; }
+.wrap h4 { font-size: 1rem; font-weight: 650; color: #475569; margin: 1.5rem 0 0.5rem; }
+.wrap p, .wrap li { font-size: 1rem; }
+.wrap p { margin: 0.85rem 0; }
+.wrap ul, .wrap ol { margin: 0.85rem 0 0.85rem 1.5rem; }
+.wrap li { margin: 0.35rem 0; }
+.wrap a { color: #8B1A2B; text-decoration: none; }
+.wrap a:hover { text-decoration: underline; }
+.wrap strong { color: #0f172a; font-weight: 650; }
+.wrap hr { border: 0; border-top: 1px solid #e2e8f0; margin: 2.5rem 0; }
+.wrap blockquote {
+ border-left: 3px solid #D94054; padding: 0.15rem 0 0.15rem 1.1rem;
+ margin: 1.25rem 0; color: #475569;
+}
+
+.wrap code {
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 0.875em; background: #f1f5f9; color: #8B1A2B;
+ padding: 0.15em 0.4em; border-radius: 4px;
+}
+.wrap pre {
+ background: #0f172a; border-radius: 10px; padding: 1.15rem 1.35rem;
+ overflow-x: auto; margin: 1.25rem 0;
+}
+.wrap pre code {
+ background: none; color: #e2e8f0; padding: 0; font-size: 0.875rem; line-height: 1.65;
+}
+
+/* Wide tables scroll inside their own container so the page never does. */
+.table-scroll { overflow-x: auto; margin: 1.25rem 0; }
+.wrap table { border-collapse: collapse; width: 100%; font-size: 0.9375rem; }
+.wrap th, .wrap td {
+ text-align: left; padding: 0.6rem 0.85rem; border-bottom: 1px solid #e2e8f0;
+ vertical-align: top;
+}
+.wrap th { color: #0f172a; font-weight: 650; background: #f8fafc; white-space: nowrap; }
+
+.footer { border-top: 1px solid #e2e8f0; background: #f8fafc; }
+.footer__inner {
+ max-width: 1160px; margin: 0 auto; padding: 2rem 1.5rem;
+ display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
+ font-size: 0.875rem; color: #64748b;
+}
+.footer a { color: #475569; text-decoration: none; }
+.footer a:hover { color: #0f172a; }
+
+@media (max-width: 640px) {
+ .wrap { padding: 2rem 1.15rem 3.5rem; }
+ .nav__inner { gap: 0.85rem; }
+}
+"""
+
+
+def _nav_html(current_slug: str = "") -> str:
+ # Built without backslashes inside f-string expressions: that is a syntax error
+ # before Python 3.12, and this script must run on the same floor as the package.
+ links = []
+ for page in PAGES[:4]:
+ current = ' aria-current="page"' if page.slug == current_slug else ""
+ links.append(
+ '{page.title}'
+ )
+ return f""""""
+
+
+def _footer_html() -> str:
+ return f""""""
+
+
+def _wrap_tables(html: str) -> str:
+ """Put each table in a horizontally scrollable container.
+
+ Wide reference tables would otherwise force the whole page to scroll
+ sideways on a narrow screen.
+ """
+ return re.sub(
+ r"(
.*?
)",
+ r'
\1
',
+ html,
+ flags=re.DOTALL,
+ )
+
+
+def _rewrite_internal_links(html: str) -> str:
+ """Point cross-references at built pages instead of source markdown.
+
+ The guides link to each other with paths like ``tutorials/cli_guide.md``,
+ which would 404 in the built site.
+ """
+ by_source_name = {p.source.name: p.output_name for p in PAGES}
+ for source_name, output_name in by_source_name.items():
+ html = re.sub(
+ rf'href="(?:\.\./)*(?:tutorials/)?{re.escape(source_name)}"',
+ f'href="{output_name}"',
+ html,
+ )
+ return html
+
+
+def _render(page: Page, body_html: str) -> str:
+ return f"""
+
+
+
+
+{page.title} — Plottle
+
+
+
+
+
+{_nav_html(page.slug)}
+
+
Scatter, line, bar, histogram, box, violin, heatmap, contour, 3D surface,
regression, and more — in Matplotlib, Seaborn, or interactive Plotly.
@@ -791,7 +794,7 @@