From 6387d685cc8072461f7605cb41165776ff20f600 Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 16:39:03 +0200 Subject: [PATCH 1/6] Fix missing styles under Lit 3 by calling super.observedAttributes ArButton overrode the static observedAttributes getter without calling super. That getter is the only thing that triggers ReactiveElement's finalize(), which computes elementStyles from `static styles`. Without it the shadow root received no stylesheet at all, so `.hidden { display: none !important }` never applied: the QR and browser-unsupported popups rendered permanently and every element fell back to display:inline. The override was always incorrect but harmless under Lit 2, where @property() -> createProperty() called finalize() itself. Lit 3 replaced that with a lighter __prepare(), leaving observedAttributes as the sole trigger, so the dependency upgrade in 9be1ae2 exposed it. Verified: elementStyles 0 -> 1, both popups back to display:none, button renders inline-flex again. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ar-button.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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') From d8c941b2a42da4920d98057b08b298e78a8de2ba Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 16:39:13 +0200 Subject: [PATCH 2/6] Fix deploy scripts: target dir, drop --delete, pair build with deploy Three bugs, each verified against the server with rsync --dry-run: - Wrong directory level: dist/ was synced to the docroot, one level above the ar-button/ folder that is actually served, so deployed files were never read. - --delete at the docroot removed the live ar-button/ directory and the .htaccess that sets the CORS headers. - deploy did not build, so deploy:prod run after build:dev would publish a dev-configured bundle (dev.yago.cloud backend, dev model slug) to production. NODE_ENV bakes those in at build time, so the build and deploy targets must not be mixed. Source maps stay excluded from prod, matching what is deployed there now. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6a46b1d..c6c60fb 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "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/" + "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", From fa045bb225deb2a0c718d583e5d70d451f70e365 Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 17:04:38 +0200 Subject: [PATCH 3/6] Point index.html at source so yarn serve works index.html referenced ./dist/ar-button.min.js, a build output. Parcel treats the page as the serve entry and resolves that script tag, so on a fresh clone `yarn install && yarn serve` (the README's instructions) failed outright: dist/ is gitignored. Even with a dist/ present it only resolves after build:prod, since build:dev emits ar-button.js. Pointing at src/ar-button.ts also restores rebuild-on-change, which is lost when serving a prebuilt bundle. build:dev/build:prod are unaffected, they use the "source" field rather than index.html. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index f8dfb4b..e7f1606 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ Yago AR Button - +

Yago AR Button

From 34093407766b399874ed219a6a0f171680777b1b Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 17:13:58 +0200 Subject: [PATCH 4/6] fixed CI --- .github/workflows/ar_button.yml | 21 +++++---- package.json | 2 + test/smoke.mjs | 78 +++++++++++++++++++++++++++++++++ yarn.lock | 19 ++++++++ 4 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 test/smoke.mjs 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/package.json b/package.json index c6c60fb..3b5bede 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "serve": "parcel ./index.html --port 8080 --target default", "watch": "parcel watch --target default", "build:dev": "rm -rf dist/* && NODE_ENV=development parcel build --target default", + "test:smoke": "node test/smoke.mjs", "build:prod": "rm -rf dist/* && NODE_ENV=production parcel build --target default && mv dist/ar-button.js dist/ar-button.min.js", "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/" @@ -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/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" From c7b13eeb42167d2f0147f3062d50d988bea5bd8a Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 17:33:24 +0200 Subject: [PATCH 5/6] Rename the dev harness to demo.html so it is not a build entry Parcel auto-detects index.html in the project root as an entry. That went unnoticed while the page pointed at ./dist/ar-button.min.js, since the reference did not resolve. Once fa045bb repointed it at ./src/ar-button.ts the page became buildable, so `yarn build:dev` started emitting index.html plus a ~326 kB page bundle into dist/ - which the deploy would have rsynced straight to the CDN. Passing the entry explicitly does not help, the detection is by filename, so the harness is renamed instead. dist/ now holds only the component bundle, and `yarn serve` still works. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html => demo.html | 0 package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename index.html => demo.html (100%) diff --git a/index.html b/demo.html similarity index 100% rename from index.html rename to demo.html diff --git a/package.json b/package.json index 3b5bede..694148a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ } }, "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", "test:smoke": "node test/smoke.mjs", From 51ad97e506e2faba9a5ec5b14f222f2cc47650a0 Mon Sep 17 00:00:00 2001 From: Raphael Anderegg Date: Wed, 22 Jul 2026 17:33:53 +0200 Subject: [PATCH 6/6] Restore Cache-Control, ship both filenames, publish identifiable builds Three related gaps in how dist/ is published, all handled by a postbuild step so the layout is produced in one place. Cache-Control: the gsutil deploys used to set it explicitly (no-store on dev, max-age on prod). The move to rsync in 950b9e0 dropped that, and nothing noticed because .htaccess only ever existed on the server. It is now in the repo per environment and shipped with the bundle, into the ar-button/ directory. Apache merges it with the docroot .htaccess that sets CORS; the two touch different headers so they do not collide. Without it responses carry no Cache-Control at all and browsers fall back to heuristic caching, holding a stale bundle for weeks. Filenames: build:prod used to `mv` the bundle to ar-button.min.js, so each environment served one name and only one. That name has shipped in customer embeds since the Vue CLI days and can never disappear, so it is now a copy rather than a rename. Both environments publish both names, which lets both move to ar-button.js without breaking anything already embedded. Identifiability: the stable names cannot be hashed, since customers control those references. Instead each build also publishes an immutable content-addressed copy (cacheable for a year, and available to pin against) plus a version.json naming the version, hash and commit that is live. Answering "which build is deployed?" previously meant comparing CDN Last-Modified headers by hand. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/htaccess.dev | 20 ++++++++++++ deploy/htaccess.prod | 19 +++++++++++ package.json | 4 +-- scripts/postbuild.mjs | 76 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 deploy/htaccess.dev create mode 100644 deploy/htaccess.prod create mode 100644 scripts/postbuild.mjs 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 694148a..7d0da22 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "scripts": { "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: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 && mv dist/ar-button.js dist/ar-button.min.js", + "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/" }, 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}`);