diff --git a/.gitignore b/.gitignore index f87d27e..888828d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ docs/TAuth .env.* qodana.yaml bin/ +PLAN.md diff --git a/ISSUES.md b/ISSUES.md index be5e186..f1024a0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -90,6 +90,7 @@ Each issue is formatted as `- [ ] [-]`. When resolved it becomes -` - Rationale for current code: Likely added to prevent focus from jumping into the iframe during card flip animation or while card is not flipped. - Fix: Conditionally set `tabindex="0"` on the iframe when the card enters the flipped state (`is-flipped` class added) and restore `tabindex="-1"` when unflipped. Update the `toggleFlip` handler in `script.js:207-225` to toggle the iframe's tabindex alongside the flip state. - Extend Playwright test `subscribe-enabled cards render LoopAware forms after flipping` to assert that the iframe is focusable (`tabindex="0"`) when the card is flipped. +- [ ] [MP-205] Add the four-way color theme switch and style all of the lements accordingly. Use theme-config in the footer to style all elements and choose theme-switcher="square". Read up @tools/mpr-ui/README.md and @tools/mpr-ui/docs/integration-guide.md ## BugFixes (300–399) @@ -107,6 +108,16 @@ Each issue is formatted as `- [ ] [-]`. When resolved it becomes -` - Root cause: The mpr-ui CDN stylesheet likely sets `position: relative` on the `mpr-footer` custom element, and our local `styles.css:536-541` does not explicitly override it to `static`. - Fix: Add `position: static;` to the `mpr-footer { ... }` rule block in `styles.css` at line 536 to ensure the host element is not positioned, regardless of CDN defaults. - Verify fix passes: `npx playwright test --grep "footer respects non-sticky"`. +- [ ] [MP-302] Subscribe iframe positioned off-screen in Safari due to nested 3D transform issues + - Symptom: LoopAware subscribe form renders inside iframe (verified via `contentDocument.body.innerHTML`) but is visually invisible when card is flipped. + - Debug findings: + - Overlay `getBoundingClientRect()`: `y: 61.9` (correct, visible) + - Iframe `getBoundingClientRect()`: `y: -580` (incorrect, ~640px above viewport) + - Iframe dimensions: `436x180` (correct) + - Iframe content: form with `#mp-subscribe-form` renders correctly + - Root cause: Safari miscalculates iframe position within nested 3D-transformed containers. The `.project-card-subscribe-overlay` has `transform: rotateY(180deg)` to counter the card flip, but Safari's compositor places the iframe at pre-transform coordinates. + - Additional issue: Cross-origin script loading from `srcdoc` iframe was blocked by Safari; fixed by fetching script content and inlining it. + - Fix: Flatten 3D context for subscribe widget content using `transform-style: flat` on the overlay, ensuring child elements use 2D positioning within the already-transformed container. ## Maintenance (400–499) diff --git a/README.md b/README.md index 2d1706c..3bfa081 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,32 @@ Each entry in the project gallery is powered by `data/projects.json`: Vectorizing images or text to SVG can be done using the tools available in the [svg_tools](https://github.com/tyemirov/svg_tools) repository. +## Adding Subscribe Forms + +Projects can display a LoopAware-powered subscribe form on their card back. To enable: + +1. **Create a site in LoopAware** – register your project at [loopaware.mprlab.com](https://loopaware.mprlab.com) and note the `site_id`. + +2. **Add the subscribe config** to the project entry in `data/projects.json`: + + ```json + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=YOUR_SITE_ID&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-YOUR_PROJECT_ID", + "title": "Get PROJECT_NAME updates", + "copy": "Drop your email to hear when PROJECT_NAME ships new features." + } + ``` + +3. **Key fields**: + - `script` – LoopAware widget URL with your `site_id` and customization params + - `target` – unique DOM ID where the form renders (use pattern `subscribe-target-{project-id}`) + - `title` – heading shown above the form + - `copy` – description text below the heading + +The card will automatically become flippable and load the subscribe widget when the user flips it. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/data/projects.json b/data/projects.json index da7ce50..f6b9769 100644 --- a/data/projects.json +++ b/data/projects.json @@ -7,26 +7,35 @@ "status": "WIP", "category": "Research", "repo": "github.com/MarcoPoloResearchLab/marcopolo.github.io", - "app": "https://github.com/MarcoPoloResearchLab/marcopolo.github.io/blob/main/ISSUES.md", - "docs": "https://github.com/MarcoPoloResearchLab/marcopolo.github.io/blob/main/ISSUES.md", - "launchEnabled": false, - "docsEnabled": false, - "subscribeEnabled": false, - "icon": "assets/projects/issues-md/icon.png" + "icon": "assets/projects/issues-md/icon.png", + "launch": { + "enabled": false, + "url": "https://github.com/MarcoPoloResearchLab/marcopolo.github.io/blob/main/ISSUES.md" + }, + "docs": { + "enabled": false, + "url": "https://github.com/MarcoPoloResearchLab/marcopolo.github.io/blob/main/ISSUES.md" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=e771ca8b-51d9-4ea4-884a-eb769ca66550&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-issues-md", + "title": "Get ISSUES.md release updates", + "copy": "Drop your email to hear when ISSUES.md ships fresh features and AI integrations.", + "height": 320 + } }, { "id": "photolab", "name": "Photolab", - "description": "Local photo library classifier and search UI that writes high-confidence labels into EXIF, indexes metadata into SQLite, and serves a minimal browser-based search grid.", + "description": "Local photo library classifier and search UI that writes high-confidence labels into EXIF, indexes metadata, and serves a minimal browser-based search grid.", "status": "WIP", "category": "Research", "repo": null, - "app": null, - "docs": null, - "launchEnabled": false, - "docsEnabled": false, - "subscribeEnabled": false, - "icon": "assets/projects/photolab/icon.svg" + "icon": "assets/projects/photolab/icon.svg", + "launch": { "enabled": false }, + "docs": { "enabled": false }, + "subscribe": { "enabled": false } }, { "id": "ctx", @@ -35,12 +44,16 @@ "status": "Production", "category": "Tools", "repo": "github.com/tyemirov/ctx", - "app": "https://ctx.mprlab.com", - "docs": "https://github.com/tyemirov/ctx#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/ctx/icon.png" + "icon": "assets/projects/ctx/icon.png", + "launch": { + "enabled": true, + "url": "https://ctx.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/ctx#readme" + }, + "subscribe": { "enabled": false } }, { "id": "gix", @@ -49,12 +62,16 @@ "status": "Production", "category": "Tools", "repo": "github.com/tyemirov/gix", - "app": "https://gix.mprlab.com", - "docs": "https://github.com/tyemirov/gix#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/gix/icon.png" + "icon": "assets/projects/gix/icon.png", + "launch": { + "enabled": true, + "url": "https://gix.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/gix#readme" + }, + "subscribe": { "enabled": false } }, { "id": "ghttp", @@ -63,12 +80,16 @@ "status": "Production", "category": "Tools", "repo": "github.com/temirov/ghttp", - "app": "https://github.com/temirov/ghttp", - "docs": "https://github.com/temirov/ghttp#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/ghttp/icon.png" + "icon": "assets/projects/ghttp/icon.png", + "launch": { + "enabled": true, + "url": "https://github.com/temirov/ghttp" + }, + "docs": { + "enabled": true, + "url": "https://github.com/temirov/ghttp#readme" + }, + "subscribe": { "enabled": false } }, { "id": "loopaware", @@ -77,14 +98,19 @@ "status": "Production", "category": "Platform", "repo": "github.com/tyemirov/loopaware", - "app": "https://loopaware.mprlab.com", - "docs": "https://github.com/tyemirov/loopaware#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": true, "icon": "assets/projects/loopaware/icon.svg", + "launch": { + "enabled": true, + "url": "https://loopaware.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/loopaware#readme" + }, "subscribe": { + "enabled": true, "script": "https://loopaware.mprlab.com/subscribe.js?site_id=a3222433-92ec-473a-9255-0797226c2273&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-loopaware", "title": "Get LoopAware release updates", "copy": "Drop your email to hear when LoopAware ships fresh drops, integrations, and subscriber tooling." } @@ -96,12 +122,23 @@ "status": "Production", "category": "Platform", "repo": "github.com/temirov/pinguin", - "app": "https://github.com/temirov/pinguin", - "docs": "https://github.com/temirov/pinguin#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/pinguin/icon.png" + "icon": "assets/projects/pinguin/icon.png", + "launch": { + "enabled": true, + "url": "https://github.com/temirov/pinguin" + }, + "docs": { + "enabled": true, + "url": "https://github.com/temirov/pinguin#readme" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=e8f235c8-ddf2-46b7-ae42-022885fe8a44&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-pinguin", + "title": "Get Pinguin release updates", + "copy": "Drop your email to hear when Pinguin ships fresh features.", + "height": 320 + } }, { "id": "ets", @@ -110,12 +147,16 @@ "status": "Beta", "category": "Platform", "repo": "github.com/tyemirov/ETS", - "app": "https://ets.mprlab.com", - "docs": "https://github.com/tyemirov/ETS#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/ets/icon.svg" + "icon": "assets/projects/ets/icon.svg", + "launch": { + "enabled": true, + "url": "https://ets.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/ETS#readme" + }, + "subscribe": { "enabled": false } }, { "id": "tauth", @@ -124,12 +165,23 @@ "status": "Production", "category": "Platform", "repo": "github.com/tyemirov/TAuth", - "app": "https://tauth.mprlab.com", - "docs": "https://github.com/tyemirov/TAuth#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/tauth/icon.svg" + "icon": "assets/projects/tauth/icon.svg", + "launch": { + "enabled": true, + "url": "https://tauth.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/TAuth#readme" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=0cdc4451-b928-4274-8db2-c8b9a23796bd&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-tauth", + "title": "Get TAuth release updates", + "copy": "Drop your email to hear when TAuth ships fresh features, OAuth integrations, and supports new platforms.", + "height": 320 + } }, { "id": "ledger", @@ -138,26 +190,48 @@ "status": "Beta", "category": "Platform", "repo": "github.com/tyemirov/ledger", - "app": "https://github.com/tyemirov/ledger", - "docs": "https://github.com/tyemirov/ledger#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/ledger/icon.png" + "icon": "assets/projects/ledger/icon.png", + "launch": { + "enabled": true, + "url": "https://github.com/tyemirov/ledger" + }, + "docs": { + "enabled": true, + "url": "https://github.com/tyemirov/ledger#readme" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=f5c0dfd1-2943-43d0-8f46-a6064d04b05e&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-ledger", + "title": "Get Ledger's release updates", + "copy": "Drop your email to hear when Ledger ships fresh features and integrations.", + "height": 320 + } }, { "id": "product-scanner", "name": "Poodle Scanner", - "description": "AI-assisted storefront auditor nicknamed “Poodle” that sniffs out PDP gaps, evaluates results against configurable rule packs, and reports issues through a CLI and authenticated dashboard.", + "description": "AI-assisted storefront auditor nicknamed 'Poodle' that sniffs out PDP gaps, evaluates results against configurable rule packs, and reports issues through a CLI and authenticated dashboard.", "status": "Beta", "category": "Products", "repo": "github.com/MarcoPoloResearchLab/ProductScanner", - "app": "https://ps.mprlab.com", - "docs": "https://github.com/MarcoPoloResearchLab/ProductScanner#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/product-scanner/icon.png" + "icon": "assets/projects/product-scanner/icon.png", + "launch": { + "enabled": true, + "url": "https://ps.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/MarcoPoloResearchLab/ProductScanner#readme" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=cc91ba37-b47c-46bf-bd4a-d92611ca4b1a&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-product-scanner", + "title": "Get Gravity Notes release updates", + "copy": "Drop your email to hear when Gravity Notes ships fresh features, AI integrations, and new plugins.", + "height": 320 + } }, { "id": "sheet2tube", @@ -166,12 +240,16 @@ "status": "Beta", "category": "Products", "repo": "github.com/MarcoPoloResearchLab/Sheet2Tube", - "app": "https://sheet2tube.mprlab.com", - "docs": "https://github.com/MarcoPoloResearchLab/Sheet2Tube#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/sheet2tube/icon.svg" + "icon": "assets/projects/sheet2tube/icon.svg", + "launch": { + "enabled": true, + "url": "https://sheet2tube.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/MarcoPoloResearchLab/Sheet2Tube#readme" + }, + "subscribe": { "enabled": false } }, { "id": "gravity-notes", @@ -180,14 +258,19 @@ "status": "Production", "category": "Products", "repo": "github.com/MarcoPoloResearchLab/gravity", - "app": "https://gravity.mprlab.com", - "docs": "https://github.com/MarcoPoloResearchLab/gravity#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": true, "icon": "assets/projects/gravity-notes/icon.png", + "launch": { + "enabled": true, + "url": "https://gravity.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/MarcoPoloResearchLab/gravity#readme" + }, "subscribe": { + "enabled": true, "script": "https://loopaware.mprlab.com/subscribe.js?site_id=d8c3d1c8-7968-43d0-8026-ee827ada7666&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-gravity-notes", "title": "Get Gravity Notes release updates", "copy": "Drop your email to hear when Gravity Notes ships fresh features, AI integrations, and new plugins.", "height": 320 @@ -200,12 +283,16 @@ "status": "Production", "category": "Products", "repo": "github.com/MarcoPoloResearchLab/RSVP", - "app": "https://rsvp.mprlab.com", - "docs": "https://github.com/MarcoPoloResearchLab/RSVP#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/rsvp/icon.png" + "icon": "assets/projects/rsvp/icon.png", + "launch": { + "enabled": true, + "url": "https://rsvp.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/MarcoPoloResearchLab/RSVP#readme" + }, + "subscribe": { "enabled": false } }, { "id": "prompt-bubbles", @@ -214,12 +301,23 @@ "status": "Production", "category": "Products", "repo": "github.com/MarcoPoloResearchLab/prompts", - "app": "https://prompts.mprlab.com", - "docs": "https://github.com/MarcoPoloResearchLab/prompts#readme", - "launchEnabled": true, - "docsEnabled": true, - "subscribeEnabled": false, - "icon": "assets/projects/prompt-bubbles/icon.svg" + "icon": "assets/projects/prompt-bubbles/icon.svg", + "launch": { + "enabled": true, + "url": "https://prompts.mprlab.com" + }, + "docs": { + "enabled": true, + "url": "https://github.com/MarcoPoloResearchLab/prompts#readme" + }, + "subscribe": { + "enabled": true, + "script": "https://loopaware.mprlab.com/subscribe.js?site_id=efad9dff-1777-4710-b6b1-3218ee8c5cbb&mode=inline&accent=%23ffd369&cta=Subscribe&success=Thanks%20for%20subscribing&name_field=false", + "target": "subscribe-target-prompt-bubbles", + "title": "Get Prompt Bubbles release updates", + "copy": "Drop your email to hear when Prompt Bubbles ships fresh features, platform integrations, and design changes.", + "height": 320 + } } ] } diff --git a/docker-compose.yml b/docker-compose.yml index 27b8f59..e94d77b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,25 @@ services: - "${GHTTP_BIND_ADDRESS}" - "--directory" - "${GHTTP_SERVE_DIRECTORY}" + - "--tls-cert" + - ${GHTTP_TLS_CERT_CONTAINER_PATH} + - "--tls-key" + - "${GHTTP_TLS_KEY_CONTAINER_PATH}" - "${CONTAINER_HTTP_PORT}" ports: - "${HOST_HTTP_PORT}:${CONTAINER_HTTP_PORT}" volumes: - ./:${GHTTP_SERVE_DIRECTORY}:ro + - ${GHTTP_TLS_CERT_HOST_PATH}:${GHTTP_TLS_CERT_CONTAINER_PATH}:ro + - ${GHTTP_TLS_KEY_HOST_PATH}:${GHTTP_TLS_KEY_CONTAINER_PATH}:ro + develop: + watch: + - action: sync + path: ./index.html + target: ${GHTTP_SERVE_DIRECTORY}/index.html + - action: sync + path: ./script.js + target: ${GHTTP_SERVE_DIRECTORY}/script.js + - action: sync + path: ./styles.css + target: ${GHTTP_SERVE_DIRECTORY}/styles.css diff --git a/index.html b/index.html index 5201dd5..d42ecf2 100644 --- a/index.html +++ b/index.html @@ -192,7 +192,6 @@

