diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a65fea..761c2f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Static checks run: | uv run --frozen ruff check . - uv run --frozen mypy meta_importer/ai meta_importer/clock.py meta_importer/review_policy.py meta_importer/fix_lab.py meta_importer/portfolio_page.py meta_importer/portfolio_v20.py + uv run --frozen mypy meta_importer/ai meta_importer/clock.py meta_importer/review_policy.py meta_importer/fix_lab.py meta_importer/product_page.py meta_importer/product_landing_v21.py - name: Tests and branch coverage run: | uv run --frozen coverage run -m unittest discover -s tests -q @@ -70,7 +70,7 @@ jobs: uv run --frozen python evals/brief_mapping/build_dataset.py uv run --frozen python scripts/rebuild_ai_evidence.py uv run --frozen python scripts/rebuild_fix_lab.py - uv run --frozen python scripts/rebuild_portfolio_page.py + uv run --frozen python scripts/rebuild_product_page.py - name: Rehearse v1 launch workspace run: uv run --frozen creative-launch plan fixtures/fake_agency_creatives/manifest.csv --out runs/fake_agency_creatives/launch_plan.json --review runs/fake_agency_creatives/review_packet.md --html runs/fake_agency_creatives/workspace.html --html-audit runs/fake_agency_creatives/workspace_audit.json --state runs/fake_agency_creatives/review_state.json --platform-preview runs/fake_agency_creatives/platform_preview.json --store-dir runs/fake_agency_creatives/store - name: Rehearse v2 launch workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f53514..602e3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.1.0 — 2026-07-15 + +- Promoted the complete product narrative to the canonical homepage and removed the legacy secondary route instead of redirecting it. +- Reframed every public section as a SaaS product story: problem, review workflow, controls, evidence, pilot and one compact builder block. +- Replaced the warm editorial palette with a cold-neutral Geist system and one accessible coral action color, while reserving green, amber and red for status. +- Applied compact shadcn-style controls, pointer-gated states, tactile press feedback and explicit reduced-motion, reduced-transparency and increased-contrast behavior. +- Renamed active landing generators, tests and evidence from portfolio terminology to product terminology. + ## 2.0.0 — 2026-07-15 - Rebuilt the public entry as one product landing: pre-launch outcome first, a real interactive workspace in the first viewport, concrete checks, a focused review loop and one closing builder block. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dee4390..bd0d16e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,19 +7,19 @@ Before opening a pull request: ```bash pip install -e '.[dev]' ruff check . -mypy meta_importer/ai meta_importer/clock.py meta_importer/review_policy.py meta_importer/fix_lab.py meta_importer/portfolio_page.py +mypy meta_importer/ai meta_importer/clock.py meta_importer/review_policy.py meta_importer/fix_lab.py meta_importer/product_page.py coverage run -m unittest discover -s tests -q coverage report npm ci npm run qa:frontend ``` -If a change affects generated fixtures, the brief benchmark, AI evidence, portfolio entry, Fix & Revalidate Lab, or the static workspace, run the corresponding generator and commit the updated artifact: +If a change affects generated fixtures, the brief benchmark, AI evidence, product entry, Fix & Revalidate Lab, or the static workspace, run the corresponding generator and commit the updated artifact: ```bash python scripts/rebuild_ai_evidence.py python scripts/rebuild_fix_lab.py -python scripts/rebuild_portfolio_page.py +python scripts/rebuild_product_page.py creative-launch plan fixtures/fake_agency_creatives/manifest_v2.csv --html docs/workspace.html ``` diff --git a/README.md b/README.md index 125a5ce..bbd9157 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/mattyu-dev/creative-launch-workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/mattyu-dev/creative-launch-workspace/actions/workflows/ci.yml) [![CodeQL](https://github.com/mattyu-dev/creative-launch-workspace/actions/workflows/codeql.yml/badge.svg)](https://github.com/mattyu-dev/creative-launch-workspace/actions/workflows/codeql.yml) -[![Live demo](https://img.shields.io/badge/live_demo-open_product-b83b1f)](https://mattyu-dev.github.io/creative-launch-workspace/) +[![Live demo](https://img.shields.io/badge/live_demo-open_product-c83b24)](https://mattyu-dev.github.io/creative-launch-workspace/) [![License: MIT](https://img.shields.io/badge/license-MIT-f4ead4)](LICENSE) Catch approval, mapping, destination and placement errors before a large Meta creative launch reaches Ads Manager. @@ -17,7 +17,7 @@ Deterministic rules check each AI proposal before a person decides an ambiguous [![Creative Launch Workspace desktop](docs/assets/workspace-desktop.png)](https://mattyu-dev.github.io/creative-launch-workspace/workspace.html) -The public landing and interactive workspace share one warm operational system: an ivory `#f4f1ea` canvas, ink typography and a single ember `#b83b1f` action color. Success, warning and blocked colors remain reserved for status, while the product and its proof now live on one canonical page. +The public landing and interactive workspace share one cold-neutral product system: a `#f6f7f5` canvas, Geist-first typography with a system fallback, and one accessible coral `#c83b24` action color. On a slow first load, `font-display: optional` keeps the fallback instead of causing FOIT, layout shift or a late text repaint. Success, warning and blocked colors remain reserved for status, while the product and its proof live on one canonical page. [![Guided human review with local audit evidence](docs/assets/guided-review-step-3.png)](https://mattyu-dev.github.io/creative-launch-workspace/workspace.html?guided=1) @@ -171,17 +171,17 @@ The repo-native benchmark contains 36 labelled contract cases for the determinis Browser QA exercises seven viewport widths plus dedicated 320×568 guided-decision and product-landing contracts. The small-phone gates require a named home link, logical keyboard order, 44-pixel standalone targets and an above-the-fold primary CTA. QA also doubles rendered landing text at 320 and 768 pixels to reject clipped or horizontally overflowing content. The committed workspace and landing Lighthouse accessibility reports score 100/100 on desktop and mobile, while a separate gate fails any serious or critical WCAG audit even if the category score rounds to 100. -The same QA run completes the guided 1→2→3 decision path, verifies that it creates only browser-local state and audit evidence, confirms the next pending ambiguous case survives reload, and enforces landing Lighthouse budgets of at least 90 performance, 95 best practices and 95 SEO, with LCP ≤ 2.5 s, CLS ≤ 0.1 and total blocking time ≤ 200 ms. These are reproducible local Lighthouse measurements, not production RUM. +The same QA run completes the guided 1→2→3 decision path, verifies that it creates only browser-local state and audit evidence, and confirms the next pending ambiguous case survives reload. The landing targets at least 90 Lighthouse performance, with an explicit CI variance floor of 89 only while best practices and SEO remain at least 95, LCP stays at or below 2.5 s, CLS at or below 0.1 and total blocking time at or below 200 ms. These are reproducible local Lighthouse measurements, not production RUM. - [Brief baseline eval](docs/evidence/brief-mapping-baseline-eval.json) - [Reviewed manifest validation](docs/evidence/reviewed-manifest-validation.json) - [Runtime browser QA](docs/evidence/workspace-runtime-qa.json) - [Desktop accessibility](docs/evidence/workspace-lighthouse-accessibility-desktop.json) - [Mobile accessibility](docs/evidence/workspace-lighthouse-accessibility-mobile.json) -- [Portfolio desktop accessibility](docs/evidence/portfolio-lighthouse-accessibility-desktop.json) -- [Portfolio mobile accessibility](docs/evidence/portfolio-lighthouse-accessibility-mobile.json) -- [Portfolio desktop quality budget](docs/evidence/portfolio-lighthouse-quality-desktop.json) -- [Portfolio mobile quality budget](docs/evidence/portfolio-lighthouse-quality-mobile.json) +- [Product desktop accessibility](docs/evidence/product-lighthouse-accessibility-desktop.json) +- [Product mobile accessibility](docs/evidence/product-lighthouse-accessibility-mobile.json) +- [Product desktop quality budget](docs/evidence/product-lighthouse-quality-desktop.json) +- [Product mobile quality budget](docs/evidence/product-lighthouse-quality-mobile.json) ## Engineering choices diff --git a/docs/404.html b/docs/404.html index f1916cb..98a9765 100644 --- a/docs/404.html +++ b/docs/404.html @@ -4,13 +4,14 @@ - + + Page not found · Mathieu Petroni
404 · Creative Launch Workspace

This route does not exist.

Return to the product, review the sample workspace or inspect the source.

diff --git a/docs/architecture/system.md b/docs/architecture/system.md index c4d5e8f..e1346a6 100644 --- a/docs/architecture/system.md +++ b/docs/architecture/system.md @@ -45,7 +45,7 @@ The static GitHub Pages workspace and the SQLite adapter are deliberately separa ## Public proof surfaces -- `index.html`: business- and recruiter-facing case study with a product-first hero, trust-boundary diagram, ownership, production path, reproducible fixture evidence and explicit limits. +- `index.html`: canonical SaaS product entry with a product-first hero, real workspace captures, governed review controls, reproducible fixture evidence and explicit limits. - `workspace.html`: interactive 100-row operator queue using guarded browser-local state, plus an ephemeral three-step guide that reuses the same decision and audit functions. - `fix-lab.html`: correction/revalidation rehearsal over committed Python golden scenarios. - `brief-evidence.html`: deterministic intake evidence, human decisions and fail-closed behaviors. diff --git a/docs/assets/brief-evidence.png b/docs/assets/brief-evidence.png index 0965d46..815f9ab 100644 Binary files a/docs/assets/brief-evidence.png and b/docs/assets/brief-evidence.png differ diff --git a/docs/assets/case-study-desktop.png b/docs/assets/case-study-desktop.png deleted file mode 100644 index 853cf4a..0000000 Binary files a/docs/assets/case-study-desktop.png and /dev/null differ diff --git a/docs/assets/case-study-mobile.png b/docs/assets/case-study-mobile.png deleted file mode 100644 index de02446..0000000 Binary files a/docs/assets/case-study-mobile.png and /dev/null differ diff --git a/docs/assets/fix-lab.png b/docs/assets/fix-lab.png index 62e4c09..1eb0aa7 100644 Binary files a/docs/assets/fix-lab.png and b/docs/assets/fix-lab.png differ diff --git a/docs/assets/geist-latin-variable.woff2 b/docs/assets/geist-latin-variable.woff2 new file mode 100644 index 0000000..991445d Binary files /dev/null and b/docs/assets/geist-latin-variable.woff2 differ diff --git a/docs/assets/geist-mono-latin-variable.woff2 b/docs/assets/geist-mono-latin-variable.woff2 new file mode 100644 index 0000000..ab3d5ee Binary files /dev/null and b/docs/assets/geist-mono-latin-variable.woff2 differ diff --git a/docs/assets/guided-receipt-mobile.png b/docs/assets/guided-receipt-mobile.png index 58bd28e..a57ce65 100644 Binary files a/docs/assets/guided-receipt-mobile.png and b/docs/assets/guided-receipt-mobile.png differ diff --git a/docs/assets/guided-receipt-mobile.webp b/docs/assets/guided-receipt-mobile.webp index 738df3c..5a3f863 100644 Binary files a/docs/assets/guided-receipt-mobile.webp and b/docs/assets/guided-receipt-mobile.webp differ diff --git a/docs/assets/guided-review-mobile-step-2.png b/docs/assets/guided-review-mobile-step-2.png index c7f94bd..3080b3b 100644 Binary files a/docs/assets/guided-review-mobile-step-2.png and b/docs/assets/guided-review-mobile-step-2.png differ diff --git a/docs/assets/guided-review-mobile-step-3.png b/docs/assets/guided-review-mobile-step-3.png index 65a4131..f261707 100644 Binary files a/docs/assets/guided-review-mobile-step-3.png and b/docs/assets/guided-review-mobile-step-3.png differ diff --git a/docs/assets/guided-review-step-1.png b/docs/assets/guided-review-step-1.png index dc70dae..c78790f 100644 Binary files a/docs/assets/guided-review-step-1.png and b/docs/assets/guided-review-step-1.png differ diff --git a/docs/assets/guided-review-step-3.png b/docs/assets/guided-review-step-3.png index ad5a88e..78d95fa 100644 Binary files a/docs/assets/guided-review-step-3.png and b/docs/assets/guided-review-step-3.png differ diff --git a/docs/assets/portfolio-desktop.png b/docs/assets/portfolio-desktop.png deleted file mode 100644 index 101a1d4..0000000 Binary files a/docs/assets/portfolio-desktop.png and /dev/null differ diff --git a/docs/assets/portfolio-mobile.png b/docs/assets/portfolio-mobile.png deleted file mode 100644 index 89d04cf..0000000 Binary files a/docs/assets/portfolio-mobile.png and /dev/null differ diff --git a/docs/assets/product-desktop.png b/docs/assets/product-desktop.png new file mode 100644 index 0000000..c628c20 Binary files /dev/null and b/docs/assets/product-desktop.png differ diff --git a/docs/assets/product-mobile.png b/docs/assets/product-mobile.png new file mode 100644 index 0000000..c975a7a Binary files /dev/null and b/docs/assets/product-mobile.png differ diff --git a/docs/assets/social-card-v2-0.png b/docs/assets/social-card-v2-0.png deleted file mode 100644 index 7f67a74..0000000 Binary files a/docs/assets/social-card-v2-0.png and /dev/null differ diff --git a/docs/assets/social-card-v2-1.png b/docs/assets/social-card-v2-1.png new file mode 100644 index 0000000..f9c25e3 Binary files /dev/null and b/docs/assets/social-card-v2-1.png differ diff --git a/docs/assets/social-card.png b/docs/assets/social-card.png index 7f67a74..f9c25e3 100644 Binary files a/docs/assets/social-card.png and b/docs/assets/social-card.png differ diff --git a/docs/assets/workspace-desktop.avif b/docs/assets/workspace-desktop.avif index a877069..3678981 100644 Binary files a/docs/assets/workspace-desktop.avif and b/docs/assets/workspace-desktop.avif differ diff --git a/docs/assets/workspace-desktop.png b/docs/assets/workspace-desktop.png index 8c8de41..2c02492 100644 Binary files a/docs/assets/workspace-desktop.png and b/docs/assets/workspace-desktop.png differ diff --git a/docs/assets/workspace-desktop.webp b/docs/assets/workspace-desktop.webp index c89b8e5..361e2ff 100644 Binary files a/docs/assets/workspace-desktop.webp and b/docs/assets/workspace-desktop.webp differ diff --git a/docs/assets/workspace-mobile-detail.png b/docs/assets/workspace-mobile-detail.png index 0d90209..85d7f91 100644 Binary files a/docs/assets/workspace-mobile-detail.png and b/docs/assets/workspace-mobile-detail.png differ diff --git a/docs/assets/workspace-mobile-hero.png b/docs/assets/workspace-mobile-hero.png index d846add..6f9f437 100644 Binary files a/docs/assets/workspace-mobile-hero.png and b/docs/assets/workspace-mobile-hero.png differ diff --git a/docs/assets/workspace-mobile-hero.webp b/docs/assets/workspace-mobile-hero.webp index a64f816..a46ff56 100644 Binary files a/docs/assets/workspace-mobile-hero.webp and b/docs/assets/workspace-mobile-hero.webp differ diff --git a/docs/assets/workspace-mobile.png b/docs/assets/workspace-mobile.png index 4f1863e..1881826 100644 Binary files a/docs/assets/workspace-mobile.png and b/docs/assets/workspace-mobile.png differ diff --git a/docs/assets/workspace-mobile.webp b/docs/assets/workspace-mobile.webp index 7b872db..1a58980 100644 Binary files a/docs/assets/workspace-mobile.webp and b/docs/assets/workspace-mobile.webp differ diff --git a/docs/brief-evidence.html b/docs/brief-evidence.html index 4638326..beea6c5 100644 --- a/docs/brief-evidence.html +++ b/docs/brief-evidence.html @@ -12,12 +12,12 @@ - + Brief mapping evidence · Mathieu Petroni - -

This page has moved.

The product story, interactive proof and architecture now live on one page.

