diff --git a/.gitattributes b/.gitattributes index 8c6f6be..59e602b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -188,3 +188,9 @@ docs/assets/pdf-links-alpha14/** -text docs/assets/pdf-links-alpha14/*.pdf binary docs/assets/pdf-links-alpha14/*.png binary docs/assets/pdf-links-alpha14/*.zip binary + +examples/component-kits/** text eol=lf +docs/assets/component-kits/** -text +docs/assets/component-kits/*.pdf binary +docs/assets/component-kits/*.png binary +docs/assets/component-kits/*.zip binary diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9e0cc5c..e9fb363 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,35 @@ concurrency: cancel-in-progress: true jobs: + component-kits: + name: Component kit composition (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: | + python -m pip install --only-binary=:all: -r examples/component-kits/requirements-check.txt -r tools/browser-requirements.txt + - run: python -m playwright install --with-deps chromium + - name: Reproduce the download and verify PDF and HTML customization + run: | + python -m unittest discover -s tools -p test_component_kits_png.py + python tools/build_component_kits.py + git diff --exit-code -- docs/assets/component-kits/project.zip docs/assets/component-kits/source.json + python tools/verify_component_kits.py --check-published + python tools/check_component_kits_browser.py + - uses: actions/upload-artifact@v7 + if: always() + with: + name: component-kits-${{ matrix.os }} + path: target/component-kits/ + retention-days: 14 accessible-showcase: name: Accessible component showcase (${{ matrix.channel }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -1098,7 +1127,7 @@ jobs: path: site deploy: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [build, accessible-showcase, showcase-authoring, component-notebook, page-band-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, pandas-components, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] + needs: [build, component-kits, accessible-showcase, showcase-authoring, component-notebook, page-band-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, pandas-components, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] runs-on: ubuntu-latest permissions: pages: write diff --git a/docs/assets/component-kits/billing-brand-1.png b/docs/assets/component-kits/billing-brand-1.png new file mode 100644 index 0000000..891081e Binary files /dev/null and b/docs/assets/component-kits/billing-brand-1.png differ diff --git a/docs/assets/component-kits/billing-brand.pdf b/docs/assets/component-kits/billing-brand.pdf new file mode 100644 index 0000000..3afa736 Binary files /dev/null and b/docs/assets/component-kits/billing-brand.pdf differ diff --git a/docs/assets/component-kits/default-1.png b/docs/assets/component-kits/default-1.png new file mode 100644 index 0000000..6278b81 Binary files /dev/null and b/docs/assets/component-kits/default-1.png differ diff --git a/docs/assets/component-kits/default.pdf b/docs/assets/component-kits/default.pdf new file mode 100644 index 0000000..f100e87 Binary files /dev/null and b/docs/assets/component-kits/default.pdf differ diff --git a/docs/assets/component-kits/impact-brand-1.png b/docs/assets/component-kits/impact-brand-1.png new file mode 100644 index 0000000..8d0b092 Binary files /dev/null and b/docs/assets/component-kits/impact-brand-1.png differ diff --git a/docs/assets/component-kits/impact-brand.pdf b/docs/assets/component-kits/impact-brand.pdf new file mode 100644 index 0000000..c610124 Binary files /dev/null and b/docs/assets/component-kits/impact-brand.pdf differ diff --git a/docs/assets/component-kits/local-override-1.png b/docs/assets/component-kits/local-override-1.png new file mode 100644 index 0000000..db43c90 Binary files /dev/null and b/docs/assets/component-kits/local-override-1.png differ diff --git a/docs/assets/component-kits/local-override.pdf b/docs/assets/component-kits/local-override.pdf new file mode 100644 index 0000000..0db35ca Binary files /dev/null and b/docs/assets/component-kits/local-override.pdf differ diff --git a/docs/assets/component-kits/project.zip b/docs/assets/component-kits/project.zip new file mode 100644 index 0000000..0e73de5 Binary files /dev/null and b/docs/assets/component-kits/project.zip differ diff --git a/docs/assets/component-kits/source.json b/docs/assets/component-kits/source.json new file mode 100644 index 0000000..0b2c13b --- /dev/null +++ b/docs/assets/component-kits/source.json @@ -0,0 +1,42 @@ +{ + "schema": "fullbleed.component-kits.v1", + "fullbleed": "2.6.0a14", + "files": [ + { + "path": "README.md", + "bytes": 3599, + "sha256": "c7233c5db4255c03057f27e40807fa389a2f2ead47cd560f0df332f6f266d82a" + }, + { + "path": "LICENSE", + "bytes": 1079, + "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9" + }, + { + "path": "requirements.txt", + "bytes": 20, + "sha256": "91731266076c657ad7eb8fbd7fbe95b69800530cfd2157a37afc15a50de5ec88" + }, + { + "path": "requirements-check.txt", + "bytes": 67, + "sha256": "4eadc7023aba8e289cb2e9b4f46eed8c350d9c55500f98c19d938010d46add8d" + }, + { + "path": "billing.py", + "bytes": 1155, + "sha256": "c61452605d01c81beefc4385dabee1422aa322788ec44085d934fdbe0bb3dd54" + }, + { + "path": "impact.py", + "bytes": 1085, + "sha256": "2e8e011f1768c26f66b643a16b07cfa0c3e1cb131253c374ab378d5e025e2938" + }, + { + "path": "report.py", + "bytes": 6453, + "sha256": "142b706f6b26812935f822258c0fec20e0cda86a29b50f43a688d025447a8738" + } + ], + "zip_sha256": "d5b4c2ffe56ee9add865a51a87f4891ef73f4fffb546fa6c225e29ed60605db7" +} diff --git a/docs/assets/component-kits/verification.json b/docs/assets/component-kits/verification.json new file mode 100644 index 0000000..5475275 --- /dev/null +++ b/docs/assets/component-kits/verification.json @@ -0,0 +1,662 @@ +{ + "schema": "fullbleed.component-kit-example-check.v1", + "at": "2026-10-23T15:52:37.437545+00:00", + "pdf_and_contract_checks": { + "ok": true, + "fullbleed": "2.6.0a14", + "zip_sha256": "d5b4c2ffe56ee9add865a51a87f4891ef73f4fffb546fa6c225e29ed60605db7", + "documents": [ + { + "variant": "default", + "pages": 1, + "pdf_sha256": "44213d83bd9319ba25a7e3917681f78eb301c28727c75ec77f9f8cbc6436d3eb", + "repeated_pdf_equal": true, + "embedded_font": true, + "text_overlaps": 0, + "required_tokens": [ + "impact.accent", + "impact.ink", + "impact.surface" + ] + }, + { + "variant": "billing-brand", + "pages": 1, + "pdf_sha256": "c0f6f45e995103e76733aaef11d6f2231b6b128405a55571bffb27bbdc091397", + "repeated_pdf_equal": true, + "embedded_font": true, + "text_overlaps": 0, + "required_tokens": [ + "impact.accent", + "impact.ink", + "impact.surface" + ] + }, + { + "variant": "impact-brand", + "pages": 1, + "pdf_sha256": "f998bf4db8bfa77790f742d2f5388f376cc147bc68b2b44c01699a8a2d4a7053", + "repeated_pdf_equal": true, + "embedded_font": true, + "text_overlaps": 0, + "required_tokens": [ + "impact.accent", + "impact.ink", + "impact.surface" + ] + }, + { + "variant": "local-override", + "pages": 1, + "pdf_sha256": "534e2eff9012e4a31d1bb8699aa871ff1c8e19839b1df524d9f49d7f5d085fdb", + "repeated_pdf_equal": true, + "embedded_font": true, + "text_overlaps": 0, + "required_tokens": [ + "impact.ink", + "impact.surface" + ] + } + ], + "pdf_pixel_isolation": [ + { + "variant": "billing-brand", + "changed_pixels_bbox": [ + 76, + 324, + 435, + 517 + ], + "allowed_region": [ + 76, + 324, + 435, + 517 + ], + "outside_region_identical": true + }, + { + "variant": "impact-brand", + "changed_pixels_bbox": [ + 457, + 324, + 817, + 517 + ], + "allowed_region": [ + 457, + 324, + 817, + 517 + ], + "outside_region_identical": true + }, + { + "variant": "local-override", + "changed_pixels_bbox": [ + 488, + 400, + 569, + 441 + ], + "allowed_region": [ + 489, + 401, + 568, + 440 + ], + "outside_region_identical": true + } + ], + "expected_errors": { + "missing_theme": { + "code": "theme.missing", + "message": "Missing theme token 'impact.surface'", + "path": [ + "Impact", + "section" + ] + }, + "impact.accent": { + "code": "theme.missing", + "message": "Missing theme token 'impact.accent'", + "path": [ + "Impact", + "value" + ] + }, + "impact.surface": { + "code": "theme.missing", + "message": "Missing theme token 'impact.surface'", + "path": [ + "Impact", + "section" + ] + }, + "impact.ink": { + "code": "theme.missing", + "message": "Missing theme token 'impact.ink'", + "path": [ + "Impact", + "label" + ] + }, + "unknown_part": { + "code": "style.part", + "message": "Unknown component parts: number. Available parts: label, value, note", + "path": [ + "Impact" + ] + } + }, + "guide_python_blocks_executed": 3, + "guide_snippets_sha256": "e9e841de9c33376d35c45ae8d55bf2034fdfca63e7500de2a7b5bc74ab13e501", + "collision": "Document-wide name; later explicit theme value wins; original theme unchanged.", + "published_assets_equal": false, + "scope": "Maintainer-authored kit contracts; no independent-author or general conformance claim." + }, + "browser_checks": { + "ok": true, + "browser": "153.0.8010.12", + "checks": [ + { + "viewport": "desktop", + "variant": "default", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 327.1875, + "y": 260.125, + "width": 303.359375, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(25, 113, 94)", + "rgb(33, 77, 64)" + ], + "x": 649.4375, + "y": 260.125, + "width": 303.375, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "desktop", + "variant": "default", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 147.875, + "width": 1264, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 249.875, + "width": 1264, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "desktop", + "variant": "billing-brand", + "export": "html", + "billing": { + "background": "rgb(243, 238, 248)", + "colors": [ + "rgb(72, 52, 97)", + "rgb(112, 71, 161)", + "rgb(72, 52, 97)" + ], + "x": 327.1875, + "y": 260.125, + "width": 303.359375, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(25, 113, 94)", + "rgb(33, 77, 64)" + ], + "x": 649.4375, + "y": 260.125, + "width": 303.375, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "desktop", + "variant": "billing-brand", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 147.875, + "width": 1264, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 249.875, + "width": 1264, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "desktop", + "variant": "impact-brand", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 327.1875, + "y": 260.125, + "width": 303.359375, + "height": 171.171875 + }, + "impact": { + "background": "rgb(250, 240, 232)", + "colors": [ + "rgb(103, 61, 41)", + "rgb(163, 78, 33)", + "rgb(103, 61, 41)" + ], + "x": 649.4375, + "y": 260.125, + "width": 303.375, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "desktop", + "variant": "impact-brand", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 147.875, + "width": 1264, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 249.875, + "width": 1264, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "desktop", + "variant": "local-override", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 327.1875, + "y": 260.125, + "width": 303.359375, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(155, 61, 100)", + "rgb(33, 77, 64)" + ], + "x": 649.4375, + "y": 260.125, + "width": 303.375, + "height": 171.171875 + }, + "overflow": false, + "styles": 18 + }, + { + "viewport": "desktop", + "variant": "local-override", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 147.875, + "width": 1264, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 249.875, + "width": 1264, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "mobile", + "variant": "default", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 16, + "y": 326.1875, + "width": 358, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(25, 113, 94)", + "rgb(33, 77, 64)" + ], + "x": 16, + "y": 516.25, + "width": 358, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "mobile", + "variant": "default", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 220.875, + "width": 374, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 322.875, + "width": 374, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "mobile", + "variant": "billing-brand", + "export": "html", + "billing": { + "background": "rgb(243, 238, 248)", + "colors": [ + "rgb(72, 52, 97)", + "rgb(112, 71, 161)", + "rgb(72, 52, 97)" + ], + "x": 16, + "y": 326.1875, + "width": 358, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(25, 113, 94)", + "rgb(33, 77, 64)" + ], + "x": 16, + "y": 516.25, + "width": 358, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "mobile", + "variant": "billing-brand", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 220.875, + "width": 374, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 322.875, + "width": 374, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "mobile", + "variant": "impact-brand", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 16, + "y": 326.1875, + "width": 358, + "height": 171.171875 + }, + "impact": { + "background": "rgb(250, 240, 232)", + "colors": [ + "rgb(103, 61, 41)", + "rgb(163, 78, 33)", + "rgb(103, 61, 41)" + ], + "x": 16, + "y": 516.25, + "width": 358, + "height": 171.171875 + }, + "overflow": false, + "styles": 17 + }, + { + "viewport": "mobile", + "variant": "impact-brand", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 220.875, + "width": 374, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 322.875, + "width": 374, + "height": 86 + }, + "overflow": false, + "styles": 0 + }, + { + "viewport": "mobile", + "variant": "local-override", + "export": "html", + "billing": { + "background": "rgb(237, 242, 249)", + "colors": [ + "rgb(32, 54, 77)", + "rgb(49, 91, 147)", + "rgb(32, 54, 77)" + ], + "x": 16, + "y": 326.1875, + "width": 358, + "height": 171.171875 + }, + "impact": { + "background": "rgb(234, 245, 239)", + "colors": [ + "rgb(33, 77, 64)", + "rgb(155, 61, 100)", + "rgb(33, 77, 64)" + ], + "x": 16, + "y": 516.25, + "width": 358, + "height": 171.171875 + }, + "overflow": false, + "styles": 18 + }, + { + "viewport": "mobile", + "variant": "local-override", + "export": "unstyled.html", + "billing": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 220.875, + "width": 374, + "height": 86 + }, + "impact": { + "background": "rgba(0, 0, 0, 0)", + "colors": [ + "rgb(0, 0, 0)", + "rgb(0, 0, 0)", + "rgb(0, 0, 0)" + ], + "x": 8, + "y": 322.875, + "width": 374, + "height": 86 + }, + "overflow": false, + "styles": 0 + } + ], + "scope": "Actual static HTML exports; no browser/PDF pagination or pixel-parity claim." + }, + "source_sha256": "02111d9a528a9c190617fc9a517a493bcdeb595aad9c1d4a30db5a7ddca2947d" +} diff --git a/docs/examples.md b/docs/examples.md index 09b6c8e..d379c7a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -45,6 +45,9 @@ For the next authoring model, [try the 2.6.0a4 Bootstrap component alpha](guides Its report combines cards, a table and progress indicators, with PDF and styled/unstyled HTML exports. The alpha requires an explicit installation. +For separate kit palettes and local customization, [download the two-kit report](guides/component-kits.md) +using **2.6.0a14**. + diff --git a/docs/guides/component-kits.md b/docs/guides/component-kits.md new file mode 100644 index 0000000..22ac450 --- /dev/null +++ b/docs/guides/component-kits.md @@ -0,0 +1,172 @@ +--- +title: Combine reusable Python component kits +description: Compose two Fullbleed component kits in one PDF and HTML report. Learn concrete defaults, explicit theme dependencies, independent palettes, and local overrides. +--- + +# Two component kits, one document + +Reusable components should explain what they need and what you can change. +This example combines a finance card and a community card in one report. +Rebrand either kit or restyle one value without inspecting generated CSS selectors. + +[Download the Python project](../assets/component-kits/project.zip){ .md-button .md-button--primary } +[Open the report](../assets/component-kits/default.pdf){ .md-button } + +!!! info "Uses the opt-in component alpha" + The project pins **Fullbleed 2.6.0a14** and requires Python 3.10 or later. + The component API remains alpha; see the [release status](component-status.md). + Both kit modules are maintainer-authored examples, not independent user feedback. + +[![A one-page Cedar Cooperative briefing with blue finance and green community cards above an action table.](../assets/component-kits/default-1.png)](../assets/component-kits/default.pdf) + +## Run and compare + +Extract the download, open `fullbleed-component-kits`, then run: + +```sh +python -m venv .venv +# Windows PowerShell: .venv\Scripts\Activate.ps1 +# macOS/Linux: source .venv/bin/activate +python -m pip install -r requirements.txt +python report.py +``` + +The `output` directory contains four PDFs and their styled and unstyled HTML. +It also contains native PNG previews, separate print source and pagination +diagnostics. All organizations and data are fictional. + +| Output | What changes | +| --- | --- | +| [Default](../assets/component-kits/default.pdf) | Blue finance card and green community card | +| [Billing brand](../assets/component-kits/billing-brand.pdf) | Finance palette becomes purple; community card is unchanged | +| [Impact brand](../assets/component-kits/impact-brand.pdf) | Community palette becomes warm brown; finance card is unchanged | +| [Local override](../assets/component-kits/local-override.pdf) | Only the community card's large value becomes rose | + +## Give each kit a clear contract + +The download demonstrates two useful choices: + +| Kit | Configuration | Document dependency | Advertised parts | +| --- | --- | --- | --- | +| `billing.py` | `billing_kit(BillingPalette(...))` returns a reusable `Balance` component | None: its palette has concrete defaults | `label`, `amount`, `note` | +| `impact.py` | `Impact(...)` uses values from the document theme | `impact.accent`, `impact.surface`, `impact.ink`; all supplied by `IMPACT_DEFAULTS` | `label`, `value`, `note` | + +Use concrete defaults for a component that should work immediately after import. +An immutable palette passed to a factory gives each kit its own settings. Use +document tokens when the document is meant to coordinate those values; export +the complete defaults and document their meaning alongside the component. + +After extracting the project, this is enough to compose its two cards: + +```python +from billing import BillingPalette, billing_kit +from impact import IMPACT_DEFAULTS, Impact +from fullbleed.components import Document, Grid, Theme, style + +Balance = billing_kit() +community = Impact("COMMUNITY REACH", "128", "people joined a workshop") +theme = Theme(IMPACT_DEFAULTS) + +document = Document( + Grid( + Balance("COMMUNITY FUND", "$24,600", "available for the next round"), + community, + ), + theme=theme, +) +``` + +`report.py` adds the page size, typography, table, explicit font registration +and output files. Neither kit loads fonts or registers global styles when imported. + +## Rebrand a kit or change one instance + +Create a separate concrete kit to change the finance palette. Derive a new theme +to change the community palette. Neither operation mutates the original: + +```python +PurpleBalance = billing_kit(BillingPalette( + accent="#7047a1", surface="#f3eef8", ink="#483461", +)) +warm_theme = theme.with_values({ + "impact.accent": "#a34e21", + "impact.surface": "#faf0e8", + "impact.ink": "#673d29", +}) +highlighted = community.with_parts(value=style(color="#9b3d64")) +``` + +Use the returned values in a new document: `PurpleBalance(...)` replaces the +finance component, `theme=warm_theme` changes the community palette, or +`highlighted` replaces just that community card. The complete report renders +these edits separately so their effect is easy to compare. + +Kit appearance belongs in `styled(...)` defaults. Ordinary function arguments +express choices such as a status or a label. Use `.with_style(...)` for a local +root change and `.with_parts(...)` for an advertised internal part. Setting a +root color does not replace a child that declares its own color. + +For two instances of the **same** kit with different palettes, use separate +concrete factories, the kit's public parts, or an explicit prefix parameter +offered by that kit. A document theme does not create an implicit local scope. + +## Find missing values before rendering + +`community.required_tokens` lists its three dependencies without compiling or +opening the kit implementation. `document.required_tokens` also includes values +used by document styles, typography and page bands. + +A missing binding in styled HTML or PDF raises `AuthoringError` with code +`theme.missing`, the missing name and a component path. For example, omitting +`impact.accent` reports the path `Impact → value`. An unknown part reports +`style.part` and lists the available parts. + +Token names are **flat and document-wide**. The `impact.` prefix is a naming +convention, not automatic scoping. Two kits using `token("accent")` share one +value. Mapping merges and `Theme.with_values(...)` use the later value for a +repeated name; they do not diagnose a collision. Use distinct prefixes when +the meanings differ and deliberately share a name only when the value is shared. + +## Export for a browser or a host stylesheet + +```python +from pathlib import Path + +Path("cards.html").write_text( + document.to_html(include_styles=True), encoding="utf-8", +) +Path("cards.unstyled.html").write_text( + document.to_html(include_styles=False), encoding="utf-8", +) +``` + +The styled export embeds the collected CSS. The unstyled export removes generated +CSS and inline declarations while retaining the content and literal classes, +including `billing-summary` and `impact-summary`. A host stylesheet can own their +appearance. Neither export includes JavaScript. + +In the complete report, the document owns the responsive layout. Its two-column +grid uses `styled(Grid, ...)` defaults so a mobile class rule can stack the cards. +An explicit `Grid(columns=2)` is an instance choice and overrides an ordinary +class rule. Keep reusable defaults separate from deliberate instance choices. + +The report includes explicit print and screen rules. On Fullbleed's separate-CSS +path, screen rules can apply when no print rule is present; keep the example's +print block when adding screen-only styling. The PDF uses bundled Inter, while +browser HTML uses local Inter or a sans-serif fallback. Browser layout is not a +PDF pagination preview. + +## What was checked + +The downloadable source produces four one-page PDFs with embedded fonts, the +expected text and no reported text overlaps. Repeated rendering produces the +same PDF bytes. PDFium pixel comparisons confirm each palette change is confined +to its own card, and the local override to its value. The checks also exercise +missing tokens, conflicting names, unknown parts and immutable defaults. + +Chromium checks both HTML exports at desktop and mobile widths, including colors, +card layout, preserved content and the absence of styles in unstyled HTML. +[Read the retained verification](../assets/component-kits/verification.json). +These are checks of the included examples, not general conformance or independent +usability evidence. Share a synthetic integration and any missing customization +point in the [component RFC](https://github.com/fullbleed-engine/fullbleed-official/discussions/86). diff --git a/docs/guides/component-preview-12.md b/docs/guides/component-preview-12.md index d62b84a..69f7155 100644 --- a/docs/guides/component-preview-12.md +++ b/docs/guides/component-preview-12.md @@ -90,6 +90,10 @@ explicit alpha 11 pin; the earlier import path continues to work in alpha 12. The [component reference](https://github.com/fullbleed-engine/fullbleed-official/blob/v2.6.0a12/docs/component-bootstrap.md) covers style precedence, named parts and static Bootstrap 5.0.0 helpers. +When combining reusable kits, [try the two-kit report](component-kits.md). +It demonstrates concrete palette defaults, documented theme dependencies and +local overrides with the current alpha 14 package. + ## Export with or without styles `document.to_html(include_styles=True)` embeds component styles, the theme and attached selector diff --git a/docs/guides/component-status.md b/docs/guides/component-status.md index 5a333bd..671a2cd 100644 --- a/docs/guides/component-status.md +++ b/docs/guides/component-status.md @@ -58,7 +58,7 @@ Two stabilization gates remain: | Gate | Current position | What closes it | | --- | --- | --- | -| Components from independent kits | Theme tokens are document-wide. Two kits using `token("accent")` share one value. Missing bindings raise `theme.missing`; explicit kit prefixes allow separate palettes. | Settle and qualify a documented kit-dependency convention with two independently authored kits: defaults, required values, conflicting names and local overrides must be clear without inspecting implementation details. A new scoping mechanism is not automatically required. | +| Components from independent kits | The [kit-composition guide](component-kits.md) qualifies concrete palette factories, explicit prefixed dependencies and local overrides with two maintainer-authored modules. Tokens remain document-wide; the example does not supply independent-kit evidence. | Confirm the documented convention with two independently authored kits: defaults, required values, conflicting names and local overrides must be clear without inspecting implementation details. A new scoping mechanism is not automatically required. | | Authoring and customization feedback | Maintainer-authored fixtures cover the intended edits, but retained independent author feedback is still missing. | Have independent authors rebrand a document, reuse a component, add a table column, change page size and diagnose a layout failure. Record where internal selectors or undocumented knowledge are needed, then resolve those contract gaps. | These are bounded API decisions, not a requirement to implement every CSS feature diff --git a/examples/component-kits/LICENSE b/examples/component-kits/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/component-kits/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fullbleed Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/component-kits/README.md b/examples/component-kits/README.md new file mode 100644 index 0000000..4bfce24 --- /dev/null +++ b/examples/component-kits/README.md @@ -0,0 +1,71 @@ +# Two component kits, one document + +A runnable example for Fullbleed's opt-in Python component alpha. All data is +synthetic. Install Python 3.10 or later, then: + +```sh +python -m venv .venv +# Windows PowerShell: .venv\Scripts\Activate.ps1 +# macOS/Linux: source .venv/bin/activate +python -m pip install -r requirements.txt +python report.py +``` + +Open `output/default.pdf` and `output/default.html`. The other three variants +change the billing brand, the impact brand, or just one displayed value. Each +variant also has unstyled HTML, separate native print source, a native PNG and +pagination diagnostics. No browser or third-party Python package is needed to +generate these files. + +## The two kit contracts + +`billing.py` exports `BillingPalette` and `billing_kit(palette)`. The returned +`Balance(label, amount, note)` component has concrete defaults, requires no theme, +and exposes the parts `label`, `amount` and `note`. Creating a new kit with a +different immutable palette does not change an earlier kit. + +`impact.py` exports `Impact(label, value, note)` and `IMPACT_DEFAULTS`. Its parts +are `label`, `value` and `note`. Its required document tokens are: + +| Token | Meaning | Default | +| --- | --- | --- | +| `impact.accent` | Main value color | `#19715e` | +| `impact.surface` | Card background | `#eaf5ef` | +| `impact.ink` | Label and note color | `#214d40` | + +Supply all defaults with `Theme(IMPACT_DEFAULTS)`. Use `theme.with_values({...})` +to derive a theme. Call `node.required_tokens` or `document.required_tokens` to +inspect dependencies without reading the kit internals. A missing binding in a +styled export or PDF raises `AuthoringError` with code `theme.missing`. + +Tokens are flat, document-wide names. A prefix is a convention, not automatic +scoping. Two components using `token("accent")` share the same binding. Mapping +merges and `Theme.with_values` use the later value for a repeated name; they do +not report a collision. Use distinct prefixes for distinct meanings. If two +instances of the same kit need different palettes, create separate concrete +kits, use the kit's advertised parts, or give a token-based kit an explicit +prefix parameter. There is no implicit per-instance theme in this example. + +## Customization and exports + +Kit defaults use `styled(...)`. Meaningful choices belong in ordinary function +arguments. Local changes use `node.with_style(...)` for the root and +`node.with_parts(value=style(...))` for a documented internal part. A root color +does not replace a child's explicitly defined color. None of these edits needs +generated selectors. + +`document.to_html(include_styles=True)` embeds the styles. Passing `False` +removes generated CSS and inline styles, retaining content and literal classes +for an application's own stylesheet. The report owns its responsive screen +layout, with explicit print and screen rules. On the separate-CSS rendering +path, Fullbleed can apply screen rules when no print rule is present; keep the +example's print block when adding screen-only styling. +The report's grid columns are reusable `styled(Grid, ...)` defaults, so the +mobile stylesheet can override them. An explicit `Grid(columns=2)` is an +instance choice and takes precedence over an ordinary class rule. +The PDF uses bundled Inter; browser HTML uses a local Inter or its +sans-serif fallback. Browser layout is not a PDF pagination preview. + +Both kit modules were authored for this example by the Fullbleed project. They +demonstrate separate dependencies and customization contracts; they do not +constitute independent author feedback or a component API stability promise. diff --git a/examples/component-kits/billing.py b/examples/component-kits/billing.py new file mode 100644 index 0000000..5220a76 --- /dev/null +++ b/examples/component-kits/billing.py @@ -0,0 +1,29 @@ +"""A kit with concrete defaults and an immutable, per-kit palette.""" +from dataclasses import dataclass + +from fullbleed.components import Card, Paragraph, component, pt, styled + + +@dataclass(frozen=True) +class BillingPalette: + accent: str = "#315b93" + surface: str = "#edf2f9" + ink: str = "#20364d" + + +def billing_kit(palette=BillingPalette()): + """Return a reusable component; no document theme is required.""" + Panel = styled(Card, background=palette.surface, border="none", + border_radius=pt(0), padding=pt(20)) + Label = styled(Paragraph, color=palette.ink, font_size=pt(9), + font_weight=700, margin=pt(0)) + Amount = styled(Paragraph, color=palette.accent, font_size=pt(34), + font_weight=700, line_height=1.1, margin=(pt(12), pt(0))) + Note = styled(Paragraph, color=palette.ink, font_size=pt(9), margin=pt(0)) + + @component(parts=("label", "amount", "note")) + def Balance(label, amount, note): + return Panel(Label(label, part="label"), Amount(amount, part="amount"), + Note(note, part="note"), class_name="billing-summary") + + return Balance diff --git a/examples/component-kits/impact.py b/examples/component-kits/impact.py new file mode 100644 index 0000000..a0a76ec --- /dev/null +++ b/examples/component-kits/impact.py @@ -0,0 +1,26 @@ +"""A kit with explicitly named document-token dependencies.""" +from types import MappingProxyType + +from fullbleed.components import Card, Paragraph, component, pt, styled, token + + +# Export every required value. Prefixes are a naming convention, not scopes. +IMPACT_DEFAULTS = MappingProxyType({ + "impact.accent": "#19715e", + "impact.surface": "#eaf5ef", + "impact.ink": "#214d40", +}) + +Panel = styled(Card, background=token("impact.surface"), border="none", + border_radius=pt(0), padding=pt(20)) +Label = styled(Paragraph, color=token("impact.ink"), font_size=pt(9), + font_weight=700, margin=pt(0)) +Value = styled(Paragraph, color=token("impact.accent"), font_size=pt(34), + font_weight=700, line_height=1.1, margin=(pt(12), pt(0))) +Note = styled(Paragraph, color=token("impact.ink"), font_size=pt(9), margin=pt(0)) + + +@component(parts=("label", "value", "note")) +def Impact(label, value, note): + return Panel(Label(label, part="label"), Value(value, part="value"), + Note(note, part="note"), class_name="impact-summary") diff --git a/examples/component-kits/report.py b/examples/component-kits/report.py new file mode 100644 index 0000000..3ca302c --- /dev/null +++ b/examples/component-kits/report.py @@ -0,0 +1,137 @@ +"""Compose two separately defined kits without reaching into their selectors.""" +import argparse +from hashlib import sha256 +from importlib.metadata import version +from importlib.resources import files +import json +from pathlib import Path + +from fullbleed.components import ( + Column, DataTable, Document, Flow, Grid, Heading, Page, Paragraph, + Renderer, Theme, mm, pct, pt, style, styled, +) + +from billing import BillingPalette, billing_kit +from impact import IMPACT_DEFAULTS, Impact + +PIN = "2.6.0a14" +VARIANTS = ("default", "billing-brand", "impact-brand", "local-override") +INK = "#233a37" +MUTED = "#5a6a66" + + +def report(variant="default"): + if variant not in VARIANTS: + raise ValueError(f"Unknown variant: {variant}") + palette = (BillingPalette(accent="#7047a1", surface="#f3eef8", ink="#483461") + if variant == "billing-brand" else BillingPalette()) + Balance = billing_kit(palette) + theme = Theme(IMPACT_DEFAULTS, text=style( + font_family="Inter, sans-serif", font_size=pt(10), line_height=1.5, color=INK)) + if variant == "impact-brand": + theme = theme.with_values({"impact.accent": "#a34e21", + "impact.surface": "#faf0e8", "impact.ink": "#673d29"}) + impact = Impact("COMMUNITY REACH", "128", "people joined a workshop this month") + if variant == "local-override": + impact = impact.with_parts(value=style(color="#9b3d64")) + + Kicker = styled(Paragraph, font_size=pt(8), letter_spacing=pt(1.2), + font_weight=700, color=MUTED, margin=pt(0)) + Title = styled(Heading, font_size=pt(32), line_height=1.08, + letter_spacing=pt(-0.8), margin=pt(0)) + Section = styled(Heading, font_size=pt(14), margin=pt(0)) + Body = styled(Paragraph, margin=pt(0)) + Metrics = styled(Grid, grid_template_columns="1fr 1fr", gap=mm(5)) + rows = ( + {"area": "Member workshops", "next": "Open the November calendar", "owner": "Program team"}, + {"area": "Community grants", "next": "Review the next four applications", "owner": "Finance team"}, + {"area": "Studio access", "next": "Add two evening sessions", "owner": "Operations"}, + ) + return Document( + Flow( + Kicker("CEDAR COOPERATIVE / OCTOBER 2026"), + Title("A shared view.\nRoom for every team.", level=1), + Body("Our monthly briefing brings finance and community programs together. " + "Each team keeps its own visual identity inside one consistent document."), + Metrics( + Balance("COMMUNITY FUND", "$24,600", "available for the next funding round"), + impact, class_name="report-metrics", + ), + Section("The next month, at a glance", level=2), + DataTable(rows, caption="November action plan", columns=( + Column("Focus", lambda row: row["area"], width=pct(25)), + Column("Next step", lambda row: row["next"]), + Column("Owner", lambda row: row["owner"], width=pct(20)), + )).with_parts( + header=style(background="#233a37", color="#ffffff", font_size=pt(8)), + cell=style(font_size=pt(9), padding=(pt(11), pt(9))), + caption=style(font_size=pt(8), color=MUTED), + ), + Flow( + Section("One report. Two reusable kits.", level=2), + Body("The finance card receives a palette when its kit is created. " + "The community card declares three document theme values. " + "Neither needs the other kit's implementation details."), + Body("SYNTHETIC EXAMPLE / No real financial or participant data.", + style=style(font_size=pt(7), color=MUTED)), + gap=pt(7), + ), + gap=pt(18), + ), + title="Cedar Cooperative — October briefing", lang="en-US", + page=Page("A4", margin=mm(18)), theme=theme, + styles=""" + @media print { + body { margin: 0; padding: 0; } + } + @media screen { + body { max-width: 174mm; margin: 24px auto; padding: 16px; } + } + @media screen and (max-width: 600px) { + .report-metrics { grid-template-columns: 1fr; } + } + """, + ) + + +def renderer(): + font = files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf") + return Renderer(font_files=[str(font)], document_timestamp="2026-10-23T00:00:00Z") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path(__file__).parent / "output") + args = parser.parse_args() + installed = version("fullbleed") + if installed != PIN: + raise RuntimeError(f"This example is qualified with fullbleed=={PIN}; found {installed}.") + args.output.mkdir(parents=True, exist_ok=True) + records = [] + for variant in VARIANTS: + document = report(variant) + prepared = document.prepare(renderer()) + pdf = prepared.render_pdf() + (args.output / f"{variant}.pdf").write_bytes(pdf) + images = prepared.render_image_pages(dpi=108) + for index, image in enumerate(images, start=1): + (args.output / f"{variant}.native-{index}.png").write_bytes(image) + trace = prepared.export_render_time_pagination_trace() + (args.output / f"{variant}.pagination.json").write_text( + json.dumps(trace, indent=2) + "\n", encoding="utf-8") + for suffix, with_styles in (("html", True), ("unstyled.html", False)): + (args.output / f"{variant}.{suffix}").write_text( + document.to_html(include_styles=with_styles), encoding="utf-8") + # Keep native print source separate from continuous browser HTML. + document.emit_artifacts(html_path=args.output / f"{variant}.print.html", + css_path=args.output / f"{variant}.print.css", target="pdf") + records.append({"variant": variant, "pdf_sha256": sha256(pdf).hexdigest(), + "pages": len(images), + "required_tokens": list(document.required_tokens)}) + result = {"version": installed, "documents": records} + (args.output / "render.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/examples/component-kits/requirements-check.txt b/examples/component-kits/requirements-check.txt new file mode 100644 index 0000000..16ec1fd --- /dev/null +++ b/examples/component-kits/requirements-check.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pypdf==6.19.0 +pypdfium2==5.14.0 +Pillow==12.3.0 diff --git a/examples/component-kits/requirements.txt b/examples/component-kits/requirements.txt new file mode 100644 index 0000000..8f2fba8 --- /dev/null +++ b/examples/component-kits/requirements.txt @@ -0,0 +1 @@ +fullbleed==2.6.0a14 diff --git a/tools/build_component_kits.py b/tools/build_component_kits.py new file mode 100644 index 0000000..59b3af8 --- /dev/null +++ b/tools/build_component_kits.py @@ -0,0 +1,35 @@ +"""Package the exact, text-normalized component kit example reproducibly.""" +from hashlib import sha256 +import json +from pathlib import Path +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/component-kits" +DEST = ROOT / "docs/assets/component-kits" +FILES = ("README.md", "LICENSE", "requirements.txt", "requirements-check.txt", + "billing.py", "impact.py", "report.py") + + +def build(): + DEST.mkdir(parents=True, exist_ok=True) + path = DEST / "project.zip" + records = [] + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name in FILES: + data = (SOURCE / name).read_text(encoding="utf-8").encode("utf-8") + entry = zipfile.ZipInfo("fullbleed-component-kits/" + name, (2026, 10, 23, 0, 0, 0)) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data, compresslevel=9) + records.append({"path": name, "bytes": len(data), "sha256": sha256(data).hexdigest()}) + result = {"schema": "fullbleed.component-kits.v1", "fullbleed": "2.6.0a14", + "files": records, "zip_sha256": sha256(path.read_bytes()).hexdigest()} + (DEST / "source.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8", newline="\n") + print(json.dumps({"ok": True, "files": len(records), "zip_sha256": result["zip_sha256"]})) + return result + + +if __name__ == "__main__": + build() diff --git a/tools/check_component_kits_browser.py b/tools/check_component_kits_browser.py new file mode 100644 index 0000000..541e0d0 --- /dev/null +++ b/tools/check_component_kits_browser.py @@ -0,0 +1,90 @@ +"""Inspect styled and unstyled kit exports in an isolated Chromium session.""" +import argparse +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +from threading import Thread + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] + + +class Handler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--render", type=Path, default=ROOT / "target/component-kits/render") + parser.add_argument("--out", type=Path, default=ROOT / "target/component-kits/browser") + parser.add_argument("--chromium", type=Path) + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + records, errors = [], [] + server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(args.render.resolve()))) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, **( + {"executable_path": str(args.chromium)} if args.chromium else {})) + browser_version = browser.version + for viewport, width in (("desktop", 1280), ("mobile", 390)): + context = browser.new_context(viewport={"width": width, "height": 900}) + page = context.new_page() + page.on("pageerror", lambda error: errors.append(str(error))) + for variant in ("default", "billing-brand", "impact-brand", "local-override"): + for kind in ("html", "unstyled.html"): + url = f"http://127.0.0.1:{server.server_port}/{variant}.{kind}" + response = page.goto(url, wait_until="networkidle") + assert response.status == 200 + assert page.get_by_role("heading", level=1).count() == 1 + assert page.get_by_text("$24,600", exact=True).count() == 1 + assert page.get_by_text("128", exact=True).count() == 1 + assert page.locator("script").count() == 0 + data = page.evaluate("""() => { + const card = selector => { + const element = document.querySelector(selector); + const p = [...element.querySelectorAll('p')]; + const rect = element.getBoundingClientRect(); + return {background: getComputedStyle(element).backgroundColor, + colors: p.map(x => getComputedStyle(x).color), + x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }; + return {billing: card('.billing-summary'), impact: card('.impact-summary'), + overflow: document.documentElement.scrollWidth > innerWidth, + styles: document.querySelectorAll('style,[style]').length}; + }""") + assert not data["overflow"], (viewport, variant, kind, data) + if kind == "html": + billing = "rgb(112, 71, 161)" if variant == "billing-brand" else "rgb(49, 91, 147)" + impact = {"impact-brand": "rgb(163, 78, 33)", + "local-override": "rgb(155, 61, 100)"}.get(variant, "rgb(25, 113, 94)") + assert data["billing"]["colors"][1] == billing, data + assert data["impact"]["colors"][1] == impact, data + if viewport == "desktop": + assert abs(data["billing"]["y"] - data["impact"]["y"]) < 1 + else: + assert data["impact"]["y"] >= data["billing"]["y"] + data["billing"]["height"] + page.screenshot(path=str(args.out / f"{viewport}-{variant}.png"), full_page=True) + else: + assert data["styles"] == 0, data + records.append({"viewport": viewport, "variant": variant, "export": kind, **data}) + context.close() + browser.close() + assert not errors, errors + result = {"ok": True, "browser": browser_version, "checks": records, + "scope": "Actual static HTML exports; no browser/PDF pagination or pixel-parity claim."} + (args.out / "verification.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"ok": True, "browser": browser_version, "exports_checked": len(records)})) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tools/test_component_kits_png.py b/tools/test_component_kits_png.py new file mode 100644 index 0000000..450695f --- /dev/null +++ b/tools/test_component_kits_png.py @@ -0,0 +1,74 @@ +"""PNG encoding changes are acceptable; changed pixels, geometry or modes are not.""" +import io +from pathlib import Path +import struct +import unittest + +from PIL import Image, UnidentifiedImageError +from PIL.PngImagePlugin import PngInfo +from verify_component_kits import compare_png_pixels + +BASELINE = Path(__file__).resolve().parents[1] / "docs/assets/component-kits/default-1.png" + + +def encode(image, **options): + out = io.BytesIO() + image.save(out, format="PNG", **options) + return out.getvalue() + + +class PngGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.original = BASELINE.read_bytes() + + def test_different_compression_preserves_every_pixel(self): + with Image.open(io.BytesIO(self.original)) as image: + encoded = encode(image, compress_level=0) + self.assertNotEqual(encoded, self.original) + result = compare_png_pixels(encoded, self.original) + self.assertFalse(result["encoding_equal"]) + self.assertEqual(result["mode"], "RGB") + + def test_one_changed_channel_in_one_pixel_fails(self): + with Image.open(io.BytesIO(self.original)) as image: + changed = image.copy() + red, green, blue = changed.getpixel((0, 0)) + changed.putpixel((0, 0), (red ^ 1, green, blue)) + with self.assertRaisesRegex(AssertionError, "PNG pixels differ"): + compare_png_pixels(encode(changed), self.original) + + def test_changed_dimensions_fail(self): + with Image.open(io.BytesIO(self.original)) as image: + changed = image.crop((0, 0, image.width - 1, image.height)) + with self.assertRaises(AssertionError): + compare_png_pixels(encode(changed), self.original) + + def test_changed_mode_fails_even_with_opaque_alpha(self): + with Image.open(io.BytesIO(self.original)) as image: + changed = image.convert("RGBA") + with self.assertRaises(AssertionError): + compare_png_pixels(encode(changed), self.original) + + def test_invalid_png_fails(self): + with self.assertRaises(UnidentifiedImageError): + compare_png_pixels(b"invalid image", self.original) + + def test_changed_gamma_metadata_fails(self): + metadata = PngInfo() + metadata.add(b"gAMA", struct.pack(">I", 45455)) + with Image.open(io.BytesIO(self.original)) as image: + changed = encode(image, pnginfo=metadata) + with self.assertRaisesRegex(AssertionError, "PNG metadata differs"): + compare_png_pixels(changed, self.original) + + def test_another_image_format_fails(self): + stream = io.BytesIO() + with Image.open(io.BytesIO(self.original)) as image: + image.save(stream, format="TIFF") + with self.assertRaises(AssertionError): + compare_png_pixels(stream.getvalue(), self.original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_component_kits.py b/tools/verify_component_kits.py new file mode 100644 index 0000000..4ba1f44 --- /dev/null +++ b/tools/verify_component_kits.py @@ -0,0 +1,226 @@ +"""Check the downloaded project, dependency contracts and saved PDF pixels.""" +import argparse +from hashlib import sha256 +from html.parser import HTMLParser +from importlib.metadata import version +import io +import json +from pathlib import Path +import re +import subprocess +import sys +import zipfile + +import fullbleed +from fullbleed.components import AuthoringError, Document, Paragraph, Theme, style, token +from PIL import Image, ImageChops +from pypdf import PdfReader +import pypdfium2 as pdfium + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "docs/assets/component-kits" + + +class Html(HTMLParser): + def __init__(self): + super().__init__() + self.styles = 0 + self.inline = 0 + self.scripts = 0 + self.classes = set() + self.text = [] + self.hidden = 0 + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + self.styles += tag == "style" + self.inline += "style" in attrs + self.scripts += tag == "script" + self.classes.update(attrs.get("class", "").split()) + if tag in ("style", "script", "title"): + self.hidden += 1 + + def handle_endtag(self, tag): + if tag in ("style", "script", "title"): + self.hidden -= 1 + + def handle_data(self, text): + if not self.hidden: + self.text.append(text) + + +def color_box(image, color): + rgb = image.convert("RGB") + channels = ImageChops.difference(rgb, Image.new("RGB", rgb.size, color)).split() + distance = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2]) + return distance.point(lambda value: 255 if value == 0 else 0).getbbox() + + +def contained(inner, outer, tolerance=1): + return (inner and outer and inner[0] >= outer[0] - tolerance + and inner[1] >= outer[1] - tolerance and inner[2] <= outer[2] + tolerance + and inner[3] <= outer[3] + tolerance) + + +def compare_png_pixels(actual, expected): + """Require identical decoded images; PNG compression can differ by platform.""" + with Image.open(io.BytesIO(actual)) as image, Image.open(io.BytesIO(expected)) as reference: + assert image.format == reference.format == "PNG", (image.format, reference.format) + assert image.mode == reference.mode, (image.mode, reference.mode) + assert image.size == reference.size, (image.size, reference.size) + assert image.info == reference.info, "PNG metadata differs" + pixels = image.tobytes() + assert pixels == reference.tobytes(), "PNG pixels differ" + return {"mode": image.mode, "size": list(image.size), + "pixels_sha256": sha256(pixels).hexdigest(), "encoding_equal": actual == expected} + + +def expect_error(call, code): + try: + call() + except AuthoringError as error: + assert error.code == code, error.to_dict() + return error.to_dict() + raise AssertionError(f"Expected {code}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ROOT / "target/component-kits") + parser.add_argument("--check-published", action="store_true") + args = parser.parse_args() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + project = out / "project" + project.mkdir(exist_ok=True) + manifest = json.loads((ASSETS / "source.json").read_text(encoding="utf-8")) + assert version("fullbleed") == manifest["fullbleed"] == "2.6.0a14" + archive_path = ASSETS / "project.zip" + assert sha256(archive_path.read_bytes()).hexdigest() == manifest["zip_sha256"] + with zipfile.ZipFile(archive_path) as archive: + assert set(archive.namelist()) == {"fullbleed-component-kits/" + r["path"] for r in manifest["files"]} + for record in manifest["files"]: + name = record["path"] + assert Path(name).name == name + data = archive.read("fullbleed-component-kits/" + name) + assert sha256(data).hexdigest() == record["sha256"] + (project / name).write_bytes(data) + rendered = out / "render" + run = subprocess.run([sys.executable, str(project / "report.py"), "--output", str(rendered)], + cwd=project, capture_output=True, text=True, encoding="utf-8", timeout=90) + (out / "render.log").write_text(run.stdout + run.stderr, encoding="utf-8") + assert run.returncode == 0, run.stdout + run.stderr + guide = (ROOT / "docs/guides/component-kits.md").read_text(encoding="utf-8") + blocks = re.findall(r"```python\n(.*?)```", guide, re.S) + assert len(blocks) == 3 + snippets = "\n\n".join(blocks) + (out / "guide-snippets.py").write_text(snippets, encoding="utf-8") + snippet_run = subprocess.run([sys.executable, "-c", snippets], cwd=project, + capture_output=True, text=True, encoding="utf-8", timeout=30) + (out / "guide-snippets.log").write_text(snippet_run.stdout + snippet_run.stderr, encoding="utf-8") + assert snippet_run.returncode == 0, snippet_run.stdout + snippet_run.stderr + assert (project / "cards.html").exists() and (project / "cards.unstyled.html").exists() + sys.path.insert(0, str(project)) + from billing import BillingPalette, billing_kit + from impact import IMPACT_DEFAULTS, Impact + from report import report, renderer, VARIANTS + + Balance = billing_kit() + plain = Balance("Balance", "$10", "A concrete kit") + assert plain.required_tokens == () + Document(plain).compile() + impact = Impact("Impact", "12", "A token kit") + assert set(impact.required_tokens) == set(IMPACT_DEFAULTS) + assert "12" in Document(impact).to_html(include_styles=False) + errors = {"missing_theme": expect_error(lambda: Document(impact).compile(), "theme.missing")} + for missing in IMPACT_DEFAULTS: + incomplete = {k: v for k, v in IMPACT_DEFAULTS.items() if k != missing} + errors[missing] = expect_error(lambda: Document(impact, theme=Theme(incomplete)).compile(), "theme.missing") + assert missing in errors[missing]["message"] + errors["unknown_part"] = expect_error(lambda: impact.with_parts(number=style(color="red")), "style.part") + # A deliberately colliding name has one document-wide binding; no automatic scope. + first = Theme({"accent": "#315b93"}) + combined = first.with_values({"accent": "#a34e21"}) + collision = Document(Paragraph("A", style=style(color=token("accent"))), + Paragraph("B", style=style(color=token("accent"))), theme=combined) + assert dict(first.values)["accent"] == "#315b93" + assert dict(combined.values)["accent"] == "#a34e21" + collision_html = collision.to_html(include_styles=True) + assert "#a34e21" in collision_html and "#315b93" not in collision_html + original = report().to_html(include_styles=True) + billing_kit(BillingPalette(accent="#7047a1"))("New", "$20", "Separate kit") + impact_before = impact.to_html(include_styles=True, theme=Theme(IMPACT_DEFAULTS)) + impact.with_parts(value=style(color="#9b3d64")) + assert impact.to_html(include_styles=True, theme=Theme(IMPACT_DEFAULTS)) == impact_before + assert report().to_html(include_styles=True) == original + assert impact.required_tokens == tuple(sorted(IMPACT_DEFAULTS)) + + records, images, published_pngs = [], {}, {} + expected_text = ("$24,600", "128", "November action plan", "Member workshops", + "Community grants", "Studio access", "SYNTHETIC EXAMPLE") + for variant in VARIANTS: + path = rendered / f"{variant}.pdf" + reader = PdfReader(path) + assert len(reader.pages) == 1 + page = reader.pages[0] + assert abs(float(page.mediabox.width) - 595.276) < 0.02 + assert abs(float(page.mediabox.height) - 841.890) < 0.02 + text = "".join(page.extract_text().split()) + assert all("".join(value.split()) in text for value in expected_text) + inspection = fullbleed.inspect_pdf(str(path)) + assert inspection["ok"] and inspection["profile"]["embedded_font_count"] > 0 + trace = json.loads((rendered / f"{variant}.pagination.json").read_text(encoding="utf-8")) + assert trace["ok"] and len(trace["pages"]) == 1 + assert trace["pages"][0]["text_overlap_count"] == 0 + assert report(variant).render_pdf(renderer()) == path.read_bytes() + with pdfium.PdfDocument(str(path)) as pdf: + bitmap = pdf[0].render(scale=1.5) + image = bitmap.to_pil().convert("RGB") + image.save(rendered / f"{variant}-1.png") + images[variant] = image + exports = [] + for suffix, styled_export in (("html", True), ("unstyled.html", False)): + html = Html() + html.feed((rendered / f"{variant}.{suffix}").read_text(encoding="utf-8")) + assert not html.scripts + assert {"billing-summary", "impact-summary", "report-metrics"} <= html.classes + assert (html.styles > 0) if styled_export else (html.styles == 0 and html.inline == 0) + content = "".join("".join(html.text).split()) + assert all("".join(value.split()) in content for value in expected_text) + exports.append(content) + assert exports[0] == exports[1] + if args.check_published: + assert path.read_bytes() == (ASSETS / f"{variant}.pdf").read_bytes(), path.name + name = f"{variant}-1.png" + published_pngs[variant] = compare_png_pixels( + (rendered / name).read_bytes(), (ASSETS / name).read_bytes()) + records.append({"variant": variant, "pages": 1, "pdf_sha256": sha256(path.read_bytes()).hexdigest(), + "repeated_pdf_equal": True, "embedded_font": True, "text_overlaps": 0, + "required_tokens": list(report(variant).required_tokens)}) + base = images["default"] + billing_region = color_box(base, "#edf2f9") + impact_region = color_box(base, "#eaf5ef") + value_region = color_box(base, "#19715e") + differences = [] + for variant, allowed, accent in (("billing-brand", billing_region, "#7047a1"), + ("impact-brand", impact_region, "#a34e21"), + ("local-override", value_region, "#9b3d64")): + changed = ImageChops.difference(base, images[variant]).getbbox() + assert contained(changed, allowed), (variant, changed, allowed) + assert color_box(images[variant], accent), (variant, accent) + differences.append({"variant": variant, "changed_pixels_bbox": changed, + "allowed_region": allowed, "outside_region_identical": True}) + result = {"ok": True, "fullbleed": version("fullbleed"), "zip_sha256": manifest["zip_sha256"], + "documents": records, "pdf_pixel_isolation": differences, "expected_errors": errors, + "guide_python_blocks_executed": len(blocks), "guide_snippets_sha256": sha256(snippets.encode()).hexdigest(), + "collision": "Document-wide name; later explicit theme value wins; original theme unchanged.", + "published_pdf_bytes_equal": True if args.check_published else None, + "published_png_pixels_equal": True if args.check_published else None, + "published_pngs": published_pngs, + "scope": "Maintainer-authored kit contracts; no independent-author or general conformance claim."} + (out / "verification.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result)) + + +if __name__ == "__main__": + main()