@aquera/nile-elements 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +6 -0
  2. package/demo/app-shell.html +852 -0
  3. package/demo/nxtgen-classes.css +2 -2
  4. package/demo/nxtgen-properties.css +2 -2
  5. package/demo/nxtgen-utilities.css +2 -2
  6. package/demo/nxtgen.css +2 -2
  7. package/demo/utilities.css +2 -2
  8. package/demo/variables.css +2 -2
  9. package/dist/index.cjs.js +1 -1
  10. package/dist/index.esm.js +1 -1
  11. package/dist/index.js +595 -302
  12. package/dist/internal/app-shell-utility-control-styles.cjs.js +2 -0
  13. package/dist/internal/app-shell-utility-control-styles.cjs.js.map +1 -0
  14. package/dist/internal/app-shell-utility-control-styles.esm.js +16 -0
  15. package/dist/nile-app-shell/index.cjs.js +1 -1
  16. package/dist/nile-app-shell/index.esm.js +1 -1
  17. package/dist/nile-app-shell/nile-app-shell.cjs.js +1 -1
  18. package/dist/nile-app-shell/nile-app-shell.cjs.js.map +1 -1
  19. package/dist/nile-app-shell/nile-app-shell.css.cjs.js +1 -1
  20. package/dist/nile-app-shell/nile-app-shell.css.cjs.js.map +1 -1
  21. package/dist/nile-app-shell/nile-app-shell.css.esm.js +277 -96
  22. package/dist/nile-app-shell/nile-app-shell.esm.js +67 -11
  23. package/dist/nile-app-shell/shell-controller.cjs.js.map +1 -1
  24. package/dist/nile-app-shell-main/nile-app-shell-main.cjs.js.map +1 -1
  25. package/dist/nile-app-shell-nav-toggle/index.cjs.js +1 -1
  26. package/dist/nile-app-shell-nav-toggle/index.esm.js +1 -1
  27. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.cjs.js +1 -1
  28. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.cjs.js.map +1 -1
  29. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.cjs.js +1 -1
  30. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.cjs.js.map +1 -1
  31. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.esm.js +7 -3
  32. package/dist/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.esm.js +3 -3
  33. package/dist/nile-app-shell-panel/index.cjs.js +1 -1
  34. package/dist/nile-app-shell-panel/index.esm.js +1 -1
  35. package/dist/nile-app-shell-panel/nile-app-shell-panel.cjs.js +1 -1
  36. package/dist/nile-app-shell-panel/nile-app-shell-panel.cjs.js.map +1 -1
  37. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js +1 -1
  38. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js.map +1 -1
  39. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.esm.js +32 -8
  40. package/dist/nile-app-shell-panel/nile-app-shell-panel.esm.js +18 -5
  41. package/dist/nile-app-shell-side-nav/nile-app-shell-side-nav.cjs.js.map +1 -1
  42. package/dist/nile-app-shell-side-nav/nile-app-shell-side-nav.css.cjs.js.map +1 -1
  43. package/dist/src/internal/app-shell-utility-control-styles.d.ts +12 -0
  44. package/dist/src/internal/app-shell-utility-control-styles.js +29 -0
  45. package/dist/src/internal/app-shell-utility-control-styles.js.map +1 -0
  46. package/dist/src/nile-app-shell/nile-app-shell-regions.test.d.ts +3 -0
  47. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js +210 -0
  48. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js.map +1 -1
  49. package/dist/src/nile-app-shell/nile-app-shell.css.d.ts +1 -9
  50. package/dist/src/nile-app-shell/nile-app-shell.css.js +277 -103
  51. package/dist/src/nile-app-shell/nile-app-shell.css.js.map +1 -1
  52. package/dist/src/nile-app-shell/nile-app-shell.d.ts +90 -122
  53. package/dist/src/nile-app-shell/nile-app-shell.js +235 -144
  54. package/dist/src/nile-app-shell/nile-app-shell.js.map +1 -1
  55. package/dist/src/nile-app-shell/nile-app-shell.test.d.ts +4 -0
  56. package/dist/src/nile-app-shell/nile-app-shell.test.js +1116 -29
  57. package/dist/src/nile-app-shell/nile-app-shell.test.js.map +1 -1
  58. package/dist/src/nile-app-shell/shell-controller.d.ts +1 -8
  59. package/dist/src/nile-app-shell/shell-controller.js +2 -14
  60. package/dist/src/nile-app-shell/shell-controller.js.map +1 -1
  61. package/dist/src/nile-app-shell-main/nile-app-shell-main.d.ts +2 -6
  62. package/dist/src/nile-app-shell-main/nile-app-shell-main.js +2 -6
  63. package/dist/src/nile-app-shell-main/nile-app-shell-main.js.map +1 -1
  64. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.d.ts +1 -4
  65. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.js +7 -5
  66. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.js.map +1 -1
  67. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.d.ts +4 -8
  68. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.js +5 -6
  69. package/dist/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.js.map +1 -1
  70. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js +31 -6
  71. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js.map +1 -1
  72. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.d.ts +18 -13
  73. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.js +45 -16
  74. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.js.map +1 -1
  75. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.d.ts +1 -4
  76. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.js +1 -4
  77. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.js.map +1 -1
  78. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.d.ts +4 -12
  79. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.js +4 -12
  80. package/dist/src/nile-app-shell-side-nav/nile-app-shell-side-nav.js.map +1 -1
  81. package/dist/src/version.js +1 -1
  82. package/dist/src/version.js.map +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/version.cjs.js +1 -1
  85. package/dist/version.cjs.js.map +1 -1
  86. package/dist/version.esm.js +1 -1
  87. package/package.json +1 -1
  88. package/plop-templates/lit/lit.test.ts.hbs +22 -0
  89. package/plop-templates/lit/lit.ts.hbs +10 -5
  90. package/plopfile.js +12 -0
  91. package/src/internal/app-shell-utility-control-styles.ts +30 -0
  92. package/src/nile-app-shell/nile-app-shell-regions.test.ts +252 -0
  93. package/src/nile-app-shell/nile-app-shell.css.ts +277 -103
  94. package/src/nile-app-shell/nile-app-shell.test.ts +1363 -29
  95. package/src/nile-app-shell/nile-app-shell.ts +249 -149
  96. package/src/nile-app-shell/shell-controller.ts +2 -14
  97. package/src/nile-app-shell-main/nile-app-shell-main.ts +2 -6
  98. package/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.css.ts +7 -5
  99. package/src/nile-app-shell-nav-toggle/nile-app-shell-nav-toggle.ts +7 -11
  100. package/src/nile-app-shell-panel/nile-app-shell-panel.css.ts +31 -6
  101. package/src/nile-app-shell-panel/nile-app-shell-panel.ts +44 -16
  102. package/src/nile-app-shell-side-nav/nile-app-shell-side-nav.css.ts +1 -4
  103. package/src/nile-app-shell-side-nav/nile-app-shell-side-nav.ts +4 -12
  104. package/vscode-html-custom-data.json +85 -18
@@ -1,14 +1,17 @@
1
1
  import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
2
+ import { setViewport } from '@web/test-runner-commands';
2
3
  import './nile-app-shell';
3
4
  import '../nile-app-shell-nav-toggle/nile-app-shell-nav-toggle';
5
+ import '../nile-app-shell-panel/nile-app-shell-panel';
6
+ import '../nile-nav-tab-group/nile-nav-tab-group';
7
+ import '../nile-nav-tab/nile-nav-tab';
4
8
  import '../nile-side-bar/nile-side-bar';
9
+ // renderGlyph fails silently on an unknown name, so the glyph tests need the real element.
10
+ import '@aquera/nile-glyph/nile-glyph';
5
11
  import type { NileAppShell } from './nile-app-shell';
6
12
  import type { NileAppShellNavToggle } from '../nile-app-shell-nav-toggle/nile-app-shell-nav-toggle';
7
13
 
