diff --git a/.github/workflows/ar_button.yml b/.github/workflows/ar_button.yml
index f423f19..6d0b838 100644
--- a/.github/workflows/ar_button.yml
+++ b/.github/workflows/ar_button.yml
@@ -6,17 +6,22 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- - name: 📦 Set up node 12
- uses: actions/setup-node@v2
+ - name: 📦 Set up node
+ uses: actions/setup-node@v4
with:
- node-version: '12'
+ node-version: '20'
+ cache: yarn
- name: 💿 Install dependencies
- run: |
- npm install yarn
- yarn install --pure-lockfile
+ run: yarn install --frozen-lockfile
- name: 🏗️ Build
- run: yarn build:dev
\ No newline at end of file
+ run: yarn build:dev
+
+ - name: 🌐 Install browser
+ run: yarn playwright install --with-deps chromium
+
+ - name: 🚨 Smoke test
+ run: yarn test:smoke
diff --git a/index.html b/demo.html
similarity index 80%
rename from index.html
rename to demo.html
index f8dfb4b..e7f1606 100644
--- a/index.html
+++ b/demo.html
@@ -5,7 +5,7 @@
Yago AR Button
-
+
Yago AR Button
diff --git a/deploy/htaccess.dev b/deploy/htaccess.dev
new file mode 100644
index 0000000..62fbed5
--- /dev/null
+++ b/deploy/htaccess.dev
@@ -0,0 +1,20 @@
+# Deployed into the ar-button/ directory by `yarn deploy:dev`.
+#
+# Apache merges this with the docroot .htaccess, which sets the CORS headers.
+# Only Cache-Control is set here, so the two never collide.
+#
+# The gsutil deploys used to set "no-store, no-cache" on dev. That was lost in
+# the move to rsync (Dec 2025), leaving responses with no Cache-Control at all,
+# so browsers fell back to heuristic caching and held the bundle for weeks.
+
+
+ # Dev must never be cached: a redeploy has to be visible on reload.
+
+ Header set Cache-Control "no-store, no-cache, must-revalidate"
+
+
+ # Content-addressed builds never change, so they can be cached forever.
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+
diff --git a/deploy/htaccess.prod b/deploy/htaccess.prod
new file mode 100644
index 0000000..47b6ca2
--- /dev/null
+++ b/deploy/htaccess.prod
@@ -0,0 +1,19 @@
+# Deployed into the ar-button/ directory by `yarn deploy:prod`.
+#
+# Apache merges this with the docroot .htaccess, which sets the CORS headers.
+# Only Cache-Control is set here, so the two never collide.
+
+
+ # Customers embed these filenames directly, so they can never be renamed or
+ # hashed. They must stay revalidating instead, or a redeploy takes weeks to
+ # reach browsers. Revalidation is cheap: ETag turns it into a 304.
+
+ Header set Cache-Control "public, max-age=300, must-revalidate"
+
+
+ # Content-addressed builds never change, so they can be cached forever.
+ # Pin one of these if you need a build that cannot shift under you.
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+
diff --git a/package.json b/package.json
index 6a46b1d..7d0da22 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,13 @@
}
},
"scripts": {
- "serve": "parcel ./index.html --port 8080 --target default",
+ "serve": "parcel ./demo.html --port 8080 --target default",
"watch": "parcel watch --target default",
- "build:dev": "rm -rf dist/* && NODE_ENV=development parcel build --target default",
- "build:prod": "rm -rf dist/* && NODE_ENV=production parcel build --target default && mv dist/ar-button.js dist/ar-button.min.js",
- "deploy:dev": "rsync -avz --delete dist/ loxanimo@bitforge.ch:www/dist.dev.yago.cloud/",
- "deploy:prod": "rsync -avz --delete dist/ loxanimo@bitforge.ch:www/dist.yago.cloud/"
+ "build:dev": "rm -rf dist && NODE_ENV=development parcel build --target default && node scripts/postbuild.mjs dev",
+ "test:smoke": "node test/smoke.mjs",
+ "build:prod": "rm -rf dist && NODE_ENV=production parcel build --target default && node scripts/postbuild.mjs prod",
+ "deploy:dev": "yarn build:dev && rsync -avz dist/ loxanimo@bitforge.ch:www/dist.dev.yago.cloud/ar-button/",
+ "deploy:prod": "yarn build:prod && rsync -avz --exclude='*.map' dist/ loxanimo@bitforge.ch:www/dist.yago.cloud/ar-button/"
},
"author": "Bitforge AG",
"license": "MIT",
@@ -37,6 +38,7 @@
"@webcomponents/webcomponentsjs": "^2.8.0",
"buffer": "^6.0.3",
"parcel": "^2.16.4",
+ "playwright": "^1.58.2",
"prettier": "^3.8.1"
},
"customElements": "custom-elements.json"
diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs
new file mode 100644
index 0000000..f2e0dcd
--- /dev/null
+++ b/scripts/postbuild.mjs
@@ -0,0 +1,76 @@
+/**
+ * Post-build step: lay out dist/ so a deploy is both stable and identifiable.
+ *
+ * Produces, from the single bundle Parcel emits:
+ *
+ * ar-button.js stable entry, what new embeds should use
+ * ar-button.min.js identical copy under the legacy filename
+ * ar-button--.js immutable, content-addressed build
+ * version.json what is actually deployed here
+ * .htaccess per-environment Cache-Control
+ *
+ * Why a copy and not the old `mv`: production URLs have shipped as
+ * ar-button.min.js since the Vue CLI days and are embedded on customer sites,
+ * so that name can never disappear. Emitting both lets both environments move
+ * to ar-button.js without breaking anything already out there.
+ *
+ * The hashed copy exists because the stable names cannot be hashed - customers
+ * control those references. It gives something immutable to pin, and makes
+ * "which build is live?" answerable with one request instead of an
+ * investigation into CDN headers.
+ */
+import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { execSync } from 'node:child_process';
+import { join } from 'node:path';
+
+const env = process.argv[2];
+if (env !== 'dev' && env !== 'prod') {
+ console.error('usage: node scripts/postbuild.mjs ');
+ process.exit(1);
+}
+
+const DIST = 'dist';
+const ENTRY = join(DIST, 'ar-button.js');
+
+if (!existsSync(ENTRY)) {
+ console.error(`✗ ${ENTRY} not found - did the parcel build run?`);
+ process.exit(1);
+}
+
+const bundle = readFileSync(ENTRY);
+const hash = createHash('sha256').update(bundle).digest('hex').slice(0, 8);
+const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
+const immutable = `ar-button-${pkg.version}-${hash}.js`;
+
+// Legacy filename, kept forever for embeds already in the wild.
+copyFileSync(ENTRY, join(DIST, 'ar-button.min.js'));
+copyFileSync(ENTRY, join(DIST, immutable));
+
+const htaccess = join('deploy', `htaccess.${env}`);
+if (!existsSync(htaccess)) {
+ console.error(`✗ ${htaccess} not found`);
+ process.exit(1);
+}
+copyFileSync(htaccess, join(DIST, '.htaccess'));
+
+let commit = 'unknown';
+try {
+ commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
+ .toString()
+ .trim();
+} catch {
+ // building outside a git checkout is fine, just less traceable
+}
+
+const info = {
+ version: pkg.version,
+ hash,
+ commit,
+ env,
+ built: new Date().toISOString(),
+ files: { latest: 'ar-button.js', legacy: 'ar-button.min.js', immutable },
+};
+writeFileSync(join(DIST, 'version.json'), JSON.stringify(info, null, 2) + '\n');
+
+console.log(`postbuild (${env}): ${immutable} commit ${commit}`);
diff --git a/src/ar-button.ts b/src/ar-button.ts
index 2c28f00..760b68b 100644
--- a/src/ar-button.ts
+++ b/src/ar-button.ts
@@ -88,7 +88,12 @@ export class ArButton extends LitElement {
qrCodeAppended = false;
- static get observedAttributes() {return [ 'model', 'lang', 'text', 'qr-size', 'qr-title', 'qr-text', 'project-color' ]}
+ static get observedAttributes() {
+ // Must include super's list: that getter is what triggers Lit's
+ // finalize(), which builds elementStyles. Without it the component
+ // renders with no styles at all.
+ return [...super.observedAttributes, 'model', 'lang', 'text', 'qr-size', 'qr-title', 'qr-text', 'project-color'];
+ }
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
if (name == 'model')
diff --git a/test/smoke.mjs b/test/smoke.mjs
new file mode 100644
index 0000000..e284830
--- /dev/null
+++ b/test/smoke.mjs
@@ -0,0 +1,78 @@
+/**
+ * Smoke test: load the built bundle in a real browser and check the component
+ * actually renders with its styles applied.
+ *
+ * Guards the regression where ArButton overrode `static observedAttributes`
+ * without calling super. That getter is what triggers Lit's finalize(), which
+ * builds elementStyles from `static styles`. Without it the element got no
+ * stylesheet at all, so `.hidden { display: none }` never applied and the QR /
+ * browser-unsupported popups rendered permanently on top of the host page.
+ *
+ * The bundle still builds perfectly when this breaks, so building is not
+ * enough - it has to be rendered to be caught.
+ */
+import { chromium } from 'playwright';
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+
+const BUNDLE = process.env.SMOKE_BUNDLE ?? 'dist/ar-button.js';
+
+if (!existsSync(BUNDLE)) {
+ console.error(`✗ Missing ${BUNDLE} - run \`yarn build:dev\` first.`);
+ process.exit(1);
+}
+
+const browser = await chromium.launch();
+
+try {
+ const page = await browser.newPage();
+ await page.setContent('');
+ await page.addScriptTag({ path: BUNDLE });
+
+ await page.waitForFunction(
+ () => document.getElementById('probe')?.shadowRoot?.querySelector('ar-modal'),
+ null,
+ { timeout: 30000 }
+ );
+ await page.evaluate(() => document.getElementById('probe').updateComplete);
+
+ const result = await page.evaluate(() => {
+ const ctor = customElements.get('ar-button');
+ const el = document.getElementById('probe');
+ const modal = el.shadowRoot.querySelector('ar-modal');
+ const link = el.shadowRoot.querySelector('a.ar-link');
+ return {
+ elementStyles: ctor.elementStyles?.length ?? 0,
+ adoptedStyleSheets: el.shadowRoot.adoptedStyleSheets.length,
+ styleTags: el.shadowRoot.querySelectorAll('style').length,
+ qrModalDisplay: getComputedStyle(modal).display,
+ buttonDisplay: link ? getComputedStyle(link).display : '(no button)',
+ };
+ });
+
+ console.log('ar-button smoke test:', result);
+
+ assert.ok(
+ result.elementStyles >= 1,
+ '`static styles` were never finalized into elementStyles - does ArButton ' +
+ 'override observedAttributes without calling super.observedAttributes?'
+ );
+ assert.ok(
+ result.adoptedStyleSheets + result.styleTags >= 1,
+ 'shadow root has no stylesheet attached, the component will render unstyled'
+ );
+ assert.equal(
+ result.qrModalDisplay,
+ 'none',
+ 'QR popup should be hidden until the button is activated'
+ );
+ assert.equal(
+ result.buttonDisplay,
+ 'inline-flex',
+ 'AR button is not picking up its own styles'
+ );
+
+ console.log('✅ ar-button renders with its styles applied');
+} finally {
+ await browser.close();
+}
diff --git a/yarn.lock b/yarn.lock
index 39b96d7..b5fdf0c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1092,6 +1092,11 @@ exec-sh@^0.2.0:
dependencies:
merge "^1.2.0"
+fsevents@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
get-port@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119"
@@ -1361,6 +1366,20 @@ picomatch@^4.0.3:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+playwright-core@1.61.1:
+ version "1.61.1"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc"
+ integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==
+
+playwright@^1.58.2:
+ version "1.61.1"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc"
+ integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==
+ dependencies:
+ playwright-core "1.61.1"
+ optionalDependencies:
+ fsevents "2.3.2"
+
postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"