diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml index 64d22a92..408f8d56 100644 --- a/.github/workflows/docs-pr.yml +++ b/.github/workflows/docs-pr.yml @@ -9,6 +9,8 @@ on: - 'overrides/**' - 'mkdocs.yml' - 'pyproject.toml' + - 'requirements-docs.txt' + - 'scripts/prepare_portal.py' - 'scripts/check-docs-analytics.py' - 'scripts/check-docs-layout.py' - 'scripts/docstring_cross_references.py' @@ -29,6 +31,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" @@ -36,18 +40,28 @@ jobs: uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python - - name: Install package and documentation dependencies - run: pip install -e '.[docs]' + - name: Install pinned documentation dependencies + run: | + pip install --require-hashes --only-binary=:all: -r requirements-docs.txt - name: Install Chromium env: NODE_OPTIONS: --use-openssl-ca NODE_EXTRA_CA_CERTS: '' NODE_USE_SYSTEM_CA: '0' NODE_TLS_REJECT_UNAUTHORIZED: '1' - run: python -m playwright install --with-deps chromium + run: python -m playwright install chromium - name: Build and check the portal run: | - mkdocs build --strict + python scripts/prepare_portal.py + mkdocs build --strict --config-file .docs-build.yml python scripts/docstring_cross_references.py --site site python scripts/check-docs-analytics.py site - python scripts/check-docs-layout.py site + python scripts/check-docs-layout.py site --screenshots docs-review + cp .docs-provenance.json docs-review/deployment-audit.json + cp .docs-api-inventory.json docs-review/api-source-inventory.json + - name: Retain rendered review screenshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-portal-review-${{ github.sha }} + path: docs-review + retention-days: 7 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4232aff6..d31535ab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,8 +9,10 @@ on: - 'overrides/**' - 'mkdocs.yml' - 'pyproject.toml' + - 'requirements-docs.txt' - 'scripts/check-docs-analytics.py' - 'scripts/check-docs-layout.py' + - 'scripts/prepare_portal.py' - 'scripts/docstring_cross_references.py' - '.github/workflows/docs.yml' workflow_dispatch: @@ -45,8 +47,9 @@ jobs: fi - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ (inputs.stable_tag != '' && format('refs/tags/{0}', inputs.stable_tag)) || github.sha }} + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" @@ -54,27 +57,49 @@ jobs: uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python + - name: Check out the selected published SDK source + if: inputs.stable_tag != '' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ format('refs/tags/{0}', inputs.stable_tag) }} + path: .docs-sdk + persist-credentials: false - name: Resolve the public documentation release channel id: release-channel env: STABLE_TAG: ${{ inputs.stable_tag }} GH_TOKEN: ${{ github.token }} run: | - version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') + docs_revision=$(git rev-parse HEAD) + test "$docs_revision" = "$GITHUB_SHA" + sdk_revision=$docs_revision + project=pyproject.toml + if [ -n "$STABLE_TAG" ]; then + project=.docs-sdk/pyproject.toml + fi + version=$(python - "$project" <<'PY' + import sys + import tomllib + print(tomllib.load(open(sys.argv[1], "rb"))["project"]["version"]) + PY + ) if [ -n "$STABLE_TAG" ]; then test "$GITHUB_EVENT_NAME" = workflow_dispatch test "$GITHUB_REF" = refs/heads/main test "$version" = "$STABLE_TAG" released=$(gh release view "$STABLE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq '(.isDraft == false and .isPrerelease == false)') test "$released" = true - source_revision=$(git rev-parse HEAD) + sdk_revision=$(git -C .docs-sdk rev-parse HEAD) tag_revision=$(git rev-parse "refs/tags/$STABLE_TAG^{commit}") - test "$source_revision" = "$tag_revision" + test "$sdk_revision" = "$tag_revision" python - "$version" <<'PY' import json import re import sys import urllib.request + from urllib.parse import urlsplit + from pathlib import Path + import os version = sys.argv[1] with urllib.request.urlopen( @@ -87,6 +112,7 @@ jobs: if package.get("info", {}).get("name") != "durable-workflow" or package["info"].get("version") != version: raise SystemExit("PyPI package identity differs from selected tag") kinds = set() + wheel = None for artifact in package.get("urls", []): if artifact.get("yanked") is not False: continue @@ -96,26 +122,59 @@ jobs: if not filename.startswith(f"durable_workflow-{version}-") and filename != f"durable_workflow-{version}.tar.gz": raise SystemExit("PyPI artifact filename differs from selected package") kinds.add(artifact.get("packagetype")) + if filename == f"durable_workflow-{version}-py3-none-any.whl": + if wheel is not None: + raise SystemExit("Selected package has duplicate universal wheels") + wheel = artifact if not {"bdist_wheel", "sdist"} <= kinds: raise SystemExit("Selected stable package has no unyanked wheel and sdist") + if wheel is None: + raise SystemExit("Selected package has no universal Python wheel") + url = urlsplit(wheel.get("url", "")) + if (url.geturl() != wheel["url"] or url.scheme != "https" or url.netloc != "files.pythonhosted.org" + or url.query or url.fragment or not url.path.startswith("/packages/") + or not url.path.endswith("/" + wheel["filename"]) + or not re.fullmatch(r"[A-Za-z0-9/_.-]+", url.path)): + raise SystemExit("Published wheel URL is outside the package authority") + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"wheel_url={wheel['url']}\nwheel_sha256={wheel['digests']['sha256']}\n") PY echo stable=true >> "$GITHUB_OUTPUT" else echo stable=false >> "$GITHUB_OUTPUT" fi echo "version=$version" >> "$GITHUB_OUTPUT" + echo "docs_revision=$docs_revision" >> "$GITHUB_OUTPUT" + echo "sdk_revision=$sdk_revision" >> "$GITHUB_OUTPUT" - name: Install package and documentation dependencies - run: pip install -e '.[docs]' + env: + STABLE_TAG: ${{ inputs.stable_tag }} + PUBLISHED_WHEEL_URL: ${{ steps.release-channel.outputs.wheel_url }} + PUBLISHED_WHEEL_SHA256: ${{ steps.release-channel.outputs.wheel_sha256 }} + run: | + pip install --require-hashes --only-binary=:all: -r requirements-docs.txt + if [ -n "$STABLE_TAG" ]; then + python - <<'PY' + import os + from pathlib import Path + requirement = f"durable-workflow @ {os.environ['PUBLISHED_WHEEL_URL']} --hash=sha256:{os.environ['PUBLISHED_WHEEL_SHA256']}\n" + Path(".docs-published-wheel.txt").write_text(requirement) + PY + pip install --require-hashes --no-deps --only-binary=:all: -r .docs-published-wheel.txt + fi - name: Install Chromium env: NODE_OPTIONS: --use-openssl-ca NODE_EXTRA_CA_CERTS: '' NODE_USE_SYSTEM_CA: '0' NODE_TLS_REJECT_UNAUTHORIZED: '1' - run: python -m playwright install --with-deps chromium + run: python -m playwright install chromium - name: Build and check the portal env: CLOUDFLARE_WEB_ANALYTICS_TOKEN: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_TOKEN }} + STABLE_TAG: ${{ inputs.stable_tag }} + DOCS_WHEEL_SHA256: ${{ steps.release-channel.outputs.wheel_sha256 }} + PYTHONDONTWRITEBYTECODE: '1' run: | if ! printf '%s' "$CLOUDFLARE_WEB_ANALYTICS_TOKEN" | grep -Eq '^[a-f0-9]{32}$'; then echo 'CLOUDFLARE_WEB_ANALYTICS_TOKEN must be the canonical 32-character site token' >&2 @@ -123,29 +182,23 @@ jobs: fi sed -i "s/__CLOUDFLARE_WEB_ANALYTICS_TOKEN__/$CLOUDFLARE_WEB_ANALYTICS_TOKEN/" \ docs/javascripts/analytics.js - mkdocs build --strict + python scripts/prepare_portal.py + mkdocs build --strict --config-file .docs-build.yml python scripts/docstring_cross_references.py --site site python scripts/check-docs-analytics.py site --require-token - python scripts/check-docs-layout.py site + python scripts/check-docs-layout.py site --screenshots docs-review test -s site/index.html test "$(cat site/CNAME)" = "python.durable-workflow.com" - - name: Bind Pages artifact to the exact source revision - env: - SDK_VERSION: ${{ steps.release-channel.outputs.version }} + - name: Bind the artifact to both documentation and SDK sources run: | - SOURCE_REVISION=$(git rev-parse HEAD) - export SOURCE_REVISION - python - <<'PY' - import json - import os - from pathlib import Path - - Path("site/deployment-audit.json").write_text(json.dumps({ - "schema": "durable-workflow.sdk-python.docs-deployment/v1", - "source_revision": os.environ["SOURCE_REVISION"], - "sdk_version": os.environ["SDK_VERSION"], - }) + "\n") - PY + cp .docs-provenance.json site/deployment-audit.json + cp .docs-api-inventory.json site/api-source-inventory.json + - name: Retain rendered review screenshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-portal-review-${{ github.sha }} + path: docs-review + retention-days: 7 - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 if: github.server_url == 'https://github.com' with: diff --git a/.gitignore b/.gitignore index d8b04350..f0620d69 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,13 @@ dist/ build/ site/ *.egg-info/ + +# Generated documentation source split and provenance +.docs-sdk/ +.docs-stage-*/ +.docs-build.yml +.docs-api-inventory.json +.docs-provenance.json +.docs-requirements.txt + +.docs-published-wheel.txt diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg index 3141555e..02879965 100644 --- a/docs/assets/favicon.svg +++ b/docs/assets/favicon.svg @@ -1,5 +1,9 @@ - - - - - + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 00000000..02879965 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 704a2561..8689adc3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ hide:
-

First-party Python SDK · 2.0 stable

+

Python SDK · 2.14.1 published

# Durable workflows in Python. @@ -33,10 +33,14 @@ namespace. ## Install +
+ ```bash pip install durable-workflow ``` +
+ Use a virtual environment and lock the resolved package version with the rest of your application dependencies. @@ -53,39 +57,12 @@ of your application dependencies. A client asks the runtime to do durable work. A worker receives tasks and dispatches them to your workflow and activity code. -
- 01ClientStarts and controls workflow runs from your app.Client API → - 02WorkerPolls a task queue and dispatches registered handlers.Worker API → - 03WorkflowDescribes replay-safe orchestration and durable decisions.Workflow API → - 04ActivityPerforms side effects such as API calls and database work.Activity API → -
- - - -
- -## Choose who runs the runtime - -The workflow types, activity types, and task queue stay the same. Only the -endpoint, credentials, and operating boundary change. - -
-
- Available without an account -

Self-hosted Server

-

Run the published Server image locally, then execute the complete Python journey below.

- Start locally -
-
- Managed runtime · limited access -

Durable Workflow Cloud

-

Connect the same program to a provisioned namespace with separate client and worker credentials.

-

- Connect a namespace - Request early access → -

-
-
+
@@ -96,16 +73,22 @@ endpoint, credentials, and operating boundary change. This source-free path runs one Python file containing an activity, workflow, worker, and client against the stable Server channel. -### 1. Start Server +### 1. Start Server { #start-server } Docker keeps this first run local. Select the stable 2.x Server channel: +
+ ```bash export DW_SERVER_IMAGE='durableworkflow/server:2' ``` +
+ Then bootstrap and start that qualified image: +
+ ```bash export DURABLE_WORKFLOW_RUNTIME_URL='http://127.0.0.1:8080' export DURABLE_WORKFLOW_RUNTIME_NAMESPACE='default' @@ -122,13 +105,28 @@ docker run -d --name durable-workflow-python-server -p 8080:8080 \ until curl -sf http://127.0.0.1:8080/api/ready >/dev/null; do sleep 1; done ``` -### 2. Save `greeter.py` +
+ +
+
+ +

Step 2 of 3

