diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0ba255e..a95deec 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -58,6 +58,8 @@ jobs:
for f in themes/puma/assets/js/*.js; do node --check "$f"; done
- name: Tiger.t() contract
run: node tests/js/tiger.i18n.test.js
+ - name: TigerButton busy state
+ run: node tests/js/tiger.button.test.js
# Needs a database — one PHP version is enough; the schema, not the interpreter, is under test.
integration:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c2d6cb..2fac286 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows
## [Unreleased]
+## [1.6.4] — 2026-09-14
+
+### Fixed
+
+- **"Continue with Google" busy state no longer breaks the button's layout.** `TigerButton` prepends a
+ spinner to buttons without a FontAwesome icon; on the Google button that landed outside the flex
+ content wrapper and pushed the label onto a second line while the settings saved. Buttons can now
+ name where the spinner goes with `data-tg-busy-host`; the Google button hosts it after its label.
+ First tests for `tiger.button.js` (`tests/js/tiger.button.test.js`, in the `javascript` CI job).
+
## [1.6.3] — 2026-09-14
**Google Analytics connect flow brought in line with Google's OAuth branding and privacy guidelines.**
diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php
index be6cc32..36abe23 100644
--- a/library/Tiger/Version.php
+++ b/library/Tiger/Version.php
@@ -9,5 +9,5 @@
class Tiger_Version
{
/** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */
- const VERSION = '1.6.3';
+ const VERSION = '1.6.4';
}
diff --git a/modules/analytics/views/scripts/admin/index.phtml b/modules/analytics/views/scripts/admin/index.phtml
index 624c81a..c2ceba7 100644
--- a/modules/analytics/views/scripts/admin/index.phtml
+++ b/modules/analytics/views/scripts/admin/index.phtml
@@ -163,7 +163,7 @@ $mode = ($this->ga['mode'] === 'byo') ? 'byo' : 'broker';
- = $this->escape($this->t('analytics.connect_google')) ?>
+ = $this->escape($this->t('analytics.connect_google')) ?>
= $this->t('analytics.connect_hint') ?>
diff --git a/tests/js/tiger.button.test.js b/tests/js/tiger.button.test.js
new file mode 100644
index 0000000..81fac5a
--- /dev/null
+++ b/tests/js/tiger.button.test.js
@@ -0,0 +1,105 @@
+/* SPDX-License-Identifier: BSD-3-Clause
+ * Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
+ *
+ * TigerButton busy state (themes/puma/assets/js/tiger.button.js) — where the temporary spinner lands.
+ *
+ * A button with no FontAwesome icon gets a spinner injected. Prepending it to the button breaks any
+ * button with its own internal layout (the Google sign-in button: an absolutely positioned state
+ * layer plus a flex content wrapper — the spinner became a third child and the label wrapped onto a
+ * second line). data-tg-busy-host names the element the spinner goes into instead.
+ *
+ * node tests/js/tiger.button.test.js
+ */
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+/** Minimal element: enough of the DOM for busy()/done() — children, querySelector by attribute/tag. */
+function el(tag, attrs, children) {
+ const node = {
+ tagName: tag, className: '', attrs: Object.assign({}, attrs || {}), children: children || [],
+ dataset: {}, disabled: false, textContent: (attrs && attrs.text) || '',
+ setAttribute(k, v) { this.attrs[k] = String(v); }, removeAttribute(k) { delete this.attrs[k]; },
+ appendChild(c) { c.parent = this; this.children.push(c); return c; },
+ insertBefore(c, ref) { c.parent = this; const i = this.children.indexOf(ref); this.children.splice(i < 0 ? this.children.length : i, 0, c); return c; },
+ remove() { const i = this.parent.children.indexOf(this); this.parent.children.splice(i, 1); },
+ querySelector(sel) {
+ const walk = (n) => { for (const c of n.children) { if (match(c, sel)) { return c; } const d = walk(c); if (d) { return d; } } return null; };
+ return walk(this);
+ },
+ };
+ node.children.forEach((c) => { c.parent = node; });
+ return node;
+}
+function match(n, sel) {
+ return sel.split(',').map((s) => s.trim()).some((s) => {
+ if (s[0] === '[') { return n.attrs[s.slice(1, -1)] != null; }
+ if (s.indexOf('.') === 0) { return (' ' + n.className + ' ').indexOf(' ' + s.slice(1) + ' ') >= 0; }
+ return n.tagName === s.split('.')[0] && (s.indexOf('.') < 0 || match(n, '.' + s.split('.').slice(1).join('.')));
+ });
+}
+
+function boot() {
+ const document = { createElement: (t) => el(t) };
+ const window = { performance: { now: () => 0 } };
+ const src = fs.readFileSync(path.join(__dirname, '../../themes/puma/assets/js/tiger.button.js'), 'utf8');
+ vm.runInNewContext(src, { window, document, Promise, setTimeout });
+ return window.TigerButton;
+}
+
+let passed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(' ok ' + name); }
+ catch (e) { console.error(' FAIL ' + name + '\n ' + e.message); process.exitCode = 1; }
+}
+
+test('a plain text button gets the spinner prepended to the button itself (unchanged behaviour)', () => {
+ const TB = boot();
+ const btn = el('button', { text: 'Save' });
+ TB.busy(btn);
+ assert.strictEqual(btn.disabled, true);
+ assert.strictEqual(btn.children.length, 1);
+ assert.strictEqual(btn.children[0].attrs['data-tg-injected'], '1');
+ assert.ok(/fa-spinner/.test(btn.children[0].className));
+ assert.ok(/\bme-2\b/.test(btn.children[0].className), 'leading spinner spaces to the right');
+});
+
+test('with data-tg-busy-host the spinner is appended INSIDE the host, not prepended to the button', () => {
+ const TB = boot();
+ const label = el('span', { 'data-tg-busy-host': '', text: 'Continue with Google' });
+ const wrapper = el('span', {}, [el('span', { class: 'icon' }), label]);
+ const btn = el('button', { text: 'Continue with Google' }, [el('span', { class: 'state' }), wrapper]);
+ TB.busy(btn);
+ assert.strictEqual(btn.children.length, 2, 'button children untouched — the layout is intact');
+ assert.strictEqual(label.children.length, 1);
+ const spin = label.children[0];
+ assert.strictEqual(spin.attrs['data-tg-injected'], '1');
+ assert.ok(/fa-spinner/.test(spin.className));
+ assert.ok(/\bms-2\b/.test(spin.className), 'trailing spinner spaces to the left');
+});
+
+test('done() removes the hosted spinner and re-enables the button', () => {
+ const TB = boot();
+ const label = el('span', { 'data-tg-busy-host': '', text: 'Continue with Google' });
+ const btn = el('button', { text: 'Continue with Google' }, [label]);
+ TB.busy(btn); TB.done(btn);
+ assert.strictEqual(label.children.length, 0);
+ assert.strictEqual(btn.disabled, false);
+ assert.strictEqual(btn.attrs['aria-busy'], undefined);
+});
+
+test('a button with a FontAwesome icon still swaps the icon and injects nothing', () => {
+ const TB = boot();
+ const ic = el('i', {}); ic.className = 'fa-solid fa-floppy-disk me-2';
+ const btn = el('button', { text: 'Save' }, [ic]);
+ TB.busy(btn);
+ assert.strictEqual(btn.children.length, 1);
+ assert.strictEqual(ic.className, 'me-2 fa-solid fa-spinner fa-spin');
+ TB.done(btn);
+ assert.strictEqual(ic.className, 'fa-solid fa-floppy-disk me-2');
+});
+
+console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')');
diff --git a/themes/puma/assets/js/tiger.button.js b/themes/puma/assets/js/tiger.button.js
index 4afd0c1..b7e38ab 100644
--- a/themes/puma/assets/js/tiger.button.js
+++ b/themes/puma/assets/js/tiger.button.js
@@ -16,6 +16,10 @@
*
* Non-FontAwesome classes (spacing like me-2) are preserved across the swap.
*
+ * Buttons whose icon is NOT FontAwesome (a brand mark as inline SVG, say) can name where the
+ * temporary spinner goes with data-tg-busy-host on a descendant; it is appended there, inside
+ * the button's own layout, instead of being prepended to the button and breaking that layout.
+ *
* Convention: buttons are always