@jskit-ai/shell-web 0.1.150 → 0.1.152

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.152 - 2026-08-13
4
+
5
+ - Remove the redundant uppercase surface subheader from the expanded drawer; the top app bar remains the single shell-owned surface label.
6
+ - Align expanded drawer icons to the collapsed rail centreline so toggling navigation reveals or hides labels without shifting the icons horizontally.
7
+ - Preserve the Material 3 80px default rail and the public `railWidth` and `navigationItemSpacing` density controls.
8
+
9
+ ## 0.1.151 - 2026-08-13
10
+
11
+ - Replace Vuetify's oversized default drawer icon spacer with one public `navigationItemSpacing` value, defaulting to 12px for both icon-to-label and widest-label-to-edge spacing.
12
+ - Keep the Material 3 80px default rail while documenting `railWidth` as the supported product-density override.
13
+
3
14
  ## 0.1.150 - 2026-08-13
4
15
 
5
16
  - Give shell navigation tooltips one opaque, theme-owned Material color pair on hover and keyboard focus.
@@ -9,6 +9,8 @@ const theme = useTheme();
9
9
  const surface = computed(() => route.path.startsWith("/w/") ? "admin" : "home");
10
10
  const surfaceLabel = computed(() => surface.value === "admin" ? "Admin" : "Home");
11
11
  const themeMode = computed(() => route.query.theme === "dark" ? "dark" : "light");
12
+ const railWidth = computed(() => Number(route.query.railWidth) || undefined);
13
+ const navigationItemSpacing = computed(() => Number(route.query.navigationItemSpacing) || undefined);
12
14
 