8
- /**
9
- * The shell derives its chrome from its own width, so most tests size the host
10
- * explicitly and wait for the ResizeObserver to land.
11
- */
14
+ // Sizes the host explicitly and waits for the ResizeObserver to land.
12
15
  const sized = async (width: number) => {
13
16
  const el = await fixture<NileAppShell>(html`
14
17
  <nile-app-shell style="width: ${width}px; height: 600px;">
@@ -39,11 +42,7 @@ const overlaying = async (width: number) => {
39
42
  return el;
40
43
  };
41
44
 
42
- /**
43
- * The side-nav track animates, so a width read straight after a state change
44
- * catches the row mid-transition and sees room that is about to go away. Waits
45
- * for two consecutive frames to agree before geometry is asserted.
46
- */
45
+ // Waits for two consecutive frames to agree before geometry is asserted.
47
46
  const settled = async (el: NileAppShell) => {
48
47
  const section = el.shadowRoot!.querySelector('.content-section')!;
49
48
  let last = -1;
@@ -104,6 +103,21 @@ describe('NileAppShell — regions', () => {
104
103
  });
105
104
  });
106
105
 
106
+ /**
107
+ * The name the control's glyph asks for, once nile-glyph has resolved it to
108
+ * real path data. An unknown name leaves an empty <svg>, so this fails loudly
109
+ * rather than letting a missing icon ship as a blank box.
110
+ */
111
+ const drawnGlyph = async (control: Element) => {
112
+ const glyph = control.querySelector('nile-glyph')!;
113
+ expect(glyph, 'the control renders a nile-glyph').to.exist;
114
+ await waitUntil(
115
+ () => (glyph.shadowRoot?.querySelector('svg')?.childElementCount ?? 0) > 0,
116
+ `${glyph.getAttribute('name')} draws no paths — is it in the nile-glyph set?`
117
+ );
118
+ return glyph.getAttribute('name');
119
+ };
120
+
107
121
  describe('NileAppShell — nav', () => {
108
122
  it('6. collapseNav()/expandNav() drive the rail and reflect it', async () => {
109
123
  const el = await sized(1440);
@@ -158,6 +172,15 @@ describe('NileAppShell — nav', () => {
158
172
  expect(button.disabled).to.be.true;
159
173
  expect(el.hasAttribute('rail')).to.be.true;
160
174
  });
175
+
176
+ it('62. the built-in toggle draws a real glyph in both states', async () => {
177
+ const el = await sized(1440);
178
+ expect(await drawnGlyph(part(el, 'nav-toggle')!)).to.equal('ng-panel-left');
179
+
180
+ el.collapseNav();
181
+ await el.updateComplete;
182
+ expect(await drawnGlyph(part(el, 'nav-toggle')!)).to.equal('ng-panel-right');
183
+ });
161
184
  });
162
185
 
163
186
  describe('NileAppShell — Ask Aquera panel', () => {
@@ -202,7 +225,7 @@ describe('NileAppShell — Ask Aquera panel', () => {
202
225
  expect(el.getAttribute('panel-mode')).to.equal('docked');
203
226
  const container = el.shadowRoot!.querySelector('.container')!;
204
227
  const panel = el.shadowRoot!.querySelector('.panel')!;
205
- // Beside the page, not over it.
228
+ // The page sits beside the panel, not under it.
206
229
  expect(container.getBoundingClientRect().right).to.be.at.most(
207
230
  Math.round(panel.getBoundingClientRect().left) + 1
208
231
  );
@@ -248,6 +271,51 @@ describe('NileAppShell — Ask Aquera panel', () => {
248
271
  expect(part(el, 'panel-title')!.textContent!.trim()).to.equal('Ask Aquera');
249
272
  });
250
273
 
274
+ // Amendment A (A-8): Figma's Assistant Title is Text lg/Semibold, 18/28; the shell sets it on the title it renders itself.
275
+ it('17b. sets its own panel title at 18/28 in text-primary-900', async () => {
276
+ const tokens =
277
+ '--ng-font-size-text-lg: 18px; --ng-line-height-text-lg: 28px; --ng-font-weight-semibold: 600; --ng-colors-text-primary-900: rgb(24, 29, 39)';
278
+ const el = await sized(1440);
279
+ el.setAttribute('style', `${el.getAttribute('style')}; ${tokens}`);
280
+ el.openPanel();
281
+ await el.updateComplete;
282
+ const own = getComputedStyle(part(el, 'panel-title')!);
283
+ expect(own.fontSize).to.equal('18px');
284
+ expect(own.lineHeight).to.equal('28px');
285
+ expect(own.fontWeight).to.equal('600');
286
+ expect(own.color).to.equal('rgb(24, 29, 39)');
287
+ });
288
+
289
+ // A-8: a slotted nile-app-shell-panel (part of App Shell) draws the same Figma title: 18/28 SemiBold, text-primary-900.
290
+ it('17c. sets the nile-app-shell-panel title at 18/28 in text-primary-900', async () => {
291
+ const tokens =
292
+ '--ng-font-size-text-sm: 14px; --ng-font-size-text-lg: 18px; --ng-line-height-text-lg: 28px; --ng-font-weight-semibold: 600; --ng-colors-text-primary-900: rgb(24, 29, 39)';
293
+ const slotted = await fixture<HTMLElement>(html`
294
+ <nile-app-shell-panel label="Ask Aquera" style="${tokens}"></nile-app-shell-panel>
295
+ `);
296
+ await (slotted as unknown as { updateComplete: Promise<unknown> }).updateComplete;
297
+ const style = getComputedStyle(slotted.shadowRoot!.querySelector('.title') as HTMLElement);
298
+ expect(style.fontSize).to.equal('18px');
299
+ expect(style.lineHeight).to.equal('28px');
300
+ expect(style.fontWeight).to.equal('600');
301
+ expect(style.color).to.equal('rgb(24, 29, 39)');
302
+ });
303
+
304
+ // The enterprise sheet keeps its own panel title: --nile-type-scale-3 and an inherited line height, exactly as before.
305
+ // Enterprise defines no --ng-* tokens, so the NxtGen line height does not apply there.
306
+ it('17d. leaves the enterprise nile-app-shell-panel title as it was', async () => {
307
+ const slotted = await fixture<HTMLElement>(html`
308
+ <nile-app-shell-panel
309
+ label="Ask Aquera"
310
+ style="--nile-type-scale-3: 14px; line-height: 20px"
311
+ ></nile-app-shell-panel>
312
+ `);
313
+ await (slotted as unknown as { updateComplete: Promise<unknown> }).updateComplete;
314
+ const style = getComputedStyle(slotted.shadowRoot!.querySelector('.title') as HTMLElement);
315
+ expect(style.fontSize).to.equal('14px');
316
+ expect(style.lineHeight).to.equal('20px');
317
+ });
318
+
251
319
  it('18. Cmd/Ctrl+J toggles the panel', async () => {
252
320
  const el = await sized(1440);
253
321
  document.dispatchEvent(
@@ -316,8 +384,7 @@ describe('NileAppShellNavToggle', () => {
316
384
  )!;
317
385
  await waitUntil(() => toggle.appShell === el);
318
386
 
319
- // The control is a nile-button, so the raw <button> is a shadow root
320
- // deeper. The part is the contract either way.
387
+ // The control is a nile-button; the part is the contract either way.
321
388
  const button = () =>
322
389
  toggle.shadowRoot!.querySelector<HTMLElement & { disabled: boolean }>(
323
390
  '[part~="button"]'
@@ -354,14 +421,33 @@ describe('NileAppShellNavToggle', () => {
354
421
  )!.disabled
355
422
  ).to.be.true;
356
423
  });
424
+
425
+ it('63. draws a real glyph in both states', async () => {
426
+ const el = await fixture<NileAppShell>(html`
427
+ <nile-app-shell style="width: 1440px; height: 600px;" no-nav-toggle>
428
+ <nav slot="side-nav">nav</nav>
429
+ <nile-app-shell-nav-toggle slot="top-bar"></nile-app-shell-nav-toggle>
430
+ <p>page</p>
431
+ </nile-app-shell>
432
+ `);
433
+ const toggle = el.querySelector<NileAppShellNavToggle>(
434
+ 'nile-app-shell-nav-toggle'
435
+ )!;
436
+ await waitUntil(() => toggle.appShell === el);
437
+ const button = () => toggle.shadowRoot!.querySelector('[part~="button"]')!;
438
+ expect(await drawnGlyph(button())).to.equal('ng-panel-left');
439
+
440
+ (button() as HTMLElement).click();
441
+ await el.updateComplete;
442
+ await toggle.updateComplete;
443
+ expect(await drawnGlyph(button())).to.equal('ng-panel-right');
444
+ });
357
445
  });
358
446
 
359
- /**
360
- * The row is nav + container + aside + panel. When the sum of what a route
361
- * mounts exceeds the shell, something has to give — and the one thing that may
362
- * not give is the row overflowing into :host's clip, which silently cuts the
363
- * panel off the right edge.
364
- */
447
+ // The nav track is theme-dependent, so tests load one token pair or the other explicitly.
448
+ const ENTERPRISE_NAV = '--nile-width-240px: 240px; --nile-width-70px: 70px;';
449
+ const NXTGEN_NAV = '--ng-spacing-296: 296px; --ng-spacing-68: 68px;';
450
+
365
451
  describe('NileAppShell — the content row never overflows', () => {
366
452
  const withAside = async (width: number) => {
367
453
  const el = await fixture<NileAppShell>(html`
@@ -386,8 +472,7 @@ describe('NileAppShell — the content row never overflows', () => {
386
472
  };
387
473
 
388
474
  it('23. tightens the side columns rather than covering the page', async () => {
389
- // 232px short of what the row prefers, so the panel and the aside give
390
- // some width back — and the page still sits beside the panel, not under.
475
+ // Short of what the row prefers, so the panel and aside give width back while the page stays beside the panel.
391
476
  const el = await withAside(1500);
392
477
  await waitUntil(() => el.navLocked);
393
478
  await settled(el);
@@ -441,12 +526,94 @@ describe('NileAppShell — the content row never overflows', () => {
441
526
  expect(container.getBoundingClientRect().width).to.be.at.least(640);
442
527
  expect(overflow(el)).to.equal(0);
443
528
  });
529
+
530
+ // Pins the stylesheet defaults to the JS geometry fallbacks so the two copies cannot drift apart.
531
+ it('71. the stylesheet defaults still match the JS geometry fallbacks', async () => {
532
+ const el = await fixture<NileAppShell>(html`
533
+ <nile-app-shell
534
+ style="width: 1440px; height: 600px; --nile-spacing-xs: 4px;"
535
+ >
536
+ <nav slot="side-nav">nav</nav>
537
+ <p>page</p>
538
+ </nile-app-shell>
539
+ `);
540
+ await el.updateComplete;
541
+ const value = (name: string) =>
542
+ parseFloat(getComputedStyle(el).getPropertyValue(name));
543
+
544
+ // No theme sheet here, so the nav track falls to its own enterprise-pair literals, which is what _geometry() carries too.
545
+ expect(value('--nile-app-shell-side-nav-width')).to.equal(240);
546
+ expect(value('--nile-app-shell-side-nav-rail-width')).to.equal(70);
547
+ expect(value('--nile-app-shell-gutter')).to.equal(4);
548
+ expect(value('--nile-app-shell-content-min-width')).to.equal(640);
549
+ expect(value('--nile-app-shell-aside-width')).to.equal(320);
550
+ expect(value('--nile-app-shell-panel-width')).to.equal(480);
551
+ });
552
+
553
+ // The nav track mirrors nile-side-bar's token expressions and resolves per theme.
554
+ it('78. resolves the nav track per theme, as nile-side-bar does', async () => {
555
+ const track = async (tokens: string) => {
556
+ const el = await fixture<NileAppShell>(html`
557
+ <nile-app-shell style="width: 1440px; height: 600px; ${tokens}">
558
+ <nav slot="side-nav">nav</nav>
559
+ <p>page</p>
560
+ </nile-app-shell>
561
+ `);
562
+ await el.updateComplete;
563
+ const read = (name: string) =>
564
+ parseFloat(getComputedStyle(el).getPropertyValue(name));
565
+ return [
566
+ read('--nile-app-shell-side-nav-width'),
567
+ read('--nile-app-shell-side-nav-rail-width'),
568
+ ];
569
+ };
570
+
571
+ expect(
572
+ await track('--nile-width-240px: 240px; --nile-width-70px: 70px;'),
573
+ 'enterprise'
574
+ ).to.deep.equal([240, 70]);
575
+
576
+ expect(
577
+ await track('--ng-spacing-296: 296px; --ng-spacing-68: 68px;'),
578
+ 'nxtgen'
579
+ ).to.deep.equal([296, 68]);
580
+ });
581
+
582
+ // The nav rails at the row's own theme-dependent width requirement (enterprise 1368, nxtgen 1424), with docked-rail-breakpoint at 0 so the measured value governs.
583
+ it('72. rails the nav at the row’s own width requirement, not a gutter early', async () => {
584
+ const at = async (width: number, tokens = ENTERPRISE_NAV) => {
585
+ const el = await fixture<NileAppShell>(html`
586
+ <nile-app-shell
587
+ style="width: ${width}px; height: 600px; ${tokens}"
588
+ docked-rail-breakpoint="0"
589
+ >
590
+ <nav slot="side-nav">nav</nav>
591
+ <p>page</p>
592
+ <div slot="panel">assistant</div>
593
+ </nile-app-shell>
594
+ `);
595
+ await waitUntil(() => el.hasAttribute('has-panel'));
596
+ el.openPanel();
597
+ await el.updateComplete;
598
+ await settled(el);
599
+ return el.navLocked;
600
+ };
601
+
602
+ // Exactly the requirement: the row fits, so the nav keeps its width.
603
+ expect(await at(1368)).to.be.false;
604
+ // One pixel under: the track is what the row cannot afford.
605
+ expect(await at(1367)).to.be.true;
606
+ expect(await at(1370)).to.be.false;
607
+
608
+ // NxtGen's wider track moves the same boundary up by 56px, following whatever the side bar measures.
609
+ expect(await at(1424, NXTGEN_NAV)).to.be.false;
610
+ expect(await at(1423, NXTGEN_NAV)).to.be.true;
611
+ // And the enterprise boundary is comfortably inside NxtGen's requirement.
612
+ expect(await at(1368, NXTGEN_NAV)).to.be.true;
613
+ });
444
614
  });
445
615
 
446
- /**
447
- * An empty flex item is not a free one: it still takes the row's gap before
448
- * it, which is enough to pull the bar's content short of its padding edge.
449
- */
616
+ // An empty flex item still takes the row's gap, pulling the bar's content short of its padding edge.
450
617
  describe('NileAppShell — the top bar reaches its padding edge', () => {
451
618
  it('28. an empty actions group reserves no gap', async () => {
452
619
  const el = await sized(1440);
@@ -477,13 +644,48 @@ describe('NileAppShell — the top bar reaches its padding edge', () => {
477
644
  expect(getComputedStyle(actions).display).to.not.equal('none');
478
645
  expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
479
646
  });
647
+
648
+ // The rule that separates the nav toggle from the header's content follows both a filled slot and a title block.
649
+ it('76. draws the toggle rule for a title block, not just a filled slot', async () => {
650
+ const rule = (el: NileAppShell) => part(el, 'rule');
651
+
652
+ const slotted = await fixture<NileAppShell>(html`
653
+ <nile-app-shell style="width: 1440px; height: 600px;">
654
+ <nav slot="side-nav">nav</nav>
655
+ <span slot="top-bar">Title</span>
656
+ <p>page</p>
657
+ </nile-app-shell>
658
+ `);
659
+ await waitUntil(() => slotted.hasAttribute('has-top-bar'));
660
+ expect(rule(slotted)).to.exist;
661
+
662
+ // The same heading through pageTitle, top-bar slot empty.
663
+ const titled = await fixture<NileAppShell>(html`
664
+ <nile-app-shell
665
+ style="width: 1440px; height: 600px;"
666
+ pageTitle="Applications"
667
+ >
668
+ <nav slot="side-nav">nav</nav>
669
+ <p>page</p>
670
+ </nile-app-shell>
671
+ `);
672
+ await titled.updateComplete;
673
+ expect(titled.hasAttribute('has-top-bar')).to.be.true;
674
+ expect(rule(titled), 'a title block earns the rule too').to.exist;
675
+
676
+ // Neither: the toggle stands alone, so there is nothing to separate.
677
+ const bare = await fixture<NileAppShell>(html`
678
+ <nile-app-shell style="width: 1440px; height: 600px;">
679
+ <nav slot="side-nav">nav</nav>
680
+ <p>page</p>
681
+ </nile-app-shell>
682
+ `);
683
+ await bare.updateComplete;
684
+ expect(rule(bare)).to.be.null;
685
+ });
480
686
  });
481
687
 
482
- /**
483
- * The Ask door moved onto the shell when nile-app-shell-header became a slot.
484
- * It is the one control in the bar that cannot be slotted markup: it turns on
485
- * whether a panel is mounted for this route, and it calls openPanel().
486
- */
688
+ // The built-in Ask button turns on whether a panel is mounted for this route and calls openPanel().
487
689
  describe('NileAppShell — the built-in Ask button', () => {
488
690
  const ask = (el: NileAppShell) => part(el, 'ask') as HTMLButtonElement | null;
489
691
 
@@ -533,4 +735,1136 @@ describe('NileAppShell — the built-in Ask button', () => {
533
735
  expect(getComputedStyle(actions).display).to.not.equal('none');
534
736
  expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
535
737
  });
738
+
739
+ // Amendment A (M8): the design's Ask is the secondary button, not the filled primary.
740
+ it('34b. renders Ask as the secondary variant', async () => {
741
+ const el = await sized(1440);
742
+ expect(ask(el)!.getAttribute('variant')).to.equal('secondary');
743
+ });
744
+
745
+ // Amendment A (M9, M10): app actions, a 16px divider, then Ask, 16px apart; with no app actions, no divider.
746
+ it('34c. draws a divider before Ask only when the page slots actions, 16px from both', async () => {
747
+ const alone = await sized(1440);
748
+ await settled(alone);
749
+ expect(part(alone, 'ask-rule')).to.be.null;
750
+
751
+ const el = await fixture<NileAppShell>(html`
752
+ <nile-app-shell style="width: 1440px; height: 600px; --ng-spacing-xl: 16px;">
753
+ <nav slot="side-nav">nav</nav>
754
+ <span slot="top-bar">Title</span>
755
+ <button slot="top-bar-actions" id="one">One</button>
756
+ <button slot="top-bar-actions" id="two">Two</button>
757
+ <p>page</p>
758
+ <div slot="panel">assistant</div>
759
+ </nile-app-shell>
760
+ `);
761
+ await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
762
+ await el.updateComplete;
763
+
764
+ const rule = part(el, 'ask-rule')!;
765
+ expect(rule).to.exist;
766
+ const one = el.querySelector('#one')!.getBoundingClientRect();
767
+ const two = el.querySelector('#two')!.getBoundingClientRect();
768
+ const ruleBox = rule.getBoundingClientRect();
769
+ const askBox = ask(el)!.getBoundingClientRect();
770
+
771
+ expect(Math.round(ruleBox.height)).to.equal(16);
772
+ expect(Math.round(two.left - one.right)).to.equal(16);
773
+ expect(Math.round(ruleBox.left - two.right)).to.equal(16);
774
+ expect(Math.round(askBox.left - ruleBox.left)).to.equal(16);
775
+
776
+ // Order: the page's actions, the divider, then Ask.
777
+ /* eslint-disable-next-line no-bitwise */
778
+ const ruleBeforeAsk = rule.compareDocumentPosition(ask(el)!) & Node.DOCUMENT_POSITION_FOLLOWING;
779
+ expect(ruleBeforeAsk).to.be.greaterThan(0);
780
+
781
+ el.noAsk = true;
782
+ await el.updateComplete;
783
+ expect(part(el, 'ask-rule'), 'no Ask, no divider').to.be.null;
784
+ });
785
+
786
+ // The shell draws its icon controls as bare glyphs via ::part(base), overriding nile-button's circle and ghost surface.
787
+ const utilityBase = (el: NileAppShell, name: string) =>
788
+ part(el, name)!.shadowRoot!.querySelector('[part~="base"]') as HTMLElement;
789
+
790
+ it('35. draws its icon controls with no resting surface', async () => {
791
+ const el = await sized(1440);
792
+ el.openPanel();
793
+ await el.updateComplete;
794
+
795
+ for (const name of ['nav-toggle', 'panel-close']) {
796
+ const base = utilityBase(el, name);
797
+ expect(getComputedStyle(base).backgroundColor).to.equal(
798
+ 'rgba(0, 0, 0, 0)'
799
+ );
800
+ }
801
+ });
802
+
803
+ it('36. sizes its icon controls to the utility box, not the button circle', async () => {
804
+ const el = await sized(1440);
805
+ el.openPanel();
806
+ await el.updateComplete;
807
+
808
+ for (const name of ['nav-toggle', 'panel-close']) {
809
+ const box = utilityBase(el, name).getBoundingClientRect();
810
+ expect(box.width).to.equal(32);
811
+ expect(box.height).to.equal(32);
812
+ }
813
+ });
814
+
815
+ it('37. holds the panel back control until panelBack asks for it', async () => {
816
+ const el = await sized(1440);
817
+ el.openPanel();
818
+ await el.updateComplete;
819
+ const mark = el.shadowRoot!.querySelector('.panel-mark')!;
820
+ // Not rendered at all in the root view, so no stylesheet override can surface it.
821
+ expect(part(el, 'panel-back')).to.not.exist;
822
+ expect(getComputedStyle(mark).display).to.not.equal('none');
823
+
824
+ el.panelBack = true;
825
+ await el.updateComplete;
826
+ const back = part(el, 'panel-back')!;
827
+ expect(back).to.exist;
828
+ expect(getComputedStyle(back).display).to.not.equal('none');
829
+ // The back control stands where the brand mark stands in the root view.
830
+ expect(getComputedStyle(mark).display).to.equal('none');
831
+ });
832
+
833
+ it('38. emits nile-panel-back and takes its label from panelBackLabel', async () => {
834
+ const el = await sized(1440);
835
+ el.panelBack = true;
836
+ el.panelBackLabel = 'Back to chats';
837
+ el.openPanel();
838
+ await el.updateComplete;
839
+
840
+ const back = part(el, 'panel-back')!;
841
+ expect(back.getAttribute('aria-label')).to.equal('Back to chats');
842
+
843
+ const went = oneEvent(el, 'nile-panel-back');
844
+ (back as HTMLButtonElement).click();
845
+ await went;
846
+ // The shell keeps no view stack, so going back leaves the panel open.
847
+ expect(el.panelOpen).to.be.true;
848
+ });
849
+
850
+ it('81. goBackPanel() emits nile-panel-back, and goBack() stays its alias', async () => {
851
+ const el = await sized(1440);
852
+
853
+ const named = oneEvent(el, 'nile-panel-back');
854
+ el.goBackPanel();
855
+ expect(await named).to.exist;
856
+
857
+ // Deprecated, but still the same event: existing callers keep working.
858
+ const aliased = oneEvent(el, 'nile-panel-back');
859
+ el.goBack();
860
+ expect(await aliased).to.exist;
861
+ });
862
+ });
863
+
864
+ describe('NileAppShell — the tab strip', () => {
865
+ /** The strip is slot-driven, so it needs something in the slot to exist. */
866
+ const tabbed = async () => {
867
+ const el = await fixture<NileAppShell>(html`
868
+ <nile-app-shell style="width: 1440px; height: 600px; ${HEADER_TOKENS}">
869
+ <nav slot="side-nav">nav</nav>
870
+ <span slot="top-bar">Title</span>
871
+ <nile-nav-tab-group slot="tabs">
872
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
873
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
874
+ </nile-nav-tab-group>
875
+ <p>page</p>
876
+ </nile-app-shell>
877
+ `);
878
+ await waitUntil(() => el.hasAttribute('has-tabs'));
879
+ return el;
880
+ };
881
+
882
+ const strip = (el: NileAppShell) =>
883
+ el.shadowRoot!.querySelector('.tabs') as HTMLElement;
884
+
885
+ it('39. mounts the strip for a filled slot', async () => {
886
+ const el = await tabbed();
887
+ expect(el.tabsVisible).to.be.true;
888
+ expect(getComputedStyle(strip(el)).display).to.not.equal('none');
889
+ });
890
+
891
+ it('40. noTabs hides the strip without emptying the slot', async () => {
892
+ const el = await tabbed();
893
+ el.noTabs = true;
894
+ await el.updateComplete;
895
+
896
+ expect(el.tabsVisible).to.be.false;
897
+ expect(getComputedStyle(strip(el)).display).to.equal('none');
898
+ // The slot is still filled — the strip is switched off, not unmounted — so the assigned element proves the content stays.
899
+ expect(el.querySelector('nile-nav-tab-group')).to.exist;
900
+ const slot = strip(el).querySelector('slot') as HTMLSlotElement;
901
+ expect(slot.assignedElements()).to.have.lengthOf(1);
902
+
903
+ el.noTabs = false;
904
+ await el.updateComplete;
905
+ expect(el.tabsVisible).to.be.true;
906
+ expect(getComputedStyle(strip(el)).display).to.not.equal('none');
907
+ });
908
+
909
+ // has-tabs advertises the visible strip, not the filled slot, matching has-panel and has-ask.
910
+ it('68. has-tabs follows the visible strip, not the filled slot', async () => {
911
+ // Slotted, not switched off: the strip is up and the flag is on.
912
+ const el = await tabbed();
913
+ expect(el.tabsVisible).to.be.true;
914
+ expect(el.hasAttribute('has-tabs')).to.be.true;
915
+
916
+ // Slotted, switched off: the slot stays filled, the flag goes with the strip.
917
+ el.noTabs = true;
918
+ await el.updateComplete;
919
+ expect(el.querySelector('nile-nav-tab-group')).to.exist;
920
+ expect(el.hasAttribute('has-tabs')).to.be.false;
921
+ expect(getComputedStyle(strip(el)).display).to.equal('none');
922
+
923
+ // And back, so the flag is not a one-way latch.
924
+ el.noTabs = false;
925
+ await el.updateComplete;
926
+ expect(el.hasAttribute('has-tabs')).to.be.true;
927
+
928
+ // Nothing slotted: no strip, no flag, whatever noTabs says.
929
+ const bare = await sized(1440);
930
+ await settled(bare);
931
+ expect(bare.hasAttribute('has-tabs')).to.be.false;
932
+ bare.noTabs = true;
933
+ await bare.updateComplete;
934
+ expect(bare.hasAttribute('has-tabs')).to.be.false;
935
+ });
936
+
937
+ it('41. leaves the strip off when the slot is empty', async () => {
938
+ const el = await sized(1440);
939
+ await settled(el);
940
+ expect(el.hasAttribute('has-tabs')).to.be.false;
941
+ expect(el.tabsVisible).to.be.false;
942
+ });
943
+
944
+ it('77. sizes bare slotted tab controls to their labels, not the strip', async () => {
945
+ const el = await fixture<NileAppShell>(html`
946
+ <nile-app-shell style="width: 1176px; height: 600px; ${HEADER_TOKENS}">
947
+ <nav slot="side-nav">nav</nav>
948
+ <span slot="top-bar">Title</span>
949
+ <button slot="tabs" id="short">A</button>
950
+ <button slot="tabs" id="long">A considerably longer tab label</button>
951
+ <p>page</p>
952
+ </nile-app-shell>
953
+ `);
954
+ await waitUntil(() => el.hasAttribute('has-tabs'));
955
+ await settled(el);
956
+
957
+ const short = el.querySelector('#short')!.getBoundingClientRect().width;
958
+ const long = el.querySelector('#long')!.getBoundingClientRect().width;
959
+ // Stretched, they would be equal; sized to content they are not close.
960
+ expect(long).to.be.greaterThan(short * 2);
961
+ });
962
+
963
+ it('43. leaves the slotted strip one rule, edge to edge', async () => {
964
+ const el = await tabbed();
965
+ const group = el.querySelector('nile-nav-tab-group') as HTMLElement;
966
+ const groupStyle = getComputedStyle(group);
967
+
968
+ expect(groupStyle.getPropertyValue('--track-width').trim()).to.equal('0px');
969
+ expect(groupStyle.alignSelf).to.equal('flex-end');
970
+
971
+ // The region's rule is as wide as the container it closes, padding and all.
972
+ const container = el.shadowRoot!.querySelector('.container') as HTMLElement;
973
+ const stripWidth = strip(el).getBoundingClientRect().width;
974
+ const inner =
975
+ container.getBoundingClientRect().width -
976
+ parseFloat(getComputedStyle(container).borderLeftWidth) -
977
+ parseFloat(getComputedStyle(container).borderRightWidth);
978
+ expect(Math.round(stripWidth)).to.equal(Math.round(inner));
979
+
980
+ // The group is wholly inside the region, and ends where the rule is.
981
+ const strippedBottom = strip(el).getBoundingClientRect().bottom;
982
+ const groupBottom = group.getBoundingClientRect().bottom;
983
+ expect(strippedBottom - groupBottom).to.be.lessThan(2);
984
+ });
985
+
986
+ // The Header Bar draws no rule of its own; the one divider belongs to the tab rail, so switching the strip off leaves the header with no rule.
987
+ it('42. draws the header rule on the strip only, never on the bar', async () => {
988
+ const el = await tabbed();
989
+ const bar = el.shadowRoot!.querySelector(
990
+ '.container > .top-bar'
991
+ ) as HTMLElement;
992
+
993
+ expect(parseFloat(getComputedStyle(bar).borderBottomWidth)).to.equal(0);
994
+ expect(
995
+ parseFloat(getComputedStyle(strip(el)).borderBottomWidth)
996
+ ).to.be.greaterThan(0);
997
+ expect(getComputedStyle(strip(el)).borderBottomColor).to.not.equal(
998
+ 'rgba(0, 0, 0, 0)'
999
+ );
1000
+
1001
+ // No tabs, no rule anywhere in the header block.
1002
+ el.noTabs = true;
1003
+ await el.updateComplete;
1004
+ expect(parseFloat(getComputedStyle(bar).borderBottomWidth)).to.equal(0);
1005
+ });
1006
+
1007
+ // The bar and the strip sit flush, with the stroke as the separation, not space.
1008
+ it('62. leaves no vertical gap between the bar and the strip', async () => {
1009
+ const el = await tabbed();
1010
+ const bar = el.shadowRoot!.querySelector(
1011
+ '.container > .top-bar'
1012
+ ) as HTMLElement;
1013
+ const gap =
1014
+ strip(el).getBoundingClientRect().top -
1015
+ bar.getBoundingClientRect().bottom;
1016
+ expect(Math.abs(gap)).to.be.lessThan(0.5);
1017
+ });
1018
+ });
1019
+
1020
+ // The test page loads no theme sheet, so the header fixtures carry the nxtgen token values the title block and tab row read.
1021
+ const HEADER_TOKENS = [
1022
+ '--ng-spacing-none: 0px',
1023
+ '--ng-border-width-1: 1px',
1024
+ '--ng-colors-border-secondary: #E9EAEB',
1025
+ '--ng-spacing-xxs: 2px',
1026
+ '--ng-spacing-xs: 4px',
1027
+ '--ng-spacing-md: 8px',
1028
+ '--ng-spacing-md-alt: 10px',
1029
+ '--ng-spacing-lg: 12px',
1030
+ '--ng-spacing-xl: 16px',
1031
+ '--ng-spacing-3xl: 24px',
1032
+ '--ng-font-size-text-xs: 12px',
1033
+ '--ng-line-height-text-xs: 18px',
1034
+ '--ng-font-size-text-sm: 14px',
1035
+ '--ng-line-height-text-sm: 20px',
1036
+ '--ng-font-size-text-md: 16px',
1037
+ '--ng-line-height-text-md: 24px',
1038
+ ].join('; ');
1039
+
1040
+ // What sits before the title: `headerBack` and the `app-icon` slot are independent, so the four combinations are named here for looping.
1041
+ type Leading = 'none' | 'back' | 'app-icon' | 'back+app-icon';
1042
+
1043
+ const LEADINGS: readonly Leading[] = ['none', 'back', 'app-icon', 'back+app-icon'];
1044
+
1045
+ const hasBack = (leading?: Leading) => leading === 'back' || leading === 'back+app-icon';
1046
+ const hasIcon = (leading?: Leading) =>
1047
+ leading === 'app-icon' || leading === 'back+app-icon';
1048
+
1049
+ interface HeaderAxes {
1050
+ pageTitle?: string;
1051
+ description?: string;
1052
+ badge?: string;
1053
+ leading?: Leading;
1054
+ headerBackLabel?: string;
1055
+ tabs?: boolean;
1056
+ actions?: boolean;
1057
+ }
1058
+
1059
+ // A shell carrying whatever header axes a test asks for; an unset axis is the empty string, meaning "absent".
1060
+ const header = async (axes: HeaderAxes = {}) => {
1061
+ const el = await fixture<NileAppShell>(html`
1062
+ <nile-app-shell
1063
+ style="width: 1176px; height: 800px; ${HEADER_TOKENS}"
1064
+ .pageTitle=${axes.pageTitle ?? ''}
1065
+ .description=${axes.description ?? ''}
1066
+ .badge=${axes.badge ?? ''}
1067
+ .headerBack=${hasBack(axes.leading)}
1068
+ .headerBackLabel=${axes.headerBackLabel ?? 'Back'}
1069
+ >
1070
+ <nav slot="side-nav">nav</nav>
1071
+ ${hasIcon(axes.leading)
1072
+ ? html`<span slot="app-icon" id="mark">A</span>`
1073
+ : ''}
1074
+ ${axes.actions
1075
+ ? html`<button slot="top-bar-actions" id="cta">Button CTA</button>`
1076
+ : ''}
1077
+ ${axes.tabs
1078
+ ? html`<nile-nav-tab-group slot="tabs">
1079
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
1080
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
1081
+ </nile-nav-tab-group>`
1082
+ : ''}
1083
+ <p>page</p>
1084
+ </nile-app-shell>
1085
+ `);
1086
+ if (axes.tabs) await waitUntil(() => el.hasAttribute('has-tabs'));
1087
+ if (hasIcon(axes.leading)) await waitUntil(() => el.hasAttribute('has-app-icon'));
1088
+ if (axes.actions) await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
1089
+ await el.updateComplete;
1090
+ return el;
1091
+ };
1092
+
1093
+ // The header title block: the shell's own heading, the Leading axis, the badge and the description line.
1094
+ describe('NileAppShell — the header title block', () => {
1095
+ it('44. draws no title block at all until an axis asks for one', async () => {
1096
+ const el = await header();
1097
+ expect(el.headerBack).to.be.false;
1098
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
1099
+ expect(part(el, 'title-area')).to.be.null;
1100
+ expect(part(el, 'page-title')).to.be.null;
1101
+ expect(part(el, 'description')).to.be.null;
1102
+ expect(part(el, 'badge')).to.be.null;
1103
+ expect(el.hasAttribute('has-description')).to.be.false;
1104
+ });
1105
+
1106
+ // description and badge are modifiers on the page title, so neither mounts the title block by itself.
1107
+ it('73. does not mount a title block for a description or badge alone', async () => {
1108
+ for (const axes of [
1109
+ { description: 'Manage the apps connected to this tenant' },
1110
+ { badge: '94 apps' },
1111
+ { description: 'Manage the apps', badge: '94 apps' },
1112
+ ]) {
1113
+ const el = await header(axes);
1114
+ const label = JSON.stringify(axes);
1115
+
1116
+ expect(part(el, 'title-area'), label).to.be.null;
1117
+ expect(part(el, 'description'), label).to.be.null;
1118
+ expect(part(el, 'badge'), label).to.be.null;
1119
+ // And no height is reserved for the line that cannot render.
1120
+ expect(el.hasAttribute('has-description'), label).to.be.false;
1121
+ }
1122
+ });
1123
+
1124
+ it('74. brings both back as soon as there is a title to hang them on', async () => {
1125
+ const el = await header({
1126
+ description: 'Manage the apps connected to this tenant',
1127
+ badge: '94 apps',
1128
+ });
1129
+ expect(part(el, 'title-area')).to.be.null;
1130
+
1131
+ el.pageTitle = 'Applications';
1132
+ await el.updateComplete;
1133
+
1134
+ expect(part(el, 'title-area')).to.exist;
1135
+ expect(part(el, 'description')).to.exist;
1136
+ expect(part(el, 'badge')).to.exist;
1137
+ expect(el.hasAttribute('has-description')).to.be.true;
1138
+ });
1139
+
1140
+ // The back arrow is the exception: it stands on its own but brings no empty title stack.
1141
+ it('75. mounts the back arrow on its own, without an empty title stack', async () => {
1142
+ const el = await header({ leading: 'back' });
1143
+
1144
+ expect(part(el, 'title-area')).to.exist;
1145
+ expect(part(el, 'back')).to.exist;
1146
+ expect(part(el, 'page-title')).to.be.null;
1147
+ expect(el.shadowRoot!.querySelector('.title-stack')).to.be.null;
1148
+ expect(el.shadowRoot!.querySelector('.title-row')).to.be.null;
1149
+ });
1150
+
1151
+ it('45. renders pageTitle as the header’s own heading', async () => {
1152
+ const el = await header({ pageTitle: 'Applications' });
1153
+ const title = part(el, 'page-title')!;
1154
+ expect(title).to.exist;
1155
+ // A heading by role, not a hard-coded h1, so the page keeps control of its outline; level 1 by default.
1156
+ expect(title.getAttribute('role')).to.equal('heading');
1157
+ expect(el.headingLevel).to.equal(1);
1158
+ expect(title.getAttribute('aria-level')).to.equal('1');
1159
+ expect(title.textContent!.trim()).to.equal('Applications');
1160
+ expect(part(el, 'title-area')).to.exist;
1161
+ });
1162
+
1163
+ // Birat's a11y review: a page with its own h1 needs the shell title one level down.
1164
+ it('79. takes its heading level from the headingLevel property and attribute', async () => {
1165
+ const el = await header({ pageTitle: 'Applications' });
1166
+ el.headingLevel = 2;
1167
+ await el.updateComplete;
1168
+ const title = part(el, 'page-title')!;
1169
+ expect(title.getAttribute('aria-level')).to.equal('2');
1170
+ expect(el.getAttribute('headinglevel')).to.equal('2');
1171
+ expect(title.textContent!.trim()).to.equal('Applications');
1172
+
1173
+ const marked = await fixture<NileAppShell>(html`
1174
+ <nile-app-shell pageTitle="Applications" headingLevel="3">
1175
+ <p>page</p>
1176
+ </nile-app-shell>
1177
+ `);
1178
+ await marked.updateComplete;
1179
+ expect(marked.headingLevel).to.equal(3);
1180
+ expect(part(marked, 'page-title')!.getAttribute('aria-level')).to.equal('3');
1181
+ });
1182
+
1183
+ it('80. clamps an invalid heading level to a safe aria-level', async () => {
1184
+ const el = await header({ pageTitle: 'Applications' });
1185
+ const cases: [unknown, string][] = [
1186
+ [0, '1'],
1187
+ [-3, '1'],
1188
+ [7, '6'],
1189
+ [2.7, '2'],
1190
+ [NaN, '1'],
1191
+ ['abc', '1'],
1192
+ [undefined, '1'],
1193
+ ];
1194
+ for (const [value, expected] of cases) {
1195
+ (el as unknown as { headingLevel: unknown }).headingLevel = value;
1196
+ await el.updateComplete;
1197
+ expect(
1198
+ part(el, 'page-title')!.getAttribute('aria-level'),
1199
+ String(value)
1200
+ ).to.equal(expected);
1201
+ }
1202
+
1203
+ // From markup too: a non-numeric attribute still leaves a level-1 heading.
1204
+ const marked = await fixture<NileAppShell>(html`
1205
+ <nile-app-shell pageTitle="Applications" headingLevel="nope">
1206
+ <p>page</p>
1207
+ </nile-app-shell>
1208
+ `);
1209
+ await marked.updateComplete;
1210
+ expect(part(marked, 'page-title')!.getAttribute('aria-level')).to.equal('1');
1211
+ expect(part(marked, 'page-title')!.textContent!.trim()).to.equal(
1212
+ 'Applications'
1213
+ );
1214
+ });
1215
+
1216
+ // The title block renders before the top-bar slot, so a page slotting its own title keeps its output.
1217
+ it('46. renders the block before whatever the page slots into top-bar', async () => {
1218
+ const el = await fixture<NileAppShell>(html`
1219
+ <nile-app-shell style="width: 1176px; height: 800px;" pageTitle="Apps">
1220
+ <nav slot="side-nav">nav</nav>
1221
+ <span slot="top-bar" id="own">the page’s own bar</span>
1222
+ <p>page</p>
1223
+ </nile-app-shell>
1224
+ `);
1225
+ await el.updateComplete;
1226
+
1227
+ const area = part(el, 'title-area')!;
1228
+ const slot = el.shadowRoot!.querySelector(
1229
+ 'slot[name="top-bar"]'
1230
+ ) as HTMLElement;
1231
+ expect(el.querySelector('#own')!.assignedSlot).to.equal(slot);
1232
+ /* eslint-disable-next-line no-bitwise */
1233
+ const areaIsFirst =
1234
+ area.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING;
1235
+ expect(areaIsFirst).to.be.greaterThan(0);
1236
+ });
1237
+
1238
+ it('47. mounts the top bar for a title block on its own', async () => {
1239
+ const el = await fixture<NileAppShell>(html`
1240
+ <nile-app-shell
1241
+ style="width: 1176px; height: 800px;"
1242
+ pageTitle="Applications"
1243
+ no-nav-toggle
1244
+ >
1245
+ <p>page</p>
1246
+ </nile-app-shell>
1247
+ `);
1248
+ await el.updateComplete;
1249
+ // Nothing is slotted anywhere, and there is no nav toggle to mount it.
1250
+ expect(el.hasAttribute('has-side-nav')).to.be.false;
1251
+ expect(el.hasAttribute('has-top-bar')).to.be.true;
1252
+ expect(part(el, 'page-title')).to.exist;
1253
+ });
1254
+
1255
+ it('48. renders a badge after the title, and only when set', async () => {
1256
+ const plain = await header({ pageTitle: 'Applications' });
1257
+ expect(part(plain, 'badge')).to.be.null;
1258
+
1259
+ const el = await header({ pageTitle: 'Applications', badge: '12 active' });
1260
+ const badge = part(el, 'badge')!;
1261
+ expect(badge).to.exist;
1262
+ expect(badge.textContent!.trim()).to.equal('12 active');
1263
+ /* eslint-disable-next-line no-bitwise */
1264
+ const titleIsFirst =
1265
+ part(el, 'page-title')!.compareDocumentPosition(badge) &
1266
+ Node.DOCUMENT_POSITION_FOLLOWING;
1267
+ expect(titleIsFirst).to.be.greaterThan(0);
1268
+ });
1269
+
1270
+ // The badge's surface tokens must resolve on both the enterprise and nxtgen sheets, so each test pins one sheet on its own.
1271
+ const badged = (tokens: string) =>
1272
+ fixture<NileAppShell>(html`
1273
+ <nile-app-shell
1274
+ style="width: 1176px; height: 800px; ${tokens}"
1275
+ pageTitle="Applications"
1276
+ badge="Beta"
1277
+ >
1278
+ <nav slot="side-nav">nav</nav>
1279
+ <p>page</p>
1280
+ </nile-app-shell>
1281
+ `);
1282
+
1283
+ it('69. resolves the badge on the enterprise sheet alone', async () => {
1284
+ const el = await badged(
1285
+ [
1286
+ '--nile-border-width-1: 1px',
1287
+ '--nile-colors-neutral-100: #FAFAFA',
1288
+ '--nile-colors-neutral-400: #E6E9EB',
1289
+ '--nile-colors-dark-500: #636363',
1290
+ // The ink the badge must not fall back to by inheriting the title's.
1291
+ '--nile-colors-dark-900: #000000',
1292
+ ].join('; ')
1293
+ );
1294
+ const style = getComputedStyle(part(el, 'badge')!);
1295
+
1296
+ expect(style.backgroundColor).to.equal('rgb(250, 250, 250)');
1297
+ expect(style.borderTopColor).to.equal('rgb(230, 233, 235)');
1298
+ expect(style.borderTopWidth).to.equal('1px');
1299
+ expect(style.color).to.equal('rgb(99, 99, 99)');
1300
+ });
1301
+
1302
+ it('70. resolves the badge on the nxtgen sheet alone', async () => {
1303
+ const el = await badged(
1304
+ [
1305
+ '--ng-border-width-1: 1px',
1306
+ '--ng-componentcolors-utility-gray-50: #FAFAFAFF',
1307
+ '--ng-componentcolors-utility-gray-200: #E9EAEBFF',
1308
+ '--ng-componentcolors-utility-gray-700: #414651FF',
1309
+ '--ng-colors-text-primary-900: #181D27FF',
1310
+ ].join('; ')
1311
+ );
1312
+ const style = getComputedStyle(part(el, 'badge')!);
1313
+
1314
+ expect(style.backgroundColor).to.equal('rgb(250, 250, 250)');
1315
+ expect(style.borderTopColor).to.equal('rgb(233, 234, 235)');
1316
+ expect(style.borderTopWidth).to.equal('1px');
1317
+ expect(style.color).to.equal('rgb(65, 70, 81)');
1318
+ });
1319
+
1320
+ it('49. renders a description line, and reserves nothing without one', async () => {
1321
+ const off = await header({ pageTitle: 'Applications' });
1322
+ expect(off.hasAttribute('has-description')).to.be.false;
1323
+ expect(part(off, 'description')).to.be.null;
1324
+
1325
+ const el = await header({
1326
+ pageTitle: 'Applications',
1327
+ description: 'Manage the apps connected to this tenant',
1328
+ });
1329
+ const line = part(el, 'description')!;
1330
+ expect(el.hasAttribute('has-description')).to.be.true;
1331
+ expect(line).to.exist;
1332
+ expect(line.textContent!.trim()).to.equal(
1333
+ 'Manage the apps connected to this tenant'
1334
+ );
1335
+
1336
+ // Clearing the string takes the axis back off.
1337
+ el.description = '';
1338
+ await el.updateComplete;
1339
+ expect(el.hasAttribute('has-description')).to.be.false;
1340
+ expect(part(el, 'description')).to.be.null;
1341
+ });
1342
+
1343
+ it('50. headerBack defaults to false and draws no arrow, and no slotted icon draws no icon', async () => {
1344
+ const el = await header({ pageTitle: 'Applications' });
1345
+ expect(el.headerBack).to.be.false;
1346
+ expect(el.hasAttribute('headerback')).to.be.false;
1347
+ expect(part(el, 'back')).to.be.null;
1348
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
1349
+ expect(getComputedStyle(part(el, 'app-icon')!).display).to.equal('none');
1350
+ expect(el.shadowRoot!.querySelector('.crumb-divider')).to.be.null;
1351
+ });
1352
+
1353
+ // Amendment A: the back control is the arrow alone, with no previous-page label and no slash.
1354
+ it('51. headerBack draws the narrow-left arrow only, then the title', async () => {
1355
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1356
+ const back = part(el, 'back')!;
1357
+ const title = part(el, 'page-title')!;
1358
+
1359
+ expect(back).to.exist;
1360
+ expect(back.tagName).to.equal('BUTTON');
1361
+ expect(back.getAttribute('type')).to.equal('button');
1362
+
1363
+ // The icons go through nile-glyph, so assert the glyph, not an <svg>.
1364
+ const glyphs = back.querySelectorAll('nile-glyph');
1365
+ expect(glyphs.length).to.equal(1);
1366
+ expect(glyphs[0].getAttribute('name')).to.equal('ng-arrow-narrow-left');
1367
+
1368
+ // No label, no divider, no slash anywhere in the header.
1369
+ expect(back.textContent!.trim()).to.equal('');
1370
+ expect(back.querySelector('span')).to.be.null;
1371
+ expect(el.shadowRoot!.querySelector('.crumb-divider')).to.be.null;
1372
+ expect(el.shadowRoot!.querySelector('nile-glyph[name="ng-slash"]')).to.be.null;
1373
+
1374
+ /* eslint-disable-next-line no-bitwise */
1375
+ const backIsFirst =
1376
+ back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING;
1377
+ expect(backIsFirst).to.be.greaterThan(0);
1378
+ expect(back.getBoundingClientRect().width).to.be.greaterThan(0);
1379
+ });
1380
+
1381
+ // camelCase property, attribute: true + reflect: true — the title-block pattern.
1382
+ it('51b. headerBack and headerBackLabel work from markup and reflect to their attributes', async () => {
1383
+ const marked = await fixture<NileAppShell>(html`
1384
+ <nile-app-shell pageTitle="Applications" headerBack headerBackLabel="Retour">
1385
+ <p>page</p>
1386
+ </nile-app-shell>
1387
+ `);
1388
+ await marked.updateComplete;
1389
+ expect(marked.headerBack).to.be.true;
1390
+ expect(marked.headerBackLabel).to.equal('Retour');
1391
+ expect(part(marked, 'back')).to.exist;
1392
+
1393
+ const el = await header({ pageTitle: 'Applications' });
1394
+ el.headerBack = true;
1395
+ el.headerBackLabel = 'Go back';
1396
+ await el.updateComplete;
1397
+ expect(el.hasAttribute('headerback')).to.be.true;
1398
+ expect(el.getAttribute('headerbacklabel')).to.equal('Go back');
1399
+ expect(part(el, 'back')).to.exist;
1400
+
1401
+ el.headerBack = false;
1402
+ await el.updateComplete;
1403
+ expect(el.hasAttribute('headerback')).to.be.false;
1404
+ expect(part(el, 'back')).to.be.null;
1405
+ });
1406
+
1407
+ // headerBackLabel names the control for assistive tech and is never drawn as text.
1408
+ it('52. headerBackLabel is the accessible name only, never visible text', async () => {
1409
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1410
+ const back = part(el, 'back')!;
1411
+ expect(back.getAttribute('aria-label')).to.equal('Back');
1412
+ expect(back.getAttribute('title')).to.equal('Back');
1413
+ expect(back.querySelector('nile-glyph')!.getAttribute('aria-hidden')).to.not.equal('false');
1414
+
1415
+ el.headerBackLabel = 'Integrations';
1416
+ await el.updateComplete;
1417
+ expect(back.getAttribute('aria-label')).to.equal('Integrations');
1418
+ // Not rendered anywhere in the header as text.
1419
+ expect(back.textContent!.trim()).to.equal('');
1420
+ expect(part(el, 'title-area')!.textContent!.includes('Integrations')).to.be.false;
1421
+ expect(part(el, 'top-bar')!.textContent!.includes('Integrations')).to.be.false;
1422
+ });
1423
+
1424
+ // The shell owns no history, so the control only announces the press, as the standard nile-click; the page navigates itself.
1425
+ it('53. the back control and goBackHeader() emit nile-click { value: "back" } from the shell', async () => {
1426
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1427
+ let retired = 0;
1428
+ el.addEventListener('nile-header-back', () => {
1429
+ retired += 1;
1430
+ });
1431
+
1432
+ const clicked = oneEvent(el, 'nile-click');
1433
+ (part(el, 'back') as HTMLButtonElement).click();
1434
+ const fromClick = (await clicked) as CustomEvent;
1435
+ expect(fromClick.detail).to.deep.equal({ value: 'back' });
1436
+ expect(fromClick.target).to.equal(el);
1437
+ expect(fromClick.bubbles).to.be.true;
1438
+ expect(fromClick.composed).to.be.true;
1439
+
1440
+ const called = oneEvent(el, 'nile-click');
1441
+ el.goBackHeader();
1442
+ const fromCall = (await called) as CustomEvent;
1443
+ expect(fromCall.detail).to.deep.equal({ value: 'back' });
1444
+ expect(fromCall.target).to.equal(el);
1445
+
1446
+ // nile-header-back and goBackPage() are gone in 2.0.12 (Amendment A, A-12).
1447
+ expect(retired).to.equal(0);
1448
+ expect((el as unknown as { goBackPage?: unknown }).goBackPage).to.be.undefined;
1449
+
1450
+ // It is the page's event, not the panel's: nothing about the panel moves.
1451
+ expect(el.panelOpen).to.be.false;
1452
+ });
1453
+
1454
+ // A nile-click bubbling up from page content must not read as the header back press.
1455
+ it('53b. a nile-click from slotted content is distinguishable by its target', async () => {
1456
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1457
+ const page = el.querySelector('p')!;
1458
+ const seen: EventTarget[] = [];
1459
+ el.addEventListener('nile-click', e => seen.push(e.target!));
1460
+ page.dispatchEvent(
1461
+ new CustomEvent('nile-click', {
1462
+ bubbles: true,
1463
+ composed: true,
1464
+ detail: { value: 'back' },
1465
+ })
1466
+ );
1467
+ (part(el, 'back') as HTMLButtonElement).click();
1468
+ expect(seen).to.deep.equal([page, el]);
1469
+ });
1470
+
1471
+ // The four combinations are independent; with an app icon the description sits under the icon, otherwise under the title.
1472
+ it('63. aligns the description under the app icon when there is one, else the title', async () => {
1473
+ for (const leading of LEADINGS) {
1474
+ const el = await header({
1475
+ pageTitle: 'Applications',
1476
+ description: 'Manage the apps connected to this tenant',
1477
+ leading,
1478
+ });
1479
+
1480
+ const anchor = hasIcon(leading) ? part(el, 'app-icon')! : part(el, 'page-title')!;
1481
+ const description = part(el, 'description')!.getBoundingClientRect();
1482
+ expect(Math.round(description.left), leading).to.equal(
1483
+ Math.round(anchor.getBoundingClientRect().left)
1484
+ );
1485
+ }
1486
+ });
1487
+
1488
+ // The arrow sits beside the stack; the app icon is the first thing in the title row.
1489
+ it('64. renders the arrow beside the title stack and the app icon inside the title row', async () => {
1490
+ for (const leading of LEADINGS) {
1491
+ const el = await header({ pageTitle: 'Applications', leading });
1492
+ const row = el.shadowRoot!.querySelector('.title-row') as HTMLElement;
1493
+ const area = part(el, 'title-area')!;
1494
+ const back = part(el, 'back');
1495
+ const icon = part(el, 'app-icon')!;
1496
+
1497
+ expect(!!back, `${leading} back`).to.equal(hasBack(leading));
1498
+ expect(el.hasAttribute('has-app-icon'), `${leading} icon`).to.equal(hasIcon(leading));
1499
+ if (back) {
1500
+ expect(row.contains(back), leading).to.be.false;
1501
+ expect(area.contains(back), leading).to.be.true;
1502
+ }
1503
+ expect(row.contains(icon), leading).to.be.true;
1504
+ if (hasIcon(leading)) {
1505
+ expect(row.firstElementChild, leading).to.equal(icon);
1506
+ }
1507
+ }
1508
+ });
1509
+
1510
+ // The arrow sits on the title's row, and its offset does not move when a description appears.
1511
+ it('65. sits the back arrow on the title row, description or not', async () => {
1512
+ for (const leading of ['back', 'back+app-icon'] as const) {
1513
+ for (const description of ['', 'Manage the apps connected here']) {
1514
+ const el = await header({
1515
+ pageTitle: 'Applications',
1516
+ leading,
1517
+ description,
1518
+ });
1519
+
1520
+ const title = part(el, 'page-title')!.getBoundingClientRect();
1521
+ const box = el.shadowRoot!.querySelector('.back-line') as HTMLElement;
1522
+ const label = `${leading} description=${!!description}`;
1523
+
1524
+ // The line box is the title's own, and it starts where the title does.
1525
+ expect(Math.round(box.getBoundingClientRect().top), label).to.equal(
1526
+ Math.round(title.top)
1527
+ );
1528
+ expect(Math.round(box.getBoundingClientRect().height), label).to.equal(
1529
+ 24
1530
+ );
1531
+ }
1532
+ }
1533
+ });
1534
+
1535
+ // Figma Title Area: arrow → 16px → [icon → 8px → title]; Breadcrumb / 01: toggle glyph → 16px → divider (16px tall) → 16px.
1536
+ it('65b. spaces the toggle, divider, arrow, icon and title as the design does', async () => {
1537
+ const el = await header({ pageTitle: 'Applications', leading: 'back+app-icon' });
1538
+ const toggle = part(el, 'nav-toggle')!;
1539
+ const glyph = toggle.querySelector('nile-glyph') ?? toggle;
1540
+ const rule = part(el, 'rule')!.getBoundingClientRect();
1541
+ const backGlyph = part(el, 'back')!.querySelector('nile-glyph')!.getBoundingClientRect();
1542
+ const icon = part(el, 'app-icon')!.getBoundingClientRect();
1543
+ const title = part(el, 'page-title')!.getBoundingClientRect();
1544
+ const toggleGlyph = glyph.getBoundingClientRect();
1545
+
1546
+ expect(Math.round(rule.height)).to.equal(16);
1547
+ expect(Math.round(rule.left - toggleGlyph.right)).to.equal(16);
1548
+ expect(Math.round(backGlyph.left - rule.left)).to.equal(16);
1549
+ expect(Math.round(backGlyph.width)).to.equal(20);
1550
+ expect(Math.round(icon.left - backGlyph.right)).to.equal(16);
1551
+ expect(Math.round(title.left - icon.right)).to.equal(8);
1552
+
1553
+ // Without an icon the title takes the icon's place, 16px after the arrow.
1554
+ const plain = await header({ pageTitle: 'Applications', leading: 'back' });
1555
+ const plainArrow = part(plain, 'back')!.querySelector('nile-glyph')!.getBoundingClientRect();
1556
+ const plainTitle = part(plain, 'page-title')!.getBoundingClientRect();
1557
+ expect(Math.round(plainTitle.left - plainArrow.right)).to.equal(16);
1558
+ });
1559
+
1560
+ // The app-icon slot works with or without headerBack, and with or without a title.
1561
+ it('65c. shows a slotted app icon independently of headerBack', async () => {
1562
+ const iconOnly = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1563
+ expect(part(iconOnly, 'back')).to.be.null;
1564
+ expect(part(iconOnly, 'app-icon')!.getBoundingClientRect().width).to.equal(20);
1565
+
1566
+ // An icon on its own still mounts the title block.
1567
+ const bare = await fixture<NileAppShell>(html`
1568
+ <nile-app-shell style="width: 1176px; height: 800px; ${HEADER_TOKENS}">
1569
+ <nav slot="side-nav">nav</nav>
1570
+ <span slot="app-icon" id="mark">A</span>
1571
+ <p>page</p>
1572
+ </nile-app-shell>
1573
+ `);
1574
+ await waitUntil(() => bare.hasAttribute('has-app-icon'));
1575
+ await bare.updateComplete;
1576
+ expect(part(bare, 'title-area')).to.exist;
1577
+ expect(part(bare, 'app-icon')!.getBoundingClientRect().width).to.equal(20);
1578
+
1579
+ // Removing the mark takes the icon (and the block it alone mounted) away again.
1580
+ bare.querySelector('#mark')!.remove();
1581
+ await waitUntil(() => !bare.hasAttribute('has-app-icon'));
1582
+ await bare.updateComplete;
1583
+ expect(part(bare, 'title-area')).to.be.null;
1584
+ });
1585
+
1586
+ // Amendment A (M11): the inactive tab colour is scoped to the shell's strip; a standalone group keeps its defaults.
1587
+ it('65e. colours the inactive header tabs through the shell only', async () => {
1588
+ const colours = [
1589
+ '--ng-colors-fg-brand-primary-alt: rgb(21, 94, 239)',
1590
+ '--ng-colors-text-brand-secondary-700: rgb(0, 78, 235)',
1591
+ '--ng-colors-text-quaternary-500: rgb(113, 118, 128)',
1592
+ '--ng-colors-text-secondary-700: rgb(65, 70, 81)',
1593
+ ].join('; ');
1594
+ const el = await header({ pageTitle: 'Applications', tabs: true });
1595
+ el.setAttribute('style', `${el.getAttribute('style')}; ${colours}`);
1596
+ await el.updateComplete;
1597
+
1598
+ const group = el.querySelector('nile-nav-tab-group') as HTMLElement;
1599
+ const inactive = el.querySelectorAll('nile-nav-tab')[1] as HTMLElement;
1600
+ // Known limitation (M12, A-7): the shell sets no indicator colour, because nile-nav-tab-group's top indicator
1601
+ // hard-codes its token in its own shadow tree; the indicator keeps the group's default.
1602
+ const indicatorOf = (g: HTMLElement) =>
1603
+ getComputedStyle(g.shadowRoot!.querySelector('.nav-tab-group__indicator') as HTMLElement)
1604
+ .borderBottomColor;
1605
+ expect(indicatorOf(group)).to.equal('rgb(0, 78, 235)');
1606
+ // The inactive label is remapped inside the shell only: nile-nav-tab itself is unchanged and still paints
1607
+ // var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700)); the shell points that fallback token at quaternary-500.
1608
+ // (The painted ink sits inside nile-link, which this runner does not render; it is checked in the browser instead.)
1609
+ const secondaryOf = (node: HTMLElement) =>
1610
+ getComputedStyle(node).getPropertyValue('--ng-colors-text-secondary-700').trim();
1611
+ expect(secondaryOf(inactive)).to.equal('rgb(113, 118, 128)');
1612
+ type Sheets = { cssText: string } | { cssText: string }[];
1613
+ const sheets = (customElements.get('nile-nav-tab') as unknown as { styles: Sheets }).styles;
1614
+ const tabStyles = ([] as { cssText: string }[]).concat(sheets).map(sheet => sheet.cssText).join('\n');
1615
+ expect(tabStyles).to.include(
1616
+ 'color: var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700));'
1617
+ );
1618
+ expect(tabStyles, 'no new hook on the shared component').to.not.include('--nile-nav-tab-text-color:');
1619
+ // Nothing else in the shell's tree is remapped.
1620
+ expect(secondaryOf(el)).to.equal('rgb(65, 70, 81)');
1621
+
1622
+ // Outside the shell nothing changes.
1623
+ const standalone = await fixture<HTMLElement>(html`
1624
+ <nile-nav-tab-group style="${colours}">
1625
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
1626
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
1627
+ </nile-nav-tab-group>
1628
+ `);
1629
+ const loneTab = standalone.querySelectorAll('nile-nav-tab')[1] as HTMLElement;
1630
+ expect(secondaryOf(loneTab), 'the global default is untouched').to.equal('rgb(65, 70, 81)');
1631
+ await (standalone as unknown as { updateComplete: Promise<unknown> }).updateComplete;
1632
+ expect(indicatorOf(standalone), 'a standalone group paints its own default').to.equal(
1633
+ 'rgb(0, 78, 235)'
1634
+ );
1635
+ });
1636
+
1637
+ it('65d. colours the description text-quaternary-500', async () => {
1638
+ const el = await header({
1639
+ pageTitle: 'Applications',
1640
+ description: 'Manage the apps connected here',
1641
+ });
1642
+ el.style.setProperty('--ng-colors-text-quaternary-500', 'rgb(113, 118, 128)');
1643
+ el.style.setProperty('--ng-colors-text-tertiary-600', 'rgb(1, 2, 3)');
1644
+ expect(getComputedStyle(part(el, 'description')!).color).to.equal(
1645
+ 'rgb(113, 118, 128)'
1646
+ );
1647
+ });
1648
+
1649
+ it('66. centres a 20px app icon in the 24px title row, two pixels down', async () => {
1650
+ const el = await header({
1651
+ pageTitle: 'Applications',
1652
+ leading: 'app-icon',
1653
+ description: 'Manage the apps connected here',
1654
+ });
1655
+
1656
+ const title = part(el, 'page-title')!.getBoundingClientRect();
1657
+ const icon = part(el, 'app-icon')!.getBoundingClientRect();
1658
+ // (24 - 20) / 2 — structural, not an offset written anywhere.
1659
+ expect(Math.round(icon.top - title.top)).to.equal(2);
1660
+ });
1661
+
1662
+ // The app icon is sized apart from the utility controls, so retuning one cannot move the other.
1663
+ it('67. sizes the app icon independently of the utility controls', async () => {
1664
+ const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1665
+ const host = getComputedStyle(el);
1666
+
1667
+ expect(host.getPropertyValue('--nile-app-shell-app-icon-size').trim()).to.equal(
1668
+ '20px'
1669
+ );
1670
+ expect(host.getPropertyValue('--nile-app-shell-utility-size').trim()).to.equal(
1671
+ '32px'
1672
+ );
1673
+
1674
+ el.style.setProperty('--nile-app-shell-app-icon-size', '28px');
1675
+ await el.updateComplete;
1676
+ expect(part(el, 'app-icon')!.getBoundingClientRect().width).to.equal(28);
1677
+ // The nav toggle is untouched by that.
1678
+ const toggle = part(el, 'nav-toggle')!;
1679
+ const base = toggle.shadowRoot!.querySelector(
1680
+ '[part~="base"]'
1681
+ ) as HTMLElement;
1682
+ expect(base.getBoundingClientRect().width).to.equal(32);
1683
+ });
1684
+
1685
+ it('54. a slotted app icon draws a 20px block around the mark', async () => {
1686
+ const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1687
+ const box = part(el, 'app-icon')!;
1688
+ expect(box).to.exist;
1689
+ expect(part(el, 'back')).to.be.null;
1690
+
1691
+ const rect = box.getBoundingClientRect();
1692
+ // Its own size, not the 32px utility control box the nav toggle uses.
1693
+ expect(rect.width).to.equal(20);
1694
+ expect(rect.height).to.equal(20);
1695
+
1696
+ // The page's mark is assigned into the shell's box.
1697
+ const slot = box.querySelector('slot[name="app-icon"]') as HTMLSlotElement;
1698
+ expect(slot).to.exist;
1699
+ expect(slot.assignedElements()).to.deep.equal([
1700
+ el.querySelector('#mark') as Element,
1701
+ ]);
1702
+
1703
+ // The block sits before the title, not after it.
1704
+ /* eslint-disable-next-line no-bitwise */
1705
+ const boxIsFirst =
1706
+ box.compareDocumentPosition(part(el, 'page-title')!) &
1707
+ Node.DOCUMENT_POSITION_FOLLOWING;
1708
+ expect(boxIsFirst).to.be.greaterThan(0);
1709
+ });
1710
+ });
1711
+
1712
+ // The four header heights are viewport-height sensitive, so these tests set a viewport where the spec numbers apply.
1713
+ describe('NileAppShell — the four header heights', () => {
1714
+ const TALL = { width: 1400, height: 900 };
1715
+ const SHORT = { width: 800, height: 600 };
1716
+
1717
+ before(async () => {
1718
+ await setViewport(TALL);
1719
+ });
1720
+
1721
+ after(async () => {
1722
+ await setViewport(SHORT);
1723
+ });
1724
+
1725
+ const bar = (el: NileAppShell) =>
1726
+ el.shadowRoot!.querySelector('.container > .top-bar') as HTMLElement;
1727
+
1728
+ const strip = (el: NileAppShell) =>
1729
+ el.shadowRoot!.querySelector('.tabs') as HTMLElement;
1730
+
1731
+ // The header block is the bar plus its strip, measured top-to-bottom so a gap between them is caught.
1732
+ const headerBlock = (el: NileAppShell) => {
1733
+ const top = bar(el).getBoundingClientRect().top;
1734
+ const bottom = el.tabsVisible
1735
+ ? strip(el).getBoundingClientRect().bottom
1736
+ : bar(el).getBoundingClientRect().bottom;
1737
+ return bottom - top;
1738
+ };
1739
+
1740
+ it('55. is 68px with neither axis on, whatever the leading', async () => {
1741
+ for (const leading of LEADINGS) {
1742
+ const el = await header({ pageTitle: 'Applications', leading });
1743
+ expect(bar(el).getBoundingClientRect().height, leading).to.equal(68);
1744
+ // The leading axis changes what is in the bar, never how tall it is.
1745
+ const area = part(el, 'title-area')!.getBoundingClientRect().height;
1746
+ expect(area, leading).to.be.at.most(bar(el).clientHeight);
1747
+ }
1748
+ });
1749
+
1750
+ // A description adds its own line (24 title + 2 gap + 18 description = 44) in a 76px bar; asserted as the lines' own boxes.
1751
+ it('56. is 76px with a description, and the second line fits it', async () => {
1752
+ const el = await header({
1753
+ pageTitle: 'Applications',
1754
+ description: 'Manage the apps connected to this tenant',
1755
+ });
1756
+
1757
+ expect(bar(el).getBoundingClientRect().height).to.equal(76);
1758
+
1759
+ const stack = el.shadowRoot!.querySelector('.title-stack') as HTMLElement;
1760
+ expect(part(el, 'page-title')!.getBoundingClientRect().height).to.equal(24);
1761
+ expect(part(el, 'description')!.getBoundingClientRect().height).to.equal(
1762
+ 18
1763
+ );
1764
+ expect(stack.getBoundingClientRect().height).to.equal(44);
1765
+
1766
+ // Nothing is clipped: the stack sits wholly inside the bar's box.
1767
+ const barBox = bar(el).getBoundingClientRect();
1768
+ const stackBox = stack.getBoundingClientRect();
1769
+ expect(stackBox.top).to.be.at.least(barBox.top);
1770
+ expect(stackBox.bottom).to.be.at.most(barBox.bottom);
1771
+ });
1772
+
1773
+ it('57. adds a 33px strip for tabs: 101px, and 109px with a description', async () => {
1774
+ const tabbed = await header({ pageTitle: 'Applications', tabs: true });
1775
+ expect(strip(tabbed).getBoundingClientRect().height).to.equal(33);
1776
+ expect(headerBlock(tabbed)).to.equal(101);
1777
+
1778
+ const described = await header({
1779
+ pageTitle: 'Applications',
1780
+ description: 'Manage the apps connected to this tenant',
1781
+ tabs: true,
1782
+ });
1783
+ expect(strip(described).getBoundingClientRect().height).to.equal(33);
1784
+ expect(headerBlock(described)).to.equal(109);
1785
+ });
1786
+
1787
+ // The three axes are independent, so all twelve combinations land on one of the four heights.
1788
+ it('58. resolves every variant to exactly the four heights', async () => {
1789
+ for (const leading of LEADINGS) {
1790
+ for (const tabs of [false, true]) {
1791
+ for (const described of [false, true]) {
1792
+ /* eslint-disable-next-line no-nested-ternary */
1793
+ const expected = tabs ? (described ? 109 : 101) : described ? 76 : 68;
1794
+ const el = await header({
1795
+ pageTitle: 'Applications',
1796
+ description: described ? 'Manage connected apps' : '',
1797
+ leading,
1798
+ tabs,
1799
+ });
1800
+ const label = `leading=${leading} tabs=${tabs} description=${described}`;
1801
+ expect(headerBlock(el), label).to.equal(expected);
1802
+ }
1803
+ }
1804
+ }
1805
+ });
1806
+
1807
+ // The strip is a control row plus its rule; the row shrinks to fit via the group's own padding hook, staying shorter than the standalone group.
1808
+ it('59. shrinks the slotted tab row to fit the strip, rather than crop it', async () => {
1809
+ const el = await header({ pageTitle: 'Applications', tabs: true });
1810
+ const standalone = await fixture<HTMLElement>(html`
1811
+ <nile-nav-tab-group style="${HEADER_TOKENS}">
1812
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
1813
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
1814
+ </nile-nav-tab-group>
1815
+ `);
1816
+
1817
+ const stripBox = strip(el).getBoundingClientRect();
1818
+ const slottedRow = (
1819
+ el.querySelector('nile-nav-tab-group') as HTMLElement
1820
+ ).getBoundingClientRect();
1821
+ const loneRow = standalone.getBoundingClientRect();
1822
+
1823
+ // The design's row: 0 + 20px label + 12px = 32, in a 33px strip.
1824
+ expect(slottedRow.height).to.equal(32);
1825
+ expect(stripBox.height).to.equal(33);
1826
+
1827
+ // The shell's hook is load-bearing: the same group is taller on its own.
1828
+ expect(loneRow.height).to.be.greaterThan(slottedRow.height);
1829
+ expect(loneRow.height).to.be.greaterThan(stripBox.height);
1830
+
1831
+ // Nothing is cropped and there is no slack: the row fills the strip above the rule it sits on.
1832
+ expect(slottedRow.top).to.be.at.least(stripBox.top);
1833
+ expect(slottedRow.bottom).to.be.at.most(stripBox.bottom);
1834
+ expect(slottedRow.top - stripBox.top).to.equal(0);
1835
+ });
1836
+
1837
+ it('60. drives the row through the group’s own padding hook', async () => {
1838
+ const el = await header({ pageTitle: 'Applications', tabs: true });
1839
+ const group = el.querySelector('nile-nav-tab-group') as HTMLElement;
1840
+ const padding = getComputedStyle(group)
1841
+ .getPropertyValue('--nile-nav-tab-padding')
1842
+ .split(/\s+/)
1843
+ .filter(Boolean);
1844
+
1845
+ // The tab node's padding is 0 / 4 / 12 / 4, the 12px bottom being the active indicator's band.
1846
+ expect(padding).to.deep.equal(['0px', '4px', '12px', '4px']);
1847
+
1848
+ // The inter-tab gap is the group's own 12px and is deliberately untouched.
1849
+ expect(
1850
+ getComputedStyle(group).getPropertyValue('--tab-gap').trim()
1851
+ ).to.equal('12px');
1852
+ });
1853
+
1854
+ // The compression under a short viewport is documented behaviour, asserted here to keep the resize honest.
1855
+ it('61. compresses to 56/64px under 600px of viewport height', async () => {
1856
+ await setViewport(SHORT);
1857
+ try {
1858
+ const plain = await header({ pageTitle: 'Applications' });
1859
+ expect(bar(plain).getBoundingClientRect().height).to.equal(56);
1860
+
1861
+ const described = await header({
1862
+ pageTitle: 'Applications',
1863
+ description: 'Manage connected apps',
1864
+ });
1865
+ expect(bar(described).getBoundingClientRect().height).to.equal(64);
1866
+ } finally {
1867
+ await setViewport(TALL);
1868
+ }
1869
+ });
536
1870
  });