+ +### Save `greeter.py` { #save-greeter } The named constants make the authoring contract visible: the decorator and start call share a workflow type, the workflow and decorator share an activity type, and the client and worker share one task queue. Values cross those boundaries with the supported Avro authoring codec. +First [start Server](#start-server), then save this complete program and +[run it in step 3](#run-greeter). Copy preserves the whole file and its indentation. + +
+
+ +
+ ```python import asyncio import logging @@ -181,17 +179,29 @@ async def main() -> None: asyncio.run(main()) ``` -### 3. Run it +
+ +
+
+ + +### 3. Run it { #run-greeter } + +
```bash python greeter.py ``` +
+
The SDK reports registration before it handles the workflow, followed by the completed result: +
+ ```text worker py-worker-… registered on python-workers Hello, world! @@ -199,6 +209,35 @@ Hello, world!
+
+ + + +
+ +## Choose who runs the runtime + +The workflow types, activity types, and task queue stay the same. Only the +endpoint, credentials, and operating boundary change. + +
+
+ Available without an account +

Self-hosted Server

+

Run the published Server image locally, then execute the complete Python journey below.

+ Start locally +
+
+ Managed runtime · limited access +

Durable Workflow Cloud

+

Connect the same program to a provisioned namespace with separate client and worker credentials.

+

+ Connect a namespace + Request early access → +

+
+
+
@@ -221,6 +260,8 @@ Replace the placeholders with values returned for your namespace, then run the same `greeter.py`. Keep client and worker tokens in their respective processes when you split the example for production. +
+ ```bash export DURABLE_WORKFLOW_RUNTIME_URL='' export DURABLE_WORKFLOW_RUNTIME_NAMESPACE='' @@ -230,6 +271,8 @@ unset DURABLE_WORKFLOW_TOKEN python greeter.py ``` +
+

Cloud runtime guideRun the Python playground →

diff --git a/docs/javascripts/identity.js b/docs/javascripts/identity.js new file mode 100644 index 00000000..7af03aaf --- /dev/null +++ b/docs/javascripts/identity.js @@ -0,0 +1,72 @@ +import { watchPublicStatus } from "./status.mjs"; + +/* Native disclosure semantics retain links and keyboard navigation without JS. */ +(() => { + const close = (switcher, restoreFocus) => { + if (!switcher.open) return; + switcher.open = false; + if (restoreFocus) switcher.querySelector("summary").focus(); + }; + document.querySelectorAll("[data-sdk-switcher]").forEach((switcher) => { + switcher.addEventListener("keydown", (event) => { + if (event.key === "Escape" && switcher.open) { + event.preventDefault(); + close(switcher, true); + } + }); + document.addEventListener("click", (event) => { + if (!switcher.contains(event.target)) close(switcher, false); + }); + }); + + document.querySelectorAll('.md-header__option label[for^="__palette_"]').forEach((control) => { + control.setAttribute("role", "button"); + control.setAttribute("aria-label", control.title); + control.tabIndex = 0; + control.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + // Material also handles Enter globally; one activation changes the theme once. + event.stopPropagation(); + control.click(); + requestAnimationFrame(() => { + document.querySelector('.md-header__option input:checked')?.nextElementSibling?.focus(); + }); + } + }); + }); + + const pill = document.querySelector("[data-public-status]"); + if (!pill) return; + const labels = { + operational: "All systems operational", + degraded: "Degraded performance", + incident: "Service incident", + maintenance: "Maintenance", + stale: "Status stale", + unverified: "Status unverified", + unavailable: "Status unavailable", + unknown: "Status unknown", + }; + const renderStatus = (state) => { + const safeState = Object.hasOwn(labels, state) ? state : "unknown"; + if (pill.dataset.publicStatus === safeState) return; + pill.dataset.publicStatus = safeState; + pill.querySelector("[data-status-label]").textContent = labels[safeState]; + }; + let stopStatus; + const startStatus = () => { + stopStatus?.(); + renderStatus("unknown"); + stopStatus = watchPublicStatus(renderStatus); + }; + startStatus(); + window.addEventListener("pagehide", () => { + stopStatus?.(); + stopStatus = undefined; + renderStatus("unknown"); + }); + window.addEventListener("pageshow", (event) => { + if (event.persisted) startStatus(); + }); +})(); diff --git a/docs/javascripts/status.mjs b/docs/javascripts/status.mjs new file mode 100644 index 00000000..2659eae6 --- /dev/null +++ b/docs/javascripts/status.mjs @@ -0,0 +1,109 @@ +// Shared public footer summary. No component rows, credentials or telemetry. +export const STATUS_API = 'https://status.durable-workflow.com/api/current.json'; +const statuses = new Set(['ok', 'warning', 'failed', 'unknown']); +const levels = new Set(['ok', 'warning', 'critical', 'unknown']); +const unknown = () => ({state: 'unknown', validUntil: 0}); +function timestamp(value) { + if (typeof value !== 'string') return Number.NaN; + const match = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d{1,9})?(Z|([+-])(\d\d):(\d\d))$/.exec(value); + if (!match) return Number.NaN; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return Number.NaN; + const offsetMinutes = match[2] === 'Z' ? 0 : Number(match[4]) * 60 + Number(match[5]); + const offsetSign = match[3] === '-' ? -1 : 1; + // Date.parse normalizes impossible days and 24:00. Round-trip the supplied + // calendar fields in their original offset before accepting the evidence. + const suppliedCalendar = new Date(parsed + offsetMinutes * offsetSign * 60000).toISOString().slice(0, 19); + return suppliedCalendar === match[1] ? parsed : Number.NaN; +} + +function evidenceWindow(snapshot, now) { + if (snapshot?.schema !== 'durable_workflow.status_page_snapshot.v1' + || snapshot.product !== 'cloud' || snapshot.environment !== 'prod' || !Number.isFinite(now)) return unknown(); + const checked = timestamp(snapshot.checked_at); + const generated = timestamp(snapshot.generated_at); + const freshness = snapshot.freshness; + if (!Number.isFinite(checked) || !Number.isFinite(generated) || checked > now + || generated > now || generated < checked || !freshness + || !Number.isFinite(freshness.stale_after_seconds) || freshness.stale_after_seconds <= 0 + || freshness.stale_after_seconds > 900 || !Number.isFinite(freshness.age_seconds) + || freshness.age_seconds < 0) return unknown(); + const validUntil = checked + freshness.stale_after_seconds * 1000; + if (freshness.status === 'stale' || now >= validUntil || freshness.age_seconds >= freshness.stale_after_seconds) return {state: 'stale', validUntil: 0}; + if (freshness.status !== 'fresh') return unknown(); + return {validUntil}; +} + +function aggregateState(snapshot, validUntil) { + const impact = snapshot.customer_impact; + if (!statuses.has(snapshot.status) || !levels.has(snapshot.alert_level) + || !impact || !statuses.has(impact.status) || !levels.has(impact.alert_level)) return unknown(); + const reason = snapshot.meta?.public_status_reason; + if (reason === 'cloud_unreachable') return {state: 'unavailable', validUntil}; + if (reason === 'monitor_stale' || reason === 'probe_unverified') return {state: 'unverified', validUntil}; + if (reason === 'publisher_stale') return {state: 'stale', validUntil: 0}; + if (reason !== undefined && reason !== null && reason !== '') return unknown(); + const stateTokens = new Set([snapshot.status, impact.status]); + const levelTokens = new Set([snapshot.alert_level, impact.alert_level]); + if (stateTokens.has('failed') || levelTokens.has('critical')) return {state: 'incident', validUntil}; + if (stateTokens.has('warning') || levelTokens.has('warning')) { + const families = impact.affected_families; + const maintenanceOnly = Array.isArray(families) && families.length === 1 && families[0] === 'maintenance'; + return {state: maintenanceOnly ? 'maintenance' : 'degraded', validUntil}; + } + if (stateTokens.has('unknown') || levelTokens.has('unknown')) return {state: 'unknown', validUntil}; + return {state: 'operational', validUntil}; +} + +export function publicStatus(snapshot, now = Date.now()) { + const evidence = evidenceWindow(snapshot, now); + return evidence.state ? evidence : aggregateState(snapshot, evidence.validUntil); +} + +export async function fetchPublicStatus({fetchImpl = globalThis.fetch, now = Date.now, signal} = {}) { + const response = await fetchImpl(STATUS_API, { + method: 'GET', mode: 'cors', credentials: 'omit', cache: 'no-store', + redirect: 'error', referrerPolicy: 'no-referrer', signal, + }); + if (!response.ok) throw new Error('Public status unavailable'); + const body = await response.text(); + if (body.length > 65536) throw new Error('Public status response too large'); + return publicStatus(JSON.parse(body), now()); +} + +// At most one request at a time, five-second deadline, sixty-second cadence. +// Invalidate a previously successful result at its evidence cutoff, even if the +// next request hangs or the tab has been backgrounded. The caller owns cleanup. +export function watchPublicStatus(onChange, {fetchImpl = globalThis.fetch, now = Date.now} = {}) { + let stopped = false; + let controller; + let timer; + let expiry; + const publish = summary => { + clearTimeout(expiry); + onChange(summary.state); + if (summary.validUntil > now()) { + expiry = setTimeout(() => onChange('stale'), summary.validUntil - now()); + } + }; + const refresh = async () => { + controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const summary = await fetchPublicStatus({fetchImpl, now, signal: controller.signal}); + if (!stopped) publish(summary); + } catch { + if (!stopped) publish(unknown()); + } finally { + clearTimeout(timeout); + if (!stopped) timer = setTimeout(refresh, 60000); + } + }; + void refresh(); + return () => { + stopped = true; + controller?.abort(); + clearTimeout(timer); + clearTimeout(expiry); + }; +} diff --git a/docs/stylesheets/fonts/inter-OFL.txt b/docs/stylesheets/fonts/inter-OFL.txt new file mode 100644 index 00000000..21f6aff9 --- /dev/null +++ b/docs/stylesheets/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/stylesheets/fonts/inter-latin.woff2 b/docs/stylesheets/fonts/inter-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/docs/stylesheets/fonts/inter-latin.woff2 differ diff --git a/docs/stylesheets/fonts/jetbrains-mono-latin.woff2 b/docs/stylesheets/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 00000000..4d09cda4 Binary files /dev/null and b/docs/stylesheets/fonts/jetbrains-mono-latin.woff2 differ diff --git a/docs/stylesheets/fonts/jetbrainsmono-OFL.txt b/docs/stylesheets/fonts/jetbrainsmono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/docs/stylesheets/fonts/jetbrainsmono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/stylesheets/fonts/manifest.json b/docs/stylesheets/fonts/manifest.json new file mode 100644 index 00000000..7eff1ef3 --- /dev/null +++ b/docs/stylesheets/fonts/manifest.json @@ -0,0 +1,38 @@ +{ + "css_source": "https://fonts.googleapis.com/css2?family=Inter:wght@400..600&family=JetBrains+Mono:wght@400..600&family=Space+Grotesk:wght@600&display=swap", + "fonts": [ + { + "bytes": 48256, + "family": "Inter", + "file": "inter-latin.woff2", + "license_file": "inter-OFL.txt", + "license_sha256": "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c57", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/inter/OFL.txt", + "sha256": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62", + "source": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2" + }, + { + "bytes": 31432, + "family": "JetBrains Mono", + "file": "jetbrains-mono-latin.woff2", + "license_file": "jetbrainsmono-OFL.txt", + "license_sha256": "b2fe5e8987594e9ffd1d2ca52a2f5d73eb8335243893c5d6254b5ad69269591d", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/jetbrainsmono/OFL.txt", + "sha256": "83c005d49d8a6a50474c73a5a36ac0468076e9c4a29da7bdb14995d80560a5be", + "source": "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2" + }, + { + "bytes": 13284, + "family": "Space Grotesk", + "file": "space-grotesk-latin.woff2", + "license_file": "spacegrotesk-OFL.txt", + "license_sha256": "564ce565c371c5e5bbf286006565a7c9aa55a9f56e7ca58d56e05d649dd61a72", + "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/spacegrotesk/OFL.txt", + "sha256": "685bbbf69fa616df1ef81847c85fc76be097ddfb3468ff2257be54511ab3130f", + "source": "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj42VnskPMA.woff2" + } + ], + "license_repository_commit": "24ddc1f324cac5d9f5aedca126d298f56e3f1640", + "runtime_cdn": false, + "self_hosted": true +} diff --git a/docs/stylesheets/fonts/space-grotesk-latin.woff2 b/docs/stylesheets/fonts/space-grotesk-latin.woff2 new file mode 100644 index 00000000..a1db41a0 Binary files /dev/null and b/docs/stylesheets/fonts/space-grotesk-latin.woff2 differ diff --git a/docs/stylesheets/fonts/spacegrotesk-OFL.txt b/docs/stylesheets/fonts/spacegrotesk-OFL.txt new file mode 100644 index 00000000..cb512b9a --- /dev/null +++ b/docs/stylesheets/fonts/spacegrotesk-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/stylesheets/identity-fonts.css b/docs/stylesheets/identity-fonts.css new file mode 100644 index 00000000..fb6e203c --- /dev/null +++ b/docs/stylesheets/identity-fonts.css @@ -0,0 +1,25 @@ +/* Self-hosted OFL fonts. Sources and hashes: fonts/manifest.json. */ +@font-face { + font-family: 'Inter'; + src: url('./fonts/inter-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('./fonts/jetbrains-mono-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 400 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Space Grotesk'; + src: url('./fonts/space-grotesk-latin.woff2') format('woff2'); + font-style: normal; + font-weight: 600; + font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/docs/stylesheets/identity-tokens.css b/docs/stylesheets/identity-tokens.css new file mode 100644 index 00000000..ab57ae7a --- /dev/null +++ b/docs/stylesheets/identity-tokens.css @@ -0,0 +1,36 @@ +/* One Durable Workflow. Shared role tokens; no framework or remote imports. */ +:root, +[data-theme='light'] { + color-scheme: light; + --dw-paper: #f6f7f5; + --dw-surface: #ffffff; + --dw-ink: #0f1513; + --dw-ink-2: #3b4541; + --dw-muted: #5e6964; + --dw-line: #dde2df; + --dw-action: #0f7a52; + --dw-tint: #e3f4ec; + --dw-on-action: #ffffff; + --dw-font-display: 'Space Grotesk', Inter, system-ui, sans-serif; + --dw-font-body: Inter, system-ui, sans-serif; + --dw-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Consolas, monospace; + --dw-code-background: #0f1513; + --dw-code-foreground: #e8edea; + --dw-code-muted: #9aa6a1; + --dw-logo-paper: #f6f7f5; + --dw-radius-action: 6px; + --dw-radius-card: 8px; +} + +[data-theme='dark'] { + color-scheme: dark; + --dw-paper: #0f1513; + --dw-surface: #172019; + --dw-ink: #e8edea; + --dw-ink-2: #b7c2bd; + --dw-muted: #9aa6a1; + --dw-line: #2e3934; + --dw-action: #5fe0a6; + --dw-tint: #123b2a; + --dw-on-action: #0f1513; +} diff --git a/docs/stylesheets/layout.css b/docs/stylesheets/layout.css index 3101e89b..7dc1ec0e 100644 --- a/docs/stylesheets/layout.css +++ b/docs/stylesheets/layout.css @@ -1,783 +1,1181 @@ -:root, -[data-md-color-scheme="default"] { - --dw-blue: #3156d3; - --dw-blue-deep: #1f3ca5; - --dw-yellow: #ffd43b; - --dw-ink: #172033; - --dw-muted: #566078; - --dw-panel: #f5f7ff; - --dw-panel-raised: #ffffff; - --dw-line: #dce2f2; - --dw-code: #111827; - --dw-code-ink: #edf2ff; +/* Material maps its persisted scheme onto the canonical shared role tokens. */ +:root, [data-md-color-scheme="default"] { + --dw-blue: var(--dw-action); + --dw-blue-deep: var(--dw-action); + --dw-panel: var(--dw-paper); + --dw-panel-raised: var(--dw-surface); + --dw-code: var(--dw-code-background); + --dw-code-ink: var(--dw-code-foreground); } [data-md-color-scheme="slate"] { - --dw-blue: #91a7ff; - --dw-blue-deep: #b6c4ff; - --dw-yellow: #ffe278; - --dw-ink: #f2f5ff; - --dw-muted: #bac3d9; - --dw-panel: #17202f; - --dw-panel-raised: #1d2839; - --dw-line: #344159; - --dw-code: #0a101b; - --dw-code-ink: #edf2ff; + color-scheme: dark; + --dw-paper: #0f1513; + --dw-surface: #172019; + --dw-ink: #e8edea; + --dw-ink-2: #b7c2bd; + --dw-muted: #9aa6a1; + --dw-line: #2e3934; + --dw-action: #5fe0a6; + --dw-tint: #123b2a; + --dw-on-action: #0f1513; } -@media screen and (max-width: 44.984375em) { - .md-header__topic { - font-size: 0.7rem; - } -} - -.dw-landing { - color: var(--dw-ink); - margin-top: 0.75rem; -} +/* Structural primitives; visual values come from the shared role tokens. */ .dw-landing * { box-sizing: border-box; } .dw-landing a { - text-underline-offset: 0.18em; + text-underline-offset: .18em; } -.dw-hero { +.dw-hero__actions, .dw-inline-actions, .dw-runtime-card__actions { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.dw-role-grid, .dw-link-grid, .dw-runtime-grid { display: grid; - gap: 1.5rem; - grid-template-columns: minmax(0, 1.3fr) minmax(18rem, 0.7fr); - padding: 1.5rem 0 1.75rem; + gap: 16px; } -.dw-eyebrow, -.dw-tag { - color: var(--dw-blue); - display: block; - font-size: 0.68rem; - font-weight: 750; - letter-spacing: 0; - margin: 0 0 0.8rem; - text-transform: uppercase; +.dw-link-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.dw-hero h1 { - color: var(--dw-ink); - font-size: 3.2rem; - letter-spacing: 0; - line-height: 0.98; - margin: 0; - max-width: 13ch; +.dw-role-card, .dw-link-card, .dw-runtime-card { + display: flex; + flex-direction: column; + min-width: 0; + text-decoration: none; } -.dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) { - color: var(--dw-muted); - font-size: 1.02rem; - line-height: 1.65; - margin: 1.25rem 0 0; - max-width: 35rem; +.dw-role-card__link, .dw-link-card > span:last-child { + margin-top: auto; + padding-top: 12px; + color: var(--dw-action); + font-size: 13px; + font-weight: 600; } -.dw-hero__actions { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 0.65rem; - margin-top: 1.55rem; +.dw-link-card { + gap: 8px; } -.dw-button { - align-items: center; - border: 1px solid var(--dw-blue); - border-radius: 0.5rem; - display: inline-flex; - font-size: 0.74rem; - font-weight: 720; - justify-content: center; - min-height: 2.55rem; - padding: 0.58rem 0.7rem; - text-decoration: none; +.dw-link-card strong { + font-size: 16px; + color: var(--dw-ink); +} + +.dw-link-card > span:nth-child(2) { + font-size: 14px; + color: var(--dw-ink-2); } -.dw-button--primary { - background: var(--dw-blue); - color: #fff !important; +.dw-first-run > h3 { + font-size: 24px; + margin: 32px 0 12px; } -[data-md-color-scheme="slate"] .dw-button--primary { - color: #111827 !important; +.dw-first-run h3 code { + background: transparent; + color: inherit; + font: inherit; } -.dw-button--secondary { - background: var(--dw-panel-raised); - color: var(--dw-blue-deep) !important; +.dw-runtime-card .dw-text-link { + align-self: flex-start; } -.dw-text-link { - color: var(--dw-blue-deep) !important; - font-size: 0.72rem; - font-weight: 700; - padding: 0.4rem 0.2rem; +.dw-runtime-card__actions { + gap: 12px; + margin-top: auto; } -.dw-button:hover, -.dw-button:focus-visible, -.dw-role-card:hover, -.dw-role-card:focus-visible, -.dw-link-card:hover, -.dw-link-card:focus-visible { - border-color: var(--dw-blue-deep); - box-shadow: 0 0 0 0.12rem var(--md-default-bg-color), 0 0 0 0.25rem var(--dw-blue); +.dw-cloud-promotion__action { + color: var(--dw-action); + font-size: 13px; + font-weight: 600; + min-height: 44px; + display: inline-flex; + align-items: center; } -.dw-button:focus-visible, -.dw-text-link:focus-visible, -.dw-role-card:focus-visible, -.dw-link-card:focus-visible, -.dw-cloud-promotion__action:focus-visible { - outline: none; +.dw-credential-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + list-style: none; + padding: 0; + margin: 0 0 24px; } -.dw-hero__facts { - color: var(--dw-muted); +.dw-credential-grid li { display: flex; - flex-wrap: wrap; - font-size: 0.68rem; - font-weight: 650; - gap: 0.45rem 1rem; - margin: 1.3rem 0 0; + flex-direction: column; + gap: 8px; + min-width: 0; } -.dw-hero__facts span::before { - background: var(--dw-blue); - border-radius: 50%; - content: ""; - display: inline-block; - height: 0.28rem; - margin-right: 0.35rem; - vertical-align: 0.08rem; - width: 0.28rem; +.dw-credential-grid strong { + font-size: 16px; } -.dw-install-card { - align-self: center; - background: var(--dw-code); - border: 1px solid rgb(255 255 255 / 12%); - border-radius: 0.4rem; - box-shadow: 0 1.2rem 2.8rem rgb(20 31 60 / 15%); - color: var(--dw-code-ink); - min-width: 0; - overflow: hidden; - padding: 1.1rem; +.dw-credential-grid span { + font-size: 14px; + color: var(--dw-ink-2); } -.dw-install-card h2 { - color: var(--dw-code-ink); - font-size: 0.72rem; - letter-spacing: 0; - margin: 0 0 0.7rem; - text-transform: uppercase; +.dw-credential-grid code { + overflow-wrap: anywhere; } -.dw-install-card .highlight { - margin: 0; +.dw-inline-actions { + gap: 12px; + margin-top: 20px; } -.dw-install-card .highlight pre { - background: rgb(255 255 255 / 6%); - border: 1px solid rgb(255 255 255 / 9%); - border-radius: 0.4rem; - color: var(--dw-code-ink); - font-size: 0.68rem; - max-width: 100%; - overflow-x: auto; +.dw-checkpoint > p { + color: var(--dw-ink-2); + font-size: 14px; } -.dw-install-card > p:not(.dw-install-card__links) { - color: #aebad0; - font-size: 0.7rem; - line-height: 1.55; - margin: 0.85rem 0 0; +@media (prefers-reduced-motion: reduce) { + .dw-landing *, + .dw-landing *::before, + .dw-landing *::after { + scroll-behavior: auto; + transition: none; + } } -.dw-install-card__links { - display: flex; - flex-wrap: wrap; - gap: 0.8rem; - margin: 0.8rem 0 0; +@media screen and (max-width: 59.984375em) { + .md-sidebar--primary .md-nav__list.dw-navigation__ancestor-list { + overflow: visible; + } + .md-nav--primary > .dw-navigation__close { + display: flex; + } + .md-sidebar--primary .md-source__repository { + font-size: 0.6rem; + } + #__search:checked ~ header .md-search__overlay { + border-radius: 0; + height: 100dvh; + inset: 0; + position: fixed; + transform: none; + width: 100vw; + } + #__search:checked ~ .md-container { + visibility: hidden; + } } -.dw-install-card__links a { - color: var(--dw-yellow) !important; - font-size: 0.68rem; - font-weight: 700; +.md-header__button[role="button"]:focus-visible, +.dw-navigation__close:focus-visible, +.md-search__icon[role="button"]:focus-visible { + border-radius: 0.1rem; + outline: 0.15rem solid currentColor; + outline-offset: 0.1rem; } -.dw-section { - border-top: 1px solid var(--dw-line); - margin-top: 2.5rem; - padding-top: 2rem; +.dw-navigation__close { + align-items: center; + background-color: var(--md-primary-bg-color); + border: 0; + border-radius: 50%; + color: var(--md-primary-fg-color); + cursor: pointer; + display: none; + height: 2rem; + justify-content: center; + position: absolute; + right: 0.4rem; + top: 0.4rem; + width: 2rem; + z-index: 2; } -.dw-section > h2 { - color: var(--dw-ink); - font-size: 2.2rem; - letter-spacing: 0; - line-height: 1.08; - margin: 0; +.dw-navigation__close svg { + height: 1.2rem; + width: 1.2rem; } -.dw-section > h2 + p { - color: var(--dw-muted); - line-height: 1.7; - margin: 0.75rem 0 1.5rem; - max-width: 42rem; +.dw-navigation__close:focus-visible { + border-radius: 50%; } -.dw-role-grid, -.dw-link-grid { - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(4, minmax(0, 1fr)); +#__search:checked ~ header .md-search__icon[for="__search"][role="button"] { + pointer-events: auto; } -.dw-role-card, -.dw-link-card { - background: var(--dw-panel-raised); - border: 1px solid var(--dw-line); - border-radius: 0.4rem; - color: var(--dw-ink) !important; - display: flex; - flex-direction: column; - min-width: 0; - padding: 1rem; - text-decoration: none; +#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:first-child { + display: none; } -.dw-role-card { - min-height: 10rem; +#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:last-child { + display: block; } -.dw-role-card__step { - color: var(--dw-blue); - font-family: var(--md-code-font-family); - font-size: 0.62rem; - font-weight: 700; +/* Keep result-group expanders in flow inside the modal instead of floating + above unrelated inert document content at the overlay boundary. */ +.md-search-result__more > summary { + position: static; } -.dw-role-card strong, -.dw-link-card strong { - font-size: 0.9rem; - margin: 0.65rem 0 0.35rem; +#__search:checked ~ header [inert] { + visibility: hidden; } -.dw-role-card > span:not(.dw-role-card__step, .dw-role-card__link), -.dw-link-card > span:nth-child(2) { - color: var(--dw-muted); - font-size: 0.7rem; - line-height: 1.5; +/* One Identity: shared chrome and Python documentation components. */ +:root, [data-md-color-scheme="default"], [data-md-color-scheme="slate"] { + --md-text-font: var(--dw-font-body); + --md-code-font: var(--dw-font-mono); + --md-default-fg-color: var(--dw-ink); + --md-default-fg-color--light: var(--dw-muted); + --md-default-bg-color: var(--dw-paper); + --md-primary-fg-color: var(--dw-action); + --md-primary-bg-color: var(--dw-on-action); + --md-accent-fg-color: var(--dw-action); + --md-code-bg-color: var(--dw-code-background); + --md-code-fg-color: var(--dw-code-foreground); + --md-code-hl-color: #123b2a; + --md-code-hl-number-color: #e8edea; + --md-code-hl-special-color: #e8edea; + --md-code-hl-function-color: #e8edea; + --md-code-hl-constant-color: #e8edea; + --md-code-hl-keyword-color: #5fe0a6; + --md-code-hl-string-color: #5fe0a6; + --md-code-hl-name-color: #e8edea; + --md-code-hl-operator-color: #e8edea; + --md-code-hl-punctuation-color: #e8edea; + --md-code-hl-comment-color: #9aa6a1; + --md-code-hl-generic-color: #9aa6a1; + --md-code-hl-variable-color: #e8edea; +} + +body, .md-main { + background: var(--dw-paper); + color: var(--dw-ink); } -.dw-role-card__link, -.dw-link-card > span:last-child { - color: var(--dw-blue-deep); - font-size: 0.68rem; - font-weight: 700; - margin-top: auto; - padding-top: 0.9rem; +body { + font-family: var(--dw-font-body); } -.dw-first-run { - counter-reset: local-step; +.md-grid { + max-width: 1200px; } -.dw-first-run > h3 { - color: var(--dw-ink); - counter-increment: local-step; - font-size: 1.05rem; - margin: 2.2rem 0 0.7rem; +.md-typeset { + font-size: 16px; + line-height: 1.5; } -.dw-first-run > h3::before { - align-items: center; - background: var(--dw-blue); - border-radius: 50%; - color: #fff; - content: counter(local-step); - display: inline-flex; - font-size: 0.65rem; - height: 1.55rem; - justify-content: center; - margin-right: 0.55rem; - vertical-align: 0.1rem; - width: 1.55rem; +.md-typeset h1, .md-typeset h2, .md-typeset h3 { + color: var(--dw-ink); + font-family: var(--dw-font-display); + font-weight: 600; + letter-spacing: -.03em; + scroll-margin-top: 150px; } -.dw-first-run > p, -.dw-version > p { - color: var(--dw-muted); - line-height: 1.7; - max-width: 45rem; +.md-typeset a { + color: var(--dw-action); } -.dw-first-run .highlight { - border: 1px solid var(--dw-line); - border-radius: 0.4rem; - max-width: 100%; - overflow: hidden; +a, button, summary, input { + -webkit-tap-highlight-color: transparent; } -.dw-first-run .highlight pre { - max-width: 100%; - overflow-x: auto; +a:focus-visible, button:focus-visible, summary:focus-visible, input:focus-visible { + outline: 2px solid var(--dw-action); + outline-offset: 4px; } -.dw-runtime-grid { - display: grid; - gap: 0.9rem; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); +.dw-header { + color: var(--dw-ink); + background: var(--dw-surface); + box-shadow: none; } -.dw-runtime-card { - border: 1px solid var(--dw-line); - border-radius: 0.4rem; +.dw-global-bar { display: flex; - flex-direction: column; - min-width: 0; - padding: 1.25rem; + align-items: center; + gap: 32px; + min-height: 76px; + padding: 12px 24px; } -.dw-runtime-card--primary { - background: var(--dw-panel); - border-left: 0.25rem solid var(--dw-blue); +.dw-brand { + display: inline-flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + color: var(--dw-ink); + text-decoration: none; } -.dw-runtime-card--secondary { - background: transparent; - border-style: dashed; +.dw-brand__mark { + display: block; + object-fit: contain; + width: 30px; + height: 30px; } -.dw-runtime-card h3, -.dw-runtime-card p { - margin: 0; +[data-md-color-scheme="slate"] .dw-brand__mark { + box-sizing: content-box; + padding: 4px; + border-radius: 8px; + background: var(--dw-logo-paper); } -.dw-runtime-card h3 { - color: var(--dw-ink); - font-size: 1.1rem; +.dw-brand__name { + font-family: var(--dw-font-display); + font-weight: 600; + font-size: 17px; + white-space: nowrap; } -.dw-runtime-card p { +.dw-brand__slash { color: var(--dw-muted); - font-size: 0.74rem; - line-height: 1.6; - margin: 0.55rem 0 1rem; + font-size: 16px; } -.dw-runtime-card .dw-button { - align-self: flex-start; - margin-top: auto; +.dw-brand__surface { + color: var(--dw-action); + font: 500 13px var(--dw-font-mono); } -.dw-runtime-card__actions { - align-items: center; +.dw-global-nav { display: flex; - flex-wrap: wrap; - gap: 0.55rem 0.85rem; - margin-top: auto; + align-items: center; + flex: 1; + gap: 24px; + font-size: 14px; } -.dw-runtime-card__actions .dw-button, -.dw-runtime-card__actions .dw-cloud-promotion__action { - margin-top: 0; +.dw-global-nav > a, .dw-sdk-switcher summary { + color: var(--dw-ink-2); + text-decoration: none; + min-height: 44px; + display: flex; + align-items: center; } -.dw-cloud-promotion__eyebrow { - letter-spacing: 0; +.dw-sdk-switcher { + position: relative; } -.dw-cloud-promotion__action { - align-self: flex-start; - color: var(--dw-blue-deep) !important; - font-size: 0.72rem; - font-weight: 700; - margin-top: auto; - padding: 0.3rem 0; +.dw-sdk-switcher summary { + cursor: pointer; + gap: 6px; + font-weight: 600; + color: var(--dw-ink); + list-style: none; } -.dw-cloud-promotion__action:hover, -.dw-cloud-promotion__action:focus-visible { - box-shadow: 0 0.15rem 0 var(--dw-blue); +.dw-sdk-switcher summary::-webkit-details-marker { + display: none; } -.dw-checkpoint { - background: var(--dw-panel); +.dw-sdk-switcher ul { + position: absolute; + z-index: 20; + min-width: 160px; + top: 44px; + left: -16px; + list-style: none; + margin: 0; + padding: 8px; border: 1px solid var(--dw-line); - border-left: 0.25rem solid var(--dw-blue); - border-radius: 0.4rem; - margin-top: 1.25rem; - padding: 0.9rem 1rem; + border-radius: 8px; + background: var(--dw-surface); } -.dw-checkpoint > p { - color: var(--dw-muted); - font-size: 0.74rem; - line-height: 1.6; - margin: 0 0 0.7rem; +.dw-sdk-switcher a { + display: flex; + justify-content: space-between; + padding: 12px; + border-radius: 4px; + color: var(--dw-ink-2); } -.dw-checkpoint .highlight { - margin: 0; +.dw-sdk-switcher a:hover, .dw-sdk-switcher a[aria-current] { + background: var(--dw-tint); + color: var(--dw-action); } -.dw-cloud-path > p { - color: var(--dw-muted); - line-height: 1.7; - max-width: 48rem; +.dw-global-actions { + display: flex; + align-items: center; + gap: 20px; + font-size: 14px; } -.dw-cloud-path > h3 { - color: var(--dw-ink); - font-size: 1.05rem; - margin: 1.8rem 0 0.8rem; +.dw-github-link { + color: var(--dw-ink-2); } -.dw-cloud-path .highlight { - border: 1px solid var(--dw-line); - border-radius: 0.4rem; - max-width: 100%; - overflow: hidden; +.dw-docs-toolbar { + padding: 0 20px; + min-height: 48px; + border-block: 1px solid var(--dw-line); + gap: 12px; } -.dw-cloud-path .highlight pre { - max-width: 100%; - overflow-x: auto; +.dw-docs-toolbar__title { + font-size: 13px; + color: var(--dw-ink-2); } -.dw-credential-grid { - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(3, minmax(0, 1fr)); - list-style: none; - margin: 0 0 1.25rem; - padding: 0; +.dw-channel-label { + font: 400 11px var(--dw-font-mono); + color: var(--dw-muted); } -.dw-credential-grid li { - background: var(--dw-panel-raised); - border: 1px solid var(--dw-line); - border-radius: 0.4rem; +.dw-docs-toolbar__controls { + margin-left: auto; display: flex; - flex-direction: column; - gap: 0.5rem; - min-width: 0; - padding: 1rem; + align-items: center; } -.dw-credential-grid strong { +.dw-docs-toolbar .md-header__button { + width: 40px; + height: 44px; + padding: 10px; + margin: 0; + color: var(--dw-ink-2); +} + +.dw-docs-toolbar .md-search__form { + background: var(--dw-paper); + border: 1px solid var(--dw-line); color: var(--dw-ink); - font-size: 0.82rem; } -.dw-credential-grid span { +.dw-docs-toolbar .md-search__input { + color: var(--dw-ink); +} + +.dw-docs-toolbar .md-search__input::placeholder { color: var(--dw-muted); - font-size: 0.7rem; +} + +.md-sidebar { + color: var(--dw-ink-2); +} + +.md-nav__link--active { + color: var(--dw-action); +} + +@media (min-width: 1220px) { + body:has(.dw-landing) .md-sidebar--primary { + display: none; + } +} + +body:has(.dw-landing) .md-main__inner { + margin-top: 0; +} + +body:has(.dw-landing) .md-content__inner { + margin: 0 24px; + padding: 0; +} + +.dw-landing { + color: var(--dw-ink); + + display: flex; + flex-direction: column; + gap: 88px; + padding: 72px 0 96px; + margin: 0; +} + +.dw-hero { + display: grid; + + padding: 0; + gap: 48px; + align-items: center; + grid-template-columns: minmax(0, 1fr) 340px; +} + +.dw-eyebrow, .dw-tag { + color: var(--dw-action); + font: 500 12px var(--dw-font-mono); + letter-spacing: .08em; + line-height: 1.5; + margin-bottom: 22px; +} + +.dw-hero h1 { + margin: 0; + font-size: 64px; + line-height: 1; + letter-spacing: -.03em; + max-width: none; +} + +.dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) { + color: var(--dw-ink-2); + font-size: 19px; line-height: 1.55; + margin: 22px 0 0; + max-width: 580px; } -.dw-credential-grid code { - overflow-wrap: anywhere; +.dw-hero__actions { + gap: 12px; + margin-top: 22px; } -.dw-inline-actions { +.dw-button, .dw-text-link { + min-height: 44px; + font-size: 15px; + font-weight: 600; +} + +.dw-button { + display: inline-flex; align-items: center; + justify-content: center; + border: 1px solid var(--dw-line); + text-decoration: none; + + border-radius: 6px; + padding: 0 20px; + border-color: var(--dw-line); +} + +.dw-button.dw-button--primary { + background: var(--dw-action); + border-color: var(--dw-action); + color: var(--dw-on-action); +} + +.dw-button.dw-button--secondary { + color: var(--dw-ink); + background: var(--dw-surface); +} + +.dw-button:hover, .dw-role-card:hover, .dw-link-card:hover { + box-shadow: none; + border-color: var(--dw-action); +} + +.dw-text-link { + display: inline-flex; + align-items: center; + color: var(--dw-action); + text-decoration: none; + + padding: 10px 8px; +} + +.dw-hero__facts { display: flex; flex-wrap: wrap; - gap: 0.65rem 1rem; - margin-top: 1.2rem; + + font: 400 13px var(--dw-font-mono); + color: var(--dw-muted); + margin-top: 22px; + gap: 8px 20px; } -.dw-inline-actions .dw-button, -.dw-inline-actions .dw-text-link { - width: auto; +.dw-hero__facts span::before { + display: none; } -.dw-link-card { - min-height: 9rem; +.dw-install-card { + align-self: center; + min-width: 0; + background: var(--dw-code-background); + color: var(--dw-code-foreground); + + border: none; + border-radius: 8px; + box-shadow: none; + padding: 0; + overflow: hidden; } -.dw-version { - background: var(--dw-panel); +.dw-install-card h2 { + display: none; +} + +.dw-install-card > p:not(.dw-install-card__links) { + padding: 0 16px 18px; + font-size: 13px; + line-height: 1.55; + color: var(--dw-code-muted); +} + +.dw-install-card__links { + display: flex; + justify-content: space-between; + + padding: 14px 16px; + margin: 0; + border-top: 1px solid #26302c; + font-size: 13px; + gap: 20px; +} + +.dw-install-card__links a { + color: #5fe0a6; +} + +.dw-section { + margin: 0; + padding: 0; + border: 0; +} + +.dw-section > h2 { + margin: 0 0 8px; + font-size: 36px; +} + +.dw-section > p { + color: var(--dw-ink-2); + margin: 0 0 24px; + line-height: 1.5; +} + +.dw-role-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.dw-role-card, .dw-link-card, .dw-runtime-card, .dw-credential-grid li { + background: var(--dw-surface); border: 1px solid var(--dw-line); - border-radius: 0.4rem; - margin-bottom: 2rem; - padding: 1.2rem; + border-radius: 8px; + box-shadow: none; + padding: 20px; } -.dw-version > h2 { - font-size: 1rem; +.dw-role-card__step { + color: var(--dw-action); + background: none; + padding: 0; + font: 400 12px var(--dw-font-mono); } -.dw-version > p { - font-size: 0.74rem; - margin-bottom: 0; +.dw-role-card strong { + color: var(--dw-ink); + font: 600 20px var(--dw-font-display); } -@media screen and (max-width: 76rem) { - .dw-role-grid, - .dw-link-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +.dw-role-card > span:not(.dw-role-card__step, .dw-role-card__link) { + color: var(--dw-ink-2); + font-size: 14px; + line-height: 1.5; +} - .dw-credential-grid { - grid-template-columns: 1fr; - } +.dw-role-card__link { + font-size: 13px; + font-weight: 600; + color: var(--dw-action); +} + +.dw-quickstart-grid { + display: grid; + grid-template-columns: minmax(0, .7fr) minmax(0, 1.3fr); + gap: 48px; + align-items: start; + margin-block: 64px; +} + +.dw-quickstart-copy h3 { + font-size: 32px; + margin: 0 0 16px; +} + +.dw-quickstart-copy p { + color: var(--dw-ink-2); + font-size: 16px; +} + +.dw-quickstart-copy .dw-eyebrow { + color: var(--dw-action); + font-size: 12px; +} + +.dw-quickstart-code { + min-width: 0; +} + +.dw-runtime-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + gap: 16px; +} + +.dw-runtime-card { + gap: 12px; + + border-top: 1px solid var(--dw-line); + min-height: 220px; +} + +.dw-runtime-card h3 { + font: 600 20px var(--dw-font-display); + margin: 0; +} + +.dw-runtime-card p { + color: var(--dw-ink-2); + font-size: 14px; + line-height: 1.5; +} + +.dw-runtime-card .dw-tag { + font-size: 11px; + margin-bottom: 12px; +} + +.dw-runtime-card--secondary .dw-tag { + color: var(--dw-muted); +} + +.dw-checkpoint, .dw-version { + background: var(--dw-surface); + border: 1px solid var(--dw-line); + border-radius: 8px; +} + +.dw-checkpoint { + padding: 16px; +} + +.dw-version { + margin: 0; + padding: 20px; +} + +.md-typeset pre, .md-typeset code { + font-family: var(--dw-font-mono); +} + +.md-typeset pre { + background: var(--dw-code-background); + color: var(--dw-code-foreground); +} + +.dw-landing .highlight { + position: relative; + background: var(--dw-code-background); + border: 1px solid #26302c; + border-radius: 8px; + overflow: hidden; } -@media screen and (max-width: 64rem) { +.dw-landing .highlight::before { + content: "terminal"; + display: block; + border-bottom: 1px solid #26302c; + padding: 12px 16px; + color: var(--dw-code-muted); + font: 400 12px var(--dw-font-mono); +} + +.dw-install-card .highlight::before { + content: "INSTALL"; + letter-spacing: .08em; +} + +.dw-quickstart-code .highlight::before { + content: "greeter.py"; +} + +.dw-checkpoint .highlight::before { + content: "output"; +} + +.dw-landing .highlight pre code { + padding: 18px 16px; + background: var(--dw-code-background); + color: var(--dw-code-foreground); + font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; +} + +.dw-quickstart-code .highlight pre code { + max-height: 640px; + overflow: auto; +} + +.dw-footer { + background: var(--dw-surface); + border-top: 1px solid var(--dw-line); +} + +.dw-footer__inner { + padding: 48px 24px 28px; +} + +.dw-footer__columns { + display: grid; + grid-template-columns: 1.5fr 1fr 1fr 1fr; + gap: 48px; +} + +.dw-footer__brand p { + max-width: 270px; + color: var(--dw-muted); + font-size: 13px; + line-height: 1.5; +} + +.dw-footer nav { + display: flex; + flex-direction: column; + gap: 12px; + font-size: 13px; +} + +.dw-footer h2 { + margin: 0 0 4px; + font: 400 11px var(--dw-font-mono); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--dw-muted); +} + +.dw-footer nav a { + color: var(--dw-ink-2); +} + +.dw-footer__bottom { + border-top: 1px solid var(--dw-line); + margin-top: 40px; + padding-top: 24px; + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--dw-muted); + font-size: 12px; +} + +.dw-status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--dw-paper); + color: var(--dw-muted); + padding: 5px 9px; + border-radius: 20px; + font: 400 11px var(--dw-font-mono); +} + +.dw-status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +@media (max-width: 1100px) { + .dw-role-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dw-global-bar { + flex-wrap: wrap; + gap: 8px 24px; + } + .dw-global-nav { + order: 3; + flex-basis: 100%; + } + .dw-global-actions { + margin-left: auto; + } .dw-hero { - gap: 1.75rem; - grid-template-columns: 1fr; - padding-block: 1.75rem 2.25rem; + grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); + gap: 32px; } - .dw-hero h1 { - font-size: 2.75rem; - } - - .dw-install-card { - width: 100%; + font-size: 56px; } } -@media screen and (max-width: 44.984375em) { - body:has(.dw-landing) .md-main__inner, +@media (max-width: 760px) { + .dw-global-bar { + padding: 12px 20px 0; + gap: 4px 12px; + } + .dw-brand { + gap: 7px; + } + .dw-brand__name { + font-size: 14px; + } + .dw-brand__surface { + font-size: 11px; + } + .dw-brand__mark { + width: 26px; + height: 26px; + } + .dw-global-actions { + gap: 0; + } + .dw-global-actions .dw-button { + display: inline-flex; + font-size: 13px; + padding: 0 12px; + } + .dw-github-link { + display: none; + } + .dw-global-nav { + gap: 22px; + font-size: 13px; + justify-content: space-between; + } + .dw-docs-toolbar { + padding: 0 12px; + gap: 8px; + } + .dw-docs-toolbar__title { + font-size: 12px; + } + .dw-channel-label { + font-size: 10px; + } body:has(.dw-landing) .md-content__inner { - margin-top: 0; + margin: 0 20px; } - .dw-landing { - margin-top: 0; + padding: 48px 0 64px; + gap: 56px; } - .dw-hero { - gap: 0.75rem; - padding-block: 0.75rem 0.5rem; + grid-template-columns: 1fr; + gap: 32px; } - .dw-hero h1 { - font-size: 1.65rem; - line-height: 1.05; - max-width: none; - } - - .dw-eyebrow { - margin-bottom: 0.45rem; + font-size: clamp(40px, 10.7vw, 56px); + max-width: 12ch; } - .dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) { - font-size: 0.82rem; - line-height: 1.45; - margin-top: 0.65rem; + font-size: 18px; } - .dw-hero__actions { - display: grid; - gap: 0.4rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-top: 0.8rem; + align-items: stretch; } - - .dw-hero__actions .dw-button--primary { - grid-column: 1 / -1; + .dw-hero__actions .dw-button, .dw-hero__actions .dw-text-link { + width: auto; } - - .dw-hero__actions .dw-button, - .dw-hero__actions .dw-text-link { - font-size: 0.66rem; - min-height: 2.15rem; - padding: 0.4rem; + .dw-install-card { + width: 100%; } - - .dw-hero__facts { - gap: 0.35rem 0.7rem; - margin-top: 0.65rem; + .dw-role-grid { + grid-template-columns: 1fr; } - - .dw-install-card { - padding: 0.75rem; + .dw-section > h2 { + font-size: 32px; } - - .dw-install-card h2 { - margin-bottom: 0.4rem; + .dw-role-card { + padding: 16px; + min-height: 205px; } - - .dw-install-card > p:not(.dw-install-card__links) { - font-size: 0.65rem; - line-height: 1.4; - margin-top: 0.55rem; + .dw-quickstart-grid { + grid-template-columns: 1fr; + gap: 24px; + margin-block: 48px; } - - .dw-install-card__links { - flex-direction: row; - justify-content: space-between; - margin-top: 0.55rem; + .dw-runtime-grid, .dw-link-grid, .dw-credential-grid { + grid-template-columns: 1fr; } - - .dw-section { - margin-top: 2.75rem; - padding-top: 2rem; + .dw-runtime-card, .dw-role-card, .dw-link-card { + box-shadow: none; } - - .dw-model { - margin-top: 0.75rem; - padding-top: 1rem; + .dw-footer__columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 32px; } - - .dw-section > h2 { - font-size: 1.65rem; + .dw-footer__brand { + grid-column: 1 / -1; } - - .dw-install-card__links { - align-items: stretch; + .dw-footer__bottom { + flex-wrap: wrap; } +} - .dw-button, - .dw-text-link { - text-align: center; - width: 100%; +@media (max-width: 370px) { + .dw-global-bar { + padding-inline: 16px; } - - .dw-role-grid, - .dw-link-grid, - .dw-runtime-grid { + .dw-brand__name { + font-size: 13px; + } + .dw-brand { + gap: 5px; + } + .dw-github-link { + font-size: 11px; + } + .dw-docs-toolbar__title { + font-size: 11px; + } + .dw-channel-label { + display: none; + } + .dw-role-grid { grid-template-columns: 1fr; } +} - .dw-runtime-card__actions, - .dw-inline-actions { - align-items: stretch; - flex-direction: column; - } +.dw-status-pill[data-public-status="operational"] { + color: var(--dw-action); + background: var(--dw-tint); +} - .dw-runtime-card__actions .dw-button, - .dw-runtime-card__actions .dw-cloud-promotion__action, - .dw-inline-actions .dw-button, - .dw-inline-actions .dw-text-link { - text-align: center; - width: 100%; - } +.dw-status-pill[data-public-status="degraded"] { + color: #8c5600; + background: #fff0d6; +} - .dw-role-card, - .dw-link-card { - min-height: 0; - } +.dw-status-pill[data-public-status="incident"] { + color: #b3261e; + background: #fce8e8; +} - .dw-first-run .highlight pre, - .dw-install-card .highlight pre { - font-size: 0.66rem; - } +.dw-status-pill[data-public-status="maintenance"] { + color: #3f51b5; + background: #e7edff; } -@media screen and (min-width: 32rem) and (max-width: 44.984375em) and (max-height: 24rem) { - .dw-hero__actions { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="degraded"] { + color: #f2c46d; + background: #3b2d17; +} - .dw-hero__actions .dw-button--primary { - grid-column: auto; - } +[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="incident"] { + color: #ffaaa5; + background: #3c211f; } -@media (prefers-reduced-motion: reduce) { - .dw-landing *, - .dw-landing *::before, - .dw-landing *::after { - scroll-behavior: auto !important; - transition: none !important; - } +[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="maintenance"] { + color: #b6c4ff; + background: #242a43; } -@media screen and (max-width: 59.984375em) { - .md-sidebar--primary .md-nav__list.dw-navigation__ancestor-list { - overflow: visible; - } +.dw-install-card .highlight { + border: 0; + border-radius: 0; + margin: 0; +} - .md-nav--primary > .dw-navigation__close { - display: flex; - } +.dw-install-card .highlight pre code { + font-size: 15px; +} - .md-sidebar--primary .md-source__repository { - font-size: 0.6rem; - } +.dw-landing button[data-clipboard-target]::after { + content: "Copy"; + display: block; + width: auto; + height: auto; + margin: 0; + font: 400 12px var(--dw-font-mono); + background: none; + mask: none; + -webkit-mask: none; +} - #__search:checked ~ header .md-search__overlay { - border-radius: 0; - height: 100dvh; - inset: 0; - position: fixed; - transform: none; - width: 100vw; - } +.dw-global-nav a:hover, .dw-github-link:hover, .dw-footer nav a:hover { + color: var(--dw-action); + text-decoration: underline; + text-underline-offset: 3px; +} - #__search:checked ~ .md-container { - visibility: hidden; - } +.md-nav--primary .md-nav__title { + background: var(--dw-surface); + color: var(--dw-ink); + box-shadow: none; +} +[data-md-color-scheme="slate"] .md-logo img { + background: var(--dw-logo-paper); + padding: 4px; + border-radius: 8px; } -.md-header__button[role="button"]:focus-visible, -.dw-navigation__close:focus-visible, -.md-search__icon[role="button"]:focus-visible { - border-radius: 0.1rem; - outline: 0.15rem solid currentColor; - outline-offset: 0.1rem; +/* Keep the maintained theme's copy action in the code header in both theme versions. */ +.dw-landing .highlight pre { + margin: 0; + max-width: 100%; + + position: static; +} +.dw-landing .md-code__nav { + position: absolute; + top: 8px; + right: 8px; } +.dw-landing button[data-clipboard-target] { + top: 8px; + right: 8px; + color: var(--dw-code-muted); + border: 1px solid #2e3934; + border-radius: 4px; + opacity: 1; -.dw-navigation__close { + width: auto; + height: 28px; + padding: 4px 8px; + background: transparent; + + display: inline-flex; align-items: center; - background-color: var(--md-primary-bg-color); - border: 0; - border-radius: 50%; - color: var(--md-primary-fg-color); - cursor: pointer; - display: none; - height: 2rem; justify-content: center; - position: absolute; - right: 0.4rem; - top: 0.4rem; - width: 2rem; - z-index: 2; + min-width: 44px; } - -.dw-navigation__close svg { - height: 1.2rem; - width: 1.2rem; +@media print { + button[data-clipboard-target] { + display: none; + } } -.dw-navigation__close:focus-visible { - border-radius: 50%; +.md-typeset ul.dw-role-grid { + display: grid; + margin: 0; + padding: 0; + list-style: none; } - -#__search:checked ~ header .md-search__icon[for="__search"][role="button"] { - pointer-events: auto; +.md-typeset ul.dw-role-grid > li { + display: flex; + min-width: 0; + margin: 0; + padding: 0; } +.dw-role-card { + min-height: 190px; + gap: 8px; -#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:first-child { - display: none; + width: 100%; } -#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:last-child { - display: block; +button[data-clipboard-target] { + color: var(--dw-code-muted); } -/* Keep result-group expanders in flow inside the modal instead of floating - above unrelated inert document content at the overlay boundary. */ -.md-search-result__more > summary { - position: static; +.md-code__nav { + background: var(--dw-code-background); } -#__search:checked ~ header [inert] { - visibility: hidden; +button[data-clipboard-target]:is(:hover, :focus-visible) { + background: var(--dw-code-background); + color: var(--dw-code-foreground); } diff --git a/mkdocs.yml b/mkdocs.yml index e4c03548..c25709b5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,7 +9,7 @@ theme: name: material custom_dir: overrides font: false - logo: assets/favicon.svg + logo: assets/logo.svg favicon: assets/favicon.svg features: - navigation.sections @@ -18,23 +18,27 @@ theme: - content.code.annotate - toc.follow palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo + - scheme: default + primary: custom + accent: custom toggle: icon: material/weather-night name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo + - scheme: slate + primary: custom + accent: custom toggle: icon: material/weather-sunny name: Switch to light mode extra_javascript: + - path: javascripts/identity.js + type: module - path: javascripts/analytics.js type: module extra_css: + - stylesheets/identity-tokens.css + - stylesheets/identity-fonts.css - stylesheets/layout.css plugins: @@ -63,6 +67,7 @@ plugins: - "!^to_dict$" markdown_extensions: + - attr_list - admonition - md_in_html - pymdownx.details @@ -101,6 +106,8 @@ nav: - Testing: reference/testing.md extra: + published_sdk_version: "2.14.1" + docs_channel: source-preview social: - icon: fontawesome/brands/github link: https://github.com/durable-workflow/sdk-python diff --git a/overrides/partials/footer.html b/overrides/partials/footer.html new file mode 100644 index 00000000..0c148a94 --- /dev/null +++ b/overrides/partials/footer.html @@ -0,0 +1,17 @@ + diff --git a/overrides/partials/header.html b/overrides/partials/header.html new file mode 100644 index 00000000..31b66e16 --- /dev/null +++ b/overrides/partials/header.html @@ -0,0 +1,49 @@ +
+
+ + + Durable Workflowpython + + + +
+ +
diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..3f4d7947 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,85 @@ +# Resolved wheel artifacts for the Linux amd64 / Python 3.12 portal builder. +# anyio-4.16.0-py3-none-any.whl +anyio==4.16.0 --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026 +# babel-2.18.0-py3-none-any.whl +babel==2.18.0 --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 +# backrefs-8.2-py312-none-any.whl +backrefs==8.2 --hash=sha256:e64a50f31e8eee2837009ca7744999ba59125acfd50996ba2349d8f18061e608 +# certifi-2026.10.23-py3-none-any.whl +certifi==2026.10.23 --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 +# charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +charset-normalizer==3.5.2 --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 +# click-8.5.0-py3-none-any.whl +click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +# colorama-0.4.6-py2.py3-none-any.whl +colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +# fastavro-1.13.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +fastavro==1.13.1 --hash=sha256:2f56a127d71e45083306d2650efff827cad0f4b0744dd42cb69c631d77943b1d +# ghp_import-2.1.0-py3-none-any.whl +ghp-import==2.1.0 --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 +# greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl +greenlet==3.5.6 --hash=sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1 +# griffelib-2.4.1-py3-none-any.whl +griffelib==2.4.1 --hash=sha256:76bc562f936ad1ce198d2cf6d0731bd5c92a7d934b69ef828ef22ee0ffdb62ce +# h11-0.16.0-py3-none-any.whl +h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +# httpcore-1.0.9-py3-none-any.whl +httpcore==1.0.9 --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +# httpx-0.28.1-py3-none-any.whl +httpx==0.28.1 --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +# idna-3.20-py3-none-any.whl +idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +# jinja2-3.1.6-py3-none-any.whl +Jinja2==3.1.6 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +# markdown-3.11.3-py3-none-any.whl +Markdown==3.11.3 --hash=sha256:e1a31a112232504d753e5c01bb8c2de90d3f0ba986b063e10770cf7695b09e73 +# markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +MarkupSafe==3.0.4 --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 +# mergedeep-1.3.4-py3-none-any.whl +mergedeep==1.3.4 --hash=sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307 +# mkdocs-1.6.1-py3-none-any.whl +mkdocs==1.6.1 --hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e +# mkdocs_autorefs-1.4.5-py3-none-any.whl +mkdocs-autorefs==1.4.5 --hash=sha256:68ee484e0800f3ba50cda1bc4a94293adbdfcc010243c322b3f5d66e15d06770 +# mkdocs_get_deps-0.2.2-py3-none-any.whl +mkdocs-get-deps==0.2.2 --hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650 +# mkdocs_material-9.8.0-py3-none-any.whl +mkdocs-material==9.8.0 --hash=sha256:19304a7263c5700428227122b3b2699aa73f432c9de8816876adaec7d7293ce3 +# mkdocs_material_extensions-1.3.1-py3-none-any.whl +mkdocs-material-extensions==1.3.1 --hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 +# mkdocstrings-1.0.8-py3-none-any.whl +mkdocstrings==1.0.8 --hash=sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c +# mkdocstrings_python-2.0.10-py3-none-any.whl +mkdocstrings-python==2.0.10 --hash=sha256:4a3aaeebb25ba5ae57a8f9e75432fe4571984889602f2b2f7d672faa760e9062 +# packaging-26.3-py3-none-any.whl +packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +# paginate-0.5.7-py2.py3-none-any.whl +paginate==0.5.7 --hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591 +# pathspec-1.1.1-py3-none-any.whl +pathspec==1.1.1 --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 +# platformdirs-4.14.5-py3-none-any.whl +platformdirs==4.14.5 --hash=sha256:8e778c6fa421516f4d3369449f8df3b0e1b72c0e0005fe88e3ec772d0a14e750 +# playwright-1.64.1-py3-none-manylinux1_x86_64.whl +playwright==1.64.1 --hash=sha256:577b8d88778c245cfbd145ae17cf53044f084b1b46c55c69081d4069d56057e2 +# pyee-14.0.0-py3-none-any.whl +pyee==14.0.0 --hash=sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d +# pygments-2.21.0-py3-none-any.whl +Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 +# pymdown_extensions-12.3-py3-none-any.whl +pymdown-extensions==12.3 --hash=sha256:345493a8e3497e24382902903a1c43a28e34a52f2b7571a1c9641429b820ca74 +# python_dateutil-4.0.0-py3-none-any.whl +python-dateutil==4.0.0 --hash=sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c +# pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc +# pyyaml_env_tag-1.1-py3-none-any.whl +pyyaml_env_tag==1.1 --hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 +# requests-2.34.2-py3-none-any.whl +requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +# typing_extensions-4.16.0-py3-none-any.whl +typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +# tzdata-2026.6-py2.py3-none-any.whl +tzdata==2026.6 --hash=sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec +# urllib3-2.8.0-py3-none-any.whl +urllib3==2.8.0 --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 +# watchdog-7.1.0-py3-none-manylinux_2_17_x86_64.whl +watchdog==7.1.0 --hash=sha256:ad6d0b473cb8cde0721692b22b30fcbea8b52102430528874b3593549888bd81 diff --git a/scripts/check-docs-analytics.py b/scripts/check-docs-analytics.py index c4b2e634..fa953c41 100644 --- a/scripts/check-docs-analytics.py +++ b/scripts/check-docs-analytics.py @@ -13,12 +13,9 @@ build_directory = args.build_directory runtime = Path("docs/javascripts/analytics.js").read_text(encoding="utf-8") -styles = Path("docs/stylesheets/layout.css").read_text(encoding="utf-8") if (build_directory / "javascripts/analytics.js").read_text(encoding="utf-8") != runtime: raise SystemExit("Rendered MkDocs analytics runtime is stale") -if not re.search(r"\.dw-cloud-promotion__eyebrow\s*\{[^}]*letter-spacing:\s*0;", styles, re.DOTALL): - raise SystemExit("Promotion eyebrow letter spacing must remain zero") for required in ( "https://static.cloudflareinsights.com/beacon.min.js", diff --git a/scripts/check-docs-layout.py b/scripts/check-docs-layout.py index 60a44e17..92427f47 100644 --- a/scripts/check-docs-layout.py +++ b/scripts/check-docs-layout.py @@ -7,12 +7,18 @@ import contextlib import functools import http.server +import importlib.metadata +import json +import platform import threading from pathlib import Path from typing import Any +import playwright as playwright_package from playwright.sync_api import Browser, Page, sync_playwright +FOCUSED = "element => element === document.activeElement" + class SiteHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, _format: str, *_args: object) -> None: @@ -74,7 +80,8 @@ def assert_rendered_health(page: Page, label: str, errors: list[str]) -> None: const style = getComputedStyle(element); const scrollable = ['auto', 'scroll'].includes(style.overflowX); return { - label: (element.getAttribute('aria-label') || element.textContent || element.tagName).trim().slice(0, 80), + label: (element.getAttribute('aria-label') || element.textContent || element.tagName) + .trim().slice(0, 80), left: box.left, right: box.right, clippedText: ['A', 'BUTTON', 'SUMMARY'].includes(element.tagName) @@ -136,7 +143,13 @@ def assert_landing_contract(page: Page) -> None: def open_page(browser: Browser, base_url: str, path: str, viewport: dict[str, int]) -> tuple[Page, list[str]]: - page = browser.new_page(viewport=viewport) + page = browser.new_page( + viewport=viewport, color_scheme="dark", permissions=["clipboard-read", "clipboard-write"], + ) + page.route( + "https://status.durable-workflow.com/api/current.json", + lambda route: route.fulfill(status=200, content_type="application/json", body="{}"), + ) errors = runtime_errors(page) response = page.goto(f"{base_url}{path}", wait_until="networkidle") assert response is not None and response.ok, f"{path} returned a non-success response" @@ -151,11 +164,12 @@ def exercise_mobile_controls(page: Page) -> None: drawer = page.locator(".md-sidebar--primary") assert drawer.is_visible(), "mobile navigation did not open" drawer_box = drawer.bounding_box() - assert drawer_box is not None and drawer_box["x"] >= -1 and drawer_box["width"] <= 390 + width = page.viewport_size["width"] if page.viewport_size else 390 + assert drawer_box is not None and drawer_box["x"] >= -1 and drawer_box["width"] <= width close_navigation = drawer.locator(".dw-navigation__close") close_navigation.click() page.wait_for_function("!document.querySelector('#__drawer').checked") - assert navigation.evaluate("element => element === document.activeElement"), "navigation focus was not restored" + assert navigation.evaluate(FOCUSED), "navigation focus was not restored" search = page.locator(".md-header__button[for='__search']") search.click() @@ -166,15 +180,128 @@ def exercise_mobile_controls(page: Page) -> None: page.wait_for_timeout(250) assert search_input.is_visible(), "mobile search did not open" search_box = page.locator(".md-search").bounding_box() - assert search_box is not None and search_box["x"] >= -1 and search_box["width"] <= 391 + assert search_box is not None and search_box["x"] >= -1 and search_box["width"] <= width + 1 page.locator(".md-search__icon[for='__search']").click() page.wait_for_function("!document.querySelector('#__search').checked") - assert search.evaluate("element => element === document.activeElement"), "search focus was not restored" + assert search.evaluate(FOCUSED), "search focus was not restored" + + +def exercise_identity(page: Page, theme: str) -> None: + page.wait_for_function("document.body.dataset.mdColorScheme === 'default'") + if theme == "dark": + toggle = page.get_by_role("button", name="Switch to dark mode", exact=True) + toggle.focus() + toggle.press("Enter") + page.wait_for_function("document.body.dataset.mdColorScheme === 'slate'") + page.wait_for_function( + "document.querySelector('.md-header__option input:checked')?.nextElementSibling === document.activeElement", + ) + page.reload(wait_until="networkidle") + page.wait_for_function("document.body.dataset.mdColorScheme === 'slate'") + page.evaluate("document.fonts.ready") + fonts = page.evaluate("[...document.fonts].filter(font => font.status === 'loaded').map(font => font.family)") + assert {"Inter", "Space Grotesk", "JetBrains Mono"} <= set(fonts), fonts + assert page.locator(".highlight pre").first.evaluate( + "element => getComputedStyle(element).backgroundColor", + ) == "rgb(15, 21, 19)" + switcher = page.locator("[data-sdk-switcher]") + trigger = switcher.locator("summary") + trigger.focus() + trigger.press("Enter") + page.wait_for_function("document.querySelector('[data-sdk-switcher]').open") + assert switcher.get_by_role("link", name="Rust", exact=True).get_attribute("href") == "https://rust.durable-workflow.com/" + trigger.press("Escape") + assert not switcher.evaluate("element => element.open") + assert trigger.evaluate(FOCUSED) + assert page.locator("[data-status-label]").text_content() == "Status unknown" + assert page.locator(".dw-global-actions .dw-button").is_visible() + + +def exercise_copy_and_steps(page: Page) -> None: + spacing = page.locator(".dw-cloud-promotion__eyebrow").evaluate( + """element => { + const style = getComputedStyle(element); + return parseFloat(style.letterSpacing) / parseFloat(style.fontSize); + }""", + ) + assert abs(spacing - 0.08) < 0.001, "Eyebrow tracking differs from the canonical design" + targets = page.locator("button[data-clipboard-target]").evaluate_all( + "buttons => buttons.map(button => button.dataset.clipboardTarget)", + ) + assert len(set(targets)) == len(targets), "Code blocks share a copy target" + for selector in (".dw-install-card", ".dw-quickstart-code"): + block = page.locator(selector) + expected = block.locator("pre code").text_content() + # Never inspect a shared host's previous clipboard contents. + page.evaluate("navigator.clipboard.writeText('sdk-python-public-copy-control')") + block.locator("button[data-clipboard-target]").click() + page.wait_for_function( + "async () => (await navigator.clipboard.readText()) !== 'sdk-python-public-copy-control'", + ) + copied = page.evaluate("navigator.clipboard.readText()") + assert copied.rstrip("\n") == expected.rstrip("\n"), "Copy changed or truncated the code" + assert page.locator("#start-server").count() == 1 + assert page.locator("#save-greeter").count() == 1 + assert page.locator("#run-greeter").count() == 1 + assert page.locator(".dw-quickstart-copy a[href='#start-server']").count() == 1 + assert page.locator(".dw-quickstart-copy a[href='#run-greeter']").count() == 1 + code = page.locator(".dw-quickstart-code pre code").text_content() or "" + assert "asyncio.run(main())" in code + assert "workflows=[GreeterWorkflow]" in code + assert "activities=[greet]" in code + assert "task_queue=TASK_QUEUE" in code + + +def check_landing_views(browser: Browser, base_url: str, screenshots: Path | None) -> None: + for width in (1440, 1024, 390, 320): + for theme in ("light", "dark"): + page, errors = open_page(browser, base_url, "/", {"width": width, "height": 920}) + exercise_identity(page, theme) + assert_landing_contract(page) + assert_rendered_health(page, f"{width} {theme} landing", errors) + exercise_copy_and_steps(page) + if width < 960: + exercise_mobile_controls(page) + assert_rendered_health(page, f"{width} {theme} controls", errors) + if screenshots: + page.screenshot(path=str(screenshots / f"python-{width}-{theme}.png"), full_page=True) + page.close() + + +def check_api_views(browser: Browser, base_url: str) -> None: + for path in ("/sdk-reference/", "/reference/client/"): + for width in (1440, 390): + for theme in ("light", "dark"): + page, errors = open_page(browser, base_url, path, {"width": width, "height": 920}) + exercise_identity(page, theme) + assert_rendered_health(page, f"{width} {theme} {path}", errors) + if width < 960: + exercise_mobile_controls(page) + page.close() + + +def browser_evidence(browser: Browser) -> dict[str, str]: + manifest_path = Path(playwright_package.__file__).parent / "driver" / "package" / "browsers.json" + manifest = json.loads(manifest_path.read_text()) + selected = next(item for item in manifest["browsers"] if item["name"] == "chromium-headless-shell") + proof = { + "playwright_version": importlib.metadata.version("playwright"), + "selection": "Playwright default bundled headless Chromium", + "bundled_revision": selected["revision"], + "bundled_version": selected["browserVersion"], + "launched_version": browser.version, + "os": platform.system(), + "architecture": platform.machine(), + } + print(json.dumps(proof, sort_keys=True)) + assert proof["launched_version"] == proof["bundled_version"], "Launched browser differs from the locked bundle" + return proof def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("site", nargs="?", type=Path, default=Path("site")) + parser.add_argument("--screenshots", type=Path) args = parser.parse_args() site = args.site.resolve() if not (site / "index.html").is_file(): @@ -183,22 +310,13 @@ def main() -> int: with serve(site) as base_url, sync_playwright() as playwright: browser = playwright.chromium.launch() try: - desktop, desktop_errors = open_page(browser, base_url, "/", {"width": 1440, "height": 920}) - assert_landing_contract(desktop) - assert_rendered_health(desktop, "desktop landing", desktop_errors) - desktop.close() - - for path in ("/sdk-reference/", "/reference/client/"): - page, errors = open_page(browser, base_url, path, {"width": 1440, "height": 920}) - assert_rendered_health(page, f"desktop {path}", errors) - page.close() + proof = browser_evidence(browser) + if args.screenshots: + args.screenshots.mkdir(parents=True, exist_ok=True) + (args.screenshots / "browser-evidence.json").write_text(json.dumps(proof, indent=2) + "\n") + check_landing_views(browser, base_url, args.screenshots) + check_api_views(browser, base_url) - mobile, mobile_errors = open_page(browser, base_url, "/", {"width": 390, "height": 844}) - assert_landing_contract(mobile) - assert_rendered_health(mobile, "mobile landing", mobile_errors) - exercise_mobile_controls(mobile) - assert_rendered_health(mobile, "mobile controls", mobile_errors) - mobile.close() finally: browser.close() diff --git a/scripts/prepare_portal.py b/scripts/prepare_portal.py new file mode 100644 index 00000000..608fc083 --- /dev/null +++ b/scripts/prepare_portal.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Bind current presentation to an explicitly selected published SDK source.""" + +from __future__ import annotations + +import hashlib +import importlib.metadata +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import tomllib +import yaml + +PRESENTATION_DIRECTORIES = ("assets", "stylesheets", "javascripts") +CODE_BLOCKS = re.compile(r"```([^\n]*)\n(.*?)```", re.DOTALL) + + +def git(root: Path, *arguments: str) -> str: + return subprocess.check_output(["git", "-C", str(root), *arguments], text=True).strip() + + +def source_files(root: Path, directory: str) -> list[Path]: + names = subprocess.check_output(["git", "-C", str(root), "ls-files", "-z", directory]).decode().split("\0") + files = [] + for name in filter(None, names): + source = root / name + if source.is_symlink() or not source.resolve().is_relative_to(root.resolve()) or not source.is_file(): + raise ValueError("Selected SDK source contains an unsupported file") + files.append(source) + return sorted(files) + + +def sdk_inventory(sdk: Path, *, verify_installed: bool) -> list[dict[str, Any]]: + distribution = importlib.metadata.distribution("durable-workflow") if verify_installed else None + rows = [] + for source in source_files(sdk, "src/durable_workflow"): + data = source.read_bytes() + relative = source.relative_to(sdk / "src").as_posix() + if distribution is not None and distribution.locate_file(relative).read_bytes() != data: + raise ValueError("Installed published SDK differs from selected API source") + rows.append({"path": relative, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) + if not rows: + raise ValueError("Selected SDK has no API source") + return rows + + +def published_identity(root: Path, sdk: Path, tag: str, environment: dict[str, str]) -> tuple[str, dict[str, Any]]: + if not re.fullmatch(r"\d+\.\d+\.\d+", tag): + raise ValueError("Stable SDK requires an exact stable tag") + revision = git(sdk, "rev-parse", "HEAD") + if revision != git(root, "rev-parse", f"refs/tags/{tag}^{{commit}}"): + raise ValueError("API checkout differs from the published tag") + if git(sdk, "status", "--porcelain", "--untracked-files=all"): + raise ValueError("Selected SDK source is modified") + project = tomllib.loads((sdk / "pyproject.toml").read_text())["project"] + if project["version"] != tag or importlib.metadata.version("durable-workflow") != tag: + raise ValueError("Published SDK source/package version differs from the selected tag") + if not re.fullmatch(r"[0-9a-f]{64}", environment.get("DOCS_WHEEL_SHA256", "")): + raise ValueError("Published wheel digest is required") + return revision, project + + +def published_landing(root: Path, sdk: Path, project: dict[str, Any], tag: str) -> str: + landing = (root / "docs/index.md").read_text() + if CODE_BLOCKS.findall(landing) != CODE_BLOCKS.findall((sdk / "docs/index.md").read_text()): + raise ValueError("Landing examples differ from the selected published SDK documentation") + floors = re.findall(r"Python (\d+\.\d+)\+", landing) + if len(floors) != 1 or project.get("requires-python") != f">={floors[0]}": + raise ValueError("Landing Python support floor differs from the published SDK") + installed_python = importlib.metadata.distribution("durable-workflow").metadata.get("Requires-Python") + if installed_python != project["requires-python"]: + raise ValueError("Installed Python support metadata differs from the selected SDK source") + landing, matches = re.subn( + r"Python SDK · \d+\.\d+\.\d+ published", f"Python SDK · {tag} published", landing, + ) + if matches != 1: + raise ValueError("Published package label is missing or ambiguous") + return landing + + +def stage_published_docs(root: Path, sdk: Path, config: dict[str, Any], project: dict[str, Any], tag: str) -> None: + stage = Path(tempfile.mkdtemp(prefix=".docs-stage-", dir=root)) + # The complete SDK documentation content comes from tracked tag files. + # Only this closed set of presentation assets and the reviewed landing + # page come from the current documentation revision. + for source in source_files(sdk, "docs"): + target = stage / source.relative_to(sdk / "docs") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + for directory in PRESENTATION_DIRECTORIES: + shutil.copytree(root / "docs" / directory, stage / directory, dirs_exist_ok=True) + landing = published_landing(root, sdk, project, tag) + (stage / "index.md").write_text(landing) + config["docs_dir"] = str(stage) + config["nav"] = yaml.safe_load((sdk / "mkdocs.yml").read_text())["nav"] + documentation = next(plugin["mkdocstrings"] for plugin in config["plugins"] + if isinstance(plugin, dict) and "mkdocstrings" in plugin) + documentation["handlers"]["python"]["paths"] = [str(sdk / "src")] + config["extra"]["published_sdk_version"] = tag + + +def prepare(root: Path, environment: dict[str, str]) -> dict[str, Any]: + for name in (".docs-build.yml", ".docs-api-inventory.json", ".docs-provenance.json"): + (root / name).unlink(missing_ok=True) + config = yaml.safe_load((root / "mkdocs.yml").read_text()) + docs_revision = git(root, "rev-parse", "HEAD") + if environment.get("GITHUB_SHA", docs_revision) != docs_revision: + raise ValueError("Documentation checkout differs from the selected workflow revision") + sdk = root + channel = "source-preview" + tag = environment.get("STABLE_TAG", "") + if tag: + sdk = root / ".docs-sdk" + revision, project = published_identity(root, sdk, tag, environment) + version = project["version"] + stage_published_docs(root, sdk, config, project, tag) + channel = "stable" + else: + revision = docs_revision + version = tomllib.loads((root / "pyproject.toml").read_text())["project"]["version"] + inventory = sdk_inventory(sdk, verify_installed=bool(tag)) + config["extra"]["docs_channel"] = channel + (root / ".docs-build.yml").write_text(yaml.safe_dump(config, sort_keys=False)) + inventory_bytes = (json.dumps(inventory, sort_keys=True, separators=(",", ":")) + "\n").encode() + (root / ".docs-api-inventory.json").write_bytes(inventory_bytes) + audit = { + "schema": "durable-workflow.sdk-python.docs-deployment/v2", + "channel": channel, + "docs_source_revision": docs_revision, + "sdk_source_revision": revision, + "sdk_version": version, + "sdk_source_inventory_sha256": hashlib.sha256(inventory_bytes).hexdigest(), + "published_wheel_sha256": environment.get("DOCS_WHEEL_SHA256") if tag else None, + } + (root / ".docs-provenance.json").write_text(json.dumps(audit, sort_keys=True) + "\n") + return audit + + +if __name__ == "__main__": + prepare(Path.cwd(), dict(os.environ)) diff --git a/tests/test_docs_release_channel.py b/tests/test_docs_release_channel.py index ee57694f..995276fa 100644 --- a/tests/test_docs_release_channel.py +++ b/tests/test_docs_release_channel.py @@ -4,6 +4,7 @@ import json import os +import shlex import subprocess import sys from pathlib import Path @@ -30,6 +31,7 @@ def select_channel( root: Path, *, version: str = "2.14.1", tag: str = "", event: str = "workflow_dispatch", ref: str = "refs/heads/main", published: bool = True, lookup_exit: int = 0, matching_source: bool = True, registry: dict[str, Any] | None = None, + docs_version: str | None = None, matching_docs: bool = True, ) -> tuple[subprocess.CompletedProcess[str], dict[str, str], str]: steps = yaml.safe_load(WORKFLOW.read_text())["jobs"]["build"]["steps"] validation = next(step["run"] for step in steps if step.get("name") == "Validate the selected stable tag") @@ -46,7 +48,8 @@ def select_channel( if registry is None: registry = {"info": {"name": "durable-workflow", "version": "2.14.1"}, "urls": [ {"filename": "durable_workflow-2.14.1-py3-none-any.whl", "packagetype": "bdist_wheel", - "yanked": False, "digests": {"sha256": "1" * 64}}, + "yanked": False, "digests": {"sha256": "1" * 64}, + "url": "https://files.pythonhosted.org/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl"}, {"filename": "durable_workflow-2.14.1.tar.gz", "packagetype": "sdist", "yanked": False, "digests": {"sha256": "2" * 64}}, ]} @@ -81,8 +84,16 @@ def git(*arguments: str) -> None: git("add", "pyproject.toml") git("commit", "-m", "Fixture source") git("tag", "2.14.1") + git("worktree", "add", "--detach", ".docs-sdk", "2.14.1") + if docs_version is not None: + (root / "pyproject.toml").write_text(f'[project]\nversion = "{docs_version}"\n') + git("add", "pyproject.toml") + git("commit", "-m", "Documentation source successor") if not matching_source: - git("commit", "--allow-empty", "-m", "Unpublished successor") + git("-C", str(root / ".docs-sdk"), "commit", "--allow-empty", "-m", "Wrong API source") + environment["GITHUB_SHA"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip() + if not matching_docs: + environment["GITHUB_SHA"] = "0" * 40 script = (validation + "\n" if tag else "") + selector result = subprocess.run( ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], @@ -96,14 +107,21 @@ def git(*arguments: str) -> None: def test_main_source_is_preview_even_with_a_numeric_version(tmp_path: Path, version: str) -> None: result, values, calls = select_channel(tmp_path, version=version, event="push") assert result.returncode == 0, result.stderr - assert values == {"stable": "false", "version": version} + assert values["stable"] == "false" + assert values["version"] == version + assert values["docs_revision"] == values["sdk_revision"] + assert len(values["docs_revision"]) == 40 assert calls == "" def test_explicit_published_tag_with_exact_source_can_select_stable(tmp_path: Path) -> None: result, values, calls = select_channel(tmp_path, tag="2.14.1") assert result.returncode == 0, result.stderr - assert values == {"stable": "true", "version": "2.14.1"} + assert values["stable"] == "true" + assert values["version"] == "2.14.1" + assert values["docs_revision"] == values["sdk_revision"] + assert values["wheel_sha256"] == "1" * 64 + assert values["wheel_url"].startswith("https://files.pythonhosted.org/packages/") assert "release view 2.14.1 --repo durable-workflow/sdk-python" in calls @@ -134,3 +152,55 @@ def test_registry_identity_or_missing_artifacts_refuse_stable(tmp_path: Path, re result, values, _ = select_channel(tmp_path, tag="2.14.1", registry=registry) assert result.returncode != 0 assert values.get("stable") != "true" + + +def test_current_docs_can_select_the_separate_published_sdk(tmp_path: Path) -> None: + result, values, _ = select_channel(tmp_path, tag="2.14.1", docs_version="2.15.0") + assert result.returncode == 0, result.stderr + assert values["stable"] == "true" + assert values["version"] == "2.14.1" + assert values["docs_revision"] != values["sdk_revision"] + + +def test_wrong_documentation_revision_refuses_before_publication_lookup(tmp_path: Path) -> None: + result, values, calls = select_channel(tmp_path, tag="2.14.1", matching_docs=False) + assert result.returncode != 0 + assert values.get("stable") != "true" + assert calls == "" + + +def test_current_workflow_owns_docs_and_published_sdk_checkouts() -> None: + steps = yaml.safe_load(WORKFLOW.read_text())["jobs"]["build"]["steps"] + checkouts = [step for step in steps if step.get("uses", "").startswith("actions/checkout@")] + assert checkouts[0]["with"]["ref"] == "${{ github.sha }}" + assert checkouts[1]["with"]["path"] == ".docs-sdk" + assert all(step["with"]["persist-credentials"] is False for step in checkouts) + + +@pytest.mark.parametrize("workflow", ["docs.yml", "docs-pr.yml"]) +def test_bundled_browser_command_has_no_system_dependency_install_or_fallback(workflow: str) -> None: + document = yaml.safe_load(WORKFLOW.with_name(workflow).read_text()) + steps = next(iter(document["jobs"].values()))["steps"] + chromium = next(step for step in steps if step.get("name") == "Install Chromium") + assert shlex.split(chromium["run"]) == ["python", "-m", "playwright", "install", "chromium"] + assert chromium["env"]["NODE_TLS_REJECT_UNAUTHORIZED"] == "1" + assert chromium["env"]["NODE_OPTIONS"] == "--use-openssl-ca" + install = next(step["run"] for step in steps if "-r requirements-docs.txt" in step.get("run", "")) + assert "--require-hashes" in shlex.split(install.splitlines()[0]) + + +@pytest.mark.parametrize("url", [ + "https://files.pythonhosted.org/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl?other=1", + "https://example.test/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl", + "https://files.pythonhosted.org/packages/fixture/\ndurable_workflow-2.14.1-py3-none-any.whl", +]) +def test_wheel_requirement_uses_only_an_exact_public_artifact_url(tmp_path: Path, url: str) -> None: + registry = {"info": {"name": "durable-workflow", "version": "2.14.1"}, "urls": [ + {"filename": "durable_workflow-2.14.1-py3-none-any.whl", "packagetype": "bdist_wheel", + "yanked": False, "digests": {"sha256": "1" * 64}, "url": url}, + {"filename": "durable_workflow-2.14.1.tar.gz", "packagetype": "sdist", + "yanked": False, "digests": {"sha256": "2" * 64}}, + ]} + result, values, _ = select_channel(tmp_path, tag="2.14.1", registry=registry) + assert result.returncode != 0 + assert values.get("stable") != "true" diff --git a/tests/test_portal_source_split.py b/tests/test_portal_source_split.py new file mode 100644 index 00000000..56f4cdd8 --- /dev/null +++ b/tests/test_portal_source_split.py @@ -0,0 +1,139 @@ +"""Exercise source/package/content separation with local inert Git fixtures.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +if sys.version_info < (3, 11): + pytest.skip("The portal build uses Python 3.12 tomllib", allow_module_level=True) + +SCRIPT = Path(__file__).parents[1] / "scripts/prepare_portal.py" +SPEC = importlib.util.spec_from_file_location("prepare_portal", SCRIPT) +assert SPEC is not None +assert SPEC.loader is not None +portal = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(portal) + + +def git(root: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=Portal fixture", + "-c", "user.email=portal@example.test", "-C", str(root), *args], text=True, + stderr=subprocess.DEVNULL, + ).strip() + + +@pytest.fixture +def sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, dict[str, str], Path]: + root = tmp_path / "repo" + root.mkdir() + files = { + "pyproject.toml": '[project]\nversion = "2.14.1"\nrequires-python = ">=3.10"\n', + ".gitignore": ".docs-sdk/\n.docs-stage-*/\n.docs-*.json\n.docs-build.yml\n", + "src/durable_workflow/__init__.py": 'PUBLIC = "published"\n', + "docs/index.md": '# Published\n```python\nprint("complete example")\n```\n', + "docs/reference/metrics.md": "Published metric contract\n", + "docs/assets/logo.svg": "published-logo", + "docs/stylesheets/layout.css": "published-style", + "docs/javascripts/identity.js": "published-script", + } + config: dict[str, Any] = { + "docs_dir": "docs", "plugins": ["search", {"mkdocstrings": {"handlers": {"python": {"paths": ["src"]}}}}], + "extra": {"published_sdk_version": "2.14.1"}, "nav": [{"Published metrics": "reference/metrics.md"}], + } + for name, body in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + (root / "mkdocs.yml").write_text(yaml.safe_dump(config)) + git(root, "init") + git(root, "add", ".") + git(root, "commit", "-m", "Published fixture") + git(root, "tag", "2.14.1") + git(root, "worktree", "add", "--detach", ".docs-sdk", "2.14.1") + (root / "pyproject.toml").write_text('[project]\nversion = "2.15.0"\n') + (root / "src/durable_workflow/__init__.py").write_text('PUBLIC = "unreleased"\n') + (root / "docs/reference/metrics.md").write_text("Unreleased metric contract\n") + (root / "docs/index.md").write_text( + '# Redesigned\nPython SDK · 2.14.1 published\nPython 3.10+\n' + '```python\nprint("complete example")\n```\n', + ) + (root / "docs/stylesheets/layout.css").write_text("redesigned-style") + config["nav"] = [{"Unreleased page": "future.md"}] + (root / "mkdocs.yml").write_text(yaml.safe_dump(config)) + git(root, "add", ".") + git(root, "commit", "-m", "Current docs source") + installed = tmp_path / "installed/durable_workflow" + installed.mkdir(parents=True) + (installed / "__init__.py").write_text('PUBLIC = "published"\n') + monkeypatch.setattr(portal.importlib.metadata, "version", lambda _name: "2.14.1") + monkeypatch.setattr(portal.importlib.metadata, "distribution", lambda _name: SimpleNamespace( + locate_file=lambda path: installed.parent / path, metadata={"Requires-Python": ">=3.10"}, + )) + env = {"STABLE_TAG": "2.14.1", "DOCS_WHEEL_SHA256": "1" * 64, "GITHUB_SHA": git(root, "rev-parse", "HEAD")} + return root, env, installed + + +def test_stable_build_combines_current_presentation_and_published_content(sources: tuple) -> None: + root, env, _installed = sources + audit = portal.prepare(root, env) + config = yaml.safe_load((root / ".docs-build.yml").read_text()) + stage = Path(config["docs_dir"]) + assert (stage / "reference/metrics.md").read_text() == "Published metric contract\n" + assert (stage / "stylesheets/layout.css").read_text() == "redesigned-style" + assert (stage / "index.md").read_text().startswith("# Redesigned") + assert config["nav"] == [{"Published metrics": "reference/metrics.md"}] + assert config["plugins"][1]["mkdocstrings"]["handlers"]["python"]["paths"] == [str(root / ".docs-sdk/src")] + assert audit["docs_source_revision"] != audit["sdk_source_revision"] + assert audit["sdk_version"] == "2.14.1" + assert audit["channel"] == "stable" + inventory = json.loads((root / ".docs-api-inventory.json").read_text()) + assert [row["path"] for row in inventory] == ["durable_workflow/__init__.py"] + assert str(root) not in (root / ".docs-provenance.json").read_text() + + +@pytest.mark.parametrize("fault", [ + "sdk-source", "installed-code", "installed-version", "example", "wheel", "docs-revision", "support-floor", +]) +def test_changed_identity_or_examples_refuse_and_remove_stale_audit( + sources: tuple, monkeypatch: pytest.MonkeyPatch, fault: str, +) -> None: + root, env, installed = sources + portal.prepare(root, env) + if fault == "sdk-source": + (root / ".docs-sdk/src/durable_workflow/__init__.py").write_text("tampered") + elif fault == "installed-code": + (installed / "__init__.py").write_text("different wheel bytes") + elif fault == "installed-version": + monkeypatch.setattr(portal.importlib.metadata, "version", lambda _name: "2.15.0") + elif fault == "example": + (root / "docs/index.md").write_text('```python\nprint("new API")\n```\n') + elif fault == "wheel": + env["DOCS_WHEEL_SHA256"] = "" + elif fault == "support-floor": + page = root / "docs/index.md" + page.write_text(page.read_text().replace("Python 3.10+", "Python 3.11+")) + else: + env["GITHUB_SHA"] = "0" * 40 + with pytest.raises(ValueError): + portal.prepare(root, env) + assert not (root / ".docs-provenance.json").exists() + + +def test_source_build_stays_preview_with_current_api_and_no_package_claim(sources: tuple) -> None: + root, env, _installed = sources + env["STABLE_TAG"] = "" + audit = portal.prepare(root, env) + assert audit["channel"] == "source-preview" + assert audit["sdk_version"] == "2.15.0" + assert audit["docs_source_revision"] == audit["sdk_source_revision"] + assert audit["published_wheel_sha256"] is None