13
15
  watchEffect(function applyFixtureTheme() {
14
16
  const themeName = `${surface.value}-${themeMode.value}`;
@@ -20,7 +22,12 @@ watchEffect(function applyFixtureTheme() {
20
22
 
21
23
  <template>
22
24
  <v-app>
23
- <ShellLayout :surface="surface" :surface-label="surfaceLabel">
25
+ <ShellLayout
26
+ :surface="surface"
27
+ :surface-label="surfaceLabel"
28
+ :rail-width="railWidth"
29
+ :navigation-item-spacing="navigationItemSpacing"
30
+ >
24
31
  <RouterView />
25
32
  </ShellLayout>
26
33
  </v-app>
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/shell-web",
4
- version: "0.1.150",
4
+ version: "0.1.152",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.150",
3
+ "version": "0.1.152",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -16,9 +16,10 @@ import {
16
16
  } from "../support/drawerPresentation.js";
17
17
  import {
18
18
  DEFAULT_SHELL_DRAWER_WIDTH,
19
+ DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
19
20
  DEFAULT_SHELL_RAIL_WIDTH,
20
- SHELL_DRAWER_LABEL_END_GAP,
21
21
  normalizeShellDrawerWidth,
22
+ normalizeShellNavigationItemSpacing,
22
23
  normalizeShellRailWidth,
23
24
  resolveContentAwareDrawerWidth
24
25
  } from "../support/drawerWidth.js";
@@ -53,6 +54,10 @@ const props = defineProps({
53
54
  railWidth: {
54
55
  type: Number,
55
56
  default: DEFAULT_SHELL_RAIL_WIDTH
57
+ },
58
+ navigationItemSpacing: {
59
+ type: Number,
60
+ default: DEFAULT_SHELL_NAVIGATION_ITEM_SPACING
56
61
  }
57
62
  });
58
63
 
@@ -111,6 +116,9 @@ const resolvedDrawerWidth = computed(() => {
111
116
  return normalizeShellDrawerWidth(measuredDrawerWidth.value);
112
117
  });
113
118
  const resolvedRailWidth = computed(() => normalizeShellRailWidth(props.railWidth));
119
+ const resolvedNavigationItemSpacing = computed(() =>
120
+ normalizeShellNavigationItemSpacing(props.navigationItemSpacing)
121
+ );
114
122
  const pullProgress = computed(() =>
115
123
  Math.min(100, Math.round((pullDistance.value / PULL_REFRESH_TRIGGER_DISTANCE) * 100))
116
124
  );
@@ -140,6 +148,7 @@ watch(
140
148
 
141
149
  watch(drawerPresentation, scheduleDrawerWidthMeasurement, { flush: "post" });
142
150
  watch(resolvedSurface, scheduleDrawerWidthMeasurement, { flush: "post" });
151
+ watch(resolvedNavigationItemSpacing, scheduleDrawerWidthMeasurement, { flush: "post" });
143
152
 
144
153
  onMounted(attachShellListeners);
145
154
  onBeforeUnmount(detachShellListeners);
@@ -299,7 +308,7 @@ function measureDrawerContentWidth() {
299
308
  }
300
309
 
301
310
  const nextWidth = resolveContentAwareDrawerWidth(measurements, {
302
- endGap: SHELL_DRAWER_LABEL_END_GAP + endBorderWidth
311
+ endGap: resolvedNavigationItemSpacing.value + endBorderWidth
303
312
  });
304
313
  if (Math.abs(nextWidth - measuredDrawerWidth.value) >= 0.25) {
305
314
  measuredDrawerWidth.value = nextWidth;
@@ -617,8 +626,12 @@ function touchListIncludesActiveTouch(touchList) {
617
626
  :data-presentation="drawerPresentation.kind"
618
627
  :data-drawer-width-mode="props.drawerWidth === null || props.drawerWidth === undefined ? 'content' : 'fixed'"
619
628
  :data-drawer-width="resolvedDrawerWidth"
629
+ :data-navigation-item-spacing="resolvedNavigationItemSpacing"
620
630
  :data-rail-width="resolvedRailWidth"
621
- :style="{ '--shell-drawer-label-end-gap': `${SHELL_DRAWER_LABEL_END_GAP}px` }"
631
+ :style="{
632
+ '--shell-navigation-item-spacing': `${resolvedNavigationItemSpacing}px`,
633
+ '--shell-navigation-rail-width': `${resolvedRailWidth}px`
634
+ }"
622
635
  :temporary="isCompactLayout"
623
636
  :permanent="!isCompactLayout"
624
637
  :width="resolvedDrawerWidth"
@@ -627,10 +640,12 @@ function touchListIncludesActiveTouch(touchList) {
627
640
  @update:model-value="handleDrawerVisibilityChange"
628
641
  >
629
642
  <slot name="menu" :surface="resolvedSurface" :rail="drawerPresentation.rail">
630
- <v-list nav density="comfortable" class="pt-2">
631
- <v-list-subheader v-if="!drawerPresentation.rail" class="text-uppercase text-caption">
632
- {{ resolvedSurfaceLabel }}
633
- </v-list-subheader>
643
+ <v-list
644
+ nav
645
+ density="comfortable"
646
+ class="pt-2"
647
+ :prepend-gap="resolvedNavigationItemSpacing"
648
+ >
634
649
  <ShellOutlet
635
650
  target="shell-layout:primary-menu"
636
651
  default
@@ -719,9 +734,26 @@ function touchListIncludesActiveTouch(touchList) {
719
734
  white-space: nowrap;
720
735
  }
721
736
 
722
- .shell-layout__drawer[data-presentation="drawer"] :deep(.v-list--nav),
737
+ .shell-layout__drawer {
738
+ --shell-navigation-drawer-inset: 12px;
739
+ --shell-navigation-icon-size: 24px;
740
+ }
741
+
742
+ .shell-layout__drawer[data-presentation="drawer"] :deep(.v-list--nav) {
743
+ padding-inline-end: calc(var(--shell-navigation-item-spacing) / 2);
744
+ padding-inline-start: var(--shell-navigation-drawer-inset);
745
+ }
746
+
723
747
  .shell-layout__drawer[data-presentation="drawer"] :deep(.v-list-item) {
724
- padding-inline-end: calc(var(--shell-drawer-label-end-gap) / 2);
748
+ padding-inline-end: calc(var(--shell-navigation-item-spacing) / 2);
749
+ padding-inline-start: max(
750
+ 0px,
751
+ calc(
752
+ var(--shell-navigation-rail-width) / 2 -
753
+ var(--shell-navigation-icon-size) / 2 -
754
+ var(--shell-navigation-drawer-inset)
755
+ )
756
+ );
725
757
  }
726
758
 
727
759
  .shell-layout__drawer[data-presentation="rail"] :deep(.v-list-item) {
@@ -4,7 +4,9 @@ const MINIMUM_SHELL_DRAWER_WIDTH = 120;
4
4
  const MAXIMUM_SHELL_DRAWER_WIDTH = 360;
5
5
  const MINIMUM_SHELL_RAIL_WIDTH = 48;
6
6
  const MAXIMUM_SHELL_RAIL_WIDTH = 160;
7
- const SHELL_DRAWER_LABEL_END_GAP = 10;
7
+ const DEFAULT_SHELL_NAVIGATION_ITEM_SPACING = 12;
8
+ const MINIMUM_SHELL_NAVIGATION_ITEM_SPACING = 8;
9
+ const MAXIMUM_SHELL_NAVIGATION_ITEM_SPACING = 24;
8
10
 
9
11
  function clampNumber(value, minimum, maximum) {
10
12
  return Math.min(maximum, Math.max(minimum, value));
@@ -34,10 +36,25 @@ function normalizeShellRailWidth(value, fallback = DEFAULT_SHELL_RAIL_WIDTH) {
34
36
  );
35
37
  }
36
38
 
39
+ function normalizeShellNavigationItemSpacing(
40
+ value,
41
+ fallback = DEFAULT_SHELL_NAVIGATION_ITEM_SPACING
42
+ ) {
43
+ const parsed = Number(value);
44
+ if (!Number.isFinite(parsed) || parsed <= 0) {
45
+ return fallback;
46
+ }
47
+ return clampNumber(
48
+ Math.round(parsed),
49
+ MINIMUM_SHELL_NAVIGATION_ITEM_SPACING,
50
+ MAXIMUM_SHELL_NAVIGATION_ITEM_SPACING
51
+ );
52
+ }
53
+
37
54
  function resolveContentAwareDrawerWidth(
38
55
  measurements = [],
39
56
  {
40
- endGap = SHELL_DRAWER_LABEL_END_GAP,
57
+ endGap = DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
41
58
  minimum = MINIMUM_SHELL_DRAWER_WIDTH,
42
59
  maximum = MAXIMUM_SHELL_DRAWER_WIDTH,
43
60
  fallback = DEFAULT_SHELL_DRAWER_WIDTH
@@ -56,19 +73,19 @@ function resolveContentAwareDrawerWidth(
56
73
  if (requiredWidth <= 0) {
57
74
  return normalizeShellDrawerWidth(fallback);
58
75
  }
59
- // Layout boxes expose integer client widths even when the rendered glyph
60
- // range is fractional. Round outward so the final glyph is never ellipsized
61
- // at the exact end-gap boundary.
62
- return clampNumber(Math.ceil(requiredWidth), minimum, maximum);
76
+ // Preserve subpixel font metrics while rounding outward enough to avoid
77
+ // clipping the final glyph at the exact spacing boundary.
78
+ return clampNumber(Math.ceil(requiredWidth * 4) / 4, minimum, maximum);
63
79
  }
64
80
 
65
81
  export {
66
82
  DEFAULT_SHELL_DRAWER_WIDTH,
83
+ DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
67
84
  DEFAULT_SHELL_RAIL_WIDTH,
68
85
  MAXIMUM_SHELL_DRAWER_WIDTH,
69
86
  MINIMUM_SHELL_DRAWER_WIDTH,
70
- SHELL_DRAWER_LABEL_END_GAP,
71
87
  normalizeShellDrawerWidth,
88
+ normalizeShellNavigationItemSpacing,
72
89
  normalizeShellRailWidth,
73
90
  resolveContentAwareDrawerWidth
74
91
  };
@@ -104,6 +104,8 @@ async function expectContentAwareDrawerFit(drawer, expect) {
104
104
  if (await drawer.getAttribute("data-drawer-width-mode") !== "content") {
105
105
  return;
106
106
  }
107
+ const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
108
+ expect(Number.isFinite(configuredSpacing)).toBe(true);
107
109
 
108
110
  const fit = await drawer.evaluate((element) => {
109
111
  const drawerRect = element.getBoundingClientRect();
@@ -125,6 +127,8 @@ async function expectContentAwareDrawerFit(drawer, expect) {
125
127
  range.selectNodeContents(label);
126
128
  const textRect = range.getBoundingClientRect();
127
129
  range.detach?.();
130
+ const icon = label.closest(".v-list-item")?.querySelector(".v-icon");
131
+ const iconRect = icon?.getBoundingClientRect();
128
132
  return {
129
133
  clipped: (
130
134
  label.scrollWidth > label.clientWidth ||
@@ -133,6 +137,9 @@ async function expectContentAwareDrawerFit(drawer, expect) {
133
137
  ? textRect.left < innerEnd - 1
134
138
  : textRect.right > innerEnd + 1)
135
139
  ),
140
+ iconLabelGap: iconRect
141
+ ? rightToLeft ? iconRect.left - textRect.right : textRect.left - iconRect.right
142
+ : null,
136
143
  logicalEnd: rightToLeft ? textRect.left : textRect.right,
137
144
  width: textRect.width
138
145
  };
@@ -154,32 +161,58 @@ async function expectContentAwareDrawerFit(drawer, expect) {
154
161
  clipped: labels.some((label) => label.clipped),
155
162
  endGap: rightToLeft
156
163
  ? furthestLabel.logicalEnd - innerEnd
157
- : innerEnd - furthestLabel.logicalEnd
164
+ : innerEnd - furthestLabel.logicalEnd,
165
+ iconLabelGaps: labels
166
+ .map((label) => label.iconLabelGap)
167
+ .filter((gap) => Number.isFinite(gap))
158
168
  };
159
169
  });
160
170
 
161
171
  expect(fit).not.toBeNull();
162
172
  expect(fit.clipped).toBe(false);
163
- expect(fit.endGap).toBeGreaterThanOrEqual(9);
164
- expect(fit.endGap).toBeLessThanOrEqual(11);
173
+ expect(Math.abs(fit.endGap - configuredSpacing)).toBeLessThanOrEqual(1.1);
174
+ expect(fit.iconLabelGaps.length).toBeGreaterThan(0);
175
+ for (const gap of fit.iconLabelGaps) {
176
+ expect(Math.abs(gap - configuredSpacing)).toBeLessThanOrEqual(1);
177
+ }
178
+ }
179
+
180
+ async function readNavigationIconCenters(drawer) {
181
+ const icons = drawer.locator("a.shell-menu-link-item[href] .v-icon");
182
+ await icons.first().waitFor({ state: "visible" });
183
+ const centers = [];
184
+ const iconCount = await icons.count();
185
+ for (let index = 0; index < iconCount; index += 1) {
186
+ const box = await icons.nth(index).boundingBox();
187
+ if (!box) {
188
+ throw new Error("Shell navigation exposed an unmeasurable icon.");
189
+ }
190
+ centers.push(box.x + box.width / 2);
191
+ }
192
+ return centers;
165
193
  }
166
194
 
167
- async function expectCentredRailNavigation(page, drawer, expect) {
195
+ async function expectCentredRailNavigation(page, drawer, expect, expandedIconCenters) {
168
196
  const links = drawer.locator("a.shell-menu-link-item[href]");
169
197
  await expect(links.first()).toBeVisible();
170
198
  const firstLink = links.first();
171
- const firstIcon = firstLink.locator(".v-icon").first();
172
- const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox(), firstIcon.boundingBox()]);
173
- const [drawerBox, linkBox, iconBox] = geometry;
199
+ const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox()]);
200
+ const [drawerBox, linkBox] = geometry;
174
201
 
175
202
  expect(drawerBox).not.toBeNull();
176
203
  expect(linkBox).not.toBeNull();
177
- expect(iconBox).not.toBeNull();
178
204
  expect(linkBox.width).toBeGreaterThanOrEqual(48);
179
205
  expect(linkBox.height).toBeGreaterThanOrEqual(48);
180
- expect(Math.abs(
181
- iconBox.x + iconBox.width / 2 - (drawerBox.x + drawerBox.width / 2)
182
- )).toBeLessThanOrEqual(1);
206
+ const railIconCenters = await readNavigationIconCenters(drawer);
207
+ expect(railIconCenters.length).toBe(expandedIconCenters.length);
208
+ for (let index = 0; index < railIconCenters.length; index += 1) {
209
+ expect(Math.abs(
210
+ railIconCenters[index] - (drawerBox.x + drawerBox.width / 2)
211
+ )).toBeLessThanOrEqual(1);
212
+ expect(Math.abs(
213
+ railIconCenters[index] - expandedIconCenters[index]
214
+ )).toBeLessThanOrEqual(1);
215
+ }
183
216
 
184
217
  const currentUrl = new URL(page.url());
185
218
  const linkCount = await links.count();
@@ -275,13 +308,14 @@ async function runAdaptiveShellSmokeCase({
275
308
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
276
309
  await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-drawer-width"));
277
310
  await expectContentAwareDrawerFit(drawer, expect);
311
+ const expandedIconCenters = await readNavigationIconCenters(drawer);
278
312
  await page.keyboard.press("Escape");
279
313
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
280
314
 
281
315
  await toggle.click();
282
316
  await expect(drawer).toHaveAttribute("data-presentation", "rail");
283
317
  await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-rail-width"));
284
- await expectCentredRailNavigation(page, drawer, expect);
318
+ await expectCentredRailNavigation(page, drawer, expect, expandedIconCenters);
285
319
  await expectNoHorizontalOverflow(page, expect);
286
320
  }
287
321
  }
@@ -6,7 +6,7 @@ export default {
6
6
  name: "ShellLayout",
7
7
  inheritAttrs: false,
8
8
  setup(_, { attrs, slots }) {
9
- // Keep drawerWidth, railWidth, and future shell props package-owned.
9
+ // Keep drawerWidth, railWidth, navigationItemSpacing, and future shell props package-owned.
10
10
  return () => h(PackageShellLayout, attrs, slots);
11
11
  }
12
12
  };
@@ -166,6 +166,13 @@ async function assertTooltipContrast(page, linkName, interaction) {
166
166
  async function assertDrawerFit(page) {
167
167
  const drawer = page.getByTestId("jskit-shell-drawer");
168
168
  await expect(drawer).toHaveAttribute("data-presentation", "drawer");
169
+ assert.equal(
170
+ await drawer.locator(".v-list-subheader").count(),
171
+ 0,
172
+ "The drawer must not repeat the surface label from the top app bar."
173
+ );
174
+ const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
175
+ assert.ok(Number.isFinite(configuredSpacing));
169
176
  await expect.poll(async () => {
170
177
  const configured = Number(await drawer.getAttribute("data-drawer-width"));
171
178
  const box = await drawer.boundingBox();
@@ -180,12 +187,14 @@ async function assertDrawerFit(page) {
180
187
  range.selectNodeContents(label);
181
188
  const rect = range.getBoundingClientRect();
182
189
  range.detach?.();
190
+ const iconRect = label.closest(".v-list-item")?.querySelector(".v-icon")?.getBoundingClientRect();
183
191
  return {
184
192
  clientHeight: label.clientHeight,
185
193
  clientWidth: label.clientWidth,
186
194
  rect,
187
195
  scrollHeight: label.scrollHeight,
188
- scrollWidth: label.scrollWidth
196
+ scrollWidth: label.scrollWidth,
197
+ iconLabelGap: iconRect ? rect.left - iconRect.right : null
189
198
  };
190
199
  });
191
200
  const furthestEnd = Math.max(...labels.map((label) => label.rect.right));
@@ -195,11 +204,24 @@ async function assertDrawerFit(page) {
195
204
  label.scrollHeight > label.clientHeight
196
205
  )),
197
206
  endGap: innerEnd - furthestEnd,
207
+ iconLabelGaps: labels
208
+ .map((label) => label.iconLabelGap)
209
+ .filter((gap) => Number.isFinite(gap)),
198
210
  overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
199
211
  };
200
212
  });
201
213
  assert.equal(result.clipped, false, "A drawer label was clipped or ellipsized.");
202
- assert.ok(result.endGap >= 9 && result.endGap <= 11, `Drawer label end gap was ${result.endGap}px.`);
214
+ assert.ok(
215
+ Math.abs(result.endGap - configuredSpacing) <= 1.1,
216
+ `Drawer label end gap was ${result.endGap}px; expected ${configuredSpacing}px.`
217
+ );
218
+ assert.ok(result.iconLabelGaps.length > 0, "Drawer links exposed no measurable icon/label gap.");
219
+ for (const gap of result.iconLabelGaps) {
220
+ assert.ok(
221
+ Math.abs(gap - configuredSpacing) <= 1,
222
+ `Drawer icon/label gap was ${gap}px; expected ${configuredSpacing}px.`
223
+ );
224
+ }
203
225
  assert.ok(result.overflow <= 1, `Page overflowed horizontally by ${result.overflow}px.`);
204
226
  }
205
227
 
@@ -282,6 +304,23 @@ test("shell-web adaptive navigation passes package-owned browser contracts", {
282
304
  await assertDrawerFit(railPage);
283
305
  await assertRailKeyboardAndSelection(railPage);
284
306
  await railPage.close();
307
+
308
+ const configuredPage = await context.newPage();
309
+ await configuredPage.setViewportSize({ width: 1280, height: 900 });
310
+ await configuredPage.goto(
311
+ "/w/acme/admin/bookings?theme=light&railWidth=64&navigationItemSpacing=8"
312
+ );
313
+ const configuredDrawer = configuredPage.getByTestId("jskit-shell-drawer");
314
+ await expect(configuredDrawer).toHaveAttribute("data-navigation-item-spacing", "8");
315
+ await assertDrawerFit(configuredPage);
316
+ await configuredPage.getByTestId("jskit-shell-nav-toggle").click();
317
+ await expect(configuredDrawer).toHaveAttribute("data-presentation", "rail");
318
+ await expect(configuredDrawer).toHaveAttribute("data-rail-width", "64");
319
+ await expect.poll(async () => {
320
+ const box = await configuredDrawer.boundingBox();
321
+ return box ? Math.abs(box.width - 64) : Number.POSITIVE_INFINITY;
322
+ }).toBeLessThanOrEqual(1);
323
+ await configuredPage.close();
285
324
  await context.close();
286
325
  } finally {
287
326
  await browser?.close();
@@ -2,29 +2,32 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
4
  DEFAULT_SHELL_DRAWER_WIDTH,
5
+ DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
5
6
  DEFAULT_SHELL_RAIL_WIDTH,
6
7
  normalizeShellDrawerWidth,
8
+ normalizeShellNavigationItemSpacing,
7
9
  normalizeShellRailWidth,
8
10
  resolveContentAwareDrawerWidth
9
11
  } from "../src/client/support/drawerWidth.js";
10
12
 
11
- test("content-aware drawer width includes the rendered label start, width, and 10px end gap", () => {
13
+ test("content-aware drawer width includes the rendered label start, width, and balanced end spacing", () => {
12
14
  assert.equal(
13
15
  resolveContentAwareDrawerWidth([
14
16
  { logicalStart: 72, textWidth: 40 },
15
17
  { logicalStart: 72, textWidth: 78 }
16
18
  ]),
17
- 160
19
+ 162
18
20
  );
19
21
  assert.equal(
20
22
  resolveContentAwareDrawerWidth([
21
23
  { logicalStart: 72, textWidth: 99.015625 }
22
24
  ]),
23
- 182
25
+ 183.25
24
26
  );
25
27
  });
26
28
 
27
29
  test("drawer and rail widths have stable defaults and bounded overrides", () => {
30
+ assert.equal(DEFAULT_SHELL_RAIL_WIDTH, 80);
28
31
  assert.equal(normalizeShellDrawerWidth(null), DEFAULT_SHELL_DRAWER_WIDTH);
29
32
  assert.equal(normalizeShellDrawerWidth(180), 180);
30
33
  assert.equal(normalizeShellDrawerWidth(1000), 360);
@@ -33,6 +36,14 @@ test("drawer and rail widths have stable defaults and bounded overrides", () =>
33
36
  assert.equal(normalizeShellRailWidth(20), 48);
34
37
  });
35
38
 
39
+ test("navigation item spacing has a Material-aligned default and bounded overrides", () => {
40
+ assert.equal(DEFAULT_SHELL_NAVIGATION_ITEM_SPACING, 12);
41
+ assert.equal(normalizeShellNavigationItemSpacing(null), DEFAULT_SHELL_NAVIGATION_ITEM_SPACING);
42
+ assert.equal(normalizeShellNavigationItemSpacing(14), 14);
43
+ assert.equal(normalizeShellNavigationItemSpacing(2), 8);
44
+ assert.equal(normalizeShellNavigationItemSpacing(100), 24);
45
+ });
46
+
36
47
  test("content-aware width ignores invalid measurements and uses a bounded fallback", () => {
37
48
  assert.equal(
38
49
  resolveContentAwareDrawerWidth([
@@ -29,13 +29,15 @@ test("adaptive shell smoke follows rendered layout state and waits for drawer tr
29
29
  const source = await readFile(path.join(PACKAGE_ROOT, "src/test/adaptiveShellSmoke.js"), "utf8");
30
30
 
31
31
  assert.match(source, /data-layout/u);
32
+ assert.match(source, /data-navigation-item-spacing/u);
32
33
  assert.match(source, /data-rail-width/u);
33
34
  assert.match(source, /expect\.poll/u);
34
35
  assert.match(source, /toBeFocused/u);
35
36
  assert.match(source, /v-navigation-drawer__scrim/u);
36
37
  assert.match(source, /page\.keyboard\.press\("Escape"\)[\s\S]*data-presentation", "drawer"/u);
37
- assert.match(source, /iconBox\.x \+ iconBox\.width \/ 2/u);
38
- assert.match(source, /endGap\)\.toBeGreaterThanOrEqual\(9\)/u);
38
+ assert.match(source, /centers\.push\(box\.x \+ box\.width \/ 2\)/u);
39
+ assert.match(source, /fit\.endGap - configuredSpacing/u);
40
+ assert.match(source, /fit\.iconLabelGaps/u);
39
41
  assert.doesNotMatch(source, /viewport\.name === "compact"/u);
40
42
  assert.doesNotMatch(source, /await new Promise.*setTimeout|waitForTimeout/u);
41
43
  });
@@ -100,13 +100,20 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
100
100
  assert.match(source, /:rail="drawerPresentation\.rail"/);
101
101
  assert.match(source, /drawerWidth/);
102
102
  assert.match(source, /railWidth/);
103
+ assert.match(source, /navigationItemSpacing/);
103
104
  assert.match(source, /:width="resolvedDrawerWidth"/);
104
105
  assert.match(source, /:rail-width="resolvedRailWidth"/);
105
106
  assert.match(source, /data-drawer-width="resolvedDrawerWidth"/);
107
+ assert.match(source, /data-navigation-item-spacing="resolvedNavigationItemSpacing"/);
106
108
  assert.match(source, /data-rail-width="resolvedRailWidth"/);
107
109
  assert.match(source, /data-layout="layoutClass"/);
108
110
  assert.match(source, /measureDrawerContentWidth/);
109
- assert.match(source, /SHELL_DRAWER_LABEL_END_GAP/);
111
+ assert.match(source, /DEFAULT_SHELL_NAVIGATION_ITEM_SPACING/);
112
+ assert.match(source, /:prepend-gap="resolvedNavigationItemSpacing"/);
113
+ assert.match(source, /--shell-navigation-drawer-inset:\s*12px/);
114
+ assert.match(source, /--shell-navigation-rail-width/);
115
+ assert.match(source, /padding-inline-start:\s*var\(--shell-navigation-drawer-inset\)/);
116
+ assert.doesNotMatch(source, /<v-list-subheader/);
110
117
  assert.doesNotMatch(source, /:width="248"/);
111
118
  assert.doesNotMatch(source, /:rail-width="80"/);
112
119
  assert.match(source, /:model-value="drawerPresentation\.visible"/);
@@ -117,7 +124,7 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
117
124
  const template = await readFile(path.join(PACKAGE_DIR, "templates", "src", "components", "ShellLayout.vue"), "utf8");
118
125
 
119
126
  assert.match(template, /PackageShellLayout from "@jskit-ai\/shell-web\/client\/components\/ShellLayout"/);
120
- assert.match(template, /drawerWidth, railWidth, and future shell props package-owned/);
127
+ assert.match(template, /drawerWidth, railWidth, navigationItemSpacing, and future shell props package-owned/);
121
128
  assert.match(template, /h\(PackageShellLayout, attrs, slots\)/);
122
129
  assert.doesNotMatch(template, /ShellOutlet|ShellRouteTransition|useShellLayoutState|pointerdown|v-navigation-drawer|v-bottom-navigation/);
123
130
  });