@aquera/nile-elements 2.0.11 → 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 (34) hide show
  1. package/README.md +3 -0
  2. package/demo/app-shell.html +21 -21
  3. package/dist/index.js +76 -66
  4. package/dist/nile-app-shell/nile-app-shell.cjs.js +1 -1
  5. package/dist/nile-app-shell/nile-app-shell.cjs.js.map +1 -1
  6. package/dist/nile-app-shell/nile-app-shell.css.cjs.js +1 -1
  7. package/dist/nile-app-shell/nile-app-shell.css.cjs.js.map +1 -1
  8. package/dist/nile-app-shell/nile-app-shell.css.esm.js +35 -32
  9. package/dist/nile-app-shell/nile-app-shell.esm.js +39 -34
  10. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js +1 -1
  11. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.cjs.js.map +1 -1
  12. package/dist/nile-app-shell-panel/nile-app-shell-panel.css.esm.js +3 -1
  13. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js +9 -5
  14. package/dist/src/nile-app-shell/nile-app-shell-regions.test.js.map +1 -1
  15. package/dist/src/nile-app-shell/nile-app-shell.css.js +33 -30
  16. package/dist/src/nile-app-shell/nile-app-shell.css.js.map +1 -1
  17. package/dist/src/nile-app-shell/nile-app-shell.d.ts +22 -22
  18. package/dist/src/nile-app-shell/nile-app-shell.js +86 -76
  19. package/dist/src/nile-app-shell/nile-app-shell.js.map +1 -1
  20. package/dist/src/nile-app-shell/nile-app-shell.test.d.ts +1 -0
  21. package/dist/src/nile-app-shell/nile-app-shell.test.js +307 -89
  22. package/dist/src/nile-app-shell/nile-app-shell.test.js.map +1 -1
  23. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js +3 -1
  24. package/dist/src/nile-app-shell-panel/nile-app-shell-panel.css.js.map +1 -1
  25. package/dist/src/version.js +1 -1
  26. package/dist/src/version.js.map +1 -1
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/src/nile-app-shell/nile-app-shell-regions.test.ts +9 -5
  30. package/src/nile-app-shell/nile-app-shell.css.ts +33 -30
  31. package/src/nile-app-shell/nile-app-shell.test.ts +348 -105
  32. package/src/nile-app-shell/nile-app-shell.ts +89 -82
  33. package/src/nile-app-shell-panel/nile-app-shell-panel.css.ts +3 -1
  34. package/vscode-html-custom-data.json +27 -37
@@ -2,6 +2,7 @@ import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
2
2
  import { setViewport } from '@web/test-runner-commands';
3
3
  import './nile-app-shell';
4
4
  import '../nile-app-shell-nav-toggle/nile-app-shell-nav-toggle';
5
+ import '../nile-app-shell-panel/nile-app-shell-panel';
5
6
  import '../nile-nav-tab-group/nile-nav-tab-group';
6
7
  import '../nile-nav-tab/nile-nav-tab';
7
8
  import '../nile-side-bar/nile-side-bar';
@@ -224,6 +225,46 @@ describe('NileAppShell — Ask Aquera panel', () => {
224
225
  expect(part(el, 'panel').getAttribute('aria-label')).to.equal('Ask Aquera');
225
226
  expect(part(el, 'panel-title').textContent.trim()).to.equal('Ask Aquera');
226
227
  });