Products

> - diff --git a/mpr-band.js b/mpr-band.js deleted file mode 100644 index 91d3c22..0000000 --- a/mpr-band.js +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-check - -class MprBand extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({mode: "open"}); - shadowRoot.innerHTML = ` - - - `; - } -} - -customElements.define("mpr-band", MprBand); diff --git a/package-lock.json b/package-lock.json index a5905e9..7b5161c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -119,7 +118,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -143,7 +141,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -381,7 +378,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -871,7 +867,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2207,7 +2202,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2257,7 +2251,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" diff --git a/script.js b/script.js index bd730c9..05061b2 100644 --- a/script.js +++ b/script.js @@ -10,19 +10,29 @@ * @property {string} description * @property {ProjectStatus} status * @property {ProjectCategory} category - * @property {string | null} app - * @property {string | null | undefined} [docs] * @property {string | null | undefined} [repo] - * @property {boolean | undefined} [launchEnabled] - * @property {boolean | undefined} [docsEnabled] - * @property {boolean | undefined} [subscribeEnabled] * @property {string | null | undefined} [icon] - * @property {ProjectSubscribeConfig | null | undefined} [subscribe] + * @property {ProjectLaunchConfig} launch + * @property {ProjectDocsConfig} docs + * @property {ProjectSubscribeConfig} subscribe + */ + +/** + * @typedef {Object} ProjectLaunchConfig + * @property {boolean} enabled + * @property {string | undefined} [url] + */ + +/** + * @typedef {Object} ProjectDocsConfig + * @property {boolean} enabled + * @property {string | undefined} [url] */ /** * @typedef {Object} ProjectSubscribeConfig - * @property {string} script + * @property {boolean} enabled + * @property {string | undefined} [script] * @property {number | undefined} [height] * @property {string | undefined} [title] * @property {string | undefined} [copy] @@ -51,34 +61,18 @@ const STATUS_CLASS = Object.freeze({ const FLIPPABLE_STATUSES = ["Beta", "WIP"]; /** - * Generates inline HTML used inside the subscribe iframe so the LoopAware script - * can render its real widget without leaking global styles into the page. - * @param {string} scriptUrl - * @returns {string} + * Loads the LoopAware subscribe script with target parameter (LA-113). + * @param {string} scriptUrl - Base URL with query parameters + * @param {string} targetId - ID of the element to render the form into */ -function buildSubscribeFrameDocument(scriptUrl) { - const safeUrl = String(scriptUrl).replace(/"/g, """); - return ` - - - - - - - - - -`; +function loadSubscribeScript(scriptUrl, targetId) { + const url = new URL(scriptUrl); + url.searchParams.set("target", targetId); + + const script = document.createElement("script"); + script.src = url.toString(); + script.async = true; + document.head.appendChild(script); } /** @@ -112,10 +106,8 @@ function buildProjectCard(project) { const inner = document.createElement("div"); inner.className = "project-card-inner"; - const subscribeConfig = project.subscribe && project.subscribe.script ? project.subscribe : null; - const subscribeEnabled = - Boolean(subscribeConfig) && (project.subscribeEnabled !== false); - const hasSubscribeWidget = subscribeEnabled; + const subscribeConfig = project.subscribe; + const hasSubscribeWidget = subscribeConfig.enabled && Boolean(subscribeConfig.script); const isFlippable = hasSubscribeWidget || FLIPPABLE_STATUSES.includes(project.status); if (isFlippable) { card.classList.add("project-card-flippable"); @@ -157,17 +149,17 @@ function buildProjectCard(project) { const shouldShowLaunch = project.status !== "WIP" && - Boolean(project.app) && - (project.launchEnabled !== false); + project.launch.enabled && + Boolean(project.launch.url); - const shouldShowDocs = Boolean(project.docs) && (project.docsEnabled !== false); + const shouldShowDocs = project.docs.enabled && Boolean(project.docs.url); const actionsRow = document.createElement("div"); actionsRow.className = "card-actions"; - if (shouldShowLaunch && project.app) { + if (shouldShowLaunch) { const link = document.createElement("a"); - link.href = project.app; + link.href = /** @type {string} */ (project.launch.url); link.className = "card-action"; link.target = "_blank"; link.rel = "noreferrer noopener"; @@ -175,9 +167,9 @@ function buildProjectCard(project) { actionsRow.append(link); } - if (shouldShowDocs && project.docs) { + if (shouldShowDocs) { const docsLink = document.createElement("a"); - docsLink.href = project.docs; + docsLink.href = /** @type {string} */ (project.docs.url); docsLink.className = "card-action"; docsLink.target = "_blank"; docsLink.rel = "noreferrer noopener"; @@ -224,7 +216,7 @@ function buildProjectCard(project) { backBody.append(backCopy); let subscribeOverlay = null; - if (hasSubscribeWidget && subscribeConfig) { + if (hasSubscribeWidget) { card.classList.add("project-card-has-subscribe"); const subscribeWidget = document.createElement("div"); @@ -242,36 +234,52 @@ function buildProjectCard(project) { subscribeConfig.copy || "Leave your email to hear when this project ships new features and announcements."; - const subscribeFrame = document.createElement("iframe"); - subscribeFrame.className = "subscribe-widget-frame"; - subscribeFrame.loading = "lazy"; - subscribeFrame.title = `${project.name} subscribe form`; - subscribeFrame.setAttribute("aria-label", `Subscribe for ${project.name} updates`); - subscribeFrame.setAttribute("tabindex", "-1"); - const idealFrameHeight = Math.max(240, Math.min(subscribeConfig.height || 320, 420)); - subscribeFrame.dataset.frameHeight = String(idealFrameHeight); - subscribeWidget.append(subscribeHeading, subscribeBlurb, subscribeFrame); + // Container for LoopAware subscribe form (rendered by subscribe.js with target param) + const subscribeFormContainer = document.createElement("div"); + subscribeFormContainer.id = subscribeConfig.target; + subscribeFormContainer.className = "subscribe-form-container"; + + subscribeWidget.append(subscribeHeading, subscribeBlurb, subscribeFormContainer); subscribeOverlay = document.createElement("div"); subscribeOverlay.className = "project-card-subscribe-overlay"; subscribeOverlay.dataset.subscribeLoaded = "false"; subscribeOverlay.append(subscribeWidget); - let subscribeFrameLoaded = false; + let subscribeScriptLoaded = false; loadSubscribeWidget = () => { - if (subscribeFrameLoaded) return; - subscribeFrameLoaded = true; - const overlayRect = subscribeOverlay.getBoundingClientRect(); - const maxFrameHeight = Math.max(160, overlayRect.height - 32); - const ideal = Number(subscribeFrame.dataset.frameHeight) || 320; - const frameHeight = Math.min(ideal, maxFrameHeight); - subscribeFrame.style.minHeight = `${frameHeight}px`; - subscribeFrame.style.height = `${frameHeight}px`; - subscribeFrame.addEventListener("load", () => { - subscribeOverlay.dataset.subscribeLoaded = "true"; - }, {once: true}); - subscribeFrame.srcdoc = buildSubscribeFrameDocument(subscribeConfig.script); + if (subscribeScriptLoaded) return; + subscribeScriptLoaded = true; + loadSubscribeScript(subscribeConfig.script, subscribeConfig.target); + subscribeOverlay.dataset.subscribeLoaded = "true"; }; + + // Listen for successful subscription and flip card back after delay + subscribeFormContainer.addEventListener("loopaware:subscribe:success", () => { + const emailInput = /** @type {HTMLInputElement} */ ( + subscribeFormContainer.querySelector("#mp-subscribe-email") + ); + const submitButton = /** @type {HTMLElement} */ ( + subscribeFormContainer.querySelector("#mp-subscribe-submit") + ); + const statusElement = /** @type {HTMLElement} */ ( + subscribeFormContainer.querySelector("#mp-subscribe-status") + ); + + // Hide form inputs, keep status message visible + emailInput.style.display = "none"; + submitButton.style.display = "none"; + + setTimeout(() => { + card.classList.remove("is-flipped"); + card.setAttribute("aria-pressed", "false"); + // Reset form state for next flip + emailInput.style.display = ""; + emailInput.value = ""; + submitButton.style.display = ""; + statusElement.textContent = ""; + }, 2000); // 2 second delay to show success message + }); } back.append(backHeader, backBody); if (subscribeOverlay) { @@ -284,7 +292,8 @@ function buildProjectCard(project) { */ const toggleFlip = event => { const target = /** @type {HTMLElement} */ (event.target); - if (target.closest("a")) { + // Don't flip when interacting with links or form elements + if (target.closest("a, input, button, textarea, select, label, #mp-subscribe-form")) { return; } @@ -293,18 +302,14 @@ function buildProjectCard(project) { if (nowFlipped && loadSubscribeWidget) { loadSubscribeWidget(); } - - const iframe = card.querySelector(".subscribe-widget-frame"); - if (iframe) { - iframe.setAttribute("tabindex", nowFlipped ? "0" : "-1"); - } }; card.addEventListener("click", toggleFlip); card.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") { const target = /** @type {HTMLElement} */ (event.target); - if (target.closest("a")) { + // Don't flip when interacting with links or form elements + if (target.closest("a, input, button, textarea, select, label, #mp-subscribe-form")) { return; } @@ -325,9 +330,7 @@ function buildProjectCard(project) { */ function buildStatusBadge(status) { const badge = document.createElement("span"); - badge.className = "status-badge"; - const modifier = STATUS_CLASS[status]; - if (modifier) badge.classList.add(modifier); + badge.className = `status-badge ${STATUS_CLASS[status]}`; badge.textContent = status; return badge; } @@ -444,7 +447,7 @@ function layoutBandRows(grid) { } function setupHeroAudioToggle() { - const video = document.getElementById("hero-video"); + const video = /** @type {HTMLVideoElement | null} */ (document.getElementById("hero-video")); const toggle = document.getElementById("hero-sound-toggle"); if (!video || !toggle) return; @@ -459,10 +462,7 @@ function setupHeroAudioToggle() { }; const ensurePlayback = () => { - const playPromise = video.play(); - if (playPromise && typeof playPromise.then === "function") { - playPromise.catch(() => {}); - } + video.play().catch(() => {}); }; toggle.addEventListener("click", () => { diff --git a/styles.css b/styles.css index 53bb5f9..41321c1 100644 --- a/styles.css +++ b/styles.css @@ -470,10 +470,6 @@ a:focus { .subscribe-widget { padding: 1rem; - border-radius: 18px; - border: 1px solid var(--accent-outline); - background: rgba(0, 40, 46, 0.6); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); overflow: hidden; height: 100%; display: flex; @@ -494,42 +490,72 @@ a:focus { font-size: 0.95rem; } -.subscribe-widget-frame { +.subscribe-form-container { width: 100%; - border: 0; - border-radius: 16px; - background: transparent; - min-height: 240px; - height: 240px; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); - overflow: hidden; +} + +/* LoopAware subscribe form styling */ +#mp-subscribe-form { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + max-width: 100% !important; + font-family: "Space Grotesk", "Roboto", sans-serif !important; + color: var(--text-gold) !important; +} + +#mp-subscribe-form > div:first-child { + display: none !important; +} + +#mp-subscribe-form input { + background: rgba(255, 255, 255, 0.08) !important; + border: 1px solid rgba(255, 255, 255, 0.2) !important; + color: #fff !important; + font-family: inherit !important; +} + +#mp-subscribe-form input::placeholder { + color: rgba(255, 255, 255, 0.4) !important; +} + +#mp-subscribe-form button { + background: var(--accent-gold) !important; + color: var(--bg-body) !important; + font-family: inherit !important; +} + +#mp-subscribe-form button:hover { + filter: brightness(1.1); +} + +#mp-subscribe-form #mp-subscribe-status { + color: rgba(255, 255, 255, 0.7) !important; } .project-card-subscribe-overlay { position: absolute; - inset: 24px; + inset: 0; display: flex; flex-direction: column; gap: 0.75rem; opacity: 0; pointer-events: none; - transform: rotateY(180deg) translateY(12px); + transform: translateY(12px); transition: opacity 0.3s ease, transform 0.3s ease; z-index: 2; - backface-visibility: hidden; overflow: hidden; - padding: 0; - border-radius: 20px; - border: 1px solid rgba(255, 211, 105, 0.15); - background: radial-gradient(circle at top, rgba(3, 38, 46, 0.9), rgba(2, 20, 25, 0.85)); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.6); + padding: 1.5rem; + border-radius: 24px; + background: rgba(2, 20, 25, 0.95); backdrop-filter: blur(18px); } .project-card-flippable.is-flipped .project-card-subscribe-overlay { opacity: 1; pointer-events: auto; - transform: rotateY(180deg) translateY(0); + transform: translateY(0); } /* ---------- Footer ---------- */ diff --git a/tests/hero.spec.js b/tests/hero.spec.js index 17db2d4..75f4228 100644 --- a/tests/hero.spec.js +++ b/tests/hero.spec.js @@ -2,7 +2,7 @@ const {test, expect} = require("@playwright/test"); -/** @type {{projects: Array<{name: string, status: string, category: string, description: string, app?: string|null, launchEnabled?: boolean}>}} */ +/** @type {{projects: Array<{name: string, status: string, category: string, description: string, launch: {enabled: boolean, url?: string}, docs: {enabled: boolean, url?: string}, subscribe: {enabled: boolean, script?: string}}>}} */ const catalog = require("../data/projects.json"); test.describe("Marco Polo Research Lab landing page", () => { @@ -45,12 +45,12 @@ test.describe("Marco Polo Research Lab landing page", () => { const action = card.locator("a.card-action").first(); const expectLaunchVisible = project.status !== "WIP" && - Boolean(project.app) && - (project.launchEnabled !== false); + project.launch.enabled && + Boolean(project.launch.url); await expect(action).toHaveCount(expectLaunchVisible ? 1 : 0); - if (expectLaunchVisible) { - await expect(action).toHaveAttribute("href", project.app); + if (expectLaunchVisible && project.launch.url) { + await expect(action).toHaveAttribute("href", project.launch.url); } } }); @@ -73,10 +73,13 @@ test.describe("Marco Polo Research Lab landing page", () => { const classesAfterClick = await card.getAttribute("class"); + const hasActiveSubscribe = + project.subscribe.enabled && + Boolean(project.subscribe.script); const shouldFlip = project.status === "Beta" || project.status === "WIP" || - Boolean(project.subscribe); + hasActiveSubscribe; if (shouldFlip) { expect(classesAfterClick || "").toMatch(/is-flipped/); @@ -184,9 +187,8 @@ test.describe("Marco Polo Research Lab landing page", () => { test("subscribe-enabled cards render LoopAware forms after flipping", async ({page}) => { const subscribeProjects = catalog.projects.filter( project => - project.subscribe && - project.subscribe.script && - project.subscribeEnabled !== false, + project.subscribe.enabled && + Boolean(project.subscribe.script), ); await page.goto("/index.html"); @@ -197,36 +199,23 @@ test.describe("Marco Polo Research Lab landing page", () => { const badge = card.locator(".status-badge").first(); const overlay = card.locator(".project-card-subscribe-overlay"); - const iframeElement = card.locator(".subscribe-widget-frame"); + const formContainer = card.locator(".subscribe-form-container"); await expect(overlay).toHaveAttribute("data-subscribe-loaded", "false"); - await expect( - iframeElement, - `${project.name} iframe should be unfocusable before flip`, - ).toHaveAttribute("tabindex", "-1"); await badge.click(); - const cardBack = card.locator(".project-card-face.project-card-back"); - const frame = cardBack.frameLocator(".subscribe-widget-frame"); - const loopAwareForm = frame.locator("#mp-subscribe-form"); - await expect(loopAwareForm, `${project.name} LoopAware form should render inside iframe`).toBeVisible(); - await expect(frame.locator("input[type='email']")).toBeVisible(); + // Form is rendered directly in the page (no iframe) via LoopAware subscribe.js + const loopAwareForm = formContainer.locator("#mp-subscribe-form"); + await expect(loopAwareForm, `${project.name} LoopAware form should render`).toBeVisible(); + await expect(formContainer.locator("input[type='email']")).toBeVisible(); await expect( - frame.locator("button"), + formContainer.locator("button"), `${project.name} LoopAware widget should expose a CTA button`, ).toContainText(/subscribe|notify/i); await expect(overlay).toHaveAttribute("data-subscribe-loaded", "true"); - await expect( - iframeElement, - `${project.name} iframe should be focusable after flip`, - ).toHaveAttribute("tabindex", "0"); await badge.click(); - await expect( - iframeElement, - `${project.name} iframe should be unfocusable after unflip`, - ).toHaveAttribute("tabindex", "-1"); } }); });