diff --git a/.gitattributes b/.gitattributes
index de3dc4e..ce92280 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -152,3 +152,8 @@ examples/accessible-showcase-alpha6/fonts/*.ttf binary
docs/assets/accessible-showcase-alpha6/** -text whitespace=cr-at-eol
docs/assets/accessible-showcase-alpha6/*.pdf binary
docs/assets/accessible-showcase-alpha6/fonts/*.ttf binary
+
+# The Jupyter download fix is a new notebook revision; old assets stay immutable.
+examples/component-notebook-alpha6-r1/** text eol=lf
+docs/assets/component-notebook-alpha6-r1/** -text
+docs/assets/component-notebook-alpha6-r1/*.pdf binary
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 74f3577..6747bf2 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -77,7 +77,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
- channel: [alpha3, alpha5, alpha6]
+ channel: [alpha3, alpha5, alpha6, alpha6-r1]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
@@ -87,6 +87,13 @@ jobs:
python -m pip install --only-binary=:all: -r tools/component-notebook-requirements.txt
- name: Execute the notebook and check PDF, HTML and ZIP downloads
run: python -I tools/verify_component_notebook.py --channel ${{ matrix.channel }} --check-downloads --out target/component-notebook
+ - name: Install the optional Jupyter browser test tools
+ if: matrix.channel == 'alpha6-r1'
+ run: |
+ python -m pip install --only-binary=:all: -r tools/component-notebook-browser-requirements.txt
+ - name: Edit, run and download in Jupyter Notebook
+ if: matrix.channel == 'alpha6-r1'
+ run: python -I tools/check_component_notebook_jupyter.py --reference target/component-notebook --out target/component-notebook/browser --browser chrome
- uses: actions/upload-artifact@v4
if: always()
with:
diff --git a/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb b/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb
new file mode 100644
index 0000000..0f75b7a
--- /dev/null
+++ b/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb
@@ -0,0 +1,330 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "welcome",
+ "metadata": {},
+ "source": [
+ "# Make a PDF from Python components\n",
+ "\n",
+ "Edit an order, customize its cards and table, and export a PDF plus styled\n",
+ "and unstyled HTML. You write Python values and components; no HTML strings\n",
+ "are needed. All sample data is fictional.\n",
+ "\n",
+ "**In Colab:** choose **Runtime → Run all**, sign in if prompted, and use a\n",
+ "standard CPU runtime. No GPU or paid plan is needed for this example;\n",
+ "[Colab availability and limits](https://research.google.com/colaboratory/faq.html)\n",
+ "still apply. Runtime files are temporary, so download your results before\n",
+ "leaving. Local Jupyter also works.\n",
+ "\n",
+ "**Experimental:** this notebook pins Fullbleed **2.6.0a6**. The component API\n",
+ "may change and has documented CSS limits. Bootstrap components are static\n",
+ "in both PDF and HTML; no Bootstrap JavaScript is included."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "install-help",
+ "metadata": {},
+ "source": [
+ "## 1. Install the tested alpha\n",
+ "\n",
+ "Run this before importing Fullbleed. If you already imported a different\n",
+ "version in this notebook session, restart its kernel/runtime first.\n",
+ "The wheel supplies the engine and font; Jupyter supplies display tools."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "install-fullbleed",
+ "metadata": {
+ "tags": [
+ "install-fullbleed"
+ ]
+ },
+ "source": [
+ "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a6\""
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "data-help",
+ "metadata": {},
+ "source": [
+ "## 2. Make the order yours\n",
+ "\n",
+ "Change a name, quantity or price, then rerun this cell and the cells below it.\n",
+ "Prices are decimal strings; this example omits tax, shipping and discounts.\n",
+ "Try `items=[]` to see the empty state, or add rows to explore pagination."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "order-data",
+ "metadata": {},
+ "source": [
+ "order = {\n",
+ " \"number\": \"1048\",\n",
+ " \"customer\": \"Moss & Fern Studio\",\n",
+ " \"collection\": \"The autumn collection\",\n",
+ " \"status\": \"Ready to ship\",\n",
+ " \"items\": [\n",
+ " {\"item\": \"Studio notebooks\", \"quantity\": 24, \"unit_price\": \"15.00\"},\n",
+ " {\"item\": \"Cotton stationery sets\", \"quantity\": 12, \"unit_price\": \"18.00\"},\n",
+ " {\"item\": \"Archival prints\", \"quantity\": 8, \"unit_price\": \"60.00\"},\n",
+ " ],\n",
+ "}"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "styles-help",
+ "metadata": {},
+ "source": [
+ "## 3. Choose the accent and type size\n",
+ "\n",
+ "These values feed ordinary Python styles. Later, `.with_parts(...)` styles\n",
+ "a card's own title and body. The custom `dispatch-note` class gets its rule\n",
+ "from `Document(styles=...)`; a class name alone does not create a style."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "style-controls",
+ "metadata": {},
+ "source": [
+ "accent = \"#205b47\"\n",
+ "title_size = 30"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "components-help",
+ "metadata": {},
+ "source": [
+ "## 4. Compose reusable components\n",
+ "\n",
+ "`Panel` supplies reusable card defaults. `Metric` is an ordinary Python\n",
+ "function. `DataTable` builds its structure from rows and column functions.\n",
+ "Bootstrap classes such as `mb-4` come with the components; your custom\n",
+ "classes need their own rules. Text values are escaped automatically.\n",
+ "\n",
+ "A column callback can return a component: `Strong(row[\"item\"])` emphasizes\n",
+ "each item inside its table cell. Return text or compose other components\n",
+ "without constructing an HTML string.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "components",
+ "metadata": {},
+ "source": [
+ "from decimal import Decimal\n",
+ "from fullbleed.ui.experimental import Document, Page, Strong, Theme, mm, pt, style, styled\n",
+ "from fullbleed.ui.experimental import bootstrap as bs\n",
+ "\n",
+ "def money(value):\n",
+ " return f\"USD {value:,.2f}\"\n",
+ "\n",
+ "Panel = styled(bs.Card, border_color=\"#d7e1de\", border_radius=\"8px\")\n",
+ "\n",
+ "def Metric(value, label):\n",
+ " return Panel(\n",
+ " bs.Heading(value, level=2, class_name=\"mb-1\", style=style(color=accent)),\n",
+ " bs.Text(label, class_name=\"text-secondary\"),\n",
+ " )\n",
+ "\n",
+ "def OrderSummary(data):\n",
+ " rows = [dict(row, amount=Decimal(row[\"unit_price\"]) * row[\"quantity\"])\n",
+ " for row in data[\"items\"]]\n",
+ " total = sum((row[\"amount\"] for row in rows), Decimal(\"0.00\"))\n",
+ " units = sum(row[\"quantity\"] for row in rows)\n",
+ " details = bs.DataTable(\n",
+ " rows,\n",
+ " columns=[\n",
+ " bs.Column(\"Item\", lambda row: Strong(row[\"item\"])),\n",
+ " bs.Column(\"Quantity\", lambda row: row[\"quantity\"], align=\"right\"),\n",
+ " bs.Column(\"Amount\", lambda row: money(row[\"amount\"]), align=\"right\"),\n",
+ " ],\n",
+ " striped=True,\n",
+ " caption=\"Order contents · USD\",\n",
+ " ) if rows else bs.Alert(\"No items in this order.\", variant=\"light\")\n",
+ " return Document(\n",
+ " bs.Box(bs.Text(\"NORTHSTAR / STUDIO\", class_name=\"fw-bold\"),\n",
+ " bs.Badge(data[\"status\"], variant=\"light\", class_name=\"ms-3\"),\n",
+ " class_name=\"mb-4\"),\n",
+ " bs.Heading(data[\"collection\"], level=1,\n",
+ " style=style(font_size=pt(title_size), color=accent, line_height=1.1)),\n",
+ " bs.Paragraph(f'Prepared for {data[\"customer\"]}', class_name=\"lead mb-4\"),\n",
+ " bs.Row(\n",
+ " bs.Col(Metric(str(units), \"Items in this order\"), span=5),\n",
+ " bs.Col(Metric(money(total), \"Order total\"), span=7),\n",
+ " gutter=3, class_name=\"mb-4 mx-0\",\n",
+ " ),\n",
+ " Panel(details, title=f'Order {data[\"number\"]}',\n",
+ " footer=\"Made with care. Packed with purpose.\").with_parts(\n",
+ " title=style(color=accent, font_size=pt(16)),\n",
+ " body=style(padding=\"1.25rem\"),\n",
+ " ),\n",
+ " bs.Alert(\"Please retain this copy with your shipment.\",\n",
+ " class_name=\"dispatch-note mt-4\"),\n",
+ " title=f'Order {data[\"number\"]}',\n",
+ " page=Page(size=\"A4\", margin=mm(16)),\n",
+ " theme=Theme(text=style(font_family=\"Inter, Arial, sans-serif\", font_size=pt(10),\n",
+ " color=\"#18382f\", line_height=1.4)),\n",
+ " styles={\".dispatch-note\": style(color=accent, border_color=accent,\n",
+ " background_color=\"#f0f5f2\")},\n",
+ " )\n",
+ "\n",
+ "document = OrderSummary(order)"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "render-help",
+ "metadata": {},
+ "source": [
+ "## 5. Render and export\n",
+ "\n",
+ "The PDF renderer receives the bundled Inter font file explicitly. A CSS\n",
+ "family name does not load a font. Styled HTML keeps the family declaration;\n",
+ "its browser host supplies Inter separately, or uses the Arial/sans-serif fallback.\n",
+ "\n",
+ "`include_styles=False` removes generated and inline styling but keeps classes\n",
+ "for a host application's CSS. Both export modes also work on an individual\n",
+ "component, such as `Metric(\"44\", \"Items\").to_html(include_styles=False)`.\n",
+ "Styled Bootstrap fragments include global Bootstrap rules; use unstyled\n",
+ "fragments when the host page already provides its stylesheet."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "render-export",
+ "metadata": {},
+ "source": [
+ "from importlib.resources import files\n",
+ "from pathlib import Path\n",
+ "from fullbleed.ui.experimental import Renderer\n",
+ "\n",
+ "output = Path(\"output\")\n",
+ "output.mkdir(exist_ok=True)\n",
+ "font = files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n",
+ "renderer = Renderer(font_files=[str(font)],\n",
+ " document_timestamp=\"2026-10-01T00:00:00Z\")\n",
+ "prepared = document.prepare(renderer)\n",
+ "pdf_path = output / \"order.pdf\"\n",
+ "pdf_path.write_bytes(prepared.render_pdf())\n",
+ "(output / \"order.styled.html\").write_text(\n",
+ " document.to_html(include_styles=True), encoding=\"utf-8\", newline=\"\\n\")\n",
+ "(output / \"order.unstyled.html\").write_text(\n",
+ " document.to_html(include_styles=False), encoding=\"utf-8\", newline=\"\\n\")\n",
+ "print(f\"Saved {pdf_path} and both HTML exports.\")"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "code",
+ "id": "preview",
+ "metadata": {},
+ "source": [
+ "from IPython.display import Image, display\n",
+ "\n",
+ "preview_paths = []\n",
+ "for number, png in enumerate(prepared.render_image_pages(dpi=110), start=1):\n",
+ " path = output / f\"order-page-{number}.png\"\n",
+ " path.write_bytes(png)\n",
+ " preview_paths.append(path)\n",
+ " display(Image(data=png, width=650))\n",
+ "print(f\"{len(preview_paths)} page(s). Review every page before using the document.\")"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "download-help",
+ "metadata": {},
+ "source": [
+ "## 6. Download your files\n",
+ "\n",
+ "This ZIP contains the current PDF, both HTML exports, and every current page\n",
+ "preview. In Colab the next cell starts the download. In local Jupyter it\n",
+ "displays a link. The runtime's `output/` folder also contains the individual\n",
+ "files. If you edit the order or styles, rerun from that edit through this cell."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "download",
+ "metadata": {},
+ "source": [
+ "import sys\n",
+ "from zipfile import ZipFile, ZIP_DEFLATED\n",
+ "\n",
+ "bundle_path = output / \"component-order.zip\"\n",
+ "current_files = [pdf_path, output / \"order.styled.html\",\n",
+ " output / \"order.unstyled.html\", *preview_paths]\n",
+ "with ZipFile(bundle_path, \"w\", compression=ZIP_DEFLATED) as bundle:\n",
+ " for path in current_files:\n",
+ " bundle.write(path, arcname=path.name)\n",
+ "\n",
+ "if \"google.colab\" in sys.modules:\n",
+ " from google.colab import files\n",
+ " files.download(str(bundle_path))\n",
+ "else:\n",
+ " from IPython.display import FileLink\n",
+ " display(FileLink(bundle_path.as_posix()))"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "next-steps",
+ "metadata": {},
+ "source": [
+ "## Keep composing\n",
+ "\n",
+ "- [Component guide and catalog](https://docs.fullbleed.dev/guides/bootstrap-components-preview/)\n",
+ " explain class names, named parts, fonts, static states and current limits.\n",
+ "- [Northstar and Common Ground source kits](https://docs.fullbleed.dev/guides/showcase-authoring/)\n",
+ " show a complete invoice and illustrated report in components and HTML/CSS.\n",
+ "- [The authored accessible guide](https://docs.fullbleed.dev/guides/accessible-showcase/)\n",
+ " includes the exact PDF, semantic source and scoped validation receipts.\n",
+ "- [Share an authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n",
+ " if a composition or styling task feels awkward. Include the small component,\n",
+ " expected result and Fullbleed version; use fictional data in public posts.\n",
+ "- [Stable Python guide](https://docs.fullbleed.dev/guides/python-components/)\n",
+ " documents the established API separately from this alpha.\n",
+ "\n",
+ "The notebook and Fullbleed are MIT licensed. This example does not establish\n",
+ "complete Bootstrap CSS, browser, accessibility or PDF conformance."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ },
+ "colab": {
+ "name": "bootstrap_components.ipynb",
+ "provenance": []
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/assets/component-notebook-alpha6-r1/browser-verification.json b/docs/assets/component-notebook-alpha6-r1/browser-verification.json
new file mode 100644
index 0000000..e6c1325
--- /dev/null
+++ b/docs/assets/component-notebook-alpha6-r1/browser-verification.json
@@ -0,0 +1,86 @@
+{
+ "ok": true,
+ "checked_at": "2026-10-18T16:41:19.430606+00:00",
+ "notebook_sha256": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997",
+ "runtime_version": "2.6.0a6",
+ "system": "Windows",
+ "python": "3.11.8",
+ "browser": "155.0.8059.40",
+ "tools": {
+ "notebook": "7.6.3",
+ "jupyter_server": "2.21.1",
+ "ipykernel": "7.4.0",
+ "playwright": "1.49.1"
+ },
+ "cases": [
+ {
+ "name": "default",
+ "pages": 1,
+ "actual_browser_download": true,
+ "filename": "component-order.zip",
+ "href_path": "/files/output/component-order.zip",
+ "zip_sha256": "559869425c983a7f71f7e12ed10c4b827982e30f0d0959c1fa7418cb3d60bcb3",
+ "artifact_hashes": {
+ "order.pdf": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6",
+ "order.styled.html": "c53d95a831b1d2fda92da28e6dcc5bf6063a7b1090cf1053930c39ff7b54103f",
+ "order.unstyled.html": "3e072e3dd684f9e7c483859a41051619effa814225a64a1a0e500e4d6be936e6",
+ "order-page-1.png": "2b6c470b1e49a906170be982a00398ed57033b353cb14e6b56f97763b99bbd2b"
+ },
+ "matches_independent_reference": true,
+ "visible_previews_match_download": true
+ },
+ {
+ "name": "edited",
+ "pages": 1,
+ "actual_browser_download": true,
+ "filename": "component-order.zip",
+ "href_path": "/files/output/component-order.zip",
+ "zip_sha256": "2fda77249124c3c4492661fa3ecf8951fb0c4c609194d8cdac76ac9ed6554a11",
+ "artifact_hashes": {
+ "order.pdf": "5823731e0146ab74806acce6bbd0676af5b77bf04566827c9c50523004cdb4c4",
+ "order.styled.html": "ffeb7c3da32ee2f88ffca18fdf98a57207217d35dfa9a8b6ad86b889ef763f49",
+ "order.unstyled.html": "7079de5ca04cb177864165f59f3a77ddf5d3d4406fb274973ea6bbf0b6254fac",
+ "order-page-1.png": "a9610eb13d8b47f2f643a0809de412302428d772e9798e1ab17d3f9361c69fa7"
+ },
+ "matches_independent_reference": true,
+ "visible_previews_match_download": true
+ },
+ {
+ "name": "long",
+ "pages": 4,
+ "actual_browser_download": true,
+ "filename": "component-order.zip",
+ "href_path": "/files/output/component-order.zip",
+ "zip_sha256": "72eb4fccf02ae5b5e9bbc3d37a1b3e4b8cfff61d89b026eebb0251b4757ffc42",
+ "artifact_hashes": {
+ "order.pdf": "c543c6cd5c6dadc7b4e77fd043a3fa5f2693dfbb0e20d9f844a5e6bb894a4735",
+ "order.styled.html": "40b7281978170db2c4da4ca73ab86c54e367014f66d1ae64ea279b83a44f148d",
+ "order.unstyled.html": "a9a83ab877726691b998e0b53fef913a927044c9f1dbaa422fc2a47d18fecbf8",
+ "order-page-1.png": "d9dac9397542e717cf089da666509b3511e0700134dd6b77fe77bbe6751450f8",
+ "order-page-2.png": "cba70b5c9faf533ff81d3ae6d0cfee2a08b5edca98de02a38f05ed0c471f2eea",
+ "order-page-3.png": "20953665812775ad39f9c1b02b80f1d083a5ac24d4e98150e091bf59cee01881",
+ "order-page-4.png": "9c7b49a79e37676a917621f4e603dcd1f1bd961ee1c2743b361a910ce4632e33"
+ },
+ "matches_independent_reference": true,
+ "visible_previews_match_download": true
+ },
+ {
+ "name": "empty-after-long",
+ "pages": 1,
+ "actual_browser_download": true,
+ "filename": "component-order.zip",
+ "href_path": "/files/output/component-order.zip",
+ "zip_sha256": "f011e7c092d956e9f978fa71d9fe16806d115d259f7bda05be2cc7bcf2f842ec",
+ "artifact_hashes": {
+ "order.pdf": "5994d1a6f3a7176c8f4f614221cd60d27028a1fd8d80a372f4510eeeb056b573",
+ "order.styled.html": "7f7576f19793dbb00055d236a6e42a31970d0eee7a6bf20a966aaec80542b934",
+ "order.unstyled.html": "8d287583c01c902d4d58f400e36c7d3437631999aacfc16f146955a1311b130a",
+ "order-page-1.png": "d18340f788498d3f1912c80675562eaa47353e85a6e0a5bb6837dbf0a6ced454"
+ },
+ "matches_independent_reference": true,
+ "visible_previews_match_download": true
+ }
+ ],
+ "stale_previews_excluded": true,
+ "scope": "Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested."
+}
diff --git a/docs/assets/component-notebook-alpha6-r1/order-page-1.png b/docs/assets/component-notebook-alpha6-r1/order-page-1.png
new file mode 100644
index 0000000..0303b90
Binary files /dev/null and b/docs/assets/component-notebook-alpha6-r1/order-page-1.png differ
diff --git a/docs/assets/component-notebook-alpha6-r1/order.pdf b/docs/assets/component-notebook-alpha6-r1/order.pdf
new file mode 100644
index 0000000..2d2bb4b
Binary files /dev/null and b/docs/assets/component-notebook-alpha6-r1/order.pdf differ
diff --git a/docs/assets/component-notebook-alpha6-r1/order.styled.html b/docs/assets/component-notebook-alpha6-r1/order.styled.html
new file mode 100644
index 0000000..42fbeb4
--- /dev/null
+++ b/docs/assets/component-notebook-alpha6-r1/order.styled.html
@@ -0,0 +1,39 @@
+
Order 1048NORTHSTAR / STUDIOReady to ship
The autumn collection
Prepared for Moss & Fern Studio
Order 1048
Order contents · USD| Item | Quantity | Amount |
|---|
| Studio notebooks | 24 | USD 360.00 |
| Cotton stationery sets | 12 | USD 216.00 |
| Archival prints | 8 | USD 480.00 |
Please retain this copy with your shipment.
\ No newline at end of file
diff --git a/docs/assets/component-notebook-alpha6-r1/order.unstyled.html b/docs/assets/component-notebook-alpha6-r1/order.unstyled.html
new file mode 100644
index 0000000..fe4de62
--- /dev/null
+++ b/docs/assets/component-notebook-alpha6-r1/order.unstyled.html
@@ -0,0 +1 @@
+Order 1048NORTHSTAR / STUDIOReady to ship
The autumn collection
Prepared for Moss & Fern Studio
Order 1048
Order contents · USD| Item | Quantity | Amount |
|---|
| Studio notebooks | 24 | USD 360.00 |
| Cotton stationery sets | 12 | USD 216.00 |
| Archival prints | 8 | USD 480.00 |
Please retain this copy with your shipment.
\ No newline at end of file
diff --git a/docs/assets/component-notebook-alpha6-r1/verification.json b/docs/assets/component-notebook-alpha6-r1/verification.json
new file mode 100644
index 0000000..7c02ceb
--- /dev/null
+++ b/docs/assets/component-notebook-alpha6-r1/verification.json
@@ -0,0 +1,67 @@
+{
+ "runtime_version": "2.6.0a6",
+ "notebook_sha256": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997",
+ "cases": [
+ {
+ "name": "default",
+ "pages": 1,
+ "row_count": 3,
+ "total": "1056.00",
+ "pdf_sha256": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6",
+ "accent": "#205b47",
+ "rich_cell_components_checked": true
+ },
+ {
+ "name": "edited",
+ "pages": 1,
+ "row_count": 3,
+ "total": "1176.00",
+ "pdf_sha256": "5823731e0146ab74806acce6bbd0676af5b77bf04566827c9c50523004cdb4c4",
+ "accent": "#763c96",
+ "rich_cell_components_checked": true
+ },
+ {
+ "name": "long",
+ "pages": 4,
+ "row_count": 72,
+ "total": "684.00",
+ "pdf_sha256": "c543c6cd5c6dadc7b4e77fd043a3fa5f2693dfbb0e20d9f844a5e6bb894a4735",
+ "accent": "#205b47",
+ "rich_cell_components_checked": true
+ },
+ {
+ "name": "empty-after-long",
+ "pages": 1,
+ "row_count": 0,
+ "total": "0.00",
+ "pdf_sha256": "5994d1a6f3a7176c8f4f614221cd60d27028a1fd8d80a372f4510eeeb056b573",
+ "accent": "#205b47",
+ "rich_cell_components_checked": true
+ }
+ ],
+ "downloads": {
+ "bootstrap_components.ipynb": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997",
+ "order.pdf": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6",
+ "order-page-1.png": "d84d30060d7593e28dbc9ae16cb0f7c4ef1a013f83ed82cff9df8e9519eae1fa",
+ "order.styled.html": "c53d95a831b1d2fda92da28e6dcc5bf6063a7b1090cf1053930c39ff7b54103f",
+ "order.unstyled.html": "3e072e3dd684f9e7c483859a41051619effa814225a64a1a0e500e4d6be936e6"
+ },
+ "preview": "PDFium rendering of the default output PDF at 110 DPI",
+ "checks": [
+ "Notebook installation and execution",
+ "Independent PDF text and page bounds",
+ "Requested PDF accent colours",
+ "Repeat/direct PDF byte equality",
+ "Static styled and unstyled HTML",
+ "Rich item cell emphasis, order and escaped text in both HTML exports",
+ "Exact ZIP contents",
+ "Long-to-empty rerun excludes old pages"
+ ],
+ "scope": "Executed Jupyter cells and independent PDF/HTML/ZIP checks. Hosted Colab runtime and download UI, exhaustive CSS/browser parity and standards conformance are not evaluated.",
+ "browser_verification": {
+ "file": "browser-verification.json",
+ "sha256": "78aeabb0c3a3cfa720f3b7718cda620cc9f31a20654ed6c6b370fccfa982ae05",
+ "system": "Windows",
+ "scope": "Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested."
+ }
+}
diff --git a/docs/getting-started/component-notebook.md b/docs/getting-started/component-notebook.md
index 3324387..3265362 100644
--- a/docs/getting-started/component-notebook.md
+++ b/docs/getting-started/component-notebook.md
@@ -11,9 +11,9 @@ The notebook contains the complete source, with no authored HTML strings.
Item cells return `Strong(...)` components, so emphasis stays part of the
component tree.
-[Open in Colab](https://colab.research.google.com/github/fullbleed-engine/docs/blob/ee8cfbef90116c4e1ea2db7c0f005dce946a11a8/examples/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button .md-button--primary }
-[Download the notebook](../assets/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button download="bootstrap_components.ipynb" }
-[View the source](https://github.com/fullbleed-engine/docs/blob/ee8cfbef90116c4e1ea2db7c0f005dce946a11a8/examples/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button }
+[Open in Colab](https://colab.research.google.com/github/fullbleed-engine/docs/blob/43aff6dee9692d371fa49221c4bcb99045d12639/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button .md-button--primary }
+[Download the notebook](../assets/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button download="bootstrap_components.ipynb" }
+[View the source](https://github.com/fullbleed-engine/docs/blob/43aff6dee9692d371fa49221c4bcb99045d12639/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button }
!!! warning "Opt-in component alpha"
The notebook installs **Fullbleed 2.6.0a6**. Its APIs under
@@ -22,7 +22,7 @@ component tree.
is included. The [stable Python guide](../guides/python-components.md)
documents the established API.
-## Run it, then make an edit
+## Run in Colab
1. Open the notebook, sign in to Google if prompted, and choose **Runtime > Run all**.
2. Edit the order data in step 2 or the accent and title size in step 3.
@@ -32,20 +32,60 @@ component tree.
Use a standard CPU runtime; this example needs no GPU or paid plan. Colab runs
the code on a hosted machine, applies its [own availability and usage limits](https://research.google.com/colaboratory/faq.html),
and deletes runtime files after the session expires. Download your results before
-leaving. You can also open the notebook in local Jupyter; the same cells display
-a download link there.
+leaving.
+
+## Run in local Jupyter
+
+Download the notebook above. In the folder containing `bootstrap_components.ipynb`,
+create a virtual environment and launch Jupyter with these commands.
+
+**Windows (PowerShell):**
+
+```powershell
+py -m venv .venv
+.\.venv\Scripts\python.exe -m pip install "notebook==7.6.3"
+.\.venv\Scripts\python.exe -m notebook bootstrap_components.ipynb
+```
+
+**macOS or Linux:**
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install "notebook==7.6.3"
+.venv/bin/python -m notebook bootstrap_components.ipynb
+```
+
+In the browser, choose **Run > Run All Cells**. The first cell installs the pinned
+Fullbleed alpha in this environment. When the kernel finishes, review the page
+previews and click **output/component-order.zip** in step 6.
+
+After editing the data or styles, use **Run > Run All Cells** again and download
+the new ZIP. Its individual PDF, HTML and PNG files also remain in the notebook's
+local `output/` folder. This notebook revision normalizes the download URL on
+Windows so the browser saves the ZIP as `component-order.zip`.
## What you will make
-[{ width="480" }](../assets/component-notebook-alpha6/order.pdf)
+[{ width="480" }](../assets/component-notebook-alpha6-r1/order.pdf)
-[Open the sample PDF](../assets/component-notebook-alpha6/order.pdf){ .md-button }
-[Open styled HTML](../assets/component-notebook-alpha6/order.styled.html){ .md-button }
-[Open unstyled HTML](../assets/component-notebook-alpha6/order.unstyled.html){ .md-button }
+[Open the sample PDF](../assets/component-notebook-alpha6-r1/order.pdf){ .md-button }
+[Open styled HTML](../assets/component-notebook-alpha6-r1/order.styled.html){ .md-button }
+[Open unstyled HTML](../assets/component-notebook-alpha6-r1/order.unstyled.html){ .md-button }
The fictional order contains 44 items totaling USD 1,056.00. Python `Decimal`
calculates the total; the example omits tax, shipping and discounts. Try an empty
-item list or add rows to explore pagination.
+item list or add rows to explore pagination. For the 72-row example, append
+this to the order-data cell in step 2 and rerun from there:
+
+```python
+order["items"] = [
+ {"item": f"Edition {n:02d} / art print", "quantity": 1, "unit_price": "9.50"}
+ for n in range(1, 73)
+]
+```
+
+Replace that assignment with `order["items"] = []` to try the empty state.
+The download includes only the current order's pages, even after a longer run.
## See how styling connects
@@ -74,9 +114,15 @@ compare repeat renders; and check both HTML exports and the ZIP's exact contents
that rich item cells preserve their emphasis, order and escaped text.
Old page previews are excluded from a shorter order's download.
-The [verification record](../assets/component-notebook-alpha6/verification.json) includes
-the sample and notebook hashes. These checks do not execute a hosted Colab runtime
-or its download UI, or establish complete CSS, browser or PDF-standard conformance.
+The [verification record](../assets/component-notebook-alpha6-r1/verification.json) includes
+the sample and notebook hashes. A separate
+[Jupyter browser record](../assets/component-notebook-alpha6-r1/browser-verification.json)
+covers all four cases in Windows Chrome with Notebook 7.6.3: editing cells,
+running them, displaying every preview, and clicking the actual ZIP download.
+Every downloaded PDF, HTML file and PNG matches the independently checked output.
+
+These checks do not execute a hosted Colab runtime or its download UI, or establish
+complete CSS, browser or PDF-standard conformance.
[Share a small authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)
if a composition or styling task feels awkward. Use fictional data in public examples.
@@ -84,3 +130,5 @@ if a composition or styling task feels awkward. Use fictional data in public exa
The [archived alpha 3 notebook](https://colab.research.google.com/github/fullbleed-engine/docs/blob/8cf4915321f2faf517ad7e5b0725e83754e227a6/examples/component-notebook/bootstrap_components.ipynb), [sample PDF](../assets/component-notebook/order.pdf) and [archived verification record](../assets/component-notebook/verification.json) retain their original runtime and bytes.
The [alpha 5 notebook guide](https://github.com/fullbleed-engine/docs/blob/9aa0b2d9b26d398df58f55c8dd3b240a2457e96e/docs/getting-started/component-notebook.md) retains its original pinned notebook and sample files.
+
+The [original alpha 6 notebook guide](https://github.com/fullbleed-engine/docs/blob/ba2540e2603787593189c1ae836b1c61702e313d/docs/getting-started/component-notebook.md) retains its original notebook and sample files. This revision uses the same Fullbleed 2.6.0a6 runtime and produces identical documents.
diff --git a/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb b/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb
new file mode 100644
index 0000000..0f75b7a
--- /dev/null
+++ b/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb
@@ -0,0 +1,330 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "welcome",
+ "metadata": {},
+ "source": [
+ "# Make a PDF from Python components\n",
+ "\n",
+ "Edit an order, customize its cards and table, and export a PDF plus styled\n",
+ "and unstyled HTML. You write Python values and components; no HTML strings\n",
+ "are needed. All sample data is fictional.\n",
+ "\n",
+ "**In Colab:** choose **Runtime → Run all**, sign in if prompted, and use a\n",
+ "standard CPU runtime. No GPU or paid plan is needed for this example;\n",
+ "[Colab availability and limits](https://research.google.com/colaboratory/faq.html)\n",
+ "still apply. Runtime files are temporary, so download your results before\n",
+ "leaving. Local Jupyter also works.\n",
+ "\n",
+ "**Experimental:** this notebook pins Fullbleed **2.6.0a6**. The component API\n",
+ "may change and has documented CSS limits. Bootstrap components are static\n",
+ "in both PDF and HTML; no Bootstrap JavaScript is included."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "install-help",
+ "metadata": {},
+ "source": [
+ "## 1. Install the tested alpha\n",
+ "\n",
+ "Run this before importing Fullbleed. If you already imported a different\n",
+ "version in this notebook session, restart its kernel/runtime first.\n",
+ "The wheel supplies the engine and font; Jupyter supplies display tools."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "install-fullbleed",
+ "metadata": {
+ "tags": [
+ "install-fullbleed"
+ ]
+ },
+ "source": [
+ "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a6\""
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "data-help",
+ "metadata": {},
+ "source": [
+ "## 2. Make the order yours\n",
+ "\n",
+ "Change a name, quantity or price, then rerun this cell and the cells below it.\n",
+ "Prices are decimal strings; this example omits tax, shipping and discounts.\n",
+ "Try `items=[]` to see the empty state, or add rows to explore pagination."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "order-data",
+ "metadata": {},
+ "source": [
+ "order = {\n",
+ " \"number\": \"1048\",\n",
+ " \"customer\": \"Moss & Fern Studio\",\n",
+ " \"collection\": \"The autumn collection\",\n",
+ " \"status\": \"Ready to ship\",\n",
+ " \"items\": [\n",
+ " {\"item\": \"Studio notebooks\", \"quantity\": 24, \"unit_price\": \"15.00\"},\n",
+ " {\"item\": \"Cotton stationery sets\", \"quantity\": 12, \"unit_price\": \"18.00\"},\n",
+ " {\"item\": \"Archival prints\", \"quantity\": 8, \"unit_price\": \"60.00\"},\n",
+ " ],\n",
+ "}"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "styles-help",
+ "metadata": {},
+ "source": [
+ "## 3. Choose the accent and type size\n",
+ "\n",
+ "These values feed ordinary Python styles. Later, `.with_parts(...)` styles\n",
+ "a card's own title and body. The custom `dispatch-note` class gets its rule\n",
+ "from `Document(styles=...)`; a class name alone does not create a style."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "style-controls",
+ "metadata": {},
+ "source": [
+ "accent = \"#205b47\"\n",
+ "title_size = 30"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "components-help",
+ "metadata": {},
+ "source": [
+ "## 4. Compose reusable components\n",
+ "\n",
+ "`Panel` supplies reusable card defaults. `Metric` is an ordinary Python\n",
+ "function. `DataTable` builds its structure from rows and column functions.\n",
+ "Bootstrap classes such as `mb-4` come with the components; your custom\n",
+ "classes need their own rules. Text values are escaped automatically.\n",
+ "\n",
+ "A column callback can return a component: `Strong(row[\"item\"])` emphasizes\n",
+ "each item inside its table cell. Return text or compose other components\n",
+ "without constructing an HTML string.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "components",
+ "metadata": {},
+ "source": [
+ "from decimal import Decimal\n",
+ "from fullbleed.ui.experimental import Document, Page, Strong, Theme, mm, pt, style, styled\n",
+ "from fullbleed.ui.experimental import bootstrap as bs\n",
+ "\n",
+ "def money(value):\n",
+ " return f\"USD {value:,.2f}\"\n",
+ "\n",
+ "Panel = styled(bs.Card, border_color=\"#d7e1de\", border_radius=\"8px\")\n",
+ "\n",
+ "def Metric(value, label):\n",
+ " return Panel(\n",
+ " bs.Heading(value, level=2, class_name=\"mb-1\", style=style(color=accent)),\n",
+ " bs.Text(label, class_name=\"text-secondary\"),\n",
+ " )\n",
+ "\n",
+ "def OrderSummary(data):\n",
+ " rows = [dict(row, amount=Decimal(row[\"unit_price\"]) * row[\"quantity\"])\n",
+ " for row in data[\"items\"]]\n",
+ " total = sum((row[\"amount\"] for row in rows), Decimal(\"0.00\"))\n",
+ " units = sum(row[\"quantity\"] for row in rows)\n",
+ " details = bs.DataTable(\n",
+ " rows,\n",
+ " columns=[\n",
+ " bs.Column(\"Item\", lambda row: Strong(row[\"item\"])),\n",
+ " bs.Column(\"Quantity\", lambda row: row[\"quantity\"], align=\"right\"),\n",
+ " bs.Column(\"Amount\", lambda row: money(row[\"amount\"]), align=\"right\"),\n",
+ " ],\n",
+ " striped=True,\n",
+ " caption=\"Order contents · USD\",\n",
+ " ) if rows else bs.Alert(\"No items in this order.\", variant=\"light\")\n",
+ " return Document(\n",
+ " bs.Box(bs.Text(\"NORTHSTAR / STUDIO\", class_name=\"fw-bold\"),\n",
+ " bs.Badge(data[\"status\"], variant=\"light\", class_name=\"ms-3\"),\n",
+ " class_name=\"mb-4\"),\n",
+ " bs.Heading(data[\"collection\"], level=1,\n",
+ " style=style(font_size=pt(title_size), color=accent, line_height=1.1)),\n",
+ " bs.Paragraph(f'Prepared for {data[\"customer\"]}', class_name=\"lead mb-4\"),\n",
+ " bs.Row(\n",
+ " bs.Col(Metric(str(units), \"Items in this order\"), span=5),\n",
+ " bs.Col(Metric(money(total), \"Order total\"), span=7),\n",
+ " gutter=3, class_name=\"mb-4 mx-0\",\n",
+ " ),\n",
+ " Panel(details, title=f'Order {data[\"number\"]}',\n",
+ " footer=\"Made with care. Packed with purpose.\").with_parts(\n",
+ " title=style(color=accent, font_size=pt(16)),\n",
+ " body=style(padding=\"1.25rem\"),\n",
+ " ),\n",
+ " bs.Alert(\"Please retain this copy with your shipment.\",\n",
+ " class_name=\"dispatch-note mt-4\"),\n",
+ " title=f'Order {data[\"number\"]}',\n",
+ " page=Page(size=\"A4\", margin=mm(16)),\n",
+ " theme=Theme(text=style(font_family=\"Inter, Arial, sans-serif\", font_size=pt(10),\n",
+ " color=\"#18382f\", line_height=1.4)),\n",
+ " styles={\".dispatch-note\": style(color=accent, border_color=accent,\n",
+ " background_color=\"#f0f5f2\")},\n",
+ " )\n",
+ "\n",
+ "document = OrderSummary(order)"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "render-help",
+ "metadata": {},
+ "source": [
+ "## 5. Render and export\n",
+ "\n",
+ "The PDF renderer receives the bundled Inter font file explicitly. A CSS\n",
+ "family name does not load a font. Styled HTML keeps the family declaration;\n",
+ "its browser host supplies Inter separately, or uses the Arial/sans-serif fallback.\n",
+ "\n",
+ "`include_styles=False` removes generated and inline styling but keeps classes\n",
+ "for a host application's CSS. Both export modes also work on an individual\n",
+ "component, such as `Metric(\"44\", \"Items\").to_html(include_styles=False)`.\n",
+ "Styled Bootstrap fragments include global Bootstrap rules; use unstyled\n",
+ "fragments when the host page already provides its stylesheet."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "render-export",
+ "metadata": {},
+ "source": [
+ "from importlib.resources import files\n",
+ "from pathlib import Path\n",
+ "from fullbleed.ui.experimental import Renderer\n",
+ "\n",
+ "output = Path(\"output\")\n",
+ "output.mkdir(exist_ok=True)\n",
+ "font = files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n",
+ "renderer = Renderer(font_files=[str(font)],\n",
+ " document_timestamp=\"2026-10-01T00:00:00Z\")\n",
+ "prepared = document.prepare(renderer)\n",
+ "pdf_path = output / \"order.pdf\"\n",
+ "pdf_path.write_bytes(prepared.render_pdf())\n",
+ "(output / \"order.styled.html\").write_text(\n",
+ " document.to_html(include_styles=True), encoding=\"utf-8\", newline=\"\\n\")\n",
+ "(output / \"order.unstyled.html\").write_text(\n",
+ " document.to_html(include_styles=False), encoding=\"utf-8\", newline=\"\\n\")\n",
+ "print(f\"Saved {pdf_path} and both HTML exports.\")"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "code",
+ "id": "preview",
+ "metadata": {},
+ "source": [
+ "from IPython.display import Image, display\n",
+ "\n",
+ "preview_paths = []\n",
+ "for number, png in enumerate(prepared.render_image_pages(dpi=110), start=1):\n",
+ " path = output / f\"order-page-{number}.png\"\n",
+ " path.write_bytes(png)\n",
+ " preview_paths.append(path)\n",
+ " display(Image(data=png, width=650))\n",
+ "print(f\"{len(preview_paths)} page(s). Review every page before using the document.\")"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "download-help",
+ "metadata": {},
+ "source": [
+ "## 6. Download your files\n",
+ "\n",
+ "This ZIP contains the current PDF, both HTML exports, and every current page\n",
+ "preview. In Colab the next cell starts the download. In local Jupyter it\n",
+ "displays a link. The runtime's `output/` folder also contains the individual\n",
+ "files. If you edit the order or styles, rerun from that edit through this cell."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "download",
+ "metadata": {},
+ "source": [
+ "import sys\n",
+ "from zipfile import ZipFile, ZIP_DEFLATED\n",
+ "\n",
+ "bundle_path = output / \"component-order.zip\"\n",
+ "current_files = [pdf_path, output / \"order.styled.html\",\n",
+ " output / \"order.unstyled.html\", *preview_paths]\n",
+ "with ZipFile(bundle_path, \"w\", compression=ZIP_DEFLATED) as bundle:\n",
+ " for path in current_files:\n",
+ " bundle.write(path, arcname=path.name)\n",
+ "\n",
+ "if \"google.colab\" in sys.modules:\n",
+ " from google.colab import files\n",
+ " files.download(str(bundle_path))\n",
+ "else:\n",
+ " from IPython.display import FileLink\n",
+ " display(FileLink(bundle_path.as_posix()))"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "next-steps",
+ "metadata": {},
+ "source": [
+ "## Keep composing\n",
+ "\n",
+ "- [Component guide and catalog](https://docs.fullbleed.dev/guides/bootstrap-components-preview/)\n",
+ " explain class names, named parts, fonts, static states and current limits.\n",
+ "- [Northstar and Common Ground source kits](https://docs.fullbleed.dev/guides/showcase-authoring/)\n",
+ " show a complete invoice and illustrated report in components and HTML/CSS.\n",
+ "- [The authored accessible guide](https://docs.fullbleed.dev/guides/accessible-showcase/)\n",
+ " includes the exact PDF, semantic source and scoped validation receipts.\n",
+ "- [Share an authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n",
+ " if a composition or styling task feels awkward. Include the small component,\n",
+ " expected result and Fullbleed version; use fictional data in public posts.\n",
+ "- [Stable Python guide](https://docs.fullbleed.dev/guides/python-components/)\n",
+ " documents the established API separately from this alpha.\n",
+ "\n",
+ "The notebook and Fullbleed are MIT licensed. This example does not establish\n",
+ "complete Bootstrap CSS, browser, accessibility or PDF conformance."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ },
+ "colab": {
+ "name": "bootstrap_components.ipynb",
+ "provenance": []
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tools/build_component_notebook_samples.py b/tools/build_component_notebook_samples.py
index 71648a1..c4e39d7 100644
--- a/tools/build_component_notebook_samples.py
+++ b/tools/build_component_notebook_samples.py
@@ -9,12 +9,13 @@
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--verification-dir", required=True, type=Path)
-parser.add_argument("--channel", choices=("alpha3", "alpha5", "alpha6"), default="alpha3")
+parser.add_argument("--browser-verification-dir", type=Path, help="Optional actual Jupyter browser check for this exact notebook")
+parser.add_argument("--channel", choices=("alpha3", "alpha5", "alpha6", "alpha6-r1"), default="alpha3")
args = parser.parse_args()
checked = args.verification_dir.resolve()
report = json.loads((checked / "verification.json").read_text(encoding="utf-8"))
folder = "component-notebook" if args.channel == "alpha3" else f"component-notebook-{args.channel}"
-runtime_version = {"alpha3": "2.6.0a3", "alpha5": "2.6.0a5", "alpha6": "2.6.0a6"}[args.channel]
+runtime_version = {"alpha3": "2.6.0a3", "alpha5": "2.6.0a5", "alpha6": "2.6.0a6", "alpha6-r1": "2.6.0a6"}[args.channel]
source = ROOT / "examples" / folder / "bootstrap_components.ipynb"
assert report["ok"] and report["runtime_version"] == runtime_version
assert report["notebook_sha256"] == sha256(source.read_bytes()).hexdigest()
@@ -45,5 +46,20 @@
for row in manifest["cases"]:
row["rich_cell_components_checked"] = True
manifest["checks"].insert(5, "Rich item cell emphasis, order and escaped text in both HTML exports")
+if args.browser_verification_dir:
+ browser_path = args.browser_verification_dir / "verification.json"
+ browser = json.loads(browser_path.read_text(encoding="utf-8"))
+ assert browser["ok"] and browser["notebook_sha256"] == report["notebook_sha256"]
+ assert browser["runtime_version"] == report["runtime_version"]
+ assert [r["name"] for r in browser["cases"]] == [r["name"] for r in report["cases"]]
+ for actual, expected in zip(browser["cases"], report["cases"]):
+ assert actual["actual_browser_download"] and actual["visible_previews_match_download"]
+ assert actual["filename"] == "component-order.zip"
+ assert actual["artifact_hashes"] == expected["artifact_hashes"]
+ assert browser["stale_previews_excluded"]
+ shutil.copyfile(browser_path, assets / "browser-verification.json")
+ manifest["browser_verification"] = {"file": "browser-verification.json",
+ "sha256": sha256(browser_path.read_bytes()).hexdigest(), "system": browser["system"],
+ "scope": browser["scope"]}
(assets / "verification.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n")
print(json.dumps({"ok": True, "downloads": len(manifest["downloads"]), "cases": len(manifest["cases"])}))
diff --git a/tools/check_component_notebook_jupyter.py b/tools/check_component_notebook_jupyter.py
new file mode 100644
index 0000000..e6859f2
--- /dev/null
+++ b/tools/check_component_notebook_jupyter.py
@@ -0,0 +1,219 @@
+"""Edit and run the notebook in a browser, then verify actual ZIP downloads.
+
+Run verify_component_notebook.py first and supply its --out as --reference.
+The reference supplies independently inspected PDF/HTML/PNG bytes. A private
+loopback Jupyter server and a separate headless browser are stopped on exit.
+"""
+from __future__ import annotations
+
+import argparse
+import ast
+import base64
+from copy import deepcopy
+from datetime import datetime, timezone
+from hashlib import sha256
+import importlib.metadata
+import json
+import os
+from pathlib import Path
+import platform
+import secrets
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from urllib.parse import urlsplit
+from zipfile import ZipFile
+
+from playwright.sync_api import expect, sync_playwright
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def write(path, value):
+ path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8", newline="\n")
+
+
+def check_browser(page, context, source, work, out, reference, token):
+ source_cells = {c["id"]: "".join(c["source"]) for c in source["cells"] if c["cell_type"] == "code"}
+ assert len(source_cells) == 7
+ order = ast.literal_eval(ast.parse(source_cells["order-data"]).body[0].value)
+ downloads = []
+ context.on("page", lambda child: child.on("download", lambda item: downloads.append(item)))
+ page.on("download", lambda item: downloads.append(item))
+ reports = []
+ page.get_by_role("menuitem", name="Run", exact=True).wait_for(timeout=60000)
+ expect(page.get_by_role("textbox")).to_have_count(7)
+ for index, name in enumerate(("default", "edited", "long", "empty-after-long")):
+ destination = out / name
+ destination.mkdir()
+ if index:
+ data = deepcopy(order)
+ accent, size = "#205b47", 30
+ if name == "edited":
+ data.update(number="1051", customer="Moss & Fern ", status="Packed")
+ data["items"][0].update(quantity=32, item="Studio & cards")
+ data["items"][1]["item"] = "Personalized note cards"
+ accent, size = "#763c96", 26
+ elif name in {"long", "empty-after-long"}:
+ data["items"] = []
+ data_source = "order = " + repr(data)
+ if name == "long":
+ data_source += "\norder['items'] = [\n dict(item=f'Edition {n:02d} / art print', quantity=1, unit_price='9.50')\n for n in range(1, 73)\n]"
+ for position, text in ((1, data_source), (2, f"accent = {accent!r}\ntitle_size = {size!r}")):
+ editor = page.get_by_role("textbox").nth(position)
+ editor.scroll_into_view_if_needed()
+ editor.fill(text)
+ assert "\n".join(editor.locator("div.cm-line").all_text_contents()) == text
+ page.get_by_role("menuitem", name="Run", exact=True).click()
+ page.get_by_role("menuitem", name="Run All Cells", exact=True).click()
+ page.get_by_text(f"[{(index + 1) * 7}]:", exact=True).wait_for(timeout=180000)
+ expect(page.get_by_role("progressbar", name="Kernel status")).to_have_attribute("aria-valuenow", "100", timeout=30000)
+ link = page.get_by_role("link", name="output/component-order.zip", exact=True)
+ link.wait_for()
+ href_path = urlsplit(link.get_attribute("href")).path
+ assert href_path == "/files/output/component-order.zip", href_path
+ page.get_by_role("button", name="Save and create checkpoint (Ctrl+S)", exact=True).click()
+ downloads.clear()
+ link.click()
+ deadline = time.monotonic() + 15
+ while not downloads and time.monotonic() < deadline:
+ page.wait_for_timeout(100)
+ assert len(downloads) == 1, "Expected a real browser download, including a temporary download tab"
+ download = downloads[0]
+ assert download.suggested_filename == "component-order.zip", download.suggested_filename
+ assert download.failure() is None
+ archive = destination / download.suggested_filename
+ download.save_as(archive)
+ expected = next(row for row in reference["cases"] if row["name"] == name)
+ hashes = {}
+ with ZipFile(archive) as bundle:
+ assert bundle.testzip() is None
+ assert len(bundle.namelist()) == len(set(bundle.namelist()))
+ assert set(bundle.namelist()) == set(expected["zip_files"])
+ for filename in bundle.namelist():
+ content = bundle.read(filename)
+ assert sha256(content).hexdigest() == expected["artifact_hashes"][filename], (name, filename)
+ assert content == (work / "output" / filename).read_bytes(), (name, filename)
+ hashes[filename] = sha256(content).hexdigest()
+ previews = page.get_by_role("feed", name="Cells").get_by_role("img")
+ assert previews.count() == expected["pages"]
+ for number in range(expected["pages"]):
+ preview = previews.nth(number)
+ preview.scroll_into_view_if_needed()
+ image = preview.evaluate("img => ({complete: img.complete, width: img.naturalWidth, src: img.src})")
+ assert image["complete"] and image["width"] > 0
+ assert image["src"].startswith("data:image/png;base64,")
+ assert sha256(base64.b64decode(image["src"].split(",", 1)[1])).hexdigest() == hashes[f"order-page-{number + 1}.png"]
+ preview.screenshot(path=str(destination / f"visible-preview-{number + 1}.png"))
+ link.scroll_into_view_if_needed()
+ page.screenshot(path=str(destination / "notebook-download.png"))
+ snapshot = page.locator("body").aria_snapshot().replace(token, "[redacted]")
+ (destination / "notebook.txt").write_text(snapshot, encoding="utf-8")
+ report = dict(name=name, pages=expected["pages"], actual_browser_download=True,
+ filename=download.suggested_filename, href_path=href_path,
+ zip_sha256=sha256(archive.read_bytes()).hexdigest(), artifact_hashes=hashes,
+ matches_independent_reference=True, visible_previews_match_download=True)
+ write(destination / "verification.json", report)
+ reports.append(report)
+ print(json.dumps(dict(case=name, pages=expected["pages"], browser_download=True)), flush=True)
+ assert (work / "output/order-page-4.png").exists()
+ assert "order-page-2.png" not in reports[-1]["artifact_hashes"]
+ return reports
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--reference", type=Path, required=True)
+ parser.add_argument("--out", type=Path, required=True)
+ parser.add_argument("--browser", choices=("chrome", "chromium"), default="chrome")
+ args = parser.parse_args()
+ out = args.out.resolve()
+ assert not out.exists(), "Use a new output directory to retain earlier evidence"
+ reference = json.loads((args.reference / "verification.json").read_text(encoding="utf-8"))
+ source_path = ROOT / reference["notebook"]
+ assert reference["ok"] and reference["install_cell_executed"]
+ assert reference["notebook"] == "examples/component-notebook-alpha6-r1/bootstrap_components.ipynb"
+ assert sha256(source_path.read_bytes()).hexdigest() == reference["notebook_sha256"]
+ source = json.loads(source_path.read_text(encoding="utf-8"))
+ assert all(not c["outputs"] and c["execution_count"] is None for c in source["cells"] if c["cell_type"] == "code")
+ out.mkdir(parents=True)
+ work = out / "work"
+ work.mkdir()
+ shutil.copyfile(source_path, work / source_path.name)
+ environment = {k: v for k, v in os.environ.items() if not k.startswith(("JUPYTER_", "IPYTHON"))}
+ token = secrets.token_urlsafe(32)
+ environment.update(JUPYTER_TOKEN=token, PYTHONUTF8="1")
+ with tempfile.TemporaryDirectory(prefix="fullbleed-notebook-browser-") as private:
+ private = Path(private)
+ for key, name in (("JUPYTER_CONFIG_DIR", "config"), ("JUPYTER_DATA_DIR", "data"),
+ ("JUPYTER_RUNTIME_DIR", "runtime"), ("IPYTHONDIR", "ipython")):
+ (private / name).mkdir()
+ environment[key] = str(private / name)
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ base = f"http://127.0.0.1:{port}"
+ with (private / "server.log").open("w", encoding="utf-8") as log:
+ server = subprocess.Popen([sys.executable, "-m", "notebook", "--no-browser", "--ServerApp.ip=127.0.0.1",
+ f"--ServerApp.port={port}", "--ServerApp.port_retries=0", f"--ServerApp.root_dir={work}",
+ "--ServerApp.shutdown_no_activity_timeout=600"], cwd=work, env=environment,
+ stdout=log, stderr=subprocess.STDOUT,
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
+ try:
+ deadline = time.monotonic() + 60
+ while True:
+ assert server.poll() is None, "The private Jupyter server exited"
+ try:
+ request = urllib.request.Request(base + "/api/status", headers={"Authorization": "token " + token})
+ with urllib.request.urlopen(request, timeout=2) as response:
+ assert response.status == 200
+ break
+ except (urllib.error.URLError, TimeoutError):
+ if time.monotonic() >= deadline:
+ raise RuntimeError("Private Jupyter server did not become ready") from None
+ time.sleep(0.2)
+ with sync_playwright() as p:
+ options = {"channel": "chrome"} if args.browser == "chrome" else {}
+ browser = p.chromium.launch(**options)
+ try:
+ context = browser.new_context(viewport={"width": 1440, "height": 1000}, accept_downloads=True)
+ page = context.new_page()
+ page.goto(base + "/tree?token=" + token, wait_until="domcontentloaded")
+ page.goto(base + "/notebooks/" + source_path.name, wait_until="domcontentloaded")
+ cases = check_browser(page, context, source, work, out, reference, token)
+ browser_version = browser.version
+ finally:
+ browser.close()
+ finally:
+ if server.poll() is None:
+ try:
+ request = urllib.request.Request(base + "/api/shutdown", data=b"", method="POST",
+ headers={"Authorization": "token " + token})
+ with urllib.request.urlopen(request, timeout=10):
+ pass
+ server.wait(timeout=30)
+ except (urllib.error.URLError, TimeoutError, subprocess.TimeoutExpired):
+ server.terminate()
+ server.wait(timeout=15)
+ write(out / "server-stopped.json", dict(stopped=server.poll() is not None, returncode=server.returncode))
+ log.flush()
+ sanitized = (private / "server.log").read_text(encoding="utf-8", errors="replace").replace(token, "[redacted]")
+ (out / "server.log").write_text(sanitized, encoding="utf-8")
+ report = dict(ok=True, checked_at=datetime.now(timezone.utc).isoformat(),
+ notebook_sha256=reference["notebook_sha256"], runtime_version=importlib.metadata.version("fullbleed"),
+ system=platform.system(), python=platform.python_version(), browser=browser_version,
+ tools={name: importlib.metadata.version(name) for name in ("notebook", "jupyter_server", "ipykernel", "playwright")},
+ cases=cases, stale_previews_excluded=True,
+ scope="Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested.")
+ assert report["runtime_version"] == reference["runtime_version"]
+ write(out / "verification.json", report)
+ print(json.dumps(dict(ok=True, cases=len(cases), pages=sum(c["pages"] for c in cases))))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/check_component_notebook_ui.py b/tools/check_component_notebook_ui.py
index c9abba2..5ae19a7 100644
--- a/tools/check_component_notebook_ui.py
+++ b/tools/check_component_notebook_ui.py
@@ -20,7 +20,7 @@ def main():
args = parser.parse_args()
root = args.site.resolve()
args.out.mkdir(parents=True, exist_ok=True)
- folder = "component-notebook-alpha6"
+ folder = "component-notebook-alpha6-r1"
assets = root / "assets" / folder
manifest = json.loads((assets / "verification.json").read_text(encoding="utf-8"))
@@ -53,7 +53,8 @@ def log_message(self, *a):
assert page.get_by_role("heading", name="Try Python components in a notebook", exact=False).is_visible()
article = page.locator("article").inner_text()
for value in ("Fullbleed 2.6.0a6", "fullbleed.ui.experimental", "no Bootstrap JavaScript",
- "class_name", "Document(styles=...)", "Strong", "72-row", "four pages"):
+ "class_name", "Document(styles=...)", "Strong", "72-row", "four pages",
+ "Run in local Jupyter", "Run All Cells", "component-order.zip"):
assert value in article, value
colab = page.get_by_role("link", name="Open in Colab", exact=True).get_attribute("href")
source = page.get_by_role("link", name="View the source", exact=True).get_attribute("href")
@@ -83,11 +84,14 @@ def log_message(self, *a):
page.screenshot(path=str(args.out / f"{label}-preview.png"), animations="disabled")
downloads = []
for filename, title in (("order.pdf", "Open the sample PDF"), ("order.styled.html", "Open styled HTML"),
- ("order.unstyled.html", "Open unstyled HTML"), ("verification.json", "verification record")):
+ ("order.unstyled.html", "Open unstyled HTML"), ("verification.json", "verification record"),
+ ("browser-verification.json", "Jupyter browser record")):
link = page.get_by_role("link", name=title, exact=True)
response = context.request.get(link.evaluate("a => a.href"))
assert response.status == 200, filename
assert response.body() == (assets / filename).read_bytes(), filename
+ if filename == "browser-verification.json":
+ assert sha256(response.body()).hexdigest() == manifest["browser_verification"]["sha256"]
downloads.append({"file": filename, "sha256": sha256(response.body()).hexdigest()})
for filename in ("order.styled.html", "order.unstyled.html"):
response = page.goto(base + "/assets/" + folder + "/" + filename, wait_until="networkidle")
diff --git a/tools/component-notebook-browser-requirements.txt b/tools/component-notebook-browser-requirements.txt
new file mode 100644
index 0000000..29910ec
--- /dev/null
+++ b/tools/component-notebook-browser-requirements.txt
@@ -0,0 +1,3 @@
+-r component-notebook-requirements.txt
+notebook==7.6.3
+playwright==1.49.1
diff --git a/tools/verify_component_notebook.py b/tools/verify_component_notebook.py
index e2ec547..1fb0fac 100644
--- a/tools/verify_component_notebook.py
+++ b/tools/verify_component_notebook.py
@@ -28,6 +28,7 @@
"alpha3": ("2.6.0a3", "component-notebook"),
"alpha5": ("2.6.0a5", "component-notebook-alpha5"),
"alpha6": ("2.6.0a6", "component-notebook-alpha6"),
+ "alpha6-r1": ("2.6.0a6", "component-notebook-alpha6-r1"),
}
@@ -235,7 +236,7 @@ def main():
edited.update(number="1051", customer="Moss & Fern ", status="Packed")
edited["items"][0]["quantity"] = 32
edited["items"][1]["item"] = "Personalized note cards"
- if args.channel in {"alpha5", "alpha6"}:
+ if args.channel in {"alpha5", "alpha6", "alpha6-r1"}:
edited["items"][0]["item"] = "Studio & cards"
long = deepcopy(default)
long["items"] = [{"item": f"Edition {n:02d} / art print", "quantity": 1, "unit_price": "9.50"}
@@ -266,7 +267,7 @@ def main():
destination = out / name
destination.mkdir()
execute(notebook, work, destination, kernel_dir)
- report = verify(work, destination, name, order, accent, version, args.channel in {"alpha5", "alpha6"})
+ report = verify(work, destination, name, order, accent, version, args.channel in {"alpha5", "alpha6", "alpha6-r1"})
reports.append(report)
print(json.dumps({"case": name, "pages": report["pages"], "ok": True}), flush=True)
assert len({r["pdf_sha256"] for r in reports}) == len(reports)