228
+ // Amendment A (A-8): Figma's Assistant Title is Text lg/Semibold, 18/28; the shell sets it on the title it renders itself.
229
+ it('17b. sets its own panel title at 18/28 in text-primary-900', async () => {
230
+ const tokens = '--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)';
231
+ const el = await sized(1440);
232
+ el.setAttribute('style', `${el.getAttribute('style')}; ${tokens}`);
233
+ el.openPanel();
234
+ await el.updateComplete;
235
+ const own = getComputedStyle(part(el, 'panel-title'));
236
+ expect(own.fontSize).to.equal('18px');
237
+ expect(own.lineHeight).to.equal('28px');
238
+ expect(own.fontWeight).to.equal('600');
239
+ expect(own.color).to.equal('rgb(24, 29, 39)');
240
+ });
241
+ // A-8: a slotted nile-app-shell-panel (part of App Shell) draws the same Figma title: 18/28 SemiBold, text-primary-900.
242
+ it('17c. sets the nile-app-shell-panel title at 18/28 in text-primary-900', async () => {
243
+ const tokens = '--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)';
244
+ const slotted = await fixture(html `
245
+ <nile-app-shell-panel label="Ask Aquera" style="${tokens}"></nile-app-shell-panel>
246
+ `);
247
+ await slotted.updateComplete;
248
+ const style = getComputedStyle(slotted.shadowRoot.querySelector('.title'));
249
+ expect(style.fontSize).to.equal('18px');
250
+ expect(style.lineHeight).to.equal('28px');
251
+ expect(style.fontWeight).to.equal('600');
252
+ expect(style.color).to.equal('rgb(24, 29, 39)');
253
+ });
254
+ // The enterprise sheet keeps its own panel title: --nile-type-scale-3 and an inherited line height, exactly as before.
255
+ // Enterprise defines no --ng-* tokens, so the NxtGen line height does not apply there.
256
+ it('17d. leaves the enterprise nile-app-shell-panel title as it was', async () => {
257
+ const slotted = await fixture(html `
258
+ <nile-app-shell-panel
259
+ label="Ask Aquera"
260
+ style="--nile-type-scale-3: 14px; line-height: 20px"
261
+ ></nile-app-shell-panel>
262
+ `);
263
+ await slotted.updateComplete;
264
+ const style = getComputedStyle(slotted.shadowRoot.querySelector('.title'));
265
+ expect(style.fontSize).to.equal('14px');
266
+ expect(style.lineHeight).to.equal('20px');
267
+ });
227
268
  it('18. Cmd/Ctrl+J toggles the panel', async () => {
228
269
  const el = await sized(1440);
229
270
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', ctrlKey: true }));
@@ -575,6 +616,46 @@ describe('NileAppShell — the built-in Ask button', () => {
575
616
  expect(getComputedStyle(actions).display).to.not.equal('none');
576
617
  expect(actions.getBoundingClientRect().width).to.be.greaterThan(0);
577
618
  });
619
+ // Amendment A (M8): the design's Ask is the secondary button, not the filled primary.
620
+ it('34b. renders Ask as the secondary variant', async () => {
621
+ const el = await sized(1440);
622
+ expect(ask(el).getAttribute('variant')).to.equal('secondary');
623
+ });
624
+ // Amendment A (M9, M10): app actions, a 16px divider, then Ask, 16px apart; with no app actions, no divider.
625
+ it('34c. draws a divider before Ask only when the page slots actions, 16px from both', async () => {
626
+ const alone = await sized(1440);
627
+ await settled(alone);
628
+ expect(part(alone, 'ask-rule')).to.be.null;
629
+ const el = await fixture(html `
630
+ <nile-app-shell style="width: 1440px; height: 600px; --ng-spacing-xl: 16px;">
631
+ <nav slot="side-nav">nav</nav>
632
+ <span slot="top-bar">Title</span>
633
+ <button slot="top-bar-actions" id="one">One</button>
634
+ <button slot="top-bar-actions" id="two">Two</button>
635
+ <p>page</p>
636
+ <div slot="panel">assistant</div>
637
+ </nile-app-shell>
638
+ `);
639
+ await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
640
+ await el.updateComplete;
641
+ const rule = part(el, 'ask-rule');
642
+ expect(rule).to.exist;
643
+ const one = el.querySelector('#one').getBoundingClientRect();
644
+ const two = el.querySelector('#two').getBoundingClientRect();
645
+ const ruleBox = rule.getBoundingClientRect();
646
+ const askBox = ask(el).getBoundingClientRect();
647
+ expect(Math.round(ruleBox.height)).to.equal(16);
648
+ expect(Math.round(two.left - one.right)).to.equal(16);
649
+ expect(Math.round(ruleBox.left - two.right)).to.equal(16);
650
+ expect(Math.round(askBox.left - ruleBox.left)).to.equal(16);
651
+ // Order: the page's actions, the divider, then Ask.
652
+ /* eslint-disable-next-line no-bitwise */
653
+ const ruleBeforeAsk = rule.compareDocumentPosition(ask(el)) & Node.DOCUMENT_POSITION_FOLLOWING;
654
+ expect(ruleBeforeAsk).to.be.greaterThan(0);
655
+ el.noAsk = true;
656
+ await el.updateComplete;
657
+ expect(part(el, 'ask-rule'), 'no Ask, no divider').to.be.null;
658
+ });
578
659
  // The shell draws its icon controls as bare glyphs via ::part(base), overriding nile-button's circle and ghost surface.
579
660
  const utilityBase = (el, name) => part(el, name).shadowRoot.querySelector('[part~="base"]');
580
661
  it('35. draws its icon controls with no resting surface', async () => {
@@ -769,6 +850,7 @@ const HEADER_TOKENS = [
769
850
  '--ng-spacing-xxs: 2px',
770
851
  '--ng-spacing-xs: 4px',
771
852
  '--ng-spacing-md: 8px',
853
+ '--ng-spacing-md-alt: 10px',
772
854
  '--ng-spacing-lg: 12px',
773
855
  '--ng-spacing-xl: 16px',
774
856
  '--ng-spacing-3xl: 24px',
@@ -779,6 +861,9 @@ const HEADER_TOKENS = [
779
861
  '--ng-font-size-text-md: 16px',
780
862
  '--ng-line-height-text-md: 24px',
781
863
  ].join('; ');
864
+ const LEADINGS = ['none', 'back', 'app-icon', 'back+app-icon'];
865
+ const hasBack = (leading) => leading === 'back' || leading === 'back+app-icon';
866
+ const hasIcon = (leading) => leading === 'app-icon' || leading === 'back+app-icon';
782
867
  // A shell carrying whatever header axes a test asks for; an unset axis is the empty string, meaning "absent".
783
868
  const header = async (axes = {}) => {
784
869
  const el = await fixture(html `
@@ -787,13 +872,16 @@ const header = async (axes = {}) => {
787
872
  .pageTitle=${axes.pageTitle ?? ''}
788
873
  .description=${axes.description ?? ''}
789
874
  .badge=${axes.badge ?? ''}
790
- leading=${axes.leading ?? 'none'}
791
- .backLabel=${axes.backLabel ?? 'Back'}
875
+ .headerBack=${hasBack(axes.leading)}
876
+ .headerBackLabel=${axes.headerBackLabel ?? 'Back'}
792
877
  >
793
878
  <nav slot="side-nav">nav</nav>
794
- ${axes.leading === 'app-icon'
879
+ ${hasIcon(axes.leading)
795
880
  ? html `<span slot="app-icon" id="mark">A</span>`
796
881
  : ''}
882
+ ${axes.actions
883
+ ? html `<button slot="top-bar-actions" id="cta">Button CTA</button>`
884
+ : ''}
797
885
  ${axes.tabs
798
886
  ? html `<nile-nav-tab-group slot="tabs">
799
887
  <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
@@ -805,6 +893,10 @@ const header = async (axes = {}) => {
805
893
  `);
806
894
  if (axes.tabs)
807
895
  await waitUntil(() => el.hasAttribute('has-tabs'));
896
+ if (hasIcon(axes.leading))
897
+ await waitUntil(() => el.hasAttribute('has-app-icon'));
898
+ if (axes.actions)
899
+ await waitUntil(() => el.hasAttribute('has-top-bar-actions'));
808
900
  await el.updateComplete;
809
901
  return el;
810
902
  };
@@ -812,7 +904,8 @@ const header = async (axes = {}) => {
812
904
  describe('NileAppShell — the header title block', () => {
813
905
  it('44. draws no title block at all until an axis asks for one', async () => {
814
906
  const el = await header();
815
- expect(el.leading).to.equal('none');
907
+ expect(el.headerBack).to.be.false;
908
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
816
909
  expect(part(el, 'title-area')).to.be.null;
817
910
  expect(part(el, 'page-title')).to.be.null;
818
911
  expect(part(el, 'description')).to.be.null;
@@ -848,8 +941,8 @@ describe('NileAppShell — the header title block', () => {
848
941
  expect(part(el, 'badge')).to.exist;
849
942
  expect(el.hasAttribute('has-description')).to.be.true;
850
943
  });
851
- // Leading is the exception: a back control stands on its own but brings no empty title stack.
852
- it('75. mounts a leading control on its own, without an empty title stack', async () => {
944
+ // The back arrow is the exception: it stands on its own but brings no empty title stack.
945
+ it('75. mounts the back arrow on its own, without an empty title stack', async () => {
853
946
  const el = await header({ leading: 'back' });
854
947
  expect(part(el, 'title-area')).to.exist;
855
948
  expect(part(el, 'back')).to.exist;
@@ -1015,120 +1108,150 @@ describe('NileAppShell — the header title block', () => {
1015
1108
  expect(el.hasAttribute('has-description')).to.be.false;
1016
1109
  expect(part(el, 'description')).to.be.null;
1017
1110
  });
1018
- it('50. leading defaults to none and draws neither control', async () => {
1111
+ it('50. headerBack defaults to false and draws no arrow, and no slotted icon draws no icon', async () => {
1019
1112
  const el = await header({ pageTitle: 'Applications' });
1020
- expect(el.leading).to.equal('none');
1113
+ expect(el.headerBack).to.be.false;
1114
+ expect(el.hasAttribute('headerback')).to.be.false;
1021
1115
  expect(part(el, 'back')).to.be.null;
1022
- expect(part(el, 'app-icon')).to.be.null;
1116
+ expect(el.hasAttribute('has-app-icon')).to.be.false;
1117
+ expect(getComputedStyle(part(el, 'app-icon')).display).to.equal('none');
1023
1118
  expect(el.shadowRoot.querySelector('.crumb-divider')).to.be.null;
1024
1119
  });
1025
- // Leading="back" draws a breadcrumb: glyph, previous-page label, slash, then the page title.
1026
- it('51. leading="back" draws glyph, label, divider, then the title', async () => {
1120
+ // Amendment A: the back control is the arrow alone, with no previous-page label and no slash.
1121
+ it('51. headerBack draws the narrow-left arrow only, then the title', async () => {
1027
1122
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1028
1123
  const back = part(el, 'back');
1029
- const divider = el.shadowRoot.querySelector('.crumb-divider');
1030
1124
  const title = part(el, 'page-title');
1031
1125
  expect(back).to.exist;
1032
1126
  expect(back.tagName).to.equal('BUTTON');
1033
1127
  expect(back.getAttribute('type')).to.equal('button');
1034
- expect(divider).to.exist;
1035
- expect(divider.getAttribute('aria-hidden')).to.equal('true');
1036
1128
  // The icons go through nile-glyph, so assert the glyph, not an <svg>.
1037
- const chevron = back.querySelector('nile-glyph');
1038
- expect(chevron).to.exist;
1039
- expect(chevron.getAttribute('name')).to.equal('ng-chevron-left');
1040
- expect(divider.querySelector('nile-glyph').getAttribute('name')).to.equal('ng-slash');
1041
- // glyph before label, inside the control.
1042
- const label = back.querySelector('span');
1129
+ const glyphs = back.querySelectorAll('nile-glyph');
1130
+ expect(glyphs.length).to.equal(1);
1131
+ expect(glyphs[0].getAttribute('name')).to.equal('ng-arrow-narrow-left');
1132
+ // No label, no divider, no slash anywhere in the header.
1133
+ expect(back.textContent.trim()).to.equal('');
1134
+ expect(back.querySelector('span')).to.be.null;
1135
+ expect(el.shadowRoot.querySelector('.crumb-divider')).to.be.null;
1136
+ expect(el.shadowRoot.querySelector('nile-glyph[name="ng-slash"]')).to.be.null;
1043
1137
  /* eslint-disable-next-line no-bitwise */
1044
- const glyphIsFirst = chevron.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING;
1045
- expect(glyphIsFirst).to.be.greaterThan(0);
1046
- // control, then divider, then title.
1047
- for (const [first, second] of [
1048
- [back, divider],
1049
- [divider, title],
1050
- ]) {
1051
- /* eslint-disable-next-line no-bitwise */
1052
- const ordered = first.compareDocumentPosition(second) &
1053
- Node.DOCUMENT_POSITION_FOLLOWING;
1054
- expect(ordered).to.be.greaterThan(0);
1055
- }
1056
- // nile-glyph is not registered on this test page, so the divider has no box to measure; only that the leading axis has not switched it off.
1138
+ const backIsFirst = back.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING;
1139
+ expect(backIsFirst).to.be.greaterThan(0);
1057
1140
  expect(back.getBoundingClientRect().width).to.be.greaterThan(0);
1058
- expect(getComputedStyle(divider).display).to.not.equal('none');
1059
1141
  });
1060
- // The slash separates the back control from the page title, so there is nothing to point at until a title is set.
1061
- it('51b. leading="back" without a title draws no divider', async () => {
1062
- const el = await header({ leading: 'back' });
1142
+ // camelCase property, attribute: true + reflect: true — the title-block pattern.
1143
+ it('51b. headerBack and headerBackLabel work from markup and reflect to their attributes', async () => {
1144
+ const marked = await fixture(html `
1145
+ <nile-app-shell pageTitle="Applications" headerBack headerBackLabel="Retour">
1146
+ <p>page</p>
1147
+ </nile-app-shell>
1148
+ `);
1149
+ await marked.updateComplete;
1150
+ expect(marked.headerBack).to.be.true;
1151
+ expect(marked.headerBackLabel).to.equal('Retour');
1152
+ expect(part(marked, 'back')).to.exist;
1153
+ const el = await header({ pageTitle: 'Applications' });
1154
+ el.headerBack = true;
1155
+ el.headerBackLabel = 'Go back';
1156
+ await el.updateComplete;
1157
+ expect(el.hasAttribute('headerback')).to.be.true;
1158
+ expect(el.getAttribute('headerbacklabel')).to.equal('Go back');
1063
1159
  expect(part(el, 'back')).to.exist;
1064
- expect(el.shadowRoot.querySelector('.crumb-divider')).to.be.null;
1065
- el.pageTitle = 'Applications';
1160
+ el.headerBack = false;
1066
1161
  await el.updateComplete;
1067
- expect(el.shadowRoot.querySelector('.crumb-divider')).to.exist;
1162
+ expect(el.hasAttribute('headerback')).to.be.false;
1163
+ expect(part(el, 'back')).to.be.null;
1068
1164
  });
1069
- it('52. takes the previous-page label from backLabel', async () => {
1165
+ // headerBackLabel names the control for assistive tech and is never drawn as text.
1166
+ it('52. headerBackLabel is the accessible name only, never visible text', async () => {
1070
1167
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1071
- // The label is the control's accessible name, so it lives in the text.
1072
- expect(part(el, 'back').textContent.trim()).to.equal('Back');
1073
- el.backLabel = 'Integrations';
1168
+ const back = part(el, 'back');
1169
+ expect(back.getAttribute('aria-label')).to.equal('Back');
1170
+ expect(back.getAttribute('title')).to.equal('Back');
1171
+ expect(back.querySelector('nile-glyph').getAttribute('aria-hidden')).to.not.equal('false');
1172
+ el.headerBackLabel = 'Integrations';
1074
1173
  await el.updateComplete;
1075
- expect(part(el, 'back').textContent.trim()).to.equal('Integrations');
1076
- });
1077
- // The shell owns no history, so the control only announces the press; the page navigates itself.
1078
- it('53. the back control emits nile-header-back, as do goBackHeader() and goBackPage()', async () => {
1174
+ expect(back.getAttribute('aria-label')).to.equal('Integrations');
1175
+ // Not rendered anywhere in the header as text.
1176
+ expect(back.textContent.trim()).to.equal('');
1177
+ expect(part(el, 'title-area').textContent.includes('Integrations')).to.be.false;
1178
+ expect(part(el, 'top-bar').textContent.includes('Integrations')).to.be.false;
1179
+ });
1180
+ // The shell owns no history, so the control only announces the press, as the standard nile-click; the page navigates itself.
1181
+ it('53. the back control and goBackHeader() emit nile-click { value: "back" } from the shell', async () => {
1079
1182
  const el = await header({ pageTitle: 'Applications', leading: 'back' });
1080
- const clicked = oneEvent(el, 'nile-header-back');
1183
+ let retired = 0;
1184
+ el.addEventListener('nile-header-back', () => {
1185
+ retired += 1;
1186
+ });
1187
+ const clicked = oneEvent(el, 'nile-click');
1081
1188
  part(el, 'back').click();
1082
- const fromClick = await clicked;
1083
- expect(fromClick).to.exist;
1084
- const called = oneEvent(el, 'nile-header-back');
1189
+ const fromClick = (await clicked);
1190
+ expect(fromClick.detail).to.deep.equal({ value: 'back' });
1191
+ expect(fromClick.target).to.equal(el);
1192
+ expect(fromClick.bubbles).to.be.true;
1193
+ expect(fromClick.composed).to.be.true;
1194
+ const called = oneEvent(el, 'nile-click');
1085
1195
  el.goBackHeader();
1086
- expect(await called).to.exist;
1087
- // goBackPage() is the deprecated alias of goBackHeader(): same event.
1088
- const aliased = oneEvent(el, 'nile-header-back');
1089
- el.goBackPage();
1090
- expect(await aliased).to.exist;
1196
+ const fromCall = (await called);
1197
+ expect(fromCall.detail).to.deep.equal({ value: 'back' });
1198
+ expect(fromCall.target).to.equal(el);
1199
+ // nile-header-back and goBackPage() are gone in 2.0.12 (Amendment A, A-12).
1200
+ expect(retired).to.equal(0);
1201
+ expect(el.goBackPage).to.be.undefined;
1091
1202
  // It is the page's event, not the panel's: nothing about the panel moves.
1092
1203
  expect(el.panelOpen).to.be.false;
1093
1204
  });
1094
- // The Description axis must not move when the Leading axis changes, so the description is always the stack's second line under the title.
1095
- it('63. aligns the description to the page title in every leading mode', async () => {
1096
- for (const leading of ['none', 'back', 'app-icon']) {
1205
+ // A nile-click bubbling up from page content must not read as the header back press.
1206
+ it('53b. a nile-click from slotted content is distinguishable by its target', async () => {
1207
+ const el = await header({ pageTitle: 'Applications', leading: 'back' });
1208
+ const page = el.querySelector('p');
1209
+ const seen = [];
1210
+ el.addEventListener('nile-click', e => seen.push(e.target));
1211
+ page.dispatchEvent(new CustomEvent('nile-click', {
1212
+ bubbles: true,
1213
+ composed: true,
1214
+ detail: { value: 'back' },
1215
+ }));
1216
+ part(el, 'back').click();
1217
+ expect(seen).to.deep.equal([page, el]);
1218
+ });
1219
+ // The four combinations are independent; with an app icon the description sits under the icon, otherwise under the title.
1220
+ it('63. aligns the description under the app icon when there is one, else the title', async () => {
1221
+ for (const leading of LEADINGS) {
1097
1222
  const el = await header({
1098
1223
  pageTitle: 'Applications',
1099
1224
  description: 'Manage the apps connected to this tenant',
1100
1225
  leading,
1101
1226
  });
1102
- const title = part(el, 'page-title').getBoundingClientRect();
1227
+ const anchor = hasIcon(leading) ? part(el, 'app-icon') : part(el, 'page-title');
1103
1228
  const description = part(el, 'description').getBoundingClientRect();
1104
- expect(Math.round(description.left), leading).to.equal(Math.round(title.left));
1229
+ expect(Math.round(description.left), leading).to.equal(Math.round(anchor.getBoundingClientRect().left));
1105
1230
  }
1106
1231
  });
1107
- // Neither leading control lives in the title row, so neither can displace the title away from the description.
1108
- it('64. renders both leading modes beside the title stack, not in the row', async () => {
1109
- const backShell = await header({ pageTitle: 'Applications', leading: 'back' });
1110
- const iconShell = await header({
1111
- pageTitle: 'Applications',
1112
- leading: 'app-icon',
1113
- });
1114
- for (const [el, name] of [
1115
- [backShell, 'back'],
1116
- [iconShell, 'app-icon'],
1117
- ]) {
1118
- const control = part(el, name);
1232
+ // The arrow sits beside the stack; the app icon is the first thing in the title row.
1233
+ it('64. renders the arrow beside the title stack and the app icon inside the title row', async () => {
1234
+ for (const leading of LEADINGS) {
1235
+ const el = await header({ pageTitle: 'Applications', leading });
1119
1236
  const row = el.shadowRoot.querySelector('.title-row');
1120
1237
  const area = part(el, 'title-area');
1121
- expect(row.contains(control), name).to.be.false;
1122
- expect(area.contains(control), name).to.be.true;
1238
+ const back = part(el, 'back');
1239
+ const icon = part(el, 'app-icon');
1240
+ expect(!!back, `${leading} back`).to.equal(hasBack(leading));
1241
+ expect(el.hasAttribute('has-app-icon'), `${leading} icon`).to.equal(hasIcon(leading));
1242
+ if (back) {
1243
+ expect(row.contains(back), leading).to.be.false;
1244
+ expect(area.contains(back), leading).to.be.true;
1245
+ }
1246
+ expect(row.contains(icon), leading).to.be.true;
1247
+ if (hasIcon(leading)) {
1248
+ expect(row.firstElementChild, leading).to.equal(icon);
1249
+ }
1123
1250
  }
1124
- // The breadcrumb divider travels with the control it belongs to.
1125
- const divider = backShell.shadowRoot.querySelector('.crumb-divider');
1126
- const row = backShell.shadowRoot.querySelector('.title-row');
1127
- expect(row.contains(divider)).to.be.false;
1128
- });
1129
- // The leading element sits on the title's row, and its offset does not move when a description appears.
1130
- it('65. sits the leading element on the title row, description or not', async () => {
1131
- for (const leading of ['back', 'app-icon']) {
1251
+ });
1252
+ // The arrow sits on the title's row, and its offset does not move when a description appears.
1253
+ it('65. sits the back arrow on the title row, description or not', async () => {
1254
+ for (const leading of ['back', 'back+app-icon']) {
1132
1255
  for (const description of ['', 'Manage the apps connected here']) {
1133
1256
  const el = await header({
1134
1257
  pageTitle: 'Applications',
@@ -1136,7 +1259,7 @@ describe('NileAppShell — the header title block', () => {
1136
1259
  description,
1137
1260
  });
1138
1261
  const title = part(el, 'page-title').getBoundingClientRect();
1139
- const box = el.shadowRoot.querySelector('.leading');
1262
+ const box = el.shadowRoot.querySelector('.back-line');
1140
1263
  const label = `${leading} description=${!!description}`;
1141
1264
  // The line box is the title's own, and it starts where the title does.
1142
1265
  expect(Math.round(box.getBoundingClientRect().top), label).to.equal(Math.round(title.top));
@@ -1144,6 +1267,101 @@ describe('NileAppShell — the header title block', () => {
1144
1267
  }
1145
1268
  }
1146
1269
  });
1270
+ // Figma Title Area: arrow → 16px → [icon → 8px → title]; Breadcrumb / 01: toggle glyph → 16px → divider (16px tall) → 16px.
1271
+ it('65b. spaces the toggle, divider, arrow, icon and title as the design does', async () => {
1272
+ const el = await header({ pageTitle: 'Applications', leading: 'back+app-icon' });
1273
+ const toggle = part(el, 'nav-toggle');
1274
+ const glyph = toggle.querySelector('nile-glyph') ?? toggle;
1275
+ const rule = part(el, 'rule').getBoundingClientRect();
1276
+ const backGlyph = part(el, 'back').querySelector('nile-glyph').getBoundingClientRect();
1277
+ const icon = part(el, 'app-icon').getBoundingClientRect();
1278
+ const title = part(el, 'page-title').getBoundingClientRect();
1279
+ const toggleGlyph = glyph.getBoundingClientRect();
1280
+ expect(Math.round(rule.height)).to.equal(16);
1281
+ expect(Math.round(rule.left - toggleGlyph.right)).to.equal(16);
1282
+ expect(Math.round(backGlyph.left - rule.left)).to.equal(16);
1283
+ expect(Math.round(backGlyph.width)).to.equal(20);
1284
+ expect(Math.round(icon.left - backGlyph.right)).to.equal(16);
1285
+ expect(Math.round(title.left - icon.right)).to.equal(8);
1286
+ // Without an icon the title takes the icon's place, 16px after the arrow.
1287
+ const plain = await header({ pageTitle: 'Applications', leading: 'back' });
1288
+ const plainArrow = part(plain, 'back').querySelector('nile-glyph').getBoundingClientRect();
1289
+ const plainTitle = part(plain, 'page-title').getBoundingClientRect();
1290
+ expect(Math.round(plainTitle.left - plainArrow.right)).to.equal(16);
1291
+ });
1292
+ // The app-icon slot works with or without headerBack, and with or without a title.
1293
+ it('65c. shows a slotted app icon independently of headerBack', async () => {
1294
+ const iconOnly = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1295
+ expect(part(iconOnly, 'back')).to.be.null;
1296
+ expect(part(iconOnly, 'app-icon').getBoundingClientRect().width).to.equal(20);
1297
+ // An icon on its own still mounts the title block.
1298
+ const bare = await fixture(html `
1299
+ <nile-app-shell style="width: 1176px; height: 800px; ${HEADER_TOKENS}">
1300
+ <nav slot="side-nav">nav</nav>
1301
+ <span slot="app-icon" id="mark">A</span>
1302
+ <p>page</p>
1303
+ </nile-app-shell>
1304
+ `);
1305
+ await waitUntil(() => bare.hasAttribute('has-app-icon'));
1306
+ await bare.updateComplete;
1307
+ expect(part(bare, 'title-area')).to.exist;
1308
+ expect(part(bare, 'app-icon').getBoundingClientRect().width).to.equal(20);
1309
+ // Removing the mark takes the icon (and the block it alone mounted) away again.
1310
+ bare.querySelector('#mark').remove();
1311
+ await waitUntil(() => !bare.hasAttribute('has-app-icon'));
1312
+ await bare.updateComplete;
1313
+ expect(part(bare, 'title-area')).to.be.null;
1314
+ });
1315
+ // Amendment A (M11): the inactive tab colour is scoped to the shell's strip; a standalone group keeps its defaults.
1316
+ it('65e. colours the inactive header tabs through the shell only', async () => {
1317
+ const colours = [
1318
+ '--ng-colors-fg-brand-primary-alt: rgb(21, 94, 239)',
1319
+ '--ng-colors-text-brand-secondary-700: rgb(0, 78, 235)',
1320
+ '--ng-colors-text-quaternary-500: rgb(113, 118, 128)',
1321
+ '--ng-colors-text-secondary-700: rgb(65, 70, 81)',
1322
+ ].join('; ');
1323
+ const el = await header({ pageTitle: 'Applications', tabs: true });
1324
+ el.setAttribute('style', `${el.getAttribute('style')}; ${colours}`);
1325
+ await el.updateComplete;
1326
+ const group = el.querySelector('nile-nav-tab-group');
1327
+ const inactive = el.querySelectorAll('nile-nav-tab')[1];
1328
+ // Known limitation (M12, A-7): the shell sets no indicator colour, because nile-nav-tab-group's top indicator
1329
+ // hard-codes its token in its own shadow tree; the indicator keeps the group's default.
1330
+ const indicatorOf = (g) => getComputedStyle(g.shadowRoot.querySelector('.nav-tab-group__indicator'))
1331
+ .borderBottomColor;
1332
+ expect(indicatorOf(group)).to.equal('rgb(0, 78, 235)');
1333
+ // The inactive label is remapped inside the shell only: nile-nav-tab itself is unchanged and still paints
1334
+ // var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700)); the shell points that fallback token at quaternary-500.
1335
+ // (The painted ink sits inside nile-link, which this runner does not render; it is checked in the browser instead.)
1336
+ const secondaryOf = (node) => getComputedStyle(node).getPropertyValue('--ng-colors-text-secondary-700').trim();
1337
+ expect(secondaryOf(inactive)).to.equal('rgb(113, 118, 128)');
1338
+ const sheets = customElements.get('nile-nav-tab').styles;
1339
+ const tabStyles = [].concat(sheets).map(sheet => sheet.cssText).join('\n');
1340
+ expect(tabStyles).to.include('color: var(--nile-colors-dark-500, var(--ng-colors-text-secondary-700));');
1341
+ expect(tabStyles, 'no new hook on the shared component').to.not.include('--nile-nav-tab-text-color:');
1342
+ // Nothing else in the shell's tree is remapped.
1343
+ expect(secondaryOf(el)).to.equal('rgb(65, 70, 81)');
1344
+ // Outside the shell nothing changes.
1345
+ const standalone = await fixture(html `
1346
+ <nile-nav-tab-group style="${colours}">
1347
+ <nile-nav-tab slot="nav" panel="apps" active>Connected</nile-nav-tab>
1348
+ <nile-nav-tab slot="nav" panel="approvals">Approvals</nile-nav-tab>
1349
+ </nile-nav-tab-group>
1350
+ `);
1351
+ const loneTab = standalone.querySelectorAll('nile-nav-tab')[1];
1352
+ expect(secondaryOf(loneTab), 'the global default is untouched').to.equal('rgb(65, 70, 81)');
1353
+ await standalone.updateComplete;
1354
+ expect(indicatorOf(standalone), 'a standalone group paints its own default').to.equal('rgb(0, 78, 235)');
1355
+ });
1356
+ it('65d. colours the description text-quaternary-500', async () => {
1357
+ const el = await header({
1358
+ pageTitle: 'Applications',
1359
+ description: 'Manage the apps connected here',
1360
+ });
1361
+ el.style.setProperty('--ng-colors-text-quaternary-500', 'rgb(113, 118, 128)');
1362
+ el.style.setProperty('--ng-colors-text-tertiary-600', 'rgb(1, 2, 3)');
1363
+ expect(getComputedStyle(part(el, 'description')).color).to.equal('rgb(113, 118, 128)');
1364
+ });
1147
1365
  it('66. centres a 20px app icon in the 24px title row, two pixels down', async () => {
1148
1366
  const el = await header({
1149
1367
  pageTitle: 'Applications',
@@ -1169,7 +1387,7 @@ describe('NileAppShell — the header title block', () => {
1169
1387
  const base = toggle.shadowRoot.querySelector('[part~="base"]');
1170
1388
  expect(base.getBoundingClientRect().width).to.equal(32);
1171
1389
  });
1172
- it('54. leading="app-icon" draws a 20px block around the slotted mark', async () => {
1390
+ it('54. a slotted app icon draws a 20px block around the mark', async () => {
1173
1391
  const el = await header({ pageTitle: 'Applications', leading: 'app-icon' });
1174
1392
  const box = part(el, 'app-icon');
1175
1393
  expect(box).to.exist;
@@ -1212,7 +1430,7 @@ describe('NileAppShell — the four header heights', () => {
1212
1430
  return bottom - top;
1213
1431
  };
1214
1432
  it('55. is 68px with neither axis on, whatever the leading', async () => {
1215
- for (const leading of ['none', 'back', 'app-icon']) {
1433
+ for (const leading of LEADINGS) {
1216
1434
  const el = await header({ pageTitle: 'Applications', leading });
1217
1435
  expect(bar(el).getBoundingClientRect().height, leading).to.equal(68);
1218
1436
  // The leading axis changes what is in the bar, never how tall it is.
@@ -1250,8 +1468,8 @@ describe('NileAppShell — the four header heights', () => {
1250
1468
  expect(headerBlock(described)).to.equal(109);
1251
1469
  });
1252
1470
  // The three axes are independent, so all twelve combinations land on one of the four heights.
1253
- it('58. resolves all twelve variants to exactly the four heights', async () => {
1254
- for (const leading of ['none', 'back', 'app-icon']) {
1471
+ it('58. resolves every variant to exactly the four heights', async () => {
1472
+ for (const leading of LEADINGS) {
1255
1473
  for (const tabs of [false, true]) {
1256
1474
  for (const described of [false, true]) {
1257
1475
  /* eslint-disable-next-line no-nested-ternary */