diff --git a/README.md b/README.md
index 2ed77beb5b..c0c6ab3b95 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz
## Project status
-The current source compatibility target is `1.6.156`. It retains the existing Node 24, Ember, Sass,
+The current source compatibility target is `1.6.157`. It retains the existing Node 24, Ember, Sass,
dependency, browser-smoke, terminal, console, and test-harness modernization.
It adds a provider-neutral OpenID Connect administration and sign-in flow with
PKCE S256, staged configuration validation, a real test login before
@@ -16,11 +16,17 @@ activation, and local-authentication recovery. Product-owned names, logos,
icons, package metadata, and visible text use PastureStack branding. API
models and protocol fields remain compatible.
-Release `1.6.156` places authenticated-page notices in the normal layout flow
-between the navigation and main content, so an error cannot cover the page
-title or right-side actions. Login/MFA notices keep their existing body host.
-See the [release note](docs/releases/web-console-1.6.156.md) for focused tests;
-packaged Server/browser acceptance remains a separate gate.
+Release `1.6.157` mounts authenticated-page notices when their component enters
+the DOM. This covers the fresh direct-URL denial missed by `1.6.156`'s route
+render callback while keeping login/MFA notices on the body. The focused source
+tests pass; packaged Server/browser acceptance is still pending. See the
+[release note](docs/releases/web-console-1.6.157.md).
+
+Release `1.6.156` introduced the in-flow authenticated notice layout, but
+Server v1.6.490 QA found that a fresh readonly direct create URL still left
+the notice fixed on the body and overlapping header actions. An in-app
+transition did mount it correctly. See the
+[1.6.156 release note](docs/releases/web-console-1.6.156.md).
Release `1.6.155` moved global notices below the navigation bar and bounded
their width. QA 8080 browser acceptance subsequently found that the fixed
diff --git a/app/authenticated/route.js b/app/authenticated/route.js
index 746f79e0bb..9c9c860f4f 100644
--- a/app/authenticated/route.js
+++ b/app/authenticated/route.js
@@ -38,7 +38,6 @@ export function projectIdFromTransition(transition) {
export default Route.extend(Subscribe, PromiseToCb, {
catalog : service(),
- growl : service(),
prefs : service(),
projects : service(),
settings : service(),
@@ -159,12 +158,6 @@ export default Route.extend(Subscribe, PromiseToCb, {
let app = this.controllerFor('application');
this._super();
- scheduleOnce('afterRender', this, function() {
- let mount = document.getElementById('growl-mount');
- if (mount && mount.isConnected) {
- this.get('growl').placeContainer(mount);
- }
- });
if ( !this.controllerFor('application').get('isPopup') && this.get('projects.current') )
{
this.connectSubscribe();
@@ -184,9 +177,6 @@ export default Route.extend(Subscribe, PromiseToCb, {
},
deactivate() {
- // Keep the plugin's container alive when Ember removes this route's mount.
- this.get('growl').placeContainer(document.body);
-
this._super();
this.disconnectSubscribe();
cancel(this.get('testTimer'));
diff --git a/app/authenticated/template.hbs b/app/authenticated/template.hbs
index 4006c97478..ccb92af65a 100644
--- a/app/authenticated/template.hbs
+++ b/app/authenticated/template.hbs
@@ -7,7 +7,7 @@
goToPrevious="goToPrevious"
}}
{{/unless}}
-
+{{growl-mount}}
{{#if (and this.forceUpgrade this.projects.current.needsUpgrade)}}
{{project-upgrade model=this.projects.current}}
diff --git a/app/components/growl-mount/component.js b/app/components/growl-mount/component.js
new file mode 100644
index 0000000000..e900464471
--- /dev/null
+++ b/app/components/growl-mount/component.js
@@ -0,0 +1,20 @@
+import Component from '@ember/component';
+import { service } from '@ember/service';
+
+export default Component.extend({
+ elementId: 'growl-mount',
+ growl: service(),
+
+ didInsertElement() {
+ this._super(...arguments);
+ this.get('growl').placeContainer(this.element);
+ },
+
+ willDestroyElement() {
+ let container = document.getElementById('jGrowl');
+ if (container && container.parentNode === this.element) {
+ this.get('growl').placeContainer(document.body);
+ }
+ this._super(...arguments);
+ },
+});
diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
index 1ea9515f9c..f2c7fde1ee 100644
--- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
+++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.156",
+ "version": "1.6.157",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
- "version": "1.6.156",
+ "version": "1.6.157",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
diff --git a/docs/releases/web-console-1.6.156.md b/docs/releases/web-console-1.6.156.md
index 60855d28c8..7f2a8b5443 100644
--- a/docs/releases/web-console-1.6.156.md
+++ b/docs/releases/web-console-1.6.156.md
@@ -23,3 +23,12 @@ the navigation, title, and header actions at those widths. These are source
and local-browser checks; official CI, the immutable Server image, and a new
QA 8080 packaged-browser receipt must be verified separately. No API,
Engine, permission, or request-payload contract changes in this release.
+
+The subsequent isolated Server v1.6.490 / Web Console 1.6.156 QA run did not
+accept the layout. On a fresh readonly direct Stack create URL at 1440px, the
+denial notice remained fixed under `BODY` despite an empty `#growl-mount` and
+overlapped the right-side header actions. A same-page client-side transition
+did move the notice into the mount and clear the actions. The route's deferred
+mount callback therefore missed the fresh-load case; `1.6.157` moves the
+placement to the mount component's insertion lifecycle. This note records the
+packaged result separately from the earlier source and local-browser checks.
diff --git a/docs/releases/web-console-1.6.157.md b/docs/releases/web-console-1.6.157.md
new file mode 100644
index 0000000000..f0f92ed74b
--- /dev/null
+++ b/docs/releases/web-console-1.6.157.md
@@ -0,0 +1,25 @@
+# Web Console 1.6.157
+
+Server v1.6.490 packaged Web Console 1.6.156. In isolated 8080 browser QA, a
+readonly user opening `/apps/stacks/add` directly on a fresh page received
+the intended permission denial, but the notice remained fixed under `BODY`.
+The authenticated template contained an empty `#growl-mount`, and the notice
+overlapped the right-side header actions at 1440px (notice y55–136; actions
+y65–97). A client-side transition on the same page moved the notice into the
+mount and cleared the actions.
+Thus `1.6.156` did not pass packaged browser acceptance for this path.
+
+The route's deferred render callback could miss the mount on a fresh load.
+The authenticated template now renders a `growl-mount` component that moves
+the existing jGrowl container when its element enters the DOM. On teardown it
+returns the container to `BODY` only if that component still owns it. This
+preserves the running plugin instance and keeps login/MFA notices on their
+existing body host. The change does not alter route permission checks, API
+authorization, notice copy, or request payloads.
+
+`npm test -- --filter='growl'` passed 7/7 Chrome 153 QUnit tests, covering
+notice placement, component insertion and teardown, delete notices, and layout.
+The layout test covers light and dark themes, LTR and RTL, English, Traditional
+Chinese, and Japanese, at 1440px, 375px, and 280px. These checks use the source
+build. A newly packaged Server image and fresh direct-URL QA 8080 acceptance
+remain pending.
diff --git a/package-lock.json b/package-lock.json
index 1ea9515f9c..f2c7fde1ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.156",
+ "version": "1.6.157",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
- "version": "1.6.156",
+ "version": "1.6.157",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
diff --git a/package.json b/package.json
index 84d5510bfe..8046cc08c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/web-console",
- "version": "1.6.156",
+ "version": "1.6.157",
"private": true,
"description": "PastureStack browser console for the compatible control platform.",
"repository": {
diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers
index 7eca9d616c..16f66c2a69 100755
--- a/scripts/check-modernization-blockers
+++ b/scripts/check-modernization-blockers
@@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f:
print(json.load(f).get('version', ''))
PY
)
-if [[ "$version" != "1.6.156" ]]; then
- echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.156"
+if [[ "$version" != "1.6.157" ]]; then
+ echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.157"
failures=$((failures + 1))
fi
diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace
index d5051eb217..de1c0b2a1f 100755
--- a/scripts/check-ui-console-workspace
+++ b/scripts/check-ui-console-workspace
@@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI
fi
printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \
- 1.6.156 browser-session broker-broadcast
+ 1.6.157 browser-session broker-broadcast
diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies
index 6c5be3fa5f..e7bc6be960 100755
--- a/scripts/check-ui-critical-high-dependencies
+++ b/scripts/check-ui-critical-high-dependencies
@@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes:
lock = json.loads(lock_bytes)
packages = lock.get("packages", {})
root = packages.get("", {})
-if package.get("version") != "1.6.156":
+if package.get("version") != "1.6.157":
fail(f"unexpected Web Console version: {package.get('version')}")
if root.get("version") != package.get("version"):
fail(f"lock root version differs: {root.get('version')}")
diff --git a/tests/integration/components/growl-mount-test.js b/tests/integration/components/growl-mount-test.js
new file mode 100644
index 0000000000..1a3795a547
--- /dev/null
+++ b/tests/integration/components/growl-mount-test.js
@@ -0,0 +1,73 @@
+import { module, test } from 'qunit';
+import $ from 'jquery';
+import { clearRender, render, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers';
+import { precompileTemplate } from '@ember/template-compilation';
+import resolver from '../../helpers/resolver';
+import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts';
+
+module('Integration | Component | growl mount', function(hooks) {
+ hooks.beforeEach(async function() {
+ this.testRoot = document.createElement('div');
+ this.testRoot.id = 'ember-testing';
+ document.body.appendChild(this.testRoot);
+ await setupContext(this, {resolver});
+ initializePodLayouts();
+ await setupRenderingContext(this);
+ });
+
+ hooks.afterEach(async function() {
+ await teardownContext(this);
+ let container = document.getElementById('jGrowl');
+ if (container) {
+ $(container).jGrowl('shutdown');
+ container.remove();
+ }
+ this.testRoot.remove();
+ });
+
+ test('moves an early denial into the rendered mount and back for login or MFA', async function(assert) {
+ let growl = this.owner.lookup('service:growl');
+ growl.error('Action unavailable', 'No create permission');
+ let container = document.getElementById('jGrowl');
+ let instance = $(container).data('jGrowl.instance');
+ assert.strictEqual(container.parentNode, document.body,
+ 'a denial raised before the authenticated template renders starts on the body');
+
+ await render(precompileTemplate('{{growl-mount}}'));
+ let mount = document.getElementById('growl-mount');
+ assert.strictEqual(container.parentNode, mount,
+ 'the component moves the existing notice when the mount actually enters the DOM');
+ assert.strictEqual(getComputedStyle(container).position, 'static',
+ 'authenticated notices reserve space above main content');
+ assert.strictEqual($(container).data('jGrowl.instance'), instance,
+ 'the running plugin instance is preserved');
+
+ await clearRender();
+ assert.strictEqual(container.parentNode, document.body,
+ 'leaving the authenticated view returns the notice to the body');
+ assert.strictEqual(getComputedStyle(container).position, 'fixed',
+ 'login and MFA retain the global notice position');
+ growl.raw('Login error', 'Try again');
+ assert.strictEqual(container.parentNode, document.body,
+ 'login and MFA notices remain outside the authenticated mount');
+ assert.strictEqual($(container).data('jGrowl.instance'), instance,
+ 'the same plugin instance remains active');
+ });
+
+ test('destroying an old mount does not reclaim a container moved elsewhere', async function(assert) {
+ let growl = this.owner.lookup('service:growl');
+ growl.error('Action unavailable', 'No create permission');
+ await render(precompileTemplate('{{growl-mount}}'));
+ let container = document.getElementById('jGrowl');
+ let nextMount = document.createElement('div');
+ document.body.appendChild(nextMount);
+
+ growl.placeContainer(nextMount);
+ await clearRender();
+ assert.strictEqual(container.parentNode, nextMount,
+ 'the old mount leaves another destination in control of the plugin');
+
+ growl.placeContainer(document.body);
+ nextMount.remove();
+ });
+});
diff --git a/tests/unit/vendor/growl-viewport-layout-test.js b/tests/unit/vendor/growl-viewport-layout-test.js
index d746a774a7..d437819291 100644
--- a/tests/unit/vendor/growl-viewport-layout-test.js
+++ b/tests/unit/vendor/growl-viewport-layout-test.js
@@ -2,6 +2,17 @@ import { module, test } from 'qunit';
module('Unit | Vendor | Growl viewport layout');
+const copy = {
+ 'zh-tw': {title: '使用者堆疊', action: '狀態',
+ header: '無法執行此動作', message: '您沒有權限在此環境中建立此資源。'},
+ 'en-us': {title: 'User Stacks', action: 'State',
+ header: 'Action unavailable',
+ message: 'You do not have permission to create this resource in this environment.'},
+ 'ja-jp': {title: 'ユーザースタック', action: '状態',
+ header: '操作できません',
+ message: 'この環境でこのリソースを作成する権限がありません。'},
+};
+
function growlFrame(asset, direction, headingLayout) {
return new Promise((resolve, reject) => {
let frame = document.createElement('iframe');
@@ -47,37 +58,51 @@ test('global notices clear the navbar and page titles and fit the viewport', asy
for (let width of [1440, 375, 280]) {
frame.style.width = `${width}px`;
await new Promise((resolve) => frame.contentWindow.requestAnimationFrame(resolve));
+ for (let locale of Object.keys(copy)) {
+ page.documentElement.lang = locale;
+ page.querySelector('.jGrowl-header').textContent = copy[locale].header;
+ page.querySelector('.jGrowl-message').textContent = copy[locale].message;
+ page.querySelector('main h1').textContent = copy[locale].title;
+ page.querySelector('main .pull-right button').textContent = copy[locale].action;
- let navbar = page.querySelector('nav.navbar').getBoundingClientRect();
- let bounds = notice.getBoundingClientRect();
- let close = page.querySelector('.jGrowl-close').getBoundingClientRect();
- let main = page.querySelector('main').getBoundingClientRect();
- let title = page.querySelector('main h1').getBoundingClientRect();
- let action = page.querySelector('main .btn').getBoundingClientRect();
- let headerActions = page.querySelector('main .pull-right').getBoundingClientRect();
- let context = `${asset} ${headingLayout} ${width}px`;
+ let navbar = page.querySelector('nav.navbar').getBoundingClientRect();
+ let mount = page.querySelector('#growl-mount').getBoundingClientRect();
+ let bounds = notice.getBoundingClientRect();
+ let close = page.querySelector('.jGrowl-close').getBoundingClientRect();
+ let main = page.querySelector('main').getBoundingClientRect();
+ let title = page.querySelector('main h1').getBoundingClientRect();
+ let action = page.querySelector('main .btn').getBoundingClientRect();
+ let headerActions = page.querySelector('main .pull-right').getBoundingClientRect();
+ let context = `${asset} ${headingLayout} ${locale} ${width}px`;
- assert.ok(bounds.top >= navbar.bottom, `${context}: notice starts below the navbar`);
- assert.ok(bounds.left >= -1 && bounds.right <= width + 1,
- `${context}: notice stays within the viewport`);
- assert.ok(bounds.bottom <= frame.contentWindow.innerHeight + 1,
- `${context}: notice stays above the viewport bottom`);
- assert.ok(bounds.width > 0 && bounds.height > 0, `${context}: notice remains visible`);
- assert.ok(close.left >= bounds.left && close.right <= bounds.right,
- `${context}: dismiss control remains inside the notice`);
- assert.ok(close.top >= bounds.top && close.bottom <= bounds.bottom,
- `${context}: dismiss control stays within the notice vertically`);
- assert.strictEqual(frame.contentWindow.getComputedStyle(notice).opacity, '1',
- `${context}: notice remains opaque and readable`);
+ assert.strictEqual(notice.parentElement.parentElement.id, 'growl-mount',
+ `${context}: notice is inside the authenticated mount`);
+ assert.strictEqual(frame.contentWindow.getComputedStyle(notice.parentElement).position,
+ 'static', `${context}: the notice container participates in page layout`);
+ assert.ok(mount.bottom >= bounds.bottom,
+ `${context}: the mount reserves the notice height`);
+ assert.ok(bounds.top >= navbar.bottom, `${context}: notice starts below the navbar`);
+ assert.ok(bounds.left >= -1 && bounds.right <= width + 1,
+ `${context}: notice stays within the viewport`);
+ assert.ok(bounds.bottom <= frame.contentWindow.innerHeight + 1,
+ `${context}: notice stays above the viewport bottom`);
+ assert.ok(bounds.width > 0 && bounds.height > 0, `${context}: notice remains visible`);
+ assert.ok(close.left >= bounds.left && close.right <= bounds.right,
+ `${context}: dismiss control remains inside the notice`);
+ assert.ok(close.top >= bounds.top && close.bottom <= bounds.bottom,
+ `${context}: dismiss control stays within the notice vertically`);
+ assert.strictEqual(frame.contentWindow.getComputedStyle(notice).opacity, '1',
+ `${context}: notice remains opaque and readable`);
- assert.ok(main.top >= bounds.bottom,
- `${context}: main content starts below the notice`);
- assert.ok(title.top >= bounds.bottom,
- `${context}: page title starts below the notice`);
- assert.ok(action.top >= bounds.bottom,
- `${context}: page action starts below the notice`);
- assert.ok(headerActions.top >= bounds.bottom && headerActions.right <= width + 1,
- `${context}: header actions clear the notice and fit the viewport`);
+ assert.ok(main.top >= bounds.bottom,
+ `${context}: main content starts below the notice`);
+ assert.ok(title.top >= bounds.bottom,
+ `${context}: page title starts below the notice`);
+ assert.ok(action.top >= bounds.bottom,
+ `${context}: page action starts below the notice`);
+ assert.ok(headerActions.top >= bounds.bottom && headerActions.right <= width + 1,
+ `${context}: header actions clear the notice and fit the viewport`);
+ }
}
frame.remove();