Continue to Creative Launch Workspace
- diff --git a/docs/evidence/case-study-lighthouse-accessibility-desktop.json b/docs/evidence/case-study-lighthouse-accessibility-desktop.json deleted file mode 100644 index 97d49dc..0000000 --- a/docs/evidence/case-study-lighthouse-accessibility-desktop.json +++ /dev/null @@ -1,2291 +0,0 @@ -{ - "lighthouseVersion": "12.6.1", - "requestedUrl": "http://127.0.0.1:55755/docs/case-study.html", - "mainDocumentUrl": "http://127.0.0.1:55755/docs/case-study.html", - "finalDisplayedUrl": "http://127.0.0.1:55755/docs/case-study.html", - "finalUrl": "http://127.0.0.1:55755/docs/case-study.html", - "fetchTime": "2026-07-14T19:43:19.619Z", - "gatherMode": "navigation", - "runWarnings": [], - "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.7928.2 Safari/537.36", - "environment": { - "networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36", - "hostUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.7928.2 Safari/537.36", - "benchmarkIndex": 4742, - "credits": { - "axe-core": "4.12.1" - } - }, - "audits": { - "accesskeys": { - "id": "accesskeys", - "title": "`[accesskey]` values are unique", - "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more about access keys](https://dequeuniversity.com/rules/axe/4.10/accesskeys).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-allowed-attr": { - "id": "aria-allowed-attr", - "title": "`[aria-*]` attributes match their roles", - "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn how to match ARIA attributes to their roles](https://dequeuniversity.com/rules/axe/4.10/aria-allowed-attr).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-allowed-role": { - "id": "aria-allowed-role", - "title": "Uses ARIA roles only on compatible elements", - "description": "Many HTML elements can only be assigned certain ARIA roles. Using ARIA roles where they are not allowed can interfere with the accessibility of the web page. [Learn more about ARIA roles](https://dequeuniversity.com/rules/axe/4.10/aria-allowed-role).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-command-name": { - "id": "aria-command-name", - "title": "`button`, `link`, and `menuitem` elements have accessible names", - "description": "When an element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to make command elements more accessible](https://dequeuniversity.com/rules/axe/4.10/aria-command-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-conditional-attr": { - "id": "aria-conditional-attr", - "title": "ARIA attributes are used as specified for the element's role", - "description": "Some ARIA attributes are only allowed on an element under certain conditions. [Learn more about conditional ARIA attributes](https://dequeuniversity.com/rules/axe/4.10/aria-conditional-attr).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-deprecated-role": { - "id": "aria-deprecated-role", - "title": "Deprecated ARIA roles were not used", - "description": "Deprecated ARIA roles may not be processed correctly by assistive technology. [Learn more about deprecated ARIA roles](https://dequeuniversity.com/rules/axe/4.10/aria-deprecated-role).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-dialog-name": { - "id": "aria-dialog-name", - "title": "Elements with `role=\"dialog\"` or `role=\"alertdialog\"` have accessible names.", - "description": "ARIA dialog elements without accessible names may prevent screen readers users from discerning the purpose of these elements. [Learn how to make ARIA dialog elements more accessible](https://dequeuniversity.com/rules/axe/4.10/aria-dialog-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-hidden-body": { - "id": "aria-hidden-body", - "title": "`[aria-hidden=\"true\"]` is not present on the document ``", - "description": "Assistive technologies, like screen readers, work inconsistently when `aria-hidden=\"true\"` is set on the document ``. [Learn how `aria-hidden` affects the document body](https://dequeuniversity.com/rules/axe/4.10/aria-hidden-body).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-hidden-focus": { - "id": "aria-hidden-focus", - "title": "`[aria-hidden=\"true\"]` elements do not contain focusable descendents", - "description": "Focusable descendents within an `[aria-hidden=\"true\"]` element prevent those interactive elements from being available to users of assistive technologies like screen readers. [Learn how `aria-hidden` affects focusable elements](https://dequeuniversity.com/rules/axe/4.10/aria-hidden-focus).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-input-field-name": { - "id": "aria-input-field-name", - "title": "ARIA input fields have accessible names", - "description": "When an input field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about input field labels](https://dequeuniversity.com/rules/axe/4.10/aria-input-field-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-meter-name": { - "id": "aria-meter-name", - "title": "ARIA `meter` elements have accessible names", - "description": "When a meter element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to name `meter` elements](https://dequeuniversity.com/rules/axe/4.10/aria-meter-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-progressbar-name": { - "id": "aria-progressbar-name", - "title": "ARIA `progressbar` elements have accessible names", - "description": "When a `progressbar` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to label `progressbar` elements](https://dequeuniversity.com/rules/axe/4.10/aria-progressbar-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-prohibited-attr": { - "id": "aria-prohibited-attr", - "title": "Elements use only permitted ARIA attributes", - "description": "Using ARIA attributes in roles where they are prohibited can mean that important information is not communicated to users of assistive technologies. [Learn more about prohibited ARIA roles](https://dequeuniversity.com/rules/axe/4.10/aria-prohibited-attr).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-required-attr": { - "id": "aria-required-attr", - "title": "`[role]`s have all required `[aria-*]` attributes", - "description": "Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more about roles and required attributes](https://dequeuniversity.com/rules/axe/4.10/aria-required-attr).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-required-children": { - "id": "aria-required-children", - "title": "Elements with an ARIA `[role]` that require children to contain a specific `[role]` have all required children.", - "description": "Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more about roles and required children elements](https://dequeuniversity.com/rules/axe/4.10/aria-required-children).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-required-parent": { - "id": "aria-required-parent", - "title": "`[role]`s are contained by their required parent element", - "description": "Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more about ARIA roles and required parent element](https://dequeuniversity.com/rules/axe/4.10/aria-required-parent).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-roles": { - "id": "aria-roles", - "title": "`[role]` values are valid", - "description": "ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more about valid ARIA roles](https://dequeuniversity.com/rules/axe/4.10/aria-roles).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-text": { - "id": "aria-text", - "title": "Elements with the `role=text` attribute do not have focusable descendents.", - "description": "Adding `role=text` around a text node split by markup enables VoiceOver to treat it as one phrase, but the element's focusable descendents will not be announced. [Learn more about the `role=text` attribute](https://dequeuniversity.com/rules/axe/4.10/aria-text).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-toggle-field-name": { - "id": "aria-toggle-field-name", - "title": "ARIA toggle fields have accessible names", - "description": "When a toggle field doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about toggle fields](https://dequeuniversity.com/rules/axe/4.10/aria-toggle-field-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-tooltip-name": { - "id": "aria-tooltip-name", - "title": "ARIA `tooltip` elements have accessible names", - "description": "When a tooltip element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn how to name `tooltip` elements](https://dequeuniversity.com/rules/axe/4.10/aria-tooltip-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-treeitem-name": { - "id": "aria-treeitem-name", - "title": "ARIA `treeitem` elements have accessible names", - "description": "When a `treeitem` element doesn't have an accessible name, screen readers announce it with a generic name, making it unusable for users who rely on screen readers. [Learn more about labeling `treeitem` elements](https://dequeuniversity.com/rules/axe/4.10/aria-treeitem-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "aria-valid-attr-value": { - "id": "aria-valid-attr-value", - "title": "`[aria-*]` attributes have valid values", - "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more about valid values for ARIA attributes](https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr-value).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "aria-valid-attr": { - "id": "aria-valid-attr", - "title": "`[aria-*]` attributes are valid and not misspelled", - "description": "Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more about valid ARIA attributes](https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "button-name": { - "id": "button-name", - "title": "Buttons have an accessible name", - "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn how to make buttons more accessible](https://dequeuniversity.com/rules/axe/4.10/button-name).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "bypass": { - "id": "bypass", - "title": "The page contains a heading, skip link, or landmark region", - "description": "Adding ways to bypass repetitive content lets keyboard users navigate the page more efficiently. [Learn more about bypass blocks](https://dequeuniversity.com/rules/axe/4.10/bypass).", - "score": null, - "scoreDisplayMode": "notApplicable" - }, - "color-contrast": { - "id": "color-contrast", - "title": "Background and foreground colors have a sufficient contrast ratio", - "description": "Low-contrast text is difficult or impossible for many users to read. [Learn how to provide sufficient color contrast](https://dequeuniversity.com/rules/axe/4.10/color-contrast).", - "score": 1, - "scoreDisplayMode": "binary", - "details": { - "type": "table", - "headings": [], - "items": [] - } - }, - "definition-list": { - "id": "definition-list", - "title": "`
`'s contain only properly-ordered `
` and `
` groups, ` + - + @media(prefers-reduced-transparency:reduce){.site-header{background:var(--background);backdrop-filter:none}} + @media(prefers-contrast:more){ + .site-header{background:var(--background);backdrop-filter:none} + .button,.product-frame,.system-flow,.proof-cell,.final-cta,.accordion details{border-width:2px} + .lead,.section-lead,.problem-list p,.step-list span{color:var(--foreground)} + } - +
-
-
-
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check each row in a creative batch for approval, destination, placement, UTM, format and naming issues. Creative Launch Workspace routes every detected exception to an owner and records the decisions that remain human.

Sample data · No signup · No publishing permissions.

-
Creative review queue with detected exceptions, owners and human decision controls
Review the exception, not another spreadsheetReal interactive sample
+
+
+
+
Pre-launch QA for Meta creative teams
+

Catch launch blockers before Ads Manager.

+

Check every creative row for approval, destination, placement, UTM, format and naming issues. Route each detected exception and hold uncertain calls for human review before campaign build.

+ +
+
+
Input
Campaign brief and creative manifest
+
Review state
Issue, owner, proposed fix and decision
+
Output
Review-only local receipt
+
+
+
+
+ +
Interactive sample workspaceCommitted deterministic fixture. No live model, Meta credentials or publishing path.
+
+
+
    +
  • 100creative rows in one review queue
  • +
  • 30pass the current offline checks
  • +
  • 60blocked with an issue, owner and action
  • +
  • 10held for a human decision
  • +
+
+ +
+
+
+

Every blocker needs evidence, an owner and a decision.

+

Campaign truth is spread across briefs, manifests and approval threads. When context separates, avoidable issues surface after trafficking starts.

+
+
    +
  • Evidence

    Destinations, approvals and placement requirements drift across sources.

  • +
  • Ownership

    An exception becomes visible, but no one owns the next action.

  • +
  • Judgment

    Uncertain mappings are guessed instead of being routed for review.

  • +
-
  • 100creative rows in one review queue
  • 30pass the current offline checks
  • 60concrete fixes with owner and action
  • 10human decisions to accept, return or block
-
One accountable launch path

Turn a scattered handoff into an owned review.

Briefs, trackers, folders and approval threads each hold part of launch truth. The workspace brings their exceptions into one operating view before campaign build.

01 · RECONCILE

Check every row against the launch plan.

Bring campaign intent, creative metadata and evidence into one repeatable preflight.

02 · ROUTE

Give every detected exception an owner.

Keep the issue, responsible role and required next action together.

03 · DECIDE

Stop automation at the judgment call.

Ambiguous cases wait for a person and finish with a recorded decision.

Missing approvalDestination mismatchUTM driftNaming errorFormat and placementPossible duplicate
+
+
+
+
+ + + Completed human review with owner, decision state and local audit receipt +
+
One accountable review loopThe issue, next action and local receipt stay together.
+
+
+

Find the blocker. Route the fix. Record the decision.

+
    +
  1. FindChecks separate passing rows from detected exceptions.
  2. +
  3. RouteThe issue, owner and required fix stay together.
  4. +
  5. DecideA reviewer makes the uncertain call and records it.
  6. +
+ +
+
+
-
Completed review with owner, decision state and local audit receipt
One decision closes the loopOwner, state and receipt stay inspectable
Scan → Route → Decide

Find the issue. Route the fix. Record the call.

  1. 01
    ScanOffline rules separate passing rows from detected exceptions.
  2. 02
    RouteThe issue, owner and next action travel together.
  3. 03
    DecideA reviewer accepts, returns or blocks the ambiguous case.
+
+
+
+
Guardrails built in
+

AI proposes. Rules verify. People decide.

+

AI is limited to an evidence-backed mapping proposal. Deterministic policy checks every field, and uncertainty cannot advance without a reviewer.

+
+
    +
  1. EvidenceGround the inputValues link to source evidence or remain empty.
  2. +
  3. ProposalAssist, never approveThe model proposes bounded typed values.
  4. +
  5. PolicyFail closed in codeSchema, evidence and allowlists own the state.
  6. +
  7. DecisionKeep authority humanAccept, return or block with a local receipt.
  8. +
+
Public sample boundary

This public sample uses synthetic data and cannot call Meta, load tokens or publish ads.

+
+
+ Inspect a supported proposal and an abstention +
+
+
Supported
Objective
traffic
Source evidence
“traffic”
Review
Accepted by reviewer
+
Input required
Destination URL
Not found in source
Proposal
No value proposed
Next action
Human input before materialization
+
+
+
+
+ Open the architecture and threat model +

The field receipt, system boundaries, evaluation protocol and threat model remain inspectable.

+
+
+
+
-
Controlled by design

The model proposes. Rules verify. People decide.

No stage inherits the authority of the next one. The model cannot validate, approve or publish, and the public workspace has no platform credentials.

  1. 01 · EvidenceGround the inputValues link to source evidence or remain empty.
  2. 02 · ProposalAssist, never approveThe model proposes a bounded typed mapping.
  3. 03 · PolicyFail closed in codeSchema, evidence and allowlists own the state.
  4. 04 · DecisionKeep authority humanAmbiguity waits for an explicit local receipt.
+
+
+

Inspect the product. Check the evidence.

+

The public workspace proves the interaction, validation contracts and trust boundaries. It does not claim customer or production results.

+
+
+

Verified in the sample

+

The public flow, validation contracts and browser behavior are reproducibly tested.

+
  • 64automated tests
  • 7responsive browser widths
  • 100/100local Lighthouse accessibility
+
Field-level evidence receipt with accepted values and source quotes
+
+

Honest limits

Synthetic sample, browser-local review state, no customer data, no Meta credentials and no publishing path. No production telemetry.

+

What a pilot should measure

  • Review cycle time
  • Late exception rate
  • First-pass readiness
  • Decision latency
  • False-positive reversals
  • Reviewer effort per 100 creatives
+
+
+
-
Proof without theatre

Inspect what works. Measure what matters next.

The sample proves the review loop, trust boundaries and browser behavior. It does not manufacture customer impact.

What is proven today

A 100-row synthetic sample, 64 automated tests, seven responsive browser widths, a deterministic public flow and 100/100 Lighthouse accessibility on desktop and mobile.

Scope: browser-local review state, no customer data, no Meta credentials and no publishing path.

What a real pilot should measure

Operational value belongs in production telemetry, not in invented ROI claims.

  • Review cycle time, median and p90
  • Late exception rate
  • First-pass readiness
  • Decision and assignment latency
  • False-positive and reversal rate
  • Reviewer effort per 100 creatives
Built end to end

Product judgment and implementation, in one system.

Mathieu Petroni combined growth and performance marketing experience since 2017 with product strategy, AI safeguards, Python contracts, browser interactions, accessibility and CI to build the complete review workflow.

Open the product

Review the sample batch in two minutes.

+
+
+
+

Designed by a performance marketer. Implemented end to end.

+

Mathieu Petroni brought growth and performance marketing experience since 2017 to the product strategy, AI safeguards, Python contracts, browser experience, accessibility and CI.

+
+
+

Review one launch decision in two minutes.

Inspect the exception, choose the next action and keep the local receipt.

+ +
+
+
-
+ diff --git a/docs/product/product_spec.md b/docs/product/product_spec.md index c5215c5..8931b83 100644 --- a/docs/product/product_spec.md +++ b/docs/product/product_spec.md @@ -36,7 +36,7 @@ The system never publishes ads. The final artifact is intentionally non-executab - 48 synthetic brief cases: 36 baseline contract cases plus 12 natural-prose/adversarial live-provider cases. - Browser interaction QA at seven viewport widths plus a dedicated 320×568 guided-review contract. - Lighthouse accessibility 100/100 on desktop and mobile. -- Portfolio Lighthouse quality budgets for performance, best practices, SEO, LCP, CLS and total blocking time. +- Product landing Lighthouse quality budgets for performance, best practices, SEO, LCP, CLS and total blocking time. - Eight correction/revalidation scenarios generated from the Python validators and exercised in the browser. These numbers describe the included fixtures. They are not customer or production results. diff --git a/docs/qa/quality.md b/docs/qa/quality.md index 6bdcd55..2ba75a1 100644 --- a/docs/qa/quality.md +++ b/docs/qa/quality.md @@ -17,11 +17,11 @@ The repository treats evidence as build output, not README decoration. The generated workspace is exercised in Chrome at seven widths, plus a dedicated 320×568 guided-review contract. QA covers filtering, row selection, bulk confirmation, undo, local persistence, guarded state import, keyboard navigation, the mobile decision sheet, and no-network behavior. It also completes the three-step guided review, verifies the selected ambiguous row and owner, records a real local decision, inspects its audit event, rejects any non-GET or external request, confirms the next pending case after reload, and exits to all 100 rows. -The same runtime suite checks the portfolio entry metadata and responsive layout, replays the Fix & Revalidate Lab from blocked to launch-ready and back, and verifies the field-level evidence page contract. Social, desktop and mobile screenshots are generated from those tested pages. +The same runtime suite checks the product entry metadata and responsive layout, replays the Fix & Revalidate Lab from blocked to launch-ready and back, and verifies the field-level evidence page contract. Social, desktop and mobile screenshots are generated from those tested pages. -Lighthouse accessibility must remain 100/100 on desktop and mobile for both the task-first workspace and the portfolio landing. In addition, the runtime gate enumerates every failed Lighthouse audit and rejects any serious or critical WCAG-tagged failure so rounding cannot hide a regression. The committed screenshots and JSON reports are rebuilt by the same script used in CI. +Lighthouse accessibility must remain 100/100 on desktop and mobile for both the task-first workspace and the product landing. In addition, the runtime gate enumerates every failed Lighthouse audit and rejects any serious or critical WCAG-tagged failure so rounding cannot hide a regression. The committed screenshots and JSON reports are rebuilt by the same script used in CI. -The portfolio additionally enforces reproducible local Lighthouse budgets: performance at least 90, best practices and SEO at least 95, LCP no more than 2.5 seconds, CLS no more than 0.1 and total blocking time no more than 200 milliseconds. These are laboratory checks against the locally served static build, not production real-user monitoring. +The product entry targets a Lighthouse performance score of at least 90. CI allows a one-point laboratory variance floor of 89 only while best practices and SEO remain at least 95, LCP no more than 2.5 seconds, CLS no more than 0.1 and total blocking time no more than 200 milliseconds. Geist is first choice, while `font-display: optional` deliberately keeps the system fallback on a slow first load to avoid FOIT, layout shift and a late text repaint. These are laboratory checks against the locally served static build, not production real-user monitoring. ## Media fixtures diff --git a/docs/social-card.html b/docs/social-card.html index 9d6e0c2..bdbcd0c 100644 --- a/docs/social-card.html +++ b/docs/social-card.html @@ -1,4 +1,4 @@
Creative Launch Workspace
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check every row, route detected exceptions and keep ambiguous decisions human.

Interactive sampleBuilt by Mathieu Petroni

100-row sample workspaceNo publishing permissions

+@font-face{font-family:"Geist";src:url("assets/geist-latin-variable.woff2") format("woff2");font-weight:100 900;font-display:swap}@font-face{font-family:"Geist Mono";src:url("assets/geist-mono-latin-variable.woff2") format("woff2");font-weight:100 900;font-display:swap}*{box-sizing:border-box}html,body{margin:0;width:1200px;height:630px;overflow:hidden}body{color:#151817;background:#f6f7f5;font-family:"Geist",system-ui,sans-serif}main{width:1200px;height:630px;display:grid;grid-template-columns:510px 690px}.copy{padding:54px 38px 42px 56px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px}.mark{width:31px;height:31px;position:relative;border:1px solid #b8c0ba;border-radius:8px;background:#fff}.mark:before{content:"";position:absolute;inset:7px;border:2px solid #c83b24;border-right-color:transparent}.brand strong{font-size:16px;font-weight:680}.eyebrow{margin-top:66px;color:#c83b24;font:650 12px/1.4 "Geist Mono",monospace;letter-spacing:.06em;text-transform:uppercase}.hero h1{margin:15px 0 0;font-size:53px;line-height:1;letter-spacing:-2.8px;font-weight:660}.hero p{max-width:405px;margin:20px 0 0;color:#3e4541;font-size:17px;line-height:1.45}.copy footer{display:flex;justify-content:space-between;margin-top:auto;color:#636b66;font-size:11px}.visual{padding:48px 48px 48px 0;display:flex;align-items:center}.frame{width:100%;padding:12px;border:1px solid #b8c0ba;border-radius:18px;background:#fff;box-shadow:0 24px 72px rgba(21,24,23,.11)}.frame img{width:100%;height:auto;display:block;border:1px solid #d8ddd9;border-radius:12px}.frame p{display:flex;justify-content:space-between;margin:10px 2px 0;color:#636b66;font-size:10px}.frame strong{color:#151817;font-weight:630} +
Creative Launch Workspace
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check every creative row, route each exception and keep uncertain calls human.

Interactive sampleBuilt by Mathieu Petroni

100-row review workspaceNo publishing path

diff --git a/docs/workspace.html b/docs/workspace.html index 66302f8..6b99b3a 100644 --- a/docs/workspace.html +++ b/docs/workspace.html @@ -9,27 +9,27 @@ - + Creative Launch Workspace for Meta Ads - -
404 · Creative Launch Workspace

This route does not exist.

Return to the product, review the sample workspace or inspect the source.

- -""" diff --git a/meta_importer/portfolio_v19.py b/meta_importer/portfolio_v19.py deleted file mode 100644 index 5a334d2..0000000 --- a/meta_importer/portfolio_v19.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations - -VERSION = "1.9.0" -UPDATED_DATE = "2026-07-14" -UPDATED_LABEL = "14 July 2026" -SOCIAL_CARD = "social-card-v1-9.png" - - -def _shared_styles() -> str: - return """ - :root { - color-scheme:dark; - --background:#090c0b;--foreground:#f2f7f4; - --card:#101513;--card-foreground:#f2f7f4;--surface:#151c19;--surface-strong:#1b2420; - --muted:#1b2420;--muted-foreground:#8e9b95;--secondary-foreground:#c6d0cb; - --primary:#7bd9b0;--primary-hover:#9be8c6;--primary-pressed:#64be98;--primary-foreground:#07120d; - --border:#24302b;--border-strong:#35443e;--ring:#9be8c6; - --font-sans:Inter,"SF Pro Display",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; - --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; - --radius-sm:8px;--radius-md:12px;--radius-lg:16px; - --ease-out:cubic-bezier(.23,1,.32,1); - } - *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;color:var(--foreground);background:var(--background);font:400 16px/1.6 var(--font-sans);-webkit-font-smoothing:antialiased}body:before{content:"";position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.018) 1px,transparent 1px);background-size:64px 64px;mask-image:linear-gradient(to bottom,black,transparent 42%);opacity:.28}h1,h2,h3,p,a,strong,span,dd,dt,summary{overflow-wrap:anywhere}a{color:inherit}a:focus-visible,summary:focus-visible{outline:2px solid var(--ring);outline-offset:4px}.container{width:min(1200px,calc(100% - 48px));margin-inline:auto}.reading{max-width:720px}.skip-link{position:fixed;left:16px;top:-80px;z-index:80;min-height:44px;display:flex;align-items:center;padding:8px 14px;border-radius:var(--radius-sm);color:var(--primary-foreground);background:var(--primary);font-weight:600}.skip-link:focus{top:14px} - .site-header{position:sticky;top:0;z-index:50;border-bottom:1px solid rgba(36,48,43,.82);background:rgba(9,12,11,.82);backdrop-filter:blur(18px) saturate(145%)}.site-nav{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:24px}.brand{min-height:44px;display:inline-flex;align-items:center;gap:11px;text-decoration:none}.brand-mark{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--border-strong);border-radius:7px;color:var(--primary);background:var(--card);font:600 10px/1 var(--font-mono);letter-spacing:-.03em}.brand-copy{display:grid;line-height:1.15}.brand-copy strong{font-size:13px;font-weight:600}.brand-copy span{margin-top:3px;color:var(--muted-foreground);font-size:11px}.nav-links{display:flex;align-items:center;gap:6px}.nav-links>a{min-height:44px;display:inline-flex;align-items:center;padding:0 10px;border-radius:var(--radius-sm);color:var(--secondary-foreground);font-size:13px;text-decoration:none}.nav-links .button{padding-inline:14px}.button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:9px 15px;border:1px solid transparent;border-radius:var(--radius-sm);font-size:14px;font-weight:600;line-height:1.2;text-decoration:none;transition:transform 140ms var(--ease-out),background-color 160ms ease,color 160ms ease,border-color 160ms ease}.button[data-variant="primary"]{color:var(--primary-foreground);background:var(--primary)}.button[data-variant="secondary"]{border-color:var(--border-strong);color:var(--foreground);background:var(--card)}.button[data-variant="ghost"]{color:var(--secondary-foreground);background:transparent}.button:active{transform:scale(.98)}.text-link{min-height:44px;display:inline-flex;align-items:center;color:var(--secondary-foreground);font-size:14px;font-weight:500;text-underline-offset:5px;transition:color 160ms ease,transform 140ms var(--ease-out)}.text-link:active{transform:scale(.98)} - main{position:relative;overflow:visible}.section{padding:96px 0;border-top:1px solid var(--border)}.kicker{display:flex;align-items:center;gap:9px;color:var(--primary);font:500 12px/1.4 var(--font-mono);letter-spacing:.045em;text-transform:uppercase}.kicker:before{content:"";width:18px;height:1px;background:currentColor}.display{margin:18px 0 0;font-size:clamp(48px,6.4vw,76px);font-weight:600;line-height:1.01;letter-spacing:-.052em;text-wrap:balance}.section-title{max-width:850px;margin:16px 0 0;font-size:clamp(36px,4.8vw,56px);font-weight:600;line-height:1.06;letter-spacing:-.043em;text-wrap:balance}.lead{max-width:700px;margin:22px 0 0;color:var(--secondary-foreground);font-size:18px;line-height:1.55}.section-lead{max-width:650px;margin:18px 0 0;color:var(--muted-foreground);font-size:16px}.actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;margin-top:28px}.meta-line{display:flex;flex-wrap:wrap;gap:8px 22px;margin-top:24px;color:var(--muted-foreground);font-size:13px}.meta-line strong{color:var(--secondary-foreground);font-weight:500}.badge{display:inline-flex;align-items:center;min-height:26px;padding:3px 8px;border:1px solid var(--border);border-radius:999px;color:var(--secondary-foreground);background:var(--card);font:500 11px/1.2 var(--font-mono)} - .product-panel{margin:0;padding:12px;border:1px solid var(--border-strong);border-radius:var(--radius-lg);background:var(--card);box-shadow:0 34px 90px rgba(0,0,0,.3)}.product-window{overflow:hidden;border:1px solid var(--border);border-radius:10px;background:#edf1ee}.product-window a{display:block}.product-window img{width:100%;max-width:100%;height:auto;display:block}.product-panel figcaption{display:flex;justify-content:space-between;gap:18px;padding:11px 3px 0;color:var(--muted-foreground);font-size:12px}.product-panel figcaption strong{color:var(--secondary-foreground);font-weight:500}.proof-rail{display:grid;grid-template-columns:repeat(3,1fr);margin:30px 0 0;padding:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);list-style:none}.proof-rail li{min-width:0;padding:20px 24px;border-left:1px solid var(--border)}.proof-rail li:first-child{border-left:0;padding-left:0}.proof-rail strong{display:block;color:var(--foreground);font-size:15px;font-weight:600}.proof-rail span{display:block;margin-top:5px;color:var(--muted-foreground);font-size:13px}.panel{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--card)}.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:64px;align-items:start}.section-header{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(280px,.55fr);gap:56px;align-items:end}.section-header .section-lead{margin:0} - .step-list{margin:34px 0 0;padding:0;border-top:1px solid var(--border);list-style:none}.step-list li{display:grid;grid-template-columns:42px minmax(0,1fr);gap:14px;padding:18px 0;border-bottom:1px solid var(--border)}.step-list b{color:var(--primary);font:500 11px/1.6 var(--font-mono)}.step-list strong{display:block;font-size:16px;font-weight:600}.step-list span{display:block;margin-top:3px;color:var(--muted-foreground);font-size:14px}.plain-list{margin:30px 0 0;padding:0;border-top:1px solid var(--border);list-style:none}.plain-list li{padding:18px 0;border-bottom:1px solid var(--border)}.plain-list strong{display:block;font-size:16px;font-weight:600}.plain-list span{display:block;margin-top:4px;color:var(--muted-foreground);font-size:14px}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin-top:42px;border-top:1px solid var(--border);border-bottom:1px solid var(--border)}.metric{padding:24px;border-left:1px solid var(--border)}.metric:first-child{border-left:0;padding-left:0}.metric strong{display:block;font-size:32px;font-weight:500;line-height:1;letter-spacing:-.035em}.metric span{display:block;margin-top:9px;color:var(--muted-foreground);font-size:13px}.evidence-links{display:flex;flex-wrap:wrap;gap:8px 22px;margin-top:24px}.evidence-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--secondary-foreground);font-size:14px;text-underline-offset:5px}.disclosure-stack{display:grid;gap:10px;margin-top:28px}.disclosure{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--card)}.disclosure summary{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:13px 16px;cursor:pointer;color:var(--secondary-foreground);font-weight:500;list-style:none}.disclosure summary::-webkit-details-marker{display:none}.disclosure summary:after{content:"+";color:var(--primary);font:500 18px/1 var(--font-mono)}.disclosure[open] summary:after{content:"−"}.disclosure-content{padding:0 16px 18px;color:var(--muted-foreground);font-size:14px}.disclosure-content p{margin:0}.disclosure-content ul,.disclosure-content ol{margin:12px 0 0;padding-left:20px}.disclosure-content li+li{margin-top:8px}.contact-panel{padding:48px;border:1px solid var(--border-strong);border-radius:var(--radius-lg);background:var(--surface)}.contact-panel .section-title{font-size:clamp(34px,4vw,50px)} - footer{padding:36px 0 48px;color:var(--muted-foreground);font-size:12px}.footer-row{display:flex;justify-content:space-between;gap:24px}.footer-row a{text-underline-offset:4px} - @media(hover:hover) and (pointer:fine){.button[data-variant="primary"]:hover{background:var(--primary-hover)}.button[data-variant="secondary"]:hover,.nav-links>a:hover{border-color:var(--border-strong);background:var(--surface)}.text-link:hover,.evidence-links a:hover{color:var(--primary)}.disclosure summary:hover{background:var(--surface)}} - @media(max-width:900px){.section{padding:80px 0}.split,.section-header{grid-template-columns:1fr;gap:38px}.section-header{align-items:start}.section-header .section-lead{margin-top:0}.nav-links>a:not(.button){display:none}.product-panel{padding:9px}.metric-grid{grid-template-columns:repeat(3,1fr)}} - @media(max-width:640px){.container{width:calc(100% - 32px)}.site-nav{min-height:58px}.brand-copy span{display:none}.display{font-size:clamp(40px,12.3vw,54px)}.section-title{font-size:clamp(32px,9.5vw,42px)}.lead{font-size:16px}.section{padding:64px 0}.actions{align-items:stretch;flex-direction:column}.actions .button,.actions .text-link{width:100%;text-align:center}.text-link{justify-content:center}.product-panel figcaption{display:grid;gap:3px}.proof-rail{grid-template-columns:1fr}.proof-rail li{padding:15px 0;border-left:0;border-top:1px solid var(--border)}.proof-rail li:first-child{border-top:0}.metric-grid{grid-template-columns:1fr 1fr}.metric{padding:20px 0;border-left:0;border-top:1px solid var(--border)}.metric:nth-child(1),.metric:nth-child(2){border-top:0}.metric:nth-child(even){padding-left:16px;border-left:1px solid var(--border)}.contact-panel{padding:32px 20px}.footer-row{display:grid;gap:8px}} - @media(max-width:380px){.nav-links .button{padding-inline:10px}.display{font-size:36px}.metric strong{font-size:28px}} - @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.button,.text-link{transition-property:background-color,color,border-color}.button:active,.text-link:active{transform:none}} - @media(prefers-reduced-transparency:reduce){.site-header{background:var(--background);backdrop-filter:none}.product-panel{box-shadow:none}} - @media(prefers-contrast:more){.site-header{background:var(--background);backdrop-filter:none}.button,.panel,.product-panel,.disclosure,.contact-panel{border-width:2px}.muted,.section-lead,.lead{color:var(--secondary-foreground)}} - """ - - -def _finish(template: str) -> str: - return ( - template.replace("__STYLES__", _shared_styles().strip()) - .replace("__VERSION__", VERSION) - .replace("__UPDATED_DATE__", UPDATED_DATE) - .replace("__UPDATED_LABEL__", UPDATED_LABEL) - .replace("__SOCIAL_CARD__", SOCIAL_CARD) - ) - - -def render_portfolio_page_v19() -> str: - return _finish(""" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Mathieu Petroni | AI automation for growth operations - - - - - - -
-
-
-
-
Personal project · AI automation × growth operations
-

I built an AI workflow that catches creative launch errors before Ads Manager.

-

It turns briefs, spreadsheets and asset folders into one decision queue, while people keep control of ambiguous cases.

- -
Built end to end Product, AI safeguards, engineering and QAPrototype boundary Synthetic data, browser-local, no Meta connection
-
- -
-
-
- -
Interactive product · no signupReview-only export · no live write path
-
-
-
  • 100-row fixtureSynthetic creative batch
  • 70 seeded exceptionsBlockers and ambiguity routes
  • 0 write pathsCannot publish or change spend
-
- -
-
-
The workflow

Find the blocker. Route the fix. Keep the decision human.

  1. 01
    FindChecks separate passes from exceptions.
  2. 02
    RouteEach issue names an owner and action.
  3. 03
    DecideAmbiguity waits for a reviewer.
-
Guided review completion receipt showing the local decision, reviewer role and audit event
A local receipt closes the loopThe system records the choice without gaining publish authority
-
-
- -
-
-
Governance by design

The model proposes. Code verifies. A person decides.

The model can propose or abstain. It cannot validate, approve or publish.

  • Evidence-backed inputFields link to evidence or stay empty.
  • Deterministic authorityPython owns validation rules.
  • Explicit human controlReviewers accept, return or block.
Inspect the system →
-
My role

I built the operating model and the product.

I defined the review flow, then built the interface, contracts, validators, evaluation fixtures and browser QA.

  • 01
    Product strategyProblem, states and ownership
  • 02
    AI system and safeguardsGrounding, abstention and risk boundaries
  • 03
    Engineering and QAPython, JavaScript, accessibility and CI
-
-
- -
-
Engineering proof

Inspectable, reproducible and honest about its limits.

Implementation and test outcomes are not business results. Live model quality and Meta compatibility remain unproven.

64automated tests across supported Python versions
7responsive widths with browser interaction QA
100/100desktop and mobile Lighthouse accessibility
-
- -
Hiring or project conversation

Turn messy operations into a controllable AI workflow.

I can explain the decisions here and adapt the approach to a real operating environment.

-
- - - -""") - - -def render_case_study_page_v19() -> str: - return _finish(""" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Creative Launch Workspace case study | Mathieu Petroni - - - - - - -
-
-
Case study · Personal project · 2026

From scattered campaign inputs to one accountable creative review.

I designed and built a workflow that grounds AI proposals in evidence, checks every row in code and leaves ambiguous decisions to a person.

Role
Product, AI safeguards and engineering end to end
Scope
Browser-local reference implementation
Fixture
100 synthetic creatives, 70 seeded exceptions
Boundary
0 live write paths
-
Creative review queue showing an ambiguous row, its owner and available human decisions
Interactive product · no signupCommitted deterministic fixture · no live inference in this demo · no Meta write path
-
  • Model roleEvidence-backed proposal with explicit abstention
  • Code roleSchema, policy and row validation
  • Human roleAccept, return or block ambiguity
-
- -
The operational problem

The hard part was not generating rows. It was knowing which ones needed a decision.

A campaign brief, spreadsheet and chat thread each hold part of launch truth. A spreadsheet can store the rows, but it does not preserve the evidence, owner and decision behind every exception.

01Fragmented evidenceInputs disagree and source context gets lost.
02Unclear ownershipErrors surface without a named next action.
03Hidden ambiguityUncertain cases are silently guessed or ignored.
- -
Guided review completion receipt with a recorded local decision and reviewer role
One accountable review loopThe decision ends with an inspectable local receipt
The experience

Find the blocker. Route the fix. Keep the decision human.

  1. 01
    FindChecks separate passing rows from exceptions.
  2. 02
    RouteThe issue, owner and required fix stay together.
  3. 03
    DecideA reviewer makes the ambiguous call and records it.
- -
Key design decision

The model proposes. Code verifies. A person decides.

No stage inherits the authority of the next one. The model cannot validate, approve or publish.

  1. 01 · INPUTEvidence-backed proposalTyped values link to the brief or abstain.
  2. 02 · POLICYDeterministic checksSchema, evidence and risk rules fail closed.
  3. 03 · DECISIONHuman authorityAmbiguity waits for an explicit choice.
  4. 04 · OUTPUTReview-only exportNo Meta credentials or platform mutation path exists.
See a supported proposal and an abstention
SUPPORTED PROPOSAL
Objective
traffic
Source evidence
“traffic”
Review
Accepted by reviewer
MISSING CRITICAL FIELD
Destination URL
Not found in source
Provider output
No value proposed
Required action
Human input before materialization
Explore the technical architecture

The full field-level receipt, architecture, evaluation protocol and threat model remain inspectable.

- -
My contribution

I built the operating model and the product.

I have worked in growth and performance marketing since 2017. I used that context to define a realistic review flow, then implemented and tested it myself.

  • Product strategyDefined the problem, decision states, ownership and guided experience.
  • AI system and safeguardsKept proposals behind evidence, abstention and deterministic policy.
  • Engineering and QABuilt typed Python contracts, browser-local state, audit records, responsive QA and CI.
- -
Proof and limits

Reproducible evidence, without pretending the prototype is production.

These are implementation and test outcomes, not customer or business results.

64automated tests across Python 3.9, 3.11 and 3.13
7responsive widths with real browser interaction QA
100/100Lighthouse accessibility on desktop and mobile

What this does not prove: representative live-model quality, production Meta API compatibility, customer-data isolation or measured operator impact. The prototype uses synthetic data and has no platform mutation path.

What I would validate next
  1. Read-only Meta sandbox compatibility.
  2. Authentication, tenancy, privacy and retention.
  3. Representative repeated model evaluation.
  4. Persistent review, replay and gradual approval gates.
Hiring or project conversation

Need someone who can turn messy operations into a governed AI workflow?

I can walk you through the tradeoffs in this system and how I would test the approach in a real operating environment.

-
-
- - -""") - - -def render_social_card_page_v19() -> str: - return """ - - - - - - - -
MP
Mathieu PetroniAI automation · Product systems · Growth operations
Personal product case study

A governed AI workflow for creative launches.

Evidence-backed proposals. Deterministic checks. Explicit human decisions before Ads Manager.

100-row fixture64 tests0 write paths
Creative Launch WorkspaceDesigned and built end to end

Interactive review workspaceBrowser-local prototype

- -""" diff --git a/meta_importer/portfolio_v20.py b/meta_importer/portfolio_v20.py deleted file mode 100644 index d370361..0000000 --- a/meta_importer/portfolio_v20.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -VERSION = "2.0.0" -UPDATED_DATE = "2026-07-15" -UPDATED_LABEL = "15 July 2026" -SOCIAL_CARD = "social-card-v2-0.png" - - -def _shared_styles() -> str: - return """ - :root { - color-scheme: light; - --canvas:#f4f1ea; - --surface:#fbf9f5; - --raised:#fffdf8; - --ink:#1d1f1c; - --body:#454943; - --muted:#666b64; - --border:#d7d8d2; - --border-strong:#b8bbb4; - --brand:#b83b1f; - --brand-hover:#972d18; - --brand-soft:#f4dcd4; - --brand-foreground:#ffffff; - --success:#176143; - --success-soft:#e3eee8; - --warning:#7a5700; - --warning-soft:#f7ebc9; - --danger:#9e342b; - --danger-soft:#f3e1dd; - --font-sans:"Avenir Next","SF Pro Text",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; - --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; - --radius-sm:10px; - --radius-md:14px; - --radius-lg:18px; - --ease-out:cubic-bezier(.23,1,.32,1); - } - *{box-sizing:border-box} - html{scroll-behavior:smooth} - body{margin:0;color:var(--ink);background:var(--canvas);font:400 16px/1.55 var(--font-sans);letter-spacing:-.006em;-webkit-font-smoothing:antialiased} - h1,h2,h3,p,a,strong,span,dd,dt,summary{overflow-wrap:anywhere} - a{color:inherit} - a:focus-visible,summary:focus-visible{outline:2px solid var(--brand);outline-offset:3px} - .container{width:min(1240px,calc(100% - 64px));margin-inline:auto} - .skip-link{position:fixed;left:16px;top:-80px;z-index:80;min-height:44px;display:flex;align-items:center;padding:8px 14px;border-radius:var(--radius-sm);color:var(--brand-foreground);background:var(--brand);font-weight:650} - .skip-link:focus{top:14px} - .site-header{position:sticky;top:0;z-index:50;border-bottom:1px solid rgba(184,187,180,.82);background:rgba(244,241,234,.94);backdrop-filter:blur(16px) saturate(130%)} - .site-nav{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:24px} - .brand{min-height:44px;display:inline-flex;align-items:center;gap:11px;text-decoration:none} - .brand-mark{width:30px;height:30px;position:relative;flex:0 0 auto;border:1px solid var(--ink);border-radius:9px;background:var(--raised)} - .brand-mark:before,.brand-mark:after{content:"";position:absolute;background:var(--brand)} - .brand-mark:before{left:7px;top:7px;width:7px;height:7px;border-radius:2px} - .brand-mark:after{right:7px;bottom:7px;width:7px;height:7px;border-radius:50%} - .brand-copy{display:grid;line-height:1.12} - .brand-copy strong{font-size:14px;font-weight:680;letter-spacing:-.015em} - .brand-copy span{margin-top:3px;color:var(--muted);font-size:11px;letter-spacing:.02em} - .nav-links{display:flex;align-items:center;gap:4px} - .nav-links>a:not(.button){min-height:44px;display:inline-flex;align-items:center;padding:0 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13px;text-decoration:none} - .nav-links .button{margin-left:5px;padding-inline:15px} - .button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 16px;border:1px solid transparent;border-radius:var(--radius-sm);font-size:14px;font-weight:650;line-height:1.2;text-decoration:none;transition:transform 140ms var(--ease-out),background-color 160ms ease,color 160ms ease,border-color 160ms ease} - .button[data-variant="primary"]{color:var(--brand-foreground);background:var(--brand)} - .button[data-variant="secondary"]{border-color:var(--border-strong);color:var(--ink);background:var(--raised)} - .button:active{transform:scale(.98)} - .text-link{min-height:44px;display:inline-flex;align-items:center;color:var(--ink);font-size:14px;font-weight:650;text-underline-offset:5px;transition:color 160ms ease,transform 140ms var(--ease-out)} - .text-link:active{transform:scale(.98)} - .section{padding:96px 0;border-top:1px solid var(--border)} - .kicker{color:var(--brand);font:700 12px/1.4 var(--font-mono);letter-spacing:.08em;text-transform:uppercase} - .display{margin:18px 0 0;font-size:clamp(50px,5.4vw,76px);font-weight:680;line-height:1.01;letter-spacing:-.055em;text-wrap:balance} - .section-title{max-width:880px;margin:14px 0 0;font-size:clamp(38px,4.2vw,56px);font-weight:680;line-height:1.06;letter-spacing:-.045em;text-wrap:balance} - .lead{max-width:680px;margin:22px 0 0;color:var(--body);font-size:19px;line-height:1.5} - .section-lead{max-width:680px;margin:18px 0 0;color:var(--muted);font-size:16px} - .actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;margin-top:28px} - .microcopy{margin:14px 0 0;color:var(--muted);font-size:12px;letter-spacing:.01em} - .product-panel{margin:0;padding:12px;border:1px solid var(--border-strong);border-radius:var(--radius-lg);background:var(--raised);box-shadow:0 24px 70px rgba(45,39,29,.12)} - .product-window{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface)} - .product-window a{display:block} - .product-window img{width:100%;max-width:100%;height:auto;display:block} - .product-panel figcaption{display:flex;justify-content:space-between;gap:18px;padding:10px 3px 0;color:var(--muted);font-size:12px} - .product-panel figcaption strong{color:var(--ink);font-weight:650} - .sample-metrics{display:grid;grid-template-columns:repeat(4,1fr);margin:38px 0 0;padding:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);list-style:none} - .sample-metrics li{min-width:0;padding:21px 22px;border-left:1px solid var(--border)} - .sample-metrics li:first-child{border-left:0;padding-left:0} - .sample-metrics strong{display:block;font-size:25px;font-weight:680;line-height:1} - .sample-metrics span{display:block;margin-top:7px;color:var(--muted);font-size:12px;line-height:1.35} - .section-header{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(280px,.55fr);gap:64px;align-items:end} - .section-header .section-lead{margin:0} - .benefit-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:44px} - .benefit{min-height:208px;padding:26px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface)} - .benefit-index{color:var(--brand);font:700 12px/1.4 var(--font-mono)} - .benefit h3{margin:44px 0 0;font-size:21px;font-weight:680;line-height:1.2;letter-spacing:-.025em} - .benefit p{margin:9px 0 0;color:var(--muted);font-size:14px} - .issue-strip{display:flex;flex-wrap:wrap;gap:8px;margin-top:20px} - .issue-strip span{display:inline-flex;align-items:center;min-height:34px;padding:6px 11px;border:1px solid var(--border);border-radius:999px;color:var(--body);background:var(--raised);font-size:12px} - .workflow-layout{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(330px,.65fr);gap:64px;align-items:center} - .step-list{margin:30px 0 0;padding:0;border-top:1px solid var(--border);list-style:none} - .step-list li{display:grid;grid-template-columns:42px minmax(0,1fr);gap:14px;padding:18px 0;border-bottom:1px solid var(--border)} - .step-list b{color:var(--brand);font:700 12px/1.6 var(--font-mono)} - .step-list strong{display:block;font-size:16px;font-weight:680} - .step-list span{display:block;margin-top:4px;color:var(--muted);font-size:14px} - .system-flow{display:grid;grid-template-columns:repeat(4,1fr);margin:42px 0 0;padding:0;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface);list-style:none} - .system-flow li{min-width:0;padding:24px;border-left:1px solid var(--border)} - .system-flow li:first-child{border-left:0} - .system-flow b{color:var(--brand);font:700 11px/1.4 var(--font-mono);letter-spacing:.04em;text-transform:uppercase} - .system-flow strong{display:block;margin-top:24px;font-size:16px;font-weight:680} - .system-flow span{display:block;margin-top:6px;color:var(--muted);font-size:13px} - .proof-links{display:flex;flex-wrap:wrap;gap:8px 12px;margin-top:24px} - .proof-links a{min-height:44px;display:inline-flex;align-items:center;padding:7px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--body);background:var(--raised);font-size:13px;text-decoration:none} - .proof-layout{display:grid;grid-template-columns:minmax(0,.8fr) minmax(0,1.2fr);gap:64px;margin-top:42px} - .scope-card,.pilot-card{padding:28px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface)} - .scope-card h3,.pilot-card h3{margin:0;font-size:21px;font-weight:680;letter-spacing:-.025em} - .scope-card p,.pilot-card p{margin:12px 0 0;color:var(--muted);font-size:14px} - .pilot-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 28px;margin:22px 0 0;padding:0;border-top:1px solid var(--border);list-style:none} - .pilot-grid li{padding:13px 0;border-bottom:1px solid var(--border);color:var(--body);font-size:13px} - .builder-card{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(280px,.65fr);gap:56px;margin-top:22px;padding:38px;border:1px solid var(--border-strong);border-radius:var(--radius-lg);color:var(--raised);background:var(--ink)} - .builder-card .kicker{color:#ef9a82} - .builder-card h2{margin:13px 0 0;font-size:clamp(32px,3.7vw,48px);font-weight:680;line-height:1.06;letter-spacing:-.04em} - .builder-card p{max-width:680px;margin:16px 0 0;color:#c9ccc6} - .builder-card .button[data-variant="primary"]{color:var(--ink);background:var(--raised)} - .reference-list{margin:0;padding:0;border-top:1px solid #3b3e39;list-style:none} - .reference-list li{border-bottom:1px solid #3b3e39} - .reference-list a{min-height:52px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:#e8e9e6;font-size:13px;text-decoration:none} - .reference-list a:after{content:"↗";color:#ef9a82} - .final-cta{display:flex;align-items:end;justify-content:space-between;gap:48px;margin-top:22px;padding:38px;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--brand-soft)} - .final-cta .kicker{color:var(--brand-hover)} - .final-cta h2{max-width:720px;margin:12px 0 0;font-size:clamp(32px,4vw,48px);font-weight:680;line-height:1.06;letter-spacing:-.04em} - .final-cta .actions{flex:0 0 auto} - footer{padding:34px 0 42px;color:var(--muted);font-size:12px} - .footer-row{display:flex;justify-content:space-between;gap:24px} - .footer-row a{text-underline-offset:4px} - @media(hover:hover) and (pointer:fine){ - .button[data-variant="primary"]:hover{background:var(--brand-hover)} - .button[data-variant="secondary"]:hover,.nav-links>a:hover,.proof-links a:hover{border-color:var(--border-strong);background:var(--surface)} - .text-link:hover{color:var(--brand)} - .benefit:hover{border-color:var(--border-strong);background:var(--raised)} - .builder-card .button[data-variant="primary"]:hover{color:var(--raised);background:var(--brand)} - .reference-list a:hover{color:#ffffff} - } - @media(max-width:1179px){.hero-grid,.workflow-layout{grid-template-columns:1fr;gap:40px}.hero-copy{max-width:820px}.hero-product{max-width:980px}.display{font-size:clamp(48px,7vw,70px)}} - @media(max-width:900px){.section{padding:78px 0}.section-header,.proof-layout,.builder-card{grid-template-columns:1fr;gap:38px}.section-header{align-items:start}.section-header .section-lead{margin-top:0}.nav-links>a:not(.button){display:none}.benefit-grid{grid-template-columns:1fr 1fr}.benefit:last-child{grid-column:1/-1}.system-flow{grid-template-columns:1fr 1fr}.system-flow li:nth-child(3){border-left:0;border-top:1px solid var(--border)}.system-flow li:nth-child(4){border-top:1px solid var(--border)}.final-cta{align-items:start;flex-direction:column}.final-cta .actions{width:100%}} - @media(max-width:640px){ - .container{width:calc(100% - 32px)}.site-nav{min-height:58px}.brand-copy span{display:none}.nav-links .button{padding-inline:11px} - .home-hero{padding-top:26px}.display{font-size:clamp(38px,11.8vw,48px);line-height:1.02}.lead{margin-top:17px;font-size:17px;line-height:1.42}.hero-copy .actions{margin-top:21px}.actions{align-items:stretch;flex-direction:column}.actions .button,.actions .text-link{width:100%;text-align:center}.text-link{justify-content:center}.microcopy{margin-top:10px}.hero-product{margin-top:24px} - .section{padding:62px 0}.section-title{font-size:clamp(32px,9.2vw,42px);line-height:1.08}.product-panel{padding:8px;border-radius:var(--radius-md)}.product-panel figcaption{display:grid;gap:3px}.sample-metrics{grid-template-columns:1fr 1fr;margin-top:26px}.sample-metrics li{padding:17px 0;border-left:0;border-top:1px solid var(--border)}.sample-metrics li:nth-child(1),.sample-metrics li:nth-child(2){border-top:0}.sample-metrics li:nth-child(even){padding-left:16px;border-left:1px solid var(--border)} - .benefit-grid,.system-flow{grid-template-columns:1fr}.benefit:last-child{grid-column:auto}.benefit{min-height:auto}.benefit h3{margin-top:30px}.system-flow li{border-left:0;border-top:1px solid var(--border)}.system-flow li:first-child{border-top:0}.pilot-grid{grid-template-columns:1fr}.scope-card,.pilot-card{padding:22px 20px}.builder-card,.final-cta{padding:30px 20px}.footer-row{display:grid;gap:8px} - } - @media(max-width:380px){.brand-copy strong{font-size:12px}.display{font-size:37px}.sample-metrics strong{font-size:22px}.hero-copy .text-link{display:none}} - @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}.button,.text-link{transition-property:background-color,color,border-color}.button:active,.text-link:active{transform:none}} - @media(prefers-reduced-transparency:reduce){.site-header{background:var(--canvas);backdrop-filter:none}} - @media(prefers-contrast:more){.site-header{background:var(--canvas)}.button,.product-panel,.benefit,.system-flow,.scope-card,.pilot-card,.builder-card,.final-cta{border-width:2px}.lead,.section-lead{color:var(--ink)}} - """ - - -def _finish(template: str) -> str: - return ( - template.replace("__STYLES__", _shared_styles().strip()) - .replace("__VERSION__", VERSION) - .replace("__UPDATED_DATE__", UPDATED_DATE) - .replace("__UPDATED_LABEL__", UPDATED_LABEL) - .replace("__SOCIAL_CARD__", SOCIAL_CARD) - ) - - -def render_portfolio_page_v20() -> str: - return _finish( - """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Creative Launch Workspace | Pre-launch QA for Meta creative teams - - - - - - -
-
-
-
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check each row in a creative batch for approval, destination, placement, UTM, format and naming issues. Creative Launch Workspace routes every detected exception to an owner and records the decisions that remain human.

Sample data · No signup · No publishing permissions.

-
Creative review queue with detected exceptions, owners and human decision controls
Review the exception, not another spreadsheetReal interactive sample
-
-
  • 100creative rows in one review queue
  • 30pass the current offline checks
  • 60concrete fixes with owner and action
  • 10human decisions to accept, return or block
-
- -
One accountable launch path

Turn a scattered handoff into an owned review.

Briefs, trackers, folders and approval threads each hold part of launch truth. The workspace brings their exceptions into one operating view before campaign build.

01 · RECONCILE

Check every row against the launch plan.

Bring campaign intent, creative metadata and evidence into one repeatable preflight.

02 · ROUTE

Give every detected exception an owner.

Keep the issue, responsible role and required next action together.

03 · DECIDE

Stop automation at the judgment call.

Ambiguous cases wait for a person and finish with a recorded decision.

Missing approvalDestination mismatchUTM driftNaming errorFormat and placementPossible duplicate
- -
Completed review with owner, decision state and local audit receipt
One decision closes the loopOwner, state and receipt stay inspectable
Scan → Route → Decide

Find the issue. Route the fix. Record the call.

  1. 01
    ScanOffline rules separate passing rows from detected exceptions.
  2. 02
    RouteThe issue, owner and next action travel together.
  3. 03
    DecideA reviewer accepts, returns or blocks the ambiguous case.
- -
Controlled by design

The model proposes. Rules verify. People decide.

No stage inherits the authority of the next one. The model cannot validate, approve or publish, and the public workspace has no platform credentials.

  1. 01 · EvidenceGround the inputValues link to source evidence or remain empty.
  2. 02 · ProposalAssist, never approveThe model proposes a bounded typed mapping.
  3. 03 · PolicyFail closed in codeSchema, evidence and allowlists own the state.
  4. 04 · DecisionKeep authority humanAmbiguity waits for an explicit local receipt.
- -
Proof without theatre

Inspect what works. Measure what matters next.

The sample proves the review loop, trust boundaries and browser behavior. It does not manufacture customer impact.

What is proven today

A 100-row synthetic sample, 64 automated tests, seven responsive browser widths, a deterministic public flow and 100/100 Lighthouse accessibility on desktop and mobile.

Scope: browser-local review state, no customer data, no Meta credentials and no publishing path.

What a real pilot should measure

Operational value belongs in production telemetry, not in invented ROI claims.

  • Review cycle time, median and p90
  • Late exception rate
  • First-pass readiness
  • Decision and assignment latency
  • False-positive and reversal rate
  • Reviewer effort per 100 creatives
Built end to end

Product judgment and implementation, in one system.

Mathieu Petroni combined growth and performance marketing experience since 2017 with product strategy, AI safeguards, Python contracts, browser interactions, accessibility and CI to build the complete review workflow.

Open the product

Review the sample batch in two minutes.

-
-
- - -""" - ) - - -def render_case_study_page_v20() -> str: - return """ - - - - - - - - - Creative Launch Workspace - - - -

This page has moved.

The product story, interactive proof and architecture now live on one page.

Continue to Creative Launch Workspace
- -""" - - -def render_social_card_page_v20() -> str: - return """ -
Creative Launch Workspace
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check every row, route detected exceptions and keep ambiguous decisions human.

Interactive sampleBuilt by Mathieu Petroni

100-row sample workspaceNo publishing permissions

-""" diff --git a/meta_importer/product_landing_v21.py b/meta_importer/product_landing_v21.py new file mode 100644 index 0000000..4fe1b83 --- /dev/null +++ b/meta_importer/product_landing_v21.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +VERSION = "2.1.0" +UPDATED_DATE = "2026-07-15" +UPDATED_LABEL = "15 July 2026" +SOCIAL_CARD = "social-card-v2-1.png" + + +def _shared_styles() -> str: + return """ + @font-face{font-family:"Geist";src:url("assets/geist-latin-variable.woff2") format("woff2");font-style:normal;font-weight:100 900;font-display:optional} + @font-face{font-family:"Geist Mono";src:url("assets/geist-mono-latin-variable.woff2") format("woff2");font-style:normal;font-weight:100 900;font-display:swap} + :root{ + color-scheme:light; + --background:#f6f7f5; + --card:#ffffff; + --surface:#eef0ed; + --foreground:#151817; + --body:#3e4541; + --muted-foreground:#636b66; + --border:#d8ddd9; + --border-strong:#b8c0ba; + --primary:#c83b24; + --primary-hover:#ae311d; + --primary-pressed:#8d2414; + --primary-soft:#fbe8e2; + --primary-foreground:#ffffff; + --success:#1f6b4c; + --success-soft:#e4f0e9; + --warning:#8b6100; + --warning-soft:#f7edcf; + --danger:#7f2d28; + --danger-soft:#f5e4e1; + --font-sans:"Geist",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + --font-mono:"Geist Mono",ui-monospace,"SFMono-Regular",Consolas,monospace; + --radius-control:8px; + --radius-card:14px; + --radius-product:18px; + --ease-out:cubic-bezier(.23,1,.32,1); + } + *{box-sizing:border-box} + html{scroll-behavior:smooth} + body{margin:0;color:var(--foreground);background:var(--background);font:400 16px/1.55 var(--font-sans);letter-spacing:-.008em;-webkit-font-smoothing:antialiased} + h1,h2,h3,p,a,strong,span,dd,dt,summary{overflow-wrap:anywhere} + h1,h2,h3,p{margin-top:0} + a{color:inherit} + a:focus-visible,summary:focus-visible{outline:2px solid var(--primary);outline-offset:3px} + .container{width:min(1240px,calc(100% - 64px));margin-inline:auto} + .skip-link{position:fixed;left:16px;top:-80px;z-index:80;min-height:44px;display:flex;align-items:center;padding:8px 14px;border-radius:var(--radius-control);color:var(--primary-foreground);background:var(--primary);font-weight:650} + .skip-link:focus{top:14px} + .site-header{position:sticky;top:0;z-index:20;border-bottom:1px solid rgba(216,221,217,.88);background:rgba(246,247,245,.94);backdrop-filter:blur(16px) saturate(125%)} + .site-nav{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:20px} + .brand{min-height:44px;display:inline-flex;align-items:center;gap:10px;text-decoration:none} + .brand-mark{width:30px;height:30px;position:relative;flex:0 0 auto;border:1px solid var(--border-strong);border-radius:var(--radius-control);background:var(--card)} + .brand-mark:before{content:"";position:absolute;inset:7px;border:2px solid var(--primary);border-right-color:transparent} + .brand-copy{display:grid;line-height:1.12} + .brand-copy strong{font-size:14px;font-weight:680;letter-spacing:-.018em} + .brand-copy span{margin-top:3px;color:var(--muted-foreground);font-size:11px;letter-spacing:.01em} + .nav-links{display:flex;align-items:center;gap:4px} + .nav-links>a:not(.button){min-height:44px;display:inline-flex;align-items:center;padding:0 10px;border-radius:var(--radius-control);color:var(--muted-foreground);font-size:13px;font-weight:540;text-decoration:none} + .nav-links .button{margin-left:6px} + .button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 16px;border:1px solid transparent;border-radius:var(--radius-control);font-size:14px;font-weight:650;line-height:1.15;white-space:nowrap;text-decoration:none;transition:transform 130ms var(--ease-out),background-color 160ms ease,color 160ms ease,border-color 160ms ease} + .button[data-variant="primary"]{color:var(--primary-foreground);background:var(--primary)} + .button[data-variant="outline"]{border-color:var(--border-strong);color:var(--foreground);background:var(--card)} + .button[data-variant="ghost"]{color:var(--foreground);background:transparent} + .button:active{transform:scale(.97);background:var(--primary-pressed)} + .button[data-variant="outline"]:active,.button[data-variant="ghost"]:active{background:var(--surface)} + .text-link{min-height:44px;display:inline-flex;align-items:center;color:var(--foreground);font-size:14px;font-weight:620;text-underline-offset:5px;transition:transform 130ms var(--ease-out),color 160ms ease} + .text-link:active{transform:scale(.97)} + .section{padding:104px 0;border-top:1px solid var(--border)} + .eyebrow{color:var(--primary);font:650 12px/1.4 var(--font-mono);letter-spacing:.06em;text-transform:uppercase} + .display{max-width:820px;margin:16px 0 0;font-size:clamp(48px,5.6vw,72px);font-weight:660;line-height:1;letter-spacing:-.055em;text-wrap:balance} + .section-title{max-width:850px;margin:0;font-size:clamp(40px,4.4vw,54px);font-weight:650;line-height:1.06;letter-spacing:-.047em;text-wrap:balance} + .lead{max-width:640px;margin:20px 0 0;color:var(--body);font-size:18px;line-height:1.5} + .section-lead{max-width:680px;margin:18px 0 0;color:var(--muted-foreground);font-size:16px} + .actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;margin-top:26px} + .product-frame{min-width:0;margin:0;padding:12px;border:1px solid var(--border-strong);border-radius:var(--radius-product);background:var(--card);box-shadow:0 24px 72px rgba(21,24,23,.11)} + .product-window{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface)} + .product-window a{display:block} + .product-window img{width:100%;max-width:100%;height:auto;display:block} + .product-frame figcaption{display:flex;justify-content:space-between;gap:20px;padding:10px 3px 0;color:var(--muted-foreground);font-size:12px} + .product-frame figcaption strong{color:var(--foreground);font-weight:630} + .hero{padding:56px 0 0} + .hero-intro{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(260px,.55fr);gap:72px;align-items:end} + .hero-copy{min-width:0;animation:enter-copy 320ms var(--ease-out) both} + .hero-context{margin:0;border-top:1px solid var(--border)} + .hero-context div{padding:13px 0;border-bottom:1px solid var(--border)} + .hero-context dt{color:var(--muted-foreground);font:520 12px/1.4 var(--font-mono)} + .hero-context dd{margin:4px 0 0;color:var(--body);font-size:14px;font-weight:560} + .hero-product{margin-top:40px;animation:enter-product 400ms 80ms var(--ease-out) both} + .sample-metrics{display:grid;grid-template-columns:repeat(4,1fr);margin:28px 0 0;padding:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);list-style:none} + .sample-metrics li{min-width:0;padding:20px 22px;border-left:1px solid var(--border)} + .sample-metrics li:first-child{border-left:0;padding-left:0} + .sample-metrics strong{display:block;font:660 27px/1 var(--font-mono);letter-spacing:-.04em} + .sample-metrics span{display:block;margin-top:7px;color:var(--muted-foreground);font-size:12px;line-height:1.4} + .problem-layout{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(320px,.65fr);gap:88px;align-items:start} + .problem-list{margin:0;padding:0;border-top:1px solid var(--border);list-style:none} + .problem-list li{padding:19px 0;border-bottom:1px solid var(--border)} + .problem-list h3{margin:0;font-size:19px;font-weight:640;letter-spacing:-.025em} + .problem-list p{margin:6px 0 0;color:var(--muted-foreground);font-size:14px} + .workflow-layout{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.6fr);gap:64px;align-items:center} + .workflow-layout>*{min-width:0} + .workflow-copy .section-title{font-size:clamp(38px,4vw,50px)} + .step-list{margin:28px 0 0;padding:0;border-top:1px solid var(--border);list-style:none} + .step-list li{display:grid;grid-template-columns:minmax(76px,.3fr) minmax(0,1fr);gap:18px;padding:17px 0;border-bottom:1px solid var(--border)} + .step-list strong{font-size:15px;font-weight:650} + .step-list span{color:var(--muted-foreground);font-size:14px} + .controls-copy{max-width:840px} + .controls-copy .eyebrow{margin-bottom:15px} + .system-flow{display:grid;grid-template-columns:repeat(4,1fr);margin:40px 0 0;padding:0;border:1px solid var(--border);border-radius:var(--radius-card);background:var(--card);list-style:none} + .system-flow li{min-width:0;padding:24px;border-left:1px solid var(--border)} + .system-flow li:first-child{border-left:0} + .system-flow b{color:var(--primary);font:620 12px/1.4 var(--font-mono)} + .system-flow strong{display:block;margin-top:22px;font-size:16px;font-weight:650;letter-spacing:-.02em} + .system-flow span{display:block;margin-top:6px;color:var(--muted-foreground);font-size:13px} + .boundary-alert{display:grid;grid-template-columns:minmax(150px,.34fr) minmax(0,1fr);gap:24px;margin-top:18px;padding:18px 20px;border:1px solid #e3c1ba;border-radius:var(--radius-card);color:var(--danger);background:var(--danger-soft)} + .boundary-alert strong{font-size:14px;font-weight:680} + .boundary-alert p{margin:0;font-size:14px} + .accordion{display:grid;gap:10px;margin-top:18px} + .accordion details{border:1px solid var(--border);border-radius:var(--radius-card);background:var(--card)} + .accordion summary{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:13px 16px;cursor:pointer;color:var(--foreground);font-weight:620;list-style:none;transition:background-color 160ms ease} + .accordion summary::-webkit-details-marker{display:none} + .accordion summary:after{content:"+";color:var(--primary);font:600 18px/1 var(--font-mono)} + .accordion details[open] summary:after{content:"−"} + .accordion-content{padding:0 16px 18px;color:var(--muted-foreground);font-size:14px} + .receipt-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} + .receipt{padding:18px;border:1px solid var(--border);border-radius:var(--radius-control);background:var(--background)} + .badge{display:inline-flex;align-items:center;min-height:26px;padding:4px 8px;border:1px solid currentColor;border-radius:999px;font:620 11px/1.2 var(--font-mono)} + .badge[data-status="supported"]{color:var(--success);background:var(--success-soft)} + .badge[data-status="review"]{color:var(--warning);background:var(--warning-soft)} + .receipt dl{margin:14px 0 0} + .receipt dl div{display:grid;grid-template-columns:minmax(110px,.6fr) minmax(0,1fr);gap:16px;padding:9px 0;border-top:1px solid var(--border)} + .receipt dt{color:var(--muted-foreground)} + .receipt dd{margin:0;color:var(--body)} + .proof-links{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:16px} + .proof-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--foreground);font-weight:610;text-underline-offset:5px} + .proof-grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px;margin-top:40px} + .proof-cell{padding:26px;border:1px solid var(--border);border-radius:var(--radius-card);background:var(--card)} + .proof-cell h3{margin:0;font-size:22px;font-weight:650;letter-spacing:-.03em} + .proof-cell>p{margin:10px 0 0;color:var(--muted-foreground);font-size:14px} + .proof-primary{grid-column:span 7;grid-row:span 2} + .proof-limits,.proof-pilot{grid-column:span 5} + .proof-limits{border-color:#e3c1ba;background:var(--danger-soft)} + .proof-limits h3,.proof-limits p{color:var(--danger)} + .test-metrics{display:grid;grid-template-columns:repeat(3,1fr);margin:22px 0 20px;padding:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);list-style:none} + .test-metrics li{padding:17px;border-left:1px solid var(--border)} + .test-metrics li:first-child{border-left:0;padding-left:0} + .test-metrics strong{display:block;font:650 24px/1 var(--font-mono)} + .test-metrics span{display:block;margin-top:6px;color:var(--muted-foreground);font-size:12px} + .evidence-shot{overflow:hidden;border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface)} + .evidence-shot img{width:100%;height:auto;display:block} + .pilot-list{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin:18px 0 0;padding:0;border-top:1px solid var(--border);list-style:none} + .pilot-list li{padding:11px 0;border-bottom:1px solid var(--border);color:var(--body);font-size:13px} + .founder{display:grid;grid-template-columns:minmax(280px,.7fr) minmax(0,1.3fr);gap:76px;padding-bottom:48px;border-bottom:1px solid var(--border)} + .founder h2{margin:0;font-size:clamp(32px,3.8vw,46px);font-weight:650;line-height:1.08;letter-spacing:-.043em} + .founder-copy p{max-width:680px;margin:0;color:var(--muted-foreground)} + .reference-links{display:flex;flex-wrap:wrap;gap:8px 20px;margin-top:18px} + .reference-links a{min-height:44px;display:inline-flex;align-items:center;color:var(--foreground);font-size:14px;font-weight:610;text-underline-offset:5px} + .final-cta{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:48px;align-items:end;margin-top:24px;padding:38px;border:1px solid #e3c1ba;border-radius:var(--radius-product);background:var(--primary-soft)} + .final-cta>*{min-width:0} + .final-cta h2{max-width:720px;margin:0;font-size:clamp(34px,4vw,50px);font-weight:660;line-height:1.04;letter-spacing:-.045em} + .final-cta p{max-width:620px;margin:14px 0 0;color:var(--body)} + .final-cta .actions{margin-top:0} + footer{padding:34px 0 42px;color:var(--muted-foreground);font-size:12px} + .footer-row{display:flex;justify-content:space-between;gap:24px} + .footer-row a{text-underline-offset:4px} + @keyframes enter-copy{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} + @keyframes enter-product{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} + @media(hover:hover) and (pointer:fine){ + .button[data-variant="primary"]:hover{background:var(--primary-hover)} + .button[data-variant="outline"]:hover,.button[data-variant="ghost"]:hover,.nav-links>a:not(.button):hover{border-color:var(--border-strong);background:var(--surface)} + .text-link:hover,.proof-links a:hover,.reference-links a:hover{color:var(--primary)} + .accordion summary:hover{background:var(--surface)} + } + @media(max-width:1179px){ + .hero-intro{grid-template-columns:minmax(0,1fr) minmax(240px,.48fr);gap:48px} + .display{font-size:clamp(48px,6.4vw,68px)} + .workflow-layout{grid-template-columns:1fr;gap:38px} + .workflow-layout .product-frame{max-width:980px} + } + @media(max-width:900px){ + .section{padding:80px 0} + .nav-links>a:not(.button){display:none} + .problem-layout,.founder{grid-template-columns:1fr;gap:38px} + .system-flow{grid-template-columns:1fr 1fr} + .system-flow li:nth-child(3){border-left:0;border-top:1px solid var(--border)} + .system-flow li:nth-child(4){border-top:1px solid var(--border)} + .proof-primary{grid-column:span 12;grid-row:auto} + .proof-limits,.proof-pilot{grid-column:span 6} + .final-cta{grid-template-columns:1fr;gap:26px;align-items:start} + .final-cta .actions{margin-top:0} + } + @media(max-width:640px){ + .container{width:calc(100% - 32px)} + .site-nav{min-height:58px;gap:8px} + .brand-copy span{display:none} + .brand-copy strong{font-size:12px} + .brand-mark{width:28px;height:28px} + .nav-links .button{margin-left:0;padding-inline:10px;font-size:12px} + .hero{padding-top:24px} + .hero-intro{grid-template-columns:1fr;gap:0} + .hero-context{display:none} + .display{font-size:clamp(40px,12.2vw,47px);line-height:1.02} + .lead{margin-top:16px;font-size:17px;line-height:1.42} + .hero-copy .actions{margin-top:20px} + .hero-copy .text-link{display:none} + .hero-product{margin-top:22px} + .product-frame{padding:8px;border-radius:var(--radius-card)} + .product-frame figcaption{display:grid;gap:3px} + .sample-metrics{grid-template-columns:1fr 1fr;margin-top:22px} + .sample-metrics li{padding:16px 0;border-left:0;border-top:1px solid var(--border)} + .sample-metrics li:nth-child(1),.sample-metrics li:nth-child(2){border-top:0} + .sample-metrics li:nth-child(even){padding-left:16px;border-left:1px solid var(--border)} + .section{padding:66px 0} + .section-title{font-size:clamp(34px,10.5vw,43px)} + .workflow-copy .section-title{font-size:clamp(34px,10vw,42px)} + .system-flow{grid-template-columns:1fr} + .system-flow li{border-left:0;border-top:1px solid var(--border)} + .system-flow li:first-child{border-top:0} + .boundary-alert{grid-template-columns:1fr;gap:7px} + .receipt-grid,.pilot-list{grid-template-columns:1fr} + .receipt dl div{grid-template-columns:1fr;gap:3px} + .proof-limits,.proof-pilot{grid-column:span 12} + .proof-cell{padding:22px 20px} + .test-metrics{grid-template-columns:1fr} + .test-metrics li{padding:14px 0;border-left:0;border-top:1px solid var(--border)} + .test-metrics li:first-child{border-top:0} + .actions{align-items:stretch;flex-direction:column} + .actions .button,.actions .text-link{width:100%;text-align:center} + .actions .button{white-space:normal} + .text-link{justify-content:center} + .founder{padding-bottom:36px} + .final-cta{padding:28px 20px} + .footer-row{display:grid;gap:8px} + } + @media(max-width:360px){ + .brand-copy strong{max-width:118px;line-height:1.05} + .display{font-size:39px} + .nav-links .button{padding-inline:8px} + } + @media(prefers-reduced-motion:reduce){ + html{scroll-behavior:auto} + .hero-copy,.hero-product{animation:none} + .button,.text-link{transition-property:background-color,color,border-color} + .button:active,.text-link:active{transform:none} + } + @media(prefers-reduced-transparency:reduce){.site-header{background:var(--background);backdrop-filter:none}} + @media(prefers-contrast:more){ + .site-header{background:var(--background);backdrop-filter:none} + .button,.product-frame,.system-flow,.proof-cell,.final-cta,.accordion details{border-width:2px} + .lead,.section-lead,.problem-list p,.step-list span{color:var(--foreground)} + } + """ + + +def _finish(template: str) -> str: + return ( + template.replace("__STYLES__", _shared_styles().strip()) + .replace("__VERSION__", VERSION) + .replace("__UPDATED_DATE__", UPDATED_DATE) + .replace("__UPDATED_LABEL__", UPDATED_LABEL) + .replace("__SOCIAL_CARD__", SOCIAL_CARD) + ) + + +def render_product_landing_v21() -> str: + return _finish( + """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Creative Launch Workspace | Pre-launch QA for Meta creative teams + + + + + + +
+
+
+
+
Pre-launch QA for Meta creative teams
+

Catch launch blockers before Ads Manager.

+

Check every creative row for approval, destination, placement, UTM, format and naming issues. Route each detected exception and hold uncertain calls for human review before campaign build.

+ +
+
+
Input
Campaign brief and creative manifest
+
Review state
Issue, owner, proposed fix and decision
+
Output
Review-only local receipt
+
+
+
+
+ +
Interactive sample workspaceCommitted deterministic fixture. No live model, Meta credentials or publishing path.
+
+
+
    +
  • 100creative rows in one review queue
  • +
  • 30pass the current offline checks
  • +
  • 60blocked with an issue, owner and action
  • +
  • 10held for a human decision
  • +
+
+ +
+
+
+

Every blocker needs evidence, an owner and a decision.

+

Campaign truth is spread across briefs, manifests and approval threads. When context separates, avoidable issues surface after trafficking starts.

+
+
    +
  • Evidence

    Destinations, approvals and placement requirements drift across sources.

  • +
  • Ownership

    An exception becomes visible, but no one owns the next action.

  • +
  • Judgment

    Uncertain mappings are guessed instead of being routed for review.

  • +
+
+
+ +
+
+
+
+ + + Completed human review with owner, decision state and local audit receipt +
+
One accountable review loopThe issue, next action and local receipt stay together.
+
+
+

Find the blocker. Route the fix. Record the decision.

+
    +
  1. FindChecks separate passing rows from detected exceptions.
  2. +
  3. RouteThe issue, owner and required fix stay together.
  4. +
  5. DecideA reviewer makes the uncertain call and records it.
  6. +
+ +
+
+
+ +
+
+
+
Guardrails built in
+

AI proposes. Rules verify. People decide.

+

AI is limited to an evidence-backed mapping proposal. Deterministic policy checks every field, and uncertainty cannot advance without a reviewer.

+
+
    +
  1. EvidenceGround the inputValues link to source evidence or remain empty.
  2. +
  3. ProposalAssist, never approveThe model proposes bounded typed values.
  4. +
  5. PolicyFail closed in codeSchema, evidence and allowlists own the state.
  6. +
  7. DecisionKeep authority humanAccept, return or block with a local receipt.
  8. +
+
Public sample boundary

This public sample uses synthetic data and cannot call Meta, load tokens or publish ads.

+
+
+ Inspect a supported proposal and an abstention +
+
+
Supported
Objective
traffic
Source evidence
“traffic”
Review
Accepted by reviewer
+
Input required
Destination URL
Not found in source
Proposal
No value proposed
Next action
Human input before materialization
+
+
+
+
+ Open the architecture and threat model +

The field receipt, system boundaries, evaluation protocol and threat model remain inspectable.

+
+
+
+
+ +
+
+

Inspect the product. Check the evidence.

+

The public workspace proves the interaction, validation contracts and trust boundaries. It does not claim customer or production results.

+
+
+

Verified in the sample

+

The public flow, validation contracts and browser behavior are reproducibly tested.

+
  • 64automated tests
  • 7responsive browser widths
  • 100/100local Lighthouse accessibility
+
Field-level evidence receipt with accepted values and source quotes
+
+

Honest limits

Synthetic sample, browser-local review state, no customer data, no Meta credentials and no publishing path. No production telemetry.

+

What a pilot should measure

  • Review cycle time
  • Late exception rate
  • First-pass readiness
  • Decision latency
  • False-positive reversals
  • Reviewer effort per 100 creatives
+
+
+
+ +
+
+
+

Designed by a performance marketer. Implemented end to end.

+

Mathieu Petroni brought growth and performance marketing experience since 2017 to the product strategy, AI safeguards, Python contracts, browser experience, accessibility and CI.

+
+
+

Review one launch decision in two minutes.

Inspect the exception, choose the next action and keep the local receipt.

+ +
+
+
+
+ + + +""" + ) + + +def render_social_card_page_v21() -> str: + return """ +
Creative Launch Workspace
Pre-launch QA for Meta creative teams

Catch launch blockers before Ads Manager.

Check every creative row, route each exception and keep uncertain calls human.

Interactive sampleBuilt by Mathieu Petroni

100-row review workspaceNo publishing path

+""" diff --git a/meta_importer/product_page.py b/meta_importer/product_page.py new file mode 100644 index 0000000..2fcde5e --- /dev/null +++ b/meta_importer/product_page.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from meta_importer.product_landing_v21 import ( + render_product_landing_v21, + render_social_card_page_v21, +) + + +def render_product_page() -> str: + return render_product_landing_v21() + + +def render_social_card_page() -> str: + return render_social_card_page_v21() + + +def render_robots_txt() -> str: + return """User-agent: * +Allow: / + +Sitemap: https://mattyu-dev.github.io/creative-launch-workspace/sitemap.xml +""" + + +def render_sitemap() -> str: + return """ + + https://mattyu-dev.github.io/creative-launch-workspace/ + https://mattyu-dev.github.io/creative-launch-workspace/workspace.html + https://mattyu-dev.github.io/creative-launch-workspace/brief-evidence.html + https://mattyu-dev.github.io/creative-launch-workspace/fix-lab.html + +""" + + +def render_not_found_page() -> str: + return """ + + + + + + + + + + + Page not found · Mathieu Petroni + + +
404 · Creative Launch Workspace

This route does not exist.

Return to the product, review the sample workspace or inspect the source.

+ +""" diff --git a/package-lock.json b/package-lock.json index 828660f..1acdf08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,39 @@ { "name": "creative-launch-workspace", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "creative-launch-workspace", - "version": "2.0.0", + "version": "2.1.0", "devDependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", "lighthouse": "12.6.1", "puppeteer-core": "25.3.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.2.8.tgz", + "integrity": "sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", diff --git a/package.json b/package.json index 39dca88..94681d8 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "creative-launch-workspace", "private": true, - "version": "2.0.0", + "version": "2.1.0", "description": "Governed AI-assisted creative launch workspace and reproducible browser QA", "scripts": { "qa:frontend": "node scripts/workspace_runtime_qa.mjs" }, "devDependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", "lighthouse": "12.6.1", "puppeteer-core": "25.3.0" } diff --git a/pyproject.toml b/pyproject.toml index 4878be5..052f9e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "creative-launch-workspace" -version = "2.0.0" +version = "2.1.0" description = "Governed AI-assisted brief intake and offline creative launch review" readme = "README.md" requires-python = ">=3.9" @@ -53,7 +53,7 @@ ignore = ["E501", "UP017"] "scripts/workspace_browser_qa.py" = ["E402"] "scripts/rebuild_ai_evidence.py" = ["E402"] "scripts/rebuild_fix_lab.py" = ["E402"] -"scripts/rebuild_portfolio_page.py" = ["E402"] +"scripts/rebuild_product_page.py" = ["E402"] [tool.mypy] python_version = "3.11" diff --git a/scripts/rebuild_portfolio_page.py b/scripts/rebuild_product_page.py similarity index 65% rename from scripts/rebuild_portfolio_page.py rename to scripts/rebuild_product_page.py index f03183b..f795b2a 100644 --- a/scripts/rebuild_portfolio_page.py +++ b/scripts/rebuild_product_page.py @@ -7,10 +7,9 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from meta_importer.portfolio_page import ( - render_case_study_page, +from meta_importer.product_page import ( render_not_found_page, - render_portfolio_page, + render_product_page, render_robots_txt, render_sitemap, render_social_card_page, @@ -18,13 +17,13 @@ def main() -> int: - (ROOT / "docs/index.html").write_text(render_portfolio_page()) - (ROOT / "docs/case-study.html").write_text(render_case_study_page()) + (ROOT / "docs/index.html").write_text(render_product_page()) + (ROOT / "docs/case-study.html").unlink(missing_ok=True) (ROOT / "docs/social-card.html").write_text(render_social_card_page()) (ROOT / "docs/robots.txt").write_text(render_robots_txt()) (ROOT / "docs/sitemap.xml").write_text(render_sitemap()) (ROOT / "docs/404.html").write_text(render_not_found_page()) - print("Rebuilt GitHub Pages portfolio entry, social card, sitemap, robots, and 404") + print("Rebuilt the canonical product entry, social card, sitemap, robots, and 404") return 0 diff --git a/scripts/workspace_runtime_qa.mjs b/scripts/workspace_runtime_qa.mjs index 1c42ab2..f75f4d9 100644 --- a/scripts/workspace_runtime_qa.mjs +++ b/scripts/workspace_runtime_qa.mjs @@ -73,6 +73,7 @@ const mimeTypes = { ".json": "application/json; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml", + ".woff2": "font/woff2", ".webp": "image/webp", ".avif": "image/avif" }; @@ -245,7 +246,7 @@ const guidedSmallPhoneStepThree = await page.evaluate(() => { footerVisible: !footer.hidden && footerRect.top >= dialogRect.top && footerRect.bottom <= dialogRect.bottom, - personalCaseStudyHref: document.querySelector("#guided-case-study")?.getAttribute("href"), + productBuilderHref: document.querySelector("#guided-product-builder")?.getAttribute("href"), linkedinHref: document.querySelector("#guided-linkedin")?.getAttribute("href") }; }); @@ -334,7 +335,7 @@ if ( || !guidedSmallPhoneStepTwo.titleUsesCustomFocus || guidedSmallPhoneStepThree.progress !== "3 of 3 · Verify" || !guidedSmallPhoneStepThree.footerVisible - || guidedSmallPhoneStepThree.personalCaseStudyHref !== "index.html#about" + || guidedSmallPhoneStepThree.productBuilderHref !== "index.html#about" || guidedSmallPhoneStepThree.linkedinHref !== "https://www.linkedin.com/in/mathieu-petroni/" || !guidedReceiptMobile.hasReceipt || guidedReceiptMobile.hasPersonalCta @@ -519,20 +520,20 @@ await page.setViewport({ width: 1440, height: 1000 }); await page.goto(url, { waitUntil: "load" }); await page.screenshot({ path: join(assetsDir, "workspace-desktop.png") }); -const portfolioUrl = `${baseUrl}/docs/index.html`; +const productUrl = `${baseUrl}/docs/index.html`; await page.setViewport({ width: 1366, height: 768 }); -await page.goto(portfolioUrl, { waitUntil: "load" }); -const portfolioHoverState = await page.evaluate(() => { +await page.goto(productUrl, { waitUntil: "load" }); +const productHoverState = await page.evaluate(() => { const styles = [...document.querySelectorAll("style")].map((item) => item.textContent).join("\n"); return { - exactHoverRule: styles.includes('.button[data-variant="primary"]:hover{background:var(--brand-hover)}'), - exactPressedRule: styles.includes('.button:active{transform:scale(.98)}'), - brand: getComputedStyle(document.documentElement).getPropertyValue("--brand").trim(), - hover: getComputedStyle(document.documentElement).getPropertyValue("--brand-hover").trim(), - foreground: getComputedStyle(document.documentElement).getPropertyValue("--brand-foreground").trim() + exactHoverRule: styles.includes('.button[data-variant="primary"]:hover{background:var(--primary-hover)}'), + exactPressedRule: styles.includes('.button:active{transform:scale(.97)'), + primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), + hover: getComputedStyle(document.documentElement).getPropertyValue("--primary-hover").trim(), + foreground: getComputedStyle(document.documentElement).getPropertyValue("--primary-foreground").trim() }; }); -const portfolioPage = await page.evaluate(() => ({ +const productPage = await page.evaluate(() => ({ title: document.querySelector("h1")?.textContent, canonical: document.querySelector('link[rel="canonical"]')?.href, ogImage: document.querySelector('meta[property="og:image"]')?.content, @@ -543,8 +544,9 @@ const portfolioPage = await page.evaluate(() => ({ scrollHeight: document.body.scrollHeight, copyFreeze: { mutationBoundary: document.body.textContent.includes("no Meta credentials and no publishing path"), - reviewState: document.body.textContent.includes("routes every detected exception to an owner"), - boundedAuthority: document.body.textContent.includes("The model cannot validate, approve or publish"), + reviewScope: document.body.textContent.includes("approval, destination, placement, UTM, format and naming issues"), + reviewState: document.body.textContent.includes("Route each detected exception"), + boundedAuthority: document.body.textContent.includes("AI proposes. Rules verify. People decide."), heroSecondaryCta: document.querySelector(".hero-copy .text-link")?.textContent.trim(), experienceSince: document.body.textContent.includes("since 2017"), structuredJobTitle: JSON.parse(document.querySelector('script[type="application/ld+json"]')?.textContent || "{}")["@graph"]?.find((item) => item["@type"] === "Person")?.jobTitle @@ -552,27 +554,32 @@ const portfolioPage = await page.evaluate(() => ({ humanizedCopy: { noLongDash: !/[—–]/.test(`${document.title}\n${document.body.innerText}`), concreteHero: document.body.textContent.includes("Catch launch blockers before Ads Manager"), - concreteProblem: document.body.textContent.includes("Briefs, trackers, folders"), - plainAiBoundary: document.body.textContent.includes("The model cannot validate, approve or publish"), + concreteProblem: document.body.textContent.includes("Every blocker needs evidence, an owner and a decision"), + plainAiBoundary: document.body.textContent.includes("AI proposes. Rules verify. People decide."), concreteProductionBoundary: document.body.textContent.includes("no Meta credentials and no publishing path"), - personalContribution: document.body.textContent.includes("Product judgment and implementation, in one system") + productBuilder: document.body.textContent.includes("Designed by a performance marketer. Implemented end to end.") }, exactTokens: Object.fromEntries([ - "--canvas", "--surface", "--raised", "--ink", "--muted", "--border", - "--border-strong", "--brand", "--brand-hover", "--brand-soft" + "--background", "--card", "--foreground", "--body", "--muted-foreground", "--border", + "--border-strong", "--primary", "--primary-hover", "--primary-pressed", "--primary-soft" ].map((token) => [token, getComputedStyle(document.documentElement).getPropertyValue(token).trim()])), noAtmosphericEffects: !document.querySelector("style")?.textContent.includes("linear-gradient") && !document.querySelector("style")?.textContent.includes("body:before"), productFirst: !document.querySelector("main")?.innerHTML.split('id="about"')[0].includes("Mathieu") && !document.querySelector("main")?.innerHTML.split('id="about"')[0].includes("I built") && !document.querySelector("main")?.innerHTML.split('id="about"')[0].includes("Personal project") - && !document.querySelector("main")?.innerHTML.split('id="about"')[0].toLowerCase().includes("case study"), + && !document.querySelector("main")?.innerHTML.split('id="about"')[0].toLowerCase().includes("case study") + && !document.querySelector("main")?.innerHTML.toLowerCase().includes("portfolio") + && !document.querySelector("main")?.innerHTML.toLowerCase().includes("hiring"), hasContact: Boolean(document.querySelector('a[href="https://www.linkedin.com/in/mathieu-petroni/"]')), structuredTypes: JSON.parse(document.querySelector('script[type="application/ld+json"]')?.textContent || "{}")["@graph"]?.map((item) => item["@type"]) || [], workflowStepCount: document.querySelectorAll(".workflow-layout .step-list li").length, controlCount: document.querySelectorAll(".system-flow li").length, evidenceCount: document.querySelectorAll(".sample-metrics li").length, - ownership: document.body.textContent.includes("Mathieu Petroni combined growth and performance marketing"), + proofCellCount: document.querySelectorAll(".proof-grid > .proof-cell").length, + eyebrowCount: document.querySelectorAll(".eyebrow").length, + aiAbsentFromHero: !document.querySelector(".hero")?.innerText.includes("AI"), + ownership: document.body.textContent.includes("Mathieu Petroni brought growth and performance marketing experience"), primaryCtaVisible: document.querySelector(".hero-copy .button")?.getBoundingClientRect().top < innerHeight, heroProductVisible: document.querySelector(".product-window")?.getBoundingClientRect().top < innerHeight, heroProductTop: Math.round(document.querySelector(".product-window")?.getBoundingClientRect().top || 0), @@ -582,53 +589,57 @@ const portfolioPage = await page.evaluate(() => ({ noDocumentOverflow: document.documentElement.scrollWidth <= innerWidth })); if ( - !portfolioPage.title?.includes("Catch launch blockers before Ads Manager") - || !portfolioPage.canonical?.endsWith("/creative-launch-workspace/") - || !portfolioPage.ogImage?.endsWith("/assets/social-card-v2-0.png") - || !portfolioPage.hasWorkspaceCta - || portfolioPage.hasCaseStudyLink - || portfolioPage.mainSectionCount !== 5 - || portfolioPage.visibleWordCount > 650 - || portfolioPage.scrollHeight > 6200 - || !portfolioPage.copyFreeze.mutationBoundary - || !portfolioPage.copyFreeze.reviewState - || !portfolioPage.copyFreeze.boundedAuthority - || portfolioPage.copyFreeze.heroSecondaryCta !== "Explore the full workspace →" - || !portfolioPage.copyFreeze.experienceSince - || portfolioPage.copyFreeze.structuredJobTitle !== "AI Automation Builder" - || !Object.values(portfolioPage.humanizedCopy).every(Boolean) - || JSON.stringify(portfolioPage.exactTokens) !== JSON.stringify({ - "--canvas": "#f4f1ea", - "--surface": "#fbf9f5", - "--raised": "#fffdf8", - "--ink": "#1d1f1c", - "--muted": "#666b64", - "--border": "#d7d8d2", - "--border-strong": "#b8bbb4", - "--brand": "#b83b1f", - "--brand-hover": "#972d18", - "--brand-soft": "#f4dcd4" + !productPage.title?.includes("Catch launch blockers before Ads Manager") + || !productPage.canonical?.endsWith("/creative-launch-workspace/") + || !productPage.ogImage?.endsWith("/assets/social-card-v2-1.png") + || !productPage.hasWorkspaceCta + || productPage.hasCaseStudyLink + || productPage.mainSectionCount !== 6 + || productPage.visibleWordCount > 850 + || productPage.scrollHeight > 9000 + || !productPage.copyFreeze.mutationBoundary + || !productPage.copyFreeze.reviewState + || !productPage.copyFreeze.boundedAuthority + || productPage.copyFreeze.heroSecondaryCta !== "See how it works" + || !productPage.copyFreeze.experienceSince + || productPage.copyFreeze.structuredJobTitle !== "AI Automation Builder" + || !Object.values(productPage.humanizedCopy).every(Boolean) + || JSON.stringify(productPage.exactTokens) !== JSON.stringify({ + "--background": "#f6f7f5", + "--card": "#ffffff", + "--foreground": "#151817", + "--body": "#3e4541", + "--muted-foreground": "#636b66", + "--border": "#d8ddd9", + "--border-strong": "#b8c0ba", + "--primary": "#c83b24", + "--primary-hover": "#ae311d", + "--primary-pressed": "#8d2414", + "--primary-soft": "#fbe8e2" }) - || !portfolioPage.noAtmosphericEffects - || !portfolioPage.productFirst - || !portfolioPage.hasContact - || !["Person", "SoftwareApplication", "WebSite"].every((item) => portfolioPage.structuredTypes.includes(item)) - || portfolioPage.workflowStepCount !== 3 - || portfolioPage.controlCount !== 4 - || portfolioPage.evidenceCount !== 4 - || !portfolioPage.ownership - || !portfolioPage.primaryCtaVisible - || !portfolioPage.heroProductVisible - || !portfolioPage.heroImageLoaded - || !portfolioPage.sectionNavTargetsResolve - || !portfolioPage.noDocumentOverflow - || !portfolioHoverState.exactHoverRule - || !portfolioHoverState.exactPressedRule - || portfolioHoverState.brand !== "#b83b1f" - || portfolioHoverState.hover !== "#972d18" - || portfolioHoverState.foreground !== "#ffffff" + || !productPage.noAtmosphericEffects + || !productPage.productFirst + || !productPage.hasContact + || !["Person", "SoftwareApplication", "WebSite"].every((item) => productPage.structuredTypes.includes(item)) + || productPage.workflowStepCount !== 3 + || productPage.controlCount !== 4 + || productPage.evidenceCount !== 4 + || productPage.proofCellCount !== 3 + || productPage.eyebrowCount !== 2 + || !productPage.aiAbsentFromHero + || !productPage.ownership + || !productPage.primaryCtaVisible + || !productPage.heroProductVisible + || !productPage.heroImageLoaded + || !productPage.sectionNavTargetsResolve + || !productPage.noDocumentOverflow + || !productHoverState.exactHoverRule + || !productHoverState.exactPressedRule + || productHoverState.primary !== "#c83b24" + || productHoverState.hover !== "#ae311d" + || productHoverState.foreground !== "#ffffff" ) { - throw new Error(`Portfolio entry contract failed: ${JSON.stringify({ portfolioPage, portfolioHoverState })}`); + throw new Error(`Product entry contract failed: ${JSON.stringify({ productPage, productHoverState })}`); } await page.evaluate(async () => { for (const image of document.images) { @@ -636,14 +647,16 @@ await page.evaluate(async () => { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); } await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); window.scrollTo(0, 0); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); -await page.screenshot({ path: join(assetsDir, "portfolio-desktop.png"), fullPage: true }); +await page.addStyleTag({ content: ".skip-link{display:none!important}.site-header{position:static!important}" }); +await page.screenshot({ path: join(assetsDir, "product-desktop.png"), fullPage: true }); await page.setViewport({ width: 390, height: 844 }); -await page.goto(portfolioUrl, { waitUntil: "load" }); -const portfolioMobile = await page.evaluate(async () => { +await page.goto(productUrl, { waitUntil: "load" }); +const productMobile = await page.evaluate(async () => { const heroImage = document.querySelector(".product-window img"); const heroAsset = await createImageBitmap(await (await fetch(heroImage.currentSrc)).blob()); const heroCta = document.querySelector(".hero-copy .button"); @@ -680,18 +693,18 @@ const portfolioMobile = await page.evaluate(async () => { return result; }); if ( - !portfolioMobile.noDocumentOverflow - || !portfolioMobile.headingVisible - || !portfolioMobile.primaryCtaVisible - || !portfolioMobile.ctaBeforeProduct - || !portfolioMobile.heroImageSource?.endsWith("/assets/workspace-mobile-hero.webp") - || portfolioMobile.heroAssetWidth !== 780 - || portfolioMobile.heroAssetHeight !== 720 - || portfolioMobile.scrollHeight > 8000 - || portfolioMobile.bodyFontPx < 16 - || portfolioMobile.evidenceColumns !== 2 + !productMobile.noDocumentOverflow + || !productMobile.headingVisible + || !productMobile.primaryCtaVisible + || !productMobile.ctaBeforeProduct + || !productMobile.heroImageSource?.endsWith("/assets/workspace-mobile-hero.webp") + || productMobile.heroAssetWidth !== 780 + || productMobile.heroAssetHeight !== 720 + || productMobile.scrollHeight > 8000 + || productMobile.bodyFontPx < 16 + || productMobile.evidenceColumns !== 2 ) { - throw new Error(`Portfolio mobile contract failed: ${JSON.stringify(portfolioMobile)}`); + throw new Error(`Product mobile contract failed: ${JSON.stringify(productMobile)}`); } await page.evaluate(async () => { for (const image of document.images) { @@ -699,14 +712,16 @@ await page.evaluate(async () => { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); } await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined))); + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); window.scrollTo(0, 0); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); -await page.screenshot({ path: join(assetsDir, "portfolio-mobile.png"), fullPage: true }); +await page.addStyleTag({ content: ".skip-link{display:none!important}.site-header{position:static!important}" }); +await page.screenshot({ path: join(assetsDir, "product-mobile.png"), fullPage: true }); await page.setViewport({ width: 320, height: 568 }); -await page.goto(portfolioUrl, { waitUntil: "load" }); -const portfolioSmallPhone = await page.evaluate(() => { +await page.goto(productUrl, { waitUntil: "load" }); +const productSmallPhone = await page.evaluate(() => { const primaryCta = document.querySelector(".hero-copy .button"); const ctaRect = primaryCta?.getBoundingClientRect(); const brandName = document.querySelector(".brand-copy strong"); @@ -731,41 +746,41 @@ const portfolioSmallPhone = await page.evaluate(() => { productTop: Math.round(document.querySelector(".hero-product")?.getBoundingClientRect().top || 0) }; }); -const portfolioBrandHandle = await page.$(".brand"); -const portfolioBrandAccessibility = portfolioBrandHandle - ? await page.accessibility.snapshot({ root: portfolioBrandHandle }) +const productBrandHandle = await page.$(".brand"); +const productBrandAccessibility = productBrandHandle + ? await page.accessibility.snapshot({ root: productBrandHandle }) : null; -portfolioSmallPhone.brandAccessibleName = portfolioBrandAccessibility?.name || ""; -await portfolioBrandHandle?.dispose(); -const portfolioSmallPhoneFocusOrder = []; +productSmallPhone.brandAccessibleName = productBrandAccessibility?.name || ""; +await productBrandHandle?.dispose(); +const productSmallPhoneFocusOrder = []; for (let index = 0; index < 3; index += 1) { await page.keyboard.press("Tab"); - portfolioSmallPhoneFocusOrder.push(await page.evaluate(() => ({ + productSmallPhoneFocusOrder.push(await page.evaluate(() => ({ className: document.activeElement?.className || "", label: document.activeElement?.getAttribute("aria-label") || document.activeElement?.textContent.trim() || "" }))); } -portfolioSmallPhone.keyboardFocusOrder = portfolioSmallPhoneFocusOrder; -const portfolioSmallPhoneKeyboardOrder = portfolioSmallPhoneFocusOrder[0]?.className.includes("skip-link") - && portfolioSmallPhoneFocusOrder[1]?.className.includes("brand") - && portfolioSmallPhoneFocusOrder[2]?.label.includes("Review a sample"); +productSmallPhone.keyboardFocusOrder = productSmallPhoneFocusOrder; +const productSmallPhoneKeyboardOrder = productSmallPhoneFocusOrder[0]?.className.includes("skip-link") + && productSmallPhoneFocusOrder[1]?.className.includes("brand") + && productSmallPhoneFocusOrder[2]?.label.includes("Review sample"); if ( - !portfolioSmallPhone.noDocumentOverflow - || !portfolioSmallPhone.primaryCtaVisible - || !portfolioSmallPhone.ctaBeforeProduct - || !portfolioSmallPhone.brandAccessibleName?.includes("Creative Launch Workspace") - || !portfolioSmallPhone.brandNameVisible - || portfolioSmallPhone.touchTargetFailures.length - || !portfolioSmallPhoneKeyboardOrder - || portfolioSmallPhone.productTop >= 568 + !productSmallPhone.noDocumentOverflow + || !productSmallPhone.primaryCtaVisible + || !productSmallPhone.ctaBeforeProduct + || !productSmallPhone.brandAccessibleName?.includes("Creative Launch Workspace") + || !productSmallPhone.brandNameVisible + || productSmallPhone.touchTargetFailures.length + || !productSmallPhoneKeyboardOrder + || productSmallPhone.productTop >= 568 ) { - throw new Error(`Portfolio small-phone contract failed: ${JSON.stringify(portfolioSmallPhone)}`); + throw new Error(`Product small-phone contract failed: ${JSON.stringify(productSmallPhone)}`); } -const portfolioTextResize = []; +const productTextResize = []; for (const width of [320, 768]) { await page.setViewport({ width, height: width === 320 ? 568 : 900 }); - await page.goto(portfolioUrl, { waitUntil: "load" }); + await page.goto(productUrl, { waitUntil: "load" }); const result = await page.evaluate(async () => { const textElements = [...document.querySelectorAll("body *")] .filter((element) => [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim())) @@ -797,35 +812,37 @@ for (const width of [320, 768]) { overflowers }; }); - portfolioTextResize.push({ width, ...result }); + productTextResize.push({ width, ...result }); } -if (portfolioTextResize.some((result) => result.documentWidth > result.viewportWidth || result.overflowers.length)) { - throw new Error(`Portfolio 200% text resize overflow: ${JSON.stringify(portfolioTextResize)}`); +if (productTextResize.some((result) => result.documentWidth > result.viewportWidth || result.overflowers.length)) { + throw new Error(`Product 200% text resize overflow: ${JSON.stringify(productTextResize)}`); } -const caseStudyUrl = `${baseUrl}/docs/case-study.html`; -const legacyRouteSource = await readFile(join(root, "docs/case-study.html"), "utf8"); -const legacyRouteRedirect = { - canonicalRoot: legacyRouteSource.includes(''), - noIndex: legacyRouteSource.includes('name="robots" content="noindex,follow"'), - refreshesToArchitecture: legacyRouteSource.includes('http-equiv="refresh" content="0; url=./#architecture"'), - replacesToArchitecture: legacyRouteSource.includes('window.location.replace("./#architecture")'), +const removedRouteUrl = `${baseUrl}/docs/case-study.html`; +const removedRoute = { + fileAbsent: !existsSync(join(root, "docs/case-study.html")), excludedFromSitemap: !(await readFile(join(root, "docs/sitemap.xml"), "utf8")).includes("case-study.html") }; await page.setViewport({ width: 1366, height: 768 }); -await page.goto(caseStudyUrl, { waitUntil: "load" }); -legacyRouteRedirect.destination = page.url(); +const removedRouteConsoleStart = consoleErrors.length; +const removedRouteResponse = await page.goto(removedRouteUrl, { waitUntil: "load" }); +removedRoute.httpStatus = removedRouteResponse?.status(); +removedRoute.destination = page.url(); +removedRoute.consoleMessages = consoleErrors.splice(removedRouteConsoleStart); if ( - !Object.values(legacyRouteRedirect).every(Boolean) - || !legacyRouteRedirect.destination.endsWith("/docs/#architecture") + !removedRoute.fileAbsent + || !removedRoute.excludedFromSitemap + || removedRoute.httpStatus !== 404 + || !removedRoute.destination.endsWith("/docs/case-study.html") + || removedRoute.consoleMessages.some((message) => !message.includes("status of 404")) ) { - throw new Error(`Legacy route redirect failed: ${JSON.stringify(legacyRouteRedirect)}`); + throw new Error(`Removed route contract failed: ${JSON.stringify(removedRoute)}`); } -const portfolioWorkspaceUrl = `${baseUrl}/docs/workspace.html`; -await page.goto(portfolioWorkspaceUrl, { waitUntil: "load" }); +const productWorkspaceUrl = `${baseUrl}/docs/workspace.html`; +await page.goto(productWorkspaceUrl, { waitUntil: "load" }); await page.evaluate(() => localStorage.clear()); -const portfolioNavigation = await page.evaluate(() => ({ +const productNavigation = await page.evaluate(() => ({ brandHref: document.querySelector(".brand")?.getAttribute("href"), returnHref: document.querySelector("#guided-return")?.getAttribute("href") })); @@ -833,8 +850,8 @@ await Promise.all([ page.waitForNavigation({ waitUntil: "load" }), page.click(".brand") ]); -portfolioNavigation.brandDestination = new URL(page.url()).pathname; -await page.goto(`${portfolioWorkspaceUrl}?guided=1`, { waitUntil: "load" }); +productNavigation.brandDestination = new URL(page.url()).pathname; +await page.goto(`${productWorkspaceUrl}?guided=1`, { waitUntil: "load" }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: "load" }); await page.click("#guided-next"); @@ -843,17 +860,17 @@ await Promise.all([ page.waitForNavigation({ waitUntil: "load" }), page.click("#guided-return") ]); -portfolioNavigation.completionDestination = new URL(page.url()).pathname; +productNavigation.completionDestination = new URL(page.url()).pathname; if ( - portfolioNavigation.brandHref !== "index.html" - || portfolioNavigation.returnHref !== "index.html" - || !portfolioNavigation.brandDestination.endsWith("/docs/index.html") - || !portfolioNavigation.completionDestination.endsWith("/docs/index.html") + productNavigation.brandHref !== "index.html" + || productNavigation.returnHref !== "index.html" + || !productNavigation.brandDestination.endsWith("/docs/index.html") + || !productNavigation.completionDestination.endsWith("/docs/index.html") ) { - throw new Error(`Portfolio navigation contract failed: ${JSON.stringify(portfolioNavigation)}`); + throw new Error(`Product navigation contract failed: ${JSON.stringify(productNavigation)}`); } await page.setViewport({ width: 390, height: 844 }); -await page.goto(portfolioUrl, { waitUntil: "load" }); +await page.goto(productUrl, { waitUntil: "load" }); const responsiveAssetFidelity = await page.evaluate(async () => { const loadImage = (src) => new Promise((resolve, reject) => { @@ -922,7 +939,7 @@ if (socialCard.productImage !== "assets/workspace-desktop.png" || !socialCard.pr throw new Error(`Social card contract failed: ${JSON.stringify(socialCard)}`); } await page.screenshot({ path: join(assetsDir, "social-card.png"), clip: { x: 0, y: 0, width: 1200, height: 630 } }); -await page.screenshot({ path: join(assetsDir, "social-card-v2-0.png"), clip: { x: 0, y: 0, width: 1200, height: 630 } }); +await page.screenshot({ path: join(assetsDir, "social-card-v2-1.png"), clip: { x: 0, y: 0, width: 1200, height: 630 } }); const labUrl = `${baseUrl}/docs/fix-lab.html`; await page.setViewport({ width: 1280, height: 900 }); @@ -989,8 +1006,8 @@ if (process.platform === "linux") lighthouseChromeFlags.push("--no-sandbox"); const lighthouseTargets = [ { surface: "workspace", formFactor: "desktop", targetUrl: url, outputPath: join(evidenceDir, "workspace-lighthouse-accessibility-desktop.json") }, { surface: "workspace", formFactor: "mobile", targetUrl: url, outputPath: join(evidenceDir, "workspace-lighthouse-accessibility-mobile.json") }, - { surface: "portfolio", formFactor: "desktop", targetUrl: portfolioUrl, outputPath: join(evidenceDir, "portfolio-lighthouse-accessibility-desktop.json") }, - { surface: "portfolio", formFactor: "mobile", targetUrl: portfolioUrl, outputPath: join(evidenceDir, "portfolio-lighthouse-accessibility-mobile.json") } + { surface: "product", formFactor: "desktop", targetUrl: productUrl, outputPath: join(evidenceDir, "product-lighthouse-accessibility-desktop.json") }, + { surface: "product", formFactor: "mobile", targetUrl: productUrl, outputPath: join(evidenceDir, "product-lighthouse-accessibility-mobile.json") } ]; for (const { formFactor, targetUrl, outputPath } of lighthouseTargets) { @@ -1012,7 +1029,7 @@ for (const { formFactor, targetUrl, outputPath } of lighthouseTargets) { }); } -const accessibility = { workspace: {}, portfolio: {} }; +const accessibility = { workspace: {}, product: {} }; const seriousAccessibilityFailures = []; for (const { surface, formFactor, outputPath } of lighthouseTargets) { const lighthouseReport = JSON.parse(await readFile(outputPath, "utf8")); @@ -1036,11 +1053,11 @@ if (seriousAccessibilityFailures.length) { throw new Error(`Serious WCAG audit failures cannot be hidden by a rounded Lighthouse score: ${JSON.stringify(seriousAccessibilityFailures)}`); } -const portfolioQualityTargets = [ - { surface: "portfolio", formFactor: "desktop", targetUrl: portfolioUrl, outputPath: join(evidenceDir, "portfolio-lighthouse-quality-desktop.json") }, - { surface: "portfolio", formFactor: "mobile", targetUrl: portfolioUrl, outputPath: join(evidenceDir, "portfolio-lighthouse-quality-mobile.json") } +const productQualityTargets = [ + { surface: "product", formFactor: "desktop", targetUrl: productUrl, outputPath: join(evidenceDir, "product-lighthouse-quality-desktop.json") }, + { surface: "product", formFactor: "mobile", targetUrl: productUrl, outputPath: join(evidenceDir, "product-lighthouse-quality-mobile.json") } ]; -for (const { formFactor, targetUrl, outputPath } of portfolioQualityTargets) { +for (const { formFactor, targetUrl, outputPath } of productQualityTargets) { const args = [ targetUrl, `--chrome-path=${chromePath}`, @@ -1059,10 +1076,19 @@ for (const { formFactor, targetUrl, outputPath } of portfolioQualityTargets) { }); } -const portfolioQuality = {}; -for (const { surface, formFactor, outputPath } of portfolioQualityTargets) { +const productQuality = {}; +const productQualityBudget = { + performanceTarget: 0.9, + performanceCiFloor: 0.89, + bestPracticesFloor: 0.95, + seoFloor: 0.95, + lcpCeilingMs: 2500, + clsCeiling: 0.1, + tbtCeilingMs: 200 +}; +for (const { surface, formFactor, outputPath } of productQualityTargets) { const lighthouseReport = JSON.parse(await readFile(outputPath, "utf8")); - portfolioQuality[`${surface}_${formFactor}`] = { + productQuality[`${surface}_${formFactor}`] = { performanceScore: lighthouseReport.categories.performance.score, bestPracticesScore: lighthouseReport.categories["best-practices"].score, seoScore: lighthouseReport.categories.seo.score, @@ -1071,15 +1097,15 @@ for (const { surface, formFactor, outputPath } of portfolioQualityTargets) { tbtMs: Math.round(lighthouseReport.audits["total-blocking-time"].numericValue) }; } -if (Object.values(portfolioQuality).some((result) => - result.performanceScore < 0.9 - || result.bestPracticesScore < 0.95 - || result.seoScore < 0.95 - || result.lcpMs > 2500 - || result.cls > 0.1 - || result.tbtMs > 200 +if (Object.values(productQuality).some((result) => + result.performanceScore < productQualityBudget.performanceCiFloor + || result.bestPracticesScore < productQualityBudget.bestPracticesFloor + || result.seoScore < productQualityBudget.seoFloor + || result.lcpMs > productQualityBudget.lcpCeilingMs + || result.cls > productQualityBudget.clsCeiling + || result.tbtMs > productQualityBudget.tbtCeilingMs )) { - throw new Error(`Portfolio Lighthouse quality budget regressed: ${JSON.stringify(portfolioQuality)}`); + throw new Error(`Product Lighthouse quality budget regressed: ${JSON.stringify({ productQualityBudget, productQuality })}`); } if (consoleErrors.length) { @@ -1087,7 +1113,7 @@ if (consoleErrors.length) { } const report = { - contract_version: "workspace_runtime_qa.v17", + contract_version: "workspace_runtime_qa.v18", tested_at: new Date().toISOString(), source: "scripts/workspace_runtime_qa.mjs", viewports, @@ -1111,13 +1137,13 @@ const report = { filter_reconciliation: filterReconciliation, persistence, reset, - portfolio_page: portfolioPage, - portfolio_hover_state: portfolioHoverState, - portfolio_mobile: portfolioMobile, - portfolio_small_phone: portfolioSmallPhone, - portfolio_text_resize: portfolioTextResize, - legacy_route_redirect: legacyRouteRedirect, - portfolio_navigation: portfolioNavigation, + product_page: productPage, + product_hover_state: productHoverState, + product_mobile: productMobile, + product_small_phone: productSmallPhone, + product_text_resize: productTextResize, + removed_route: removedRoute, + product_navigation: productNavigation, responsive_asset_fidelity: responsiveAssetFidelity, social_card: socialCard, fix_lab: { initial: labInitial, fixed: labFixed, reset: labReset }, @@ -1125,7 +1151,8 @@ const report = { console_errors: consoleErrors, lighthouse_accessibility: accessibility, serious_accessibility_failures: seriousAccessibilityFailures, - lighthouse_portfolio_quality: portfolioQuality, + lighthouse_product_quality: productQuality, + lighthouse_product_quality_budget: productQualityBudget, mutation_allowed: false, meta_api_compatibility: "not_claimed" }; @@ -1134,4 +1161,4 @@ await writeFile(join(evidenceDir, "workspace-runtime-qa.json"), `${JSON.stringif await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); -console.log(JSON.stringify({ status: "pass", accessibility, portfolioQuality, viewports: viewports.length }, null, 2)); +console.log(JSON.stringify({ status: "pass", accessibility, productQuality, viewports: viewports.length }, null, 2)); diff --git a/tests/test_launch_workspace.py b/tests/test_launch_workspace.py index 4a3af00..3c60ed0 100644 --- a/tests/test_launch_workspace.py +++ b/tests/test_launch_workspace.py @@ -107,8 +107,8 @@ def test_html_workspace_has_first_screen_controls(self) -> None: self.assertNotIn('class="brand" href="index.html" aria-label=', html) self.assertIn('id="guided-return" href="index.html"', html) self.assertIn("Back to the product", html) - self.assertIn("See how Mathieu built the product", html) - self.assertIn('id="guided-case-study" href="index.html#about"', html) + self.assertIn("Meet the product builder", html) + self.assertIn('id="guided-product-builder" href="index.html#about"', html) self.assertIn( 'id="guided-linkedin" href="https://www.linkedin.com/in/mathieu-petroni/"', html, diff --git a/tests/test_portfolio_surface.py b/tests/test_product_surface.py similarity index 75% rename from tests/test_portfolio_surface.py rename to tests/test_product_surface.py index d328168..ffd6bd8 100644 --- a/tests/test_portfolio_surface.py +++ b/tests/test_product_surface.py @@ -3,59 +3,62 @@ import json import re import unittest +from pathlib import Path from urllib.parse import urljoin from meta_importer.ai.contracts import FIELD_NAMES from meta_importer.ai.evidence_page import render_evidence_page from meta_importer.fix_lab import build_fix_lab_rule_pack, render_fix_lab -from meta_importer.portfolio_page import ( - render_case_study_page, +from meta_importer.product_page import ( render_not_found_page, - render_portfolio_page, + render_product_page, render_robots_txt, render_sitemap, render_social_card_page, ) -class PortfolioSurfaceTests(unittest.TestCase): - def test_linkedin_entry_is_a_product_first_landing_with_final_references(self) -> None: - html = render_portfolio_page() +class ProductSurfaceTests(unittest.TestCase): + def test_homepage_sells_one_product_with_inspectable_boundaries(self) -> None: + html = render_product_page() self.assertIn('property="og:image"', html) self.assertIn('property="og:site_name" content="Creative Launch Workspace"', html) self.assertIn('name="author" content="Mathieu Petroni"', html) self.assertIn('property="og:type" content="website"', html) - self.assertIn("social-card-v2-0.png", html) + self.assertIn("social-card-v2-1.png", html) self.assertIn('content="Catch launch blockers before Ads Manager"', html) - self.assertIn('name="theme-color" content="#f4f1ea"', html) + self.assertIn('name="theme-color" content="#f6f7f5"', html) self.assertIn('name="twitter:image:alt"', html) self.assertIn('name="twitter:card" content="summary_large_image"', html) for token in ( - "--canvas:#f4f1ea", - "--surface:#fbf9f5", - "--raised:#fffdf8", - "--ink:#1d1f1c", - "--muted:#666b64", - "--border:#d7d8d2", - "--brand:#b83b1f", - "--brand-hover:#972d18", - "--brand-soft:#f4dcd4", + "--background:#f6f7f5", + "--card:#ffffff", + "--foreground:#151817", + "--body:#3e4541", + "--muted-foreground:#636b66", + "--border:#d8ddd9", + "--primary:#c83b24", + "--primary-hover:#ae311d", + "--primary-pressed:#8d2414", + "--primary-soft:#fbe8e2", ): self.assertIn(token, html) + self.assertIn('@font-face{font-family:"Geist"', html) + self.assertIn('@font-face{font-family:"Geist Mono"', html) + self.assertNotIn("Avenir Next", html) self.assertNotIn("#7bd9b0", html) self.assertNotIn("#5e6ad2", html) self.assertNotIn("body:before", html) self.assertNotIn("linear-gradient", html) self.assertIn( - '.button[data-variant="primary"]:hover{background:var(--brand-hover)}', + '.button[data-variant="primary"]:hover{background:var(--primary-hover)}', html, ) - self.assertIn('.button:active{transform:scale(.98)}', html) + self.assertIn('.button:active{transform:scale(.97)', html) self.assertIn('data-variant="primary"', html) self.assertNotIn("--serif", html) self.assertIn('href="workspace.html?guided=1"', html) - self.assertIn('href="workspace.html"', html) self.assertNotIn('href="case-study.html"', html) self.assertIn('class="skip-link" href="#main"', html) self.assertIn('rel="me" href="https://www.linkedin.com/in/mathieu-petroni/"', html) @@ -63,27 +66,32 @@ def test_linkedin_entry_is_a_product_first_landing_with_final_references(self) - self.assertIn("Catch launch blockers before Ads Manager.", html) self.assertIn("Review a sample batch", html) self.assertIn("Pre-launch QA for Meta creative teams", html) - self.assertIn("routes every detected exception to an owner", html) - self.assertIn("The model proposes. Rules verify. People decide.", html) - self.assertIn("Product judgment and implementation, in one system.", html) - self.assertIn("64 automated tests", html) - self.assertIn("It does not manufacture customer impact.", html) + self.assertIn("Every blocker needs evidence, an owner and a decision.", html) + self.assertIn("Find the blocker. Route the fix. Record the decision.", html) + self.assertIn("AI proposes. Rules verify. People decide.", html) + self.assertIn("64automated tests", html) + self.assertIn("It does not claim customer or production results.", html) self.assertIn("no customer data, no Meta credentials and no publishing path", html) - self.assertIn('softwareVersion":"2.0.0"', html) + self.assertIn('softwareVersion":"2.1.0"', html) self.assertNotIn("object-fit:cover", html) self.assertIn("since 2017", html) - self.assertEqual(html.count('
  • '), 7) + self.assertEqual(html.count('
      How it works', html) - self.assertIn('Controls', html) + self.assertIn('Controls', html) self.assertNotIn('My role', html) self.assertIn('type="image/avif"', html) self.assertIn('type="image/webp"', html) + self.assertIn('font-family:"Geist"', html) + self.assertIn("font-display:optional", html) self.assertIn('decoding="async" fetchpriority="high"', html) self.assertIn("workspace-mobile-hero.webp", html) self.assertIn("workspace-mobile-hero.png", html) self.assertIn("guided-receipt-mobile.webp", html) self.assertIn("guided-review-step-3.png", html) + self.assertIn("brief-evidence.png", html) visible_main = re.search(r"]*>(.*?)", html, re.DOTALL) self.assertIsNotNone(visible_main) before_builder = visible_main.group(1).split('id="about"', maxsplit=1)[0] # type: ignore[union-attr] @@ -92,10 +100,24 @@ def test_linkedin_entry_is_a_product_first_landing_with_final_references(self) - self.assertNotIn("Personal project", before_builder) self.assertNotIn("case study", before_builder.lower()) self.assertNotIn("Hiring", before_builder) + hero = re.search(r'
      ', html, re.DOTALL) + self.assertIsNotNone(hero) + self.assertNotIn("AI", re.sub(r"<[^>]+>", " ", hero.group(0))) # type: ignore[union-attr] body = re.search(r"(.*?)", html, re.DOTALL) self.assertIsNotNone(body) visible_words = re.findall(r"\b[\w'-]+\b", re.sub(r"<[^>]+>", " ", body.group(1))) # type: ignore[union-attr] - self.assertLessEqual(len(visible_words), 620) + self.assertLessEqual(len(visible_words), 850) + visible_text = re.sub(r"<[^>]+>", " ", body.group(1)) # type: ignore[union-attr] + for banned in ( + "case study", + "personal project", + "portfolio", + "hiring", + "my contribution", + "proof without theatre", + ): + self.assertNotIn(banned, visible_text.lower()) + self.assertNotRegex(visible_text, "[\u2013\u2014]") json_ld_match = re.search( r'', html, re.DOTALL @@ -109,19 +131,9 @@ def test_linkedin_entry_is_a_product_first_landing_with_final_references(self) - person = next(item for item in graph if item["@type"] == "Person") self.assertEqual(person["jobTitle"], "AI Automation Builder") - def test_legacy_case_study_redirects_to_the_single_product_page(self) -> None: - html = render_case_study_page() - - self.assertIn( - '', - html, - ) - self.assertIn('name="robots" content="noindex,follow"', html) - self.assertIn('http-equiv="refresh" content="0; url=./#architecture"', html) - self.assertIn('window.location.replace("./#architecture")', html) - self.assertIn('href="./#architecture"', html) - self.assertNotIn('property="og:type" content="article"', html) - self.assertNotIn("Technical case study", html) + def test_removed_route_has_no_generated_file_or_sitemap_entry(self) -> None: + self.assertFalse(Path("docs/case-study.html").exists()) + self.assertNotIn("case-study.html", render_sitemap()) def test_social_card_has_safe_dedicated_composition(self) -> None: html = render_social_card_page() @@ -131,12 +143,12 @@ def test_social_card_has_safe_dedicated_composition(self) -> None: self.assertIn("Creative Launch Workspace", html) self.assertIn("workspace-desktop.png", html) self.assertIn("Catch launch blockers before Ads Manager.", html) - self.assertIn("Check every row, route detected exceptions", html) + self.assertIn("Check every creative row, route each exception", html) self.assertIn("Interactive sample", html) - self.assertIn("No publishing permissions", html) + self.assertIn("No publishing path", html) self.assertNotIn("Personal product case study", html) - self.assertIn("#f4f1ea", html) - self.assertIn("#b83b1f", html) + self.assertIn("#f6f7f5", html) + self.assertIn("#c83b24", html) self.assertNotIn("#5e6ad2", html) self.assertNotIn("object-fit:cover", html) self.assertNotIn("30", html) @@ -210,7 +222,7 @@ def test_secondary_evidence_page_converts_back_to_mathieu(self) -> None: self.assertIn("Back to product", rendered) self.assertNotIn("case study", rendered.lower()) self.assertNotIn("hiring", rendered.lower()) - self.assertIn("social-card-v2-0.png", rendered) + self.assertIn("social-card-v2-1.png", rendered) def test_github_pages_discovery_and_not_found_surfaces(self) -> None: robots = render_robots_txt() diff --git a/uv.lock b/uv.lock index eff5563..acd2c8b 100644 --- a/uv.lock +++ b/uv.lock @@ -424,7 +424,7 @@ toml = [ [[package]] name = "creative-launch-workspace" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } [package.optional-dependencies]