diff --git a/.gitignore b/.gitignore
index 22e2be679..fdfa5dae5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,9 +15,12 @@ build/
# MkDocs
docs/site/
-# Generated docs-serving copy of the canonical Three.js (synced from
-# src/ by `pixi run docs-sync-vendored-js`; the source of truth is src/)
+# Generated docs-serving copies of the canonical JS runtimes and the
+# shared figure loader (synced from src/ by `pixi run
+# docs-sync-vendored-js`; the source of truth is src/)
docs/docs/assets/javascripts/vendor/threejs/
+docs/docs/assets/javascripts/vendor/plotly/
+docs/docs/assets/javascripts/ed-figures.js
# Jupyter Notebooks
.ipynb_checkpoints
diff --git a/.prettierignore b/.prettierignore
index 32b5970dc..b856d3cd3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -27,10 +27,14 @@ node_modules
# Vendored snapshots
src/easydiffraction/display/structure/renderers/vendor/
+src/easydiffraction/display/plotters/vendor/
src/easydiffraction/report/templates/html/vendor/
src/easydiffraction/report/templates/tex/styles/
src/easydiffraction/utils/_vendored/jupyter_dark_detect/
+# Figure loader served verbatim (same as its docs/docs/assets/ origin)
+src/easydiffraction/display/plotters/assets/
+
# Tox
.tox
diff --git a/docs/dev/adrs/accepted/lint-rule-exceptions.md b/docs/dev/adrs/accepted/lint-rule-exceptions.md
new file mode 100644
index 000000000..346a9cf7a
--- /dev/null
+++ b/docs/dev/adrs/accepted/lint-rule-exceptions.md
@@ -0,0 +1,79 @@
+# ADR: Lint Rule Scope and Test-File Exceptions
+
+## Status
+
+Accepted.
+
+## Date
+
+2026-06-08
+
+## Group
+
+Quality.
+
+## Context
+
+A full audit of ruff's disabled rules (merged in #194) adopted a
+low-risk subset and recorded which remaining suppressions are
+deliberate. Three of those suppressions are standing policy rather than
+temporary noise, and the audit flagged each as needing a recorded
+decision because it either scopes or extends the
+[`lint-complexity-thresholds.md`](lint-complexity-thresholds.md) ADR
+(which treats PLR complexity limits as guardrails that must not be
+silenced):
+
+- The PLR complexity rules `PLR0913`, `PLR0914`, `PLR0915`, and
+ `PLR0917` are ignored for `tests/**`.
+- `N812` (lowercase import alias) is ignored for `tests/**`.
+- The flake8-builtins `A` family is not enabled at all, so CIF-aligned
+ field names such as `id` and `type` do not trip builtin-shadowing
+ rules.
+
+The audit document itself was a one-time roadmap and is not retained;
+this ADR records the durable decisions it surfaced.
+
+## Decision
+
+1. **Test-file complexity exception.** `PLR0913`, `PLR0914`, `PLR0915`,
+ and `PLR0917` stay ignored under `tests/**` via
+ `[tool.ruff.lint.per-file-ignores]`. This is a deliberate, scoped
+ exception to `lint-complexity-thresholds.md`: test bodies
+ legitimately accumulate many arguments, locals, and statements
+ (fixtures, parametrisation, arrange-act-assert) where the complexity
+ is not a maintainability signal. Production code under `src/**`
+ remains fully governed by `lint-complexity-thresholds.md` — the
+ guardrail is not relaxed there, and `# noqa` / threshold raises
+ remain disallowed in `src/**`.
+
+2. **Test-file import-alias exception.** `N812` stays ignored under
+ `tests/**` so tests may import a module-under-test with a lowercase
+ alias (the `MUT` / `mut` idiom) without renaming convention churn.
+
+3. **CIF-aligned builtin names.** The flake8-builtins `A` family stays
+ disabled project-wide so categories can use the CIF-aligned field
+ names `id` and `type` (mandated by IUCr CIF tag alignment) without
+ builtin-shadowing warnings. Renaming these would break CIF
+ correspondence; the lint cost is not worth the divergence.
+
+These exceptions are the complete set of standing PLR/naming/builtin
+suppressions. Any further permanent suppression that conflicts with or
+extends `lint-complexity-thresholds.md` needs its own recorded decision
+before adoption.
+
+## Consequences
+
+### Positive
+
+- The standing test-file and CIF-naming suppressions are documented
+ decisions rather than unexplained `pyproject.toml` entries.
+- `lint-complexity-thresholds.md` keeps its full force over `src/**`;
+ the scope of the relaxation is explicit and bounded to `tests/**`.
+
+### Trade-offs
+
+- Test code can grow more complex without lint feedback; reviewers carry
+ that judgement instead of the linter.
+- The `id` / `type` field names continue to shadow builtins by design,
+ so contributors must keep CIF-alignment context in mind when reading
+ those categories.
diff --git a/docs/dev/adrs/index.md b/docs/dev/adrs/index.md
index 4733588fb..c282806d3 100644
--- a/docs/dev/adrs/index.md
+++ b/docs/dev/adrs/index.md
@@ -36,6 +36,7 @@ folders.
| Documentation | Suggestion | Documentation CI and Build Verification | Proposes strict MkDocs builds, API-derived docs, snippet smoke tests, link checks, and prose/spelling checks. | [`documentation-ci-build.md`](suggestions/documentation-ci-build.md) |
| Experiment model | Accepted | Immutable Experiment Type | Makes experiment type axes creation-time state rather than mutable runtime state. | [`immutable-experiment-type.md`](accepted/immutable-experiment-type.md) |
| Experiment model | Accepted | Automatic Line-Segment Background Estimation | Detects line-segment background control points from the measured pattern, peak-insensitive and editable. | [`background-auto-estimate.md`](accepted/background-auto-estimate.md) |
+| Experiment model | Suggestion | Calculation Without Measured Data | Adds a writable `data_range` category so a structure-only experiment is calculable and plottable without loaded data. | [`calculation-without-measured-data.md`](suggestions/calculation-without-measured-data.md) |
| Factories | Accepted | Factory Contracts and Metadata | Standardizes factory construction, metadata, compatibility, and registration behavior. | [`factory-contracts.md`](accepted/factory-contracts.md) |
| Naming | Accepted | Factory Tag Naming | Defines canonical factory tag style and standard abbreviations. | [`factory-tag-naming.md`](accepted/factory-tag-naming.md) |
| Persistence | Accepted | Free-Flag CIF Encoding | Encodes fit free/fixed state through CIF uncertainty syntax instead of a separate free list. | [`free-flag-cif-encoding.md`](accepted/free-flag-cif-encoding.md) |
@@ -44,6 +45,7 @@ folders.
| Persistence | Accepted | IUCr CIF Tag Alignment | Aligns default CIF tags with IUCr dictionaries and adds a clean IUCr-aligned report export. | [`iucr-cif-tag-alignment.md`](accepted/iucr-cif-tag-alignment.md) |
| Persistence | Accepted | Python and CIF Category Correspondence | Compares current Python paths and CIF tags, then records scoped one-to-one mapping for project-level categories. | [`python-cif-category-correspondence.md`](accepted/python-cif-category-correspondence.md) |
| Quality | Accepted | Lint Complexity Thresholds | Treats ruff PLR complexity limits as design guardrails that should not be bypassed. | [`lint-complexity-thresholds.md`](accepted/lint-complexity-thresholds.md) |
+| Quality | Accepted | Lint Rule Scope and Test-File Exceptions | Records the standing tests/\*\* PLR/N812 ignores and CIF-aligned `id`/`type` builtin exception from the lint audit. | [`lint-rule-exceptions.md`](accepted/lint-rule-exceptions.md) |
| Quality | Accepted | Test Strategy | Defines layered unit, functional, integration, script, and notebook testing. | [`test-strategy.md`](accepted/test-strategy.md) |
| Quality | Accepted | Test Suite and Validation Strategy | Strict test layers, cost tiers, coverage/codecov policy, cross-engine verification docs, and a nightly validation harness. | [`test-suite-and-validation.md`](accepted/test-suite-and-validation.md) |
| Structure model | Accepted | Type-Neutral ADP Parameters | Keeps ADP parameter object identities stable across B/U and iso/ani switches. | [`type-neutral-adp-parameters.md`](accepted/type-neutral-adp-parameters.md) |
diff --git a/docs/dev/adrs/suggestions/calculation-without-measured-data.md b/docs/dev/adrs/suggestions/calculation-without-measured-data.md
new file mode 100644
index 000000000..a58b11495
--- /dev/null
+++ b/docs/dev/adrs/suggestions/calculation-without-measured-data.md
@@ -0,0 +1,183 @@
+# ADR: Calculation Without Measured Data
+
+## Status
+
+Proposed.
+
+## Date
+
+2026-06-06
+
+## Group
+
+Experiment model.
+
+## Context
+
+An experiment built with `ExperimentFactory.from_scratch(...)` has no
+data points. The x-grid that every calculation runs on — the set of 2θ
+or time-of-flight values — lives inside `experiment.data` and is only
+ever populated by loading measured data
+(`_create_items_set_xcoord_and_id`). With no grid, a calculation cannot
+run:
+
+- cryspy derives its scan range from `experiment.data.x.min()/max()`
+ (`_cif_range_section`), which raises
+ `ValueError: zero-size array to reduction operation minimum` on the
+ empty array.
+- crysfml passes `experiment.data.x.tolist()` as the scan, i.e. an empty
+ scan.
+
+So `project.display.pattern(expt_name=...)` fails for a structure that
+has never been measured, even though everything needed to _calculate_ a
+pattern (structure, instrument, peak shape, background) is present.
+
+Scientists routinely want to **simulate** a pattern — or, for a single
+crystal, per-reflection F² — from a structural model alone: choose a
+range, calculate, and view the calculated curve with its background and
+Bragg reflections, with no measurement to load or invent. This is also
+how a saved project with no measured block should restore from the CLI.
+
+Two existing decisions frame the solution:
+
+- [Unified Pattern View](../accepted/pattern-display-unification.md)
+ already establishes that `pattern()` renders whatever the project
+ state supports. "Only calculated data is available" should simply be
+ one more supported state; today the display gates instead require
+ measured data before background or Bragg can appear.
+- The IUCr powder and core dictionaries already model an evenly-spaced
+ scan **by range**: `_pd_meas.2theta_range_{min,max,inc}` and
+ `_pd_proc.2theta_range_{min,max,inc}` are defined to be used "in place
+ of the `2theta_scan` values" for constant-step data, and
+ `_refln.sin_theta_over_lambda` / `_refln.d_spacing` are
+ instrument-independent reciprocal coordinates shared by powder and
+ single-crystal data.
+
+## Decision
+
+Introduce a `data_range` category that defines the reciprocal-space
+region (and, for powder, the profile step) to calculate over, and let
+the calculation and display paths fall back to it whenever no measured
+scan exists.
+
+1. **New category, type-determined.** `data_range` is a flat sibling of
+ `data`, with per-type concrete classes created through a factory
+ (`CwlDataRange`, `TofDataRange`, `ScDataRange`) and exposed uniformly
+ as `experiment.data_range`. It is fixed by the experiment type, so it
+ has **no** `type` selector — the same treatment
+ [Switchable Category API](../accepted/switchable-category-api.md)
+ prescribes for fixed, single-type categories, and the same pattern
+ `instrument` already uses for its per-beam-mode classes
+ ([Immutable Experiment Type](../accepted/immutable-experiment-type.md)).
+
+2. **Stored truth is the natural input axis (writable).** The values a
+ user sets and that serialise to CIF are the experiment's own axis:
+ - CWL powder: `two_theta_{min,max,inc}`
+ - TOF powder: `time_of_flight_{min,max,inc}`
+ - Single crystal: `sin_theta_over_lambda_{min,max}` (no `inc`)
+
+3. **sinθ/λ is the derived shared currency.** Every type also exposes
+ `sin_theta_over_lambda` and `d_spacing` (related by
+ `sinθ/λ = 1/(2·d)`, instrument-free), plus `x_{min,max,step}` aliases
+ onto the active axis (mirroring the existing plotting x-array alias).
+ Generic code — plotting and reflection generation — reads sinθ/λ. For
+ CWL and TOF the sinθ/λ and d views derive from the stored axis
+ through the instrument (λ for 2θ, DIFC/DIFA for TOF), so
+ **recalibration keeps the stored axis window fixed and re-derives
+ sinθ/λ**. For single crystal there is no measurement axis, so sinθ/λ
+ is itself the stored truth.
+
+4. **Bounds bound generation; step is powder-only.** `min`/`max` (as
+ sinθ/λ) bound reflection generation — powder through the engine's
+ 2θ/TOF range, single crystal through cryspy
+ `Crystal.calc_hkl(sthovl_max)`, which enumerates hkl with the space
+ group's systematic absences applied. `inc` is the profile point
+ spacing on the measurement axis and exists only for powder; a single
+ crystal has bounds but no step.
+
+5. **Writable, guarded by measurement.** Following
+ [Guarded Public Properties](../accepted/guarded-public-properties.md),
+ the `data_range` axis attributes are writable public properties. The
+ setter raises when a measured scan is present, because then the range
+ is an _observed_ property of the data rather than an input; the
+ getter returns the measured-derived range in that case (subsuming
+ today's `experiment.measured_range`) and the stored or default range
+ otherwise. Loaders and project restore seed values through a private
+ `_set_`.
+
+6. **Defaults authored in d-spacing.** Default ranges are stored in
+ d-spacing and projected onto each axis through the instrument, so a
+ `from_scratch` experiment is calculable with no manual setup and the
+ TOF default — meaningless in absolute µs without calibration — stays
+ well defined.
+
+7. **No `simulate()` method.** Accessing or plotting `data` (powder) or
+ `refln` (single crystal) with no measured scan builds the grid or
+ reflection list from `data_range` and calculates on it. The grid is
+ model state, not a one-shot action, so it is expressed as a
+ serialisable category rather than a method call.
+
+8. **Display extends the unified view.** Building on
+ [Unified Pattern View](../accepted/pattern-display-unification.md),
+ `background` and `bragg` become available with calculated-only data —
+ the measured-data requirement in their availability gates is dropped.
+ "No measurement" is represented as _absent_ intensities (not a
+ zero-filled array), so no phantom measured curve or residual is
+ drawn. A calc-only powder view is the calculated curve plus
+ background on the main panel and a Bragg row; a calc-only
+ single-crystal view shows per-reflection calculated intensities.
+
+9. **CIF mapping.** CWL bounds reuse the standard
+ `_pd_meas.2theta_range_{min,max,inc}`. TOF, single-crystal, and the
+ sinθ/λ–d bounds have no standard range tag, so custom tags are chosen
+ in line with
+ [IUCr CIF Tag Alignment](../accepted/iucr-cif-tag-alignment.md) and
+ [Python and CIF Category Correspondence](../accepted/python-cif-category-correspondence.md).
+
+## Consequences
+
+- A structure-only experiment can be calculated and plotted with no data
+ loaded: set `data_range` (or accept the defaults), then call
+ `project.display.pattern(...)`. The original failure is resolved.
+- One uniform `experiment.data_range` spans all experiment types;
+ generic display and calculator code read the shared sinθ/λ view and
+ need not branch on beam mode.
+- `experiment.measured_range` is subsumed by the derived getter on
+ `data_range`.
+- The project is in beta, so this adds the category with no
+ compatibility shim; tutorials and tests adopt it directly.
+- New code spans a `data_range` category, factory, and per-type classes;
+ calc-on-access grid and reflection generation; single-crystal hkl
+ generation via `calc_hkl`; and the display-gate relaxation plus a
+ calc-only single-crystal view.
+
+## Alternatives Considered
+
+- **A single generic `data_range.min/max/inc`.** Rejected: a bare `min`
+ is not self-explaining, because even for CWL a range may be thought of
+ in 2θ, d-spacing, or sinθ/λ. Axis-named attributes that cross-convert
+ read better and still expose the shared sinθ/λ view for generic code.
+- **A custom `pd_calc.2theta_range_*`.** Rejected: `pd_calc` has no
+ range in the dictionary (it is intensities, and reuses the meas/proc
+ point grid), so a custom calc range would lose interoperability with
+ no clear benefit over the standard `pd_meas` range plus the universal
+ `refln` reciprocal coordinates.
+- **An input/output split** — a writable "requested range" input plus a
+ read-only derived output — mirroring
+ [Minimizer Input/Output Split](../accepted/minimizer-input-output-split.md).
+ Rejected as heavier than needed here; a single guarded writable
+ property covers both roles.
+- **A `simulate(x_min, x_max, x_step)` method.** Rejected: the range is
+ persistent, restorable model state, which a method call is not.
+- **A range read directly by the calculators, with no data points.**
+ Rejected: it is more invasive (both engines plus plotting) and breaks
+ the invariant that the data points define the grid.
+
+## Deferred Work
+
+- Single-crystal reflection-generation wiring (the cryspy `calc_hkl`
+ path and validation of absence handling); crysfml single-crystal
+ structure-factor support is expected soon and adopts the same path.
+- Final custom CIF tag names for the TOF, sinθ/λ, and d-spacing bounds.
+- Concrete default numeric ranges and steps per experiment type.
+- The calc-only single-crystal plot specifics.
diff --git a/docs/dev/issues/open.md b/docs/dev/issues/open.md
index 42e37755c..052a4a254 100644
--- a/docs/dev/issues/open.md
+++ b/docs/dev/issues/open.md
@@ -438,6 +438,61 @@ mapping and the hardcoded defaults need verification.
---
+## 116. 🟡 cryspy Diverges on TOF Jorgensen–Von Dreele Lorentzian
+
+**Type:** Correctness
+
+For time-of-flight powder data using the Jorgensen–Von Dreele peak
+profile, the `cryspy` backend diverges from FullProf and `crysfml`
+whenever the Lorentzian term (`broad_lorentz_gamma_*`) is non-zero. On
+the Si Verification reference case the profile difference reaches ≈22%
+with an integrated-intensity ratio ≈0.72–0.76, while `crysfml` matches
+FullProf to <1%. When the Lorentzian term is zero (NaCaAlF) `cryspy`
+agrees to <1%, which localises the problem to the cryspy translation of
+the pseudo-Voigt (Gaussian ⊗ Lorentzian) mixing for TOF.
+
+**Fix:** verify how `broad_lorentz_gamma_*` is passed to cryspy for the
+`jorgensen-von-dreele` profile and reconcile the convention with
+crysfml/FullProf.
+
+**Visible on:** the Si TOF Verification page (`pd-neut-tof_jvd_si`),
+whose closeness table flags the `cryspy` rows in red — reported via a
+non-raising agreement check, not enforced, so CI stays green.
+Re-introduce a strict check, or skip the page via
+`docs/docs/verification/ci_skip.txt`, once work on the cryspy backend
+begins.
+
+**Depends on:** nothing.
+
+---
+
+## 117. 🟡 Add SyCos/SySin Systematic Peak-Position Corrections
+
+**Type:** Feature / Experiment model
+
+FullProf models systematic peak-position aberrations with `SyCos`
+(sample displacement) and `SySin` (transparency), shifting peaks as a
+function of angle on top of the `Zero` offset. EasyDiffraction has no
+category for these, so it cannot reproduce datasets that use them. The
+cryspy side is implemented in
+[cryspy PR #46](https://github.com/ikibalin/cryspy/pull/46) (see
+[issue #38](https://github.com/ikibalin/cryspy/issues/38)); the
+EasyDiffraction side — an instrument-category parameter pair plus the
+calculator wiring — is still to do.
+
+A prepared verification page,
+`docs/docs/verification/pd-neut-cwl_tch-fcj_lab6.py`, uses the issue #38
+LaB6 dataset and is skipped via `ci_skip.txt`. Finishing it also needs a
+custom ¹¹B scattering length, the Thompson–Cox–Hastings profile, and a
+FullProf-style polynomial background, which that dataset relies on.
+
+**Fix:** add `SyCos`/`SySin` to the CWL instrument category, pass them
+to the calculators, then un-skip the LaB6 page.
+
+**Depends on:** nothing.
+
+---
+
## 22. 🟢 Check CrysPy Single-Crystal Instrument Mapping
**Type:** Correctness
@@ -1986,6 +2041,151 @@ a baseline-cleanup plan.
---
+## 117. 🟢 Live-Notebook Plotly Delivery: Loader vs Native Mimetype
+
+**Type:** Display / Architecture
+
+Records the two viable strategies for rendering interactive Plotly
+figures in live notebooks, so the trade-off is not re-litigated. See
+[`plotting-docs-performance.md`](../adrs/accepted/plotting-docs-performance.md).
+
+**Background.** Live notebooks historically rendered via
+`display(HTML(pio.to_html(..., include_plotlyjs='cdn')))`, which caused
+an empty first plot after kernel restart (the CDN `'
+ )
+ display(HTML(self._wrap_html_figure(fig, target_html) + script))
+
+ @classmethod
+ def _live_runtime_bootstrap_js(cls) -> str:
+ """
+ Return one-time runtime + loader JavaScript for live notebooks.
+
+ On the first call in a kernel session this returns the
+ self-hosted Plotly bundle and the shared ``ed-figures.js``
+ loader as raw JavaScript (for a Javascript output); later calls
+ return an empty string. Running inline means the loader never
+ races an async runtime download.
+
+ Returns
+ -------
+ str
+ The bootstrap JavaScript, or ``''`` once already injected
+ this session.
+ """
+ if cls._live_runtime_injected:
+ return ''
+ cls._live_runtime_injected = True
+ runtime = _packaged_asset(_PLOTLY_RUNTIME_ASSET)
+ loader = _packaged_asset(_FIGURE_LOADER_ASSET)
+ # The leading ';' guards against the runtime's last statement
+ # swallowing the loader IIFE through automatic semicolon rules.
+ return f'{runtime}\n;\n{loader}\n;\n'
@staticmethod
def _ed_theme_payload() -> dict:
@@ -1836,17 +1916,14 @@ def _figure_height(fig: object) -> int:
return DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT
@classmethod
- def _serialize_html_shared(cls, fig: object) -> str:
+ def _figure_spec_json(cls, fig: object) -> str:
"""
- Serialize a figure as a lazy SHARED-mode placeholder.
+ Serialize a figure to the JSON spec the loader renders.
- Emits a skeleton plus the figure spec as ``application/json``
- for the shared ``ed-figures.js`` loader to render on demand. No
- Plotly bundle or per-figure post-script is embedded; the runtime
- loads once per page and the loader owns theme-sync, resize, and
- legend. Bulk float64 arrays are downcast to float32 (visually
- lossless, ~7 significant figures) to roughly halve the embedded
- data.
+ Carries the trace data, layout, config, and the theme/legend
+ metadata the loader needs. Bulk float64 arrays are downcast to
+ float32 (visually lossless, ~7 significant figures) to roughly
+ halve the embedded data.
Parameters
----------
@@ -1856,7 +1933,8 @@ def _serialize_html_shared(cls, fig: object) -> str:
Returns
-------
str
- Placeholder HTML carrying the figure spec.
+ The figure spec as a JSON string, with ``<`` escaped so it
+ is safe inside a ``') == 1 # exactly one render script element
+ assert 'renderSpec' in first
+ assert 'Plotly' in first
+
+ captured.clear()
+ plotter._show_figure(go.Figure())
+ second = captured[0]
+ # Later figures reference the already-injected runtime, not re-embed.
+ assert 'renderSpec' in second
+ assert 'Plotly' not in second
+ assert len(second) < len(first)
diff --git a/tools/bump_vendored_js.py b/tools/bump_vendored_js.py
index b62ae11a4..974780155 100644
--- a/tools/bump_vendored_js.py
+++ b/tools/bump_vendored_js.py
@@ -81,7 +81,7 @@ class VendoredRuntime:
name='Plotly.js (cartesian bundle)',
package='plotly.js',
version='3.5.0',
- dest_dir=Path('docs/docs/assets/javascripts/vendor/plotly'),
+ dest_dir=Path('src/easydiffraction/display/plotters/vendor/plotly'),
licence=(
'MIT — Copyright 2012-2026 Plotly, Inc. '
'See `https://github.com/plotly/plotly.js/blob/master/LICENSE`.'
diff --git a/tools/lint_rule_audit.py b/tools/lint_rule_audit.py
index 67aaf34eb..f342a8f5b 100644
--- a/tools/lint_rule_audit.py
+++ b/tools/lint_rule_audit.py
@@ -2,9 +2,10 @@
# SPDX-License-Identifier: BSD-3-Clause
"""Regenerate the disabled-rule inventory for the lint-rule audit.
-This helper reproduces the table in
-``docs/dev/plans/lint-rule-audit.md`` without modifying any tracked
-file. It layers the currently-disabled Ruff rules onto the *unmodified*
+This helper reproduces the disabled-rule inventory behind
+``docs/dev/adrs/accepted/lint-rule-exceptions.md`` without modifying any
+tracked file. It layers the currently-disabled Ruff rules onto the
+*unmodified*
``pyproject.toml`` at the command line, runs ``ruff check``, and prints
a per-rule breakdown by source scope (``src``/``tests``/``tutorials``)
and auto-fixability::
diff --git a/tools/sync_docs_vendored_js.py b/tools/sync_docs_vendored_js.py
index ffd7ab207..785c3b582 100644
--- a/tools/sync_docs_vendored_js.py
+++ b/tools/sync_docs_vendored_js.py
@@ -1,16 +1,20 @@
"""
-Sync the canonical vendored Three.js into the docs assets.
+Sync the canonical JavaScript runtimes into the docs assets.
MkDocs can only serve files under ``docs/docs``, so the canonical
-Three.js snapshot — which ships in the wheel from ``src/`` — is copied
-into ``docs/docs/assets/javascripts/vendor/threejs/`` for the site to
-serve. That docs copy is generated (git-ignored); the single source of
-truth is ``src/``. Plotly needs no sync: its docs-only bundle already
-lives under ``docs/docs/assets``.
+snapshots — which ship in the wheel from ``src/`` — are copied into
+``docs/docs/assets/javascripts/`` for the site to serve. Those docs
+copies are generated (git-ignored); the single source of truth is
+``src/``. This covers:
+
+* Three.js (``vendor/threejs/``) — structure views,
+* the Plotly cartesian bundle (``vendor/plotly/``) — interactive plots,
+* ``ed-figures.js`` — the shared figure loader used by both the docs
+ site and live notebooks.
Run automatically before ``mkdocs build``/``serve`` via the
-``docs-sync-vendored-js`` pixi task; the asset names come from the same
-pinned table as ``tools/bump_vendored_js.py``.
+``docs-sync-vendored-js`` pixi task; the vendored asset names come from
+the same pinned table as ``tools/bump_vendored_js.py``.
"""
from __future__ import annotations
@@ -19,33 +23,62 @@
import sys
from pathlib import Path
+from bump_vendored_js import PLOTLY
from bump_vendored_js import THREEJS
_REPO_ROOT = Path(__file__).resolve().parent.parent
-_DOCS_VENDOR_DIR = Path('docs/docs/assets/javascripts/vendor/threejs')
+_DOCS_JS_DIR = Path('docs/docs/assets/javascripts')
+
+# The shared figure loader is project code (not a fetched third-party
+# snapshot), so it lives outside the vendor tree.
+_ED_FIGURES_SOURCE = Path('src/easydiffraction/display/plotters/assets/ed-figures.js')
+_ED_FIGURES_DEST = _DOCS_JS_DIR / 'ed-figures.js'
+
+
+def _copy(source: Path, dest: Path) -> bool:
+ """
+ Copy one canonical file into the docs assets.
+
+ Parameters
+ ----------
+ source : Path
+ Repo-relative canonical source path.
+ dest : Path
+ Repo-relative docs destination path.
+
+ Returns
+ -------
+ bool
+ ``True`` on success; ``False`` if the source is missing.
+ """
+ absolute_source = _REPO_ROOT / source
+ if not absolute_source.is_file():
+ print(f'missing canonical source: {source}')
+ print('Run `pixi run vendor-update-js` first.')
+ return False
+ absolute_dest = _REPO_ROOT / dest
+ absolute_dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(absolute_source, absolute_dest)
+ print(f' synced {dest}')
+ return True
def sync() -> int:
"""
- Copy the canonical Three.js files into the docs assets.
+ Copy the canonical runtimes and loader into the docs assets.
Returns
-------
int
- ``0`` on success; ``1`` if a canonical source file is missing.
+ ``0`` on success; ``1`` if any canonical source file is missing.
"""
- src_dir = _REPO_ROOT / THREEJS.dest_dir
- dest_dir = _REPO_ROOT / _DOCS_VENDOR_DIR
- dest_dir.mkdir(parents=True, exist_ok=True)
- for asset in THREEJS.assets:
- source = src_dir / asset.filename
- if not source.is_file():
- print(f'missing canonical source: {THREEJS.dest_dir / asset.filename}')
- print('Run `pixi run vendor-update-js` first.')
- return 1
- shutil.copy2(source, dest_dir / asset.filename)
- print(f' synced {_DOCS_VENDOR_DIR / asset.filename}')
- return 0
+ ok = True
+ for runtime in (THREEJS, PLOTLY):
+ docs_dir = _DOCS_JS_DIR / 'vendor' / runtime.dest_dir.name
+ for asset in runtime.assets:
+ ok = _copy(runtime.dest_dir / asset.filename, docs_dir / asset.filename) and ok
+ ok = _copy(_ED_FIGURES_SOURCE, _ED_FIGURES_DEST) and ok
+ return 0 if ok else 1
if __name__ == '__main__':
diff --git a/tools/test_scripts.py b/tools/test_scripts.py
index 12df2e6ba..f5d96c689 100644
--- a/tools/test_scripts.py
+++ b/tools/test_scripts.py
@@ -20,14 +20,31 @@
_repo_root = Path(__file__).resolve().parents[1]
_src_root = _repo_root / 'src'
+_CI_SKIP_FILE = _repo_root / 'docs' / 'docs' / 'verification' / 'ci_skip.txt'
-# Discover tutorial and verification scripts, excluding checkpoint files.
+
+def _ci_skipped_stems():
+ """Verification notebook stems to skip (shared with the nbmake conftest)."""
+ if not _CI_SKIP_FILE.is_file():
+ return set()
+ stems = set()
+ for line in _CI_SKIP_FILE.read_text(encoding='utf-8').splitlines():
+ entry = line.split('#', 1)[0].strip()
+ if entry:
+ stems.add(entry)
+ return stems
+
+
+_CI_SKIP = _ci_skipped_stems()
+
+# Discover tutorial and verification scripts, excluding checkpoint files
+# and pytest conftest modules.
_SCRIPT_DIRS = ('docs/docs/tutorials', 'docs/docs/verification')
TUTORIALS = [
p
for directory in _SCRIPT_DIRS
for p in Path(directory).rglob('*.py')
- if '.ipynb_checkpoints' not in p.parts
+ if '.ipynb_checkpoints' not in p.parts and p.name != 'conftest.py'
]
@@ -38,6 +55,9 @@ def test_script_runs(script_path: Path):
Each script is run in the context of __main__ to mimic standalone
execution.
"""
+ if script_path.stem in _CI_SKIP:
+ pytest.skip(f'{script_path.stem} is skipped in CI (ci_skip.txt)')
+
env = os.environ.copy()
if _src_root.exists():
existing = env.get('PYTHONPATH', '')