@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.142 → 2.0.0-next.144

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 (36) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +131 -30
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +54 -20
  4. package/dist/components/alert-list-details/alert-list-details.js +2 -2
  5. package/dist/components/alert-list-details/alert-list-details.js.map +1 -1
  6. package/dist/components/app-button/app-button.css.js +56 -0
  7. package/dist/components/app-button/app-button.css.js.map +1 -1
  8. package/dist/integration-systems/integration-app-bar/integration-app-bar.css.js +12 -1
  9. package/dist/integration-systems/integration-app-bar/integration-app-bar.css.js.map +1 -1
  10. package/dist/integration-systems/integration-app-bar/integration-app-bar.d.ts +2 -2
  11. package/dist/integration-systems/integration-app-bar/integration-app-bar.d.ts.map +1 -1
  12. package/dist/integration-systems/integration-app-bar/integration-app-bar.js +1 -21
  13. package/dist/integration-systems/integration-app-bar/integration-app-bar.js.map +1 -1
  14. package/dist/navigation-instruments/readout/readout.d.ts +11 -2
  15. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  16. package/dist/navigation-instruments/readout/readout.js +18 -0
  17. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  18. package/dist/navigation-instruments/readout-list-item/readout-list-item.css.js +25 -0
  19. package/dist/navigation-instruments/readout-list-item/readout-list-item.css.js.map +1 -1
  20. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +20 -4
  21. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  22. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +18 -6
  23. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/alert-list-details/alert-list-details.ts +2 -2
  26. package/src/components/app-button/app-button.css +2 -0
  27. package/src/components/app-button/app-button.stories.ts +26 -0
  28. package/src/integration-systems/integration-app-bar/integration-app-bar-sizing.stories.ts +130 -0
  29. package/src/integration-systems/integration-app-bar/integration-app-bar.css +11 -1
  30. package/src/integration-systems/integration-app-bar/integration-app-bar.ts +3 -27
  31. package/src/navigation-instruments/readout/readout.stories.ts +57 -4
  32. package/src/navigation-instruments/readout/readout.ts +28 -1
  33. package/src/navigation-instruments/readout-list/readout-list.stories.ts +62 -2
  34. package/src/navigation-instruments/readout-list-item/readout-list-item.css +21 -0
  35. package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +56 -3
  36. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +37 -19
@@ -0,0 +1,130 @@
1
+ import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
+ import {ObcIntegrationAppBar} from './integration-app-bar.js';
3
+ import './integration-app-bar.js';
4
+ import '../../components/app-button/app-button.js';
5
+ import '../../icons/icon-placeholder.js';
6
+ import {html} from 'lit';
7
+
8
+ const appLabels = [
9
+ 'Home',
10
+ 'Diagnostics',
11
+ 'Data Explorer',
12
+ 'Settings',
13
+ 'Alerts',
14
+ 'Reports',
15
+ 'System Overview',
16
+ 'Logs',
17
+ ];
18
+
19
+ const appBar = (labels: string[]) => html`
20
+ <obc-integration-app-bar>
21
+ ${labels.map(
22
+ (label, i) => html`
23
+ <obc-app-button
24
+ slot="apps"
25
+ size="small"
26
+ label=${label}
27
+ integration
28
+ ?checked=${i === 0}
29
+ >
30
+ <obi-placeholder slot="icon"></obi-placeholder>
31
+ </obc-app-button>
32
+ `
33
+ )}
34
+ </obc-integration-app-bar>
35
+ `;
36
+
37
+ const meta: Meta<typeof ObcIntegrationAppBar> = {
38
+ title: 'Integration Systems/App Bar/Sizing',
39
+ tags: ['experimental'],
40
+ component: 'obc-integration-app-bar',
41
+ parameters: {
42
+ layout: 'fullscreen',
43
+ docs: {
44
+ description: {
45
+ component:
46
+ 'How `obc-integration-app-bar` sizes labelled app buttons. The app row is a grid of auto-placed columns sized `minmax(var(--obc-integration-app-bar-app-width, max-content), 1fr)`; the cases below show how those equal-width tracks respond to mixed label lengths, narrow containers and wrapper elements.',
47
+ },
48
+ },
49
+ },
50
+ };
51
+
52
+ export default meta;
53
+ type Story = StoryObj<ObcIntegrationAppBar>;
54
+
55
+ export const EightLabelledApps: Story = {
56
+ render: () => appBar(appLabels),
57
+ parameters: {
58
+ docs: {
59
+ description: {
60
+ story:
61
+ 'In a wide bar, the grid gives every app the same column width, resolved from the widest label in the set. Short labels keep extra inline space, long labels stay on one line, and the row remains centred because every track uses the same size.',
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ export const NarrowBar: Story = {
68
+ render: () => html`
69
+ <div style="width: 560px; outline: 1px dashed magenta;">
70
+ ${appBar(appLabels)}
71
+ </div>
72
+ `,
73
+ parameters: {
74
+ docs: {
75
+ description: {
76
+ story:
77
+ 'The same eight buttons in a 560px wide bar. The grid keeps equal columns, but their combined minimum width can still exceed the available space, so the centred app row overflows the dashed frame instead of shrinking the buttons.',
78
+ },
79
+ },
80
+ },
81
+ };
82
+
83
+ export const LabelsSetAfterFirstRender: Story = {
84
+ render: () =>
85
+ appBar(['App', 'App', 'App', 'App', 'App', 'App', 'App', 'App']),
86
+ play: async ({canvasElement}) => {
87
+ await new Promise((resolve) => setTimeout(resolve, 1000));
88
+ const buttons = canvasElement.querySelectorAll('obc-app-button');
89
+ buttons.forEach((button, i) => {
90
+ button.label = appLabels[i];
91
+ });
92
+ },
93
+ parameters: {
94
+ docs: {
95
+ description: {
96
+ story:
97
+ 'The bar renders with placeholder labels and the real labels are assigned a second later, which matches data-bound or translated labels. CSS Grid recalculates the column sizing from the updated content, so the buttons expand from the placeholder width to the widest final label without a second measurement pass.',
98
+ },
99
+ },
100
+ },
101
+ };
102
+
103
+ export const ButtonsWrappedInAnElement: Story = {
104
+ render: () => html`
105
+ <obc-integration-app-bar>
106
+ ${appLabels.map(
107
+ (label, i) => html`
108
+ <div slot="apps">
109
+ <obc-app-button
110
+ size="small"
111
+ label=${label}
112
+ integration
113
+ ?checked=${i === 0}
114
+ >
115
+ <obi-placeholder slot="icon"></obi-placeholder>
116
+ </obc-app-button>
117
+ </div>
118
+ `
119
+ )}
120
+ </obc-integration-app-bar>
121
+ `,
122
+ parameters: {
123
+ docs: {
124
+ description: {
125
+ story:
126
+ 'Every app button sits inside a wrapper element, here a plain `div` standing in for the tooltip trigger a framework wrapper emits. The slotted wrapper is flattened with `display: contents`, so the button still becomes the grid item and keeps the same equal-width column sizing as the unwrapped case.',
127
+ },
128
+ },
129
+ },
130
+ };
@@ -24,8 +24,18 @@
24
24
  }
25
25
 
26
26
  .center {
27
- display: flex;
27
+ display: grid;
28
+ grid-auto-flow: column;
29
+ grid-auto-columns: minmax(
30
+ var(--obc-integration-app-bar-app-width, max-content),
31
+ 1fr
32
+ );
28
33
  gap: var(--app-components-app-menu-app-button-spacing);
29
34
  justify-content: center;
30
35
  }
31
36
  }
37
+
38
+ /* Svelte wrappers add a slot container that must not become a grid item. */
39
+ slot[name="apps"]::slotted(:not(obc-app-button)) {
40
+ display: contents;
41
+ }
@@ -3,7 +3,8 @@ import {customElement} from '../../decorator.js';
3
3
  import compentStyle from './integration-app-bar.css?inline';
4
4
 
5
5
  /**
6
- * @slot apps - Application buttons; slotted elements are normalized to a uniform width
6
+ * @slot apps - Application buttons; every button is laid out at the width of the widest one
7
+ * @cssprop [--obc-integration-app-bar-app-width=max-content] - Minimum width of each app-button grid track. Tracks may grow equally to fit wider content.
7
8
  * @experimental
8
9
  */
9
10
  @customElement('obc-integration-app-bar')
@@ -13,38 +14,13 @@ export class ObcIntegrationAppBar extends LitElement {
13
14
  <div class="wrapper">
14
15
  <div class="left-side"></div>
15
16
  <div class="center">
16
- <slot name="apps" @slotchange=${this.handleAppsSlotChange}></slot>
17
+ <slot name="apps"></slot>
17
18
  </div>
18
19
  <div class="right-side"></div>
19
20
  </div>
20
21
  `;
21
22
  }
22
23
 
23
- private handleAppsSlotChange(event: Event) {
24
- const slot = event.target as HTMLSlotElement;
25
- const buttons = slot.assignedElements() as HTMLElement[];
26
-
27
- if (buttons.length === 0) {
28
- return;
29
- }
30
-
31
- for (const btn of buttons) {
32
- btn.style.width = '';
33
- }
34
-
35
- requestAnimationFrame(() => {
36
- const maxWidth = Math.max(
37
- ...buttons.map((btn) => btn.getBoundingClientRect().width)
38
- );
39
-
40
- if (maxWidth > 0) {
41
- for (const btn of buttons) {
42
- btn.style.width = `${maxWidth}px`;
43
- }
44
- }
45
- });
46
- }
47
-
48
24
  static override styles = unsafeCSS(compentStyle);
49
25
  }
50
26
 
@@ -1,6 +1,6 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {html, nothing} from 'lit';
3
- import {userEvent} from 'storybook/test';
3
+ import {expect, spyOn, userEvent} from 'storybook/test';
4
4
  import {gsap} from 'gsap';
5
5
  import './readout.js';
6
6
  import '../../components/navigation-item/navigation-item.js';
@@ -1587,20 +1587,17 @@ export const TestCases: Story = {
1587
1587
  </style>
1588
1588
  <div class="test-case-container">
1589
1589
  <obc-readout
1590
- .size=${ReadoutSize.small}
1591
1590
  .direction=${ReadoutDirection.horizontal}
1592
1591
  .value=${123}
1593
1592
  .unit=${'kn'}
1594
1593
  ></obc-readout>
1595
1594
  <obc-readout
1596
- .size=${ReadoutSize.small}
1597
1595
  .direction=${ReadoutDirection.horizontal}
1598
1596
  .value=${'Thermo On'}
1599
1597
  .valueType=${ReadoutValueType.text}
1600
1598
  .label=${'Operating mode'}
1601
1599
  ></obc-readout>
1602
1600
  <obc-readout
1603
- .size=${ReadoutSize.small}
1604
1601
  .direction=${ReadoutDirection.horizontal}
1605
1602
  .value=${'Normal'}
1606
1603
  .valueType=${ReadoutValueType.text}
@@ -1610,6 +1607,62 @@ export const TestCases: Story = {
1610
1607
  },
1611
1608
  };
1612
1609
 
1610
+ /**
1611
+ * A horizontal readout given a `size` other than `large` warns once per
1612
+ * element. The tier is discarded (the arrangement exists in the large tier
1613
+ * only), and a silent discard is indistinguishable from a bug to a consumer
1614
+ * (#1182). A removed `size` attribute arrives as `null` through Lit's String
1615
+ * converter and counts as unset. Driven through `willUpdate` on detached
1616
+ * elements, like `Readout Block → TestValidationSurvivesUnrelatedUpdate`.
1617
+ */
1618
+ export const TestHorizontalSizeWarning: Story = {
1619
+ render: () => html`<span>Regression test — see the play function.</span>`,
1620
+ play: async () => {
1621
+ type Probe = HTMLElement & {
1622
+ size?: ReadoutSize;
1623
+ direction?: ReadoutDirection;
1624
+ willUpdate: (changed: Map<string, unknown>) => void;
1625
+ };
1626
+ const probe = (
1627
+ size: ReadoutSize | undefined,
1628
+ direction: ReadoutDirection
1629
+ ) => {
1630
+ const el = document.createElement('obc-readout') as Probe;
1631
+ el.size = size;
1632
+ el.direction = direction;
1633
+ return el;
1634
+ };
1635
+ const update = (el: Probe) => el.willUpdate(new Map());
1636
+ const warn = spyOn(console, 'warn').mockImplementation(() => undefined);
1637
+ try {
1638
+ update(probe(ReadoutSize.large, ReadoutDirection.horizontal));
1639
+ update(probe(ReadoutSize.small, ReadoutDirection.vertical));
1640
+ update(probe(undefined, ReadoutDirection.horizontal));
1641
+ const removed = probe(ReadoutSize.small, ReadoutDirection.horizontal);
1642
+ removed.setAttribute('size', ReadoutSize.small);
1643
+ removed.removeAttribute('size');
1644
+ update(removed);
1645
+ await expect(warn).not.toHaveBeenCalled();
1646
+
1647
+ const small = probe(ReadoutSize.small, ReadoutDirection.horizontal);
1648
+ update(small);
1649
+ await expect(warn).toHaveBeenCalledTimes(1);
1650
+ await expect(warn.mock.calls[0][0]).toMatch(
1651
+ /\[obc-readout\].*size.*horizontal/
1652
+ );
1653
+ await expect(warn.mock.calls[0][1]).toBe(small);
1654
+
1655
+ update(small);
1656
+ await expect(warn).toHaveBeenCalledTimes(1);
1657
+
1658
+ update(probe(ReadoutSize.medium, ReadoutDirection.horizontal));
1659
+ await expect(warn).toHaveBeenCalledTimes(2);
1660
+ } finally {
1661
+ warn.mockRestore();
1662
+ }
1663
+ },
1664
+ };
1665
+
1613
1666
  /**
1614
1667
  * Figma 6.1 "Equal size": value and setpoint always share the primary size —
1615
1668
  * the missing fourth mode beside primary-secondary (`always-visible`),
@@ -249,7 +249,8 @@ export interface ReadoutSourceOptions extends ReadoutSrcOptions {
249
249
  * @property size - Density tier. Applies to the vertical direction only — the horizontal
250
250
  * arrangement exists in the large tier alone (Figma 6.1: it relies on the
251
251
  * label+unit stack aligning with the L-size value caps), so `size` is
252
- * ignored when `direction` is `horizontal`.
252
+ * ignored when `direction` is `horizontal`; a horizontal readout given any
253
+ * other `size` logs a console warning once per element.
253
254
  * @availableWhen size direction==vertical
254
255
  * @availableWhen stacking direction==vertical
255
256
  * @availableWhen alignment direction==vertical && stacking==stacked
@@ -365,6 +366,7 @@ export class ObcReadout extends LitElement {
365
366
  ReadoutBlockHidePhase.none;
366
367
  private deferredSetpointHideTimer?: number;
367
368
  private hasCompletedFirstUpdate = false;
369
+ private hasWarnedHorizontalSize = false;
368
370
 
369
371
  @state() private sourcePickerContentVisible = false;
370
372
  @state() private sourcePickerOptions: ContextMenuOption[] = [];
@@ -1310,6 +1312,31 @@ export class ObcReadout extends LitElement {
1310
1312
  // exactly the silent failure this assertion exists to prevent.
1311
1313
  assertReadoutValueType('obc-readout', this.value, this.valueType);
1312
1314
  assertReadoutFractionDigits('obc-readout', this.fractionDigits);
1315
+ this.warnHorizontalSizeIgnored();
1316
+ }
1317
+
1318
+ /**
1319
+ * A horizontal readout discards any `size` but `large` (see `resolvedSize`).
1320
+ * A silent discard is indistinguishable from a bug to a consumer, so each
1321
+ * offending element says so once and logs itself for locating (#1182).
1322
+ * A removed `size` attribute arrives as `null` and counts as unset.
1323
+ */
1324
+ private warnHorizontalSizeIgnored(): void {
1325
+ if (
1326
+ this.hasWarnedHorizontalSize ||
1327
+ !this.isHorizontal ||
1328
+ !this.size ||
1329
+ this.size === ReadoutSize.large
1330
+ ) {
1331
+ return;
1332
+ }
1333
+ this.hasWarnedHorizontalSize = true;
1334
+ console.warn(
1335
+ `[obc-readout] size="${this.size}" is ignored when direction="horizontal": ` +
1336
+ 'the horizontal arrangement exists in the large tier only. Remove the ' +
1337
+ 'size, or use direction="vertical" for the small / medium tiers.',
1338
+ this
1339
+ );
1313
1340
  }
1314
1341
 
1315
1342
  override updated(changed: Map<string, unknown>): void {
@@ -6,9 +6,12 @@ import {
6
6
  ReadoutListItemSize,
7
7
  ReadoutListItemPriority,
8
8
  ReadoutListItemDataQuality,
9
+ ReadoutListItemStacking,
10
+ ObcTextboxFontWeight,
9
11
  type ReadoutValueOptions,
10
12
  ReadoutValueType,
11
13
  } from '../readout-list-item/readout-list-item.js';
14
+ import {ObcTextboxSize} from '../../components/textbox/textbox.js';
12
15
  import '../readout-list-item/readout-list-item.js';
13
16
  import type {AlertFrameConfig} from '../../components/alert-frame/alert-frame.js';
14
17
  import {
@@ -23,13 +26,17 @@ type ListArgs = {
23
26
 
24
27
  type Row = {
25
28
  label: string;
29
+ src?: string;
26
30
  value: number | string | null;
27
31
  valueType?: ReadoutValueType;
28
32
  unit: string;
29
33
  size?: ReadoutListItemSize;
34
+ stacking?: ReadoutListItemStacking;
35
+ labelSize?: ObcTextboxSize;
30
36
  hasDegree?: boolean;
31
37
  fractionDigits?: number;
32
38
  priority?: ReadoutListItemPriority;
39
+ weight?: ObcTextboxFontWeight;
33
40
  hasSetpoint?: boolean;
34
41
  setpoint?: number;
35
42
  hasAdvice?: boolean;
@@ -46,8 +53,12 @@ type Row = {
46
53
 
47
54
  function renderRow(row: Row) {
48
55
  const valueOptions =
49
- row.valueDataQuality || row.valueAlert
50
- ? {dataQuality: row.valueDataQuality, alert: row.valueAlert}
56
+ row.valueDataQuality || row.valueAlert || row.weight
57
+ ? {
58
+ dataQuality: row.valueDataQuality,
59
+ alert: row.valueAlert,
60
+ weight: row.weight,
61
+ }
51
62
  : undefined;
52
63
  const setpointOptions = row.setpointDataQuality
53
64
  ? {dataQuality: row.setpointDataQuality}
@@ -55,10 +66,13 @@ function renderRow(row: Row) {
55
66
  return html`
56
67
  <obc-readout-list-item
57
68
  .label=${row.label}
69
+ .src=${row.src}
58
70
  .unit=${row.unit}
59
71
  .value=${row.value}
60
72
  .valueType=${row.valueType ?? ReadoutValueType.number}
61
73
  .size=${row.size ?? ReadoutListItemSize.small}
74
+ .stacking=${row.stacking}
75
+ .labelOptions=${row.labelSize ? {size: row.labelSize} : undefined}
62
76
  .hasDegree=${row.hasDegree ?? false}
63
77
  .fractionDigits=${row.fractionDigits ?? 0}
64
78
  .priority=${row.priority}
@@ -169,6 +183,52 @@ export const Degrees: Story = {
169
183
  render: (args) => renderList(DEGREE_ROWS, args.showDebugOverlay),
170
184
  };
171
185
 
186
+ /**
187
+ * **Source on the label's line.** `stacking="leading-src-inline"` keeps each
188
+ * row one line high with the source after the label (Figma 46596:102880 pairs
189
+ * an `s` label with the `xs` source). Inside a list the source column is
190
+ * reserved to the longest source, so the readings stay aligned across rows
191
+ * that have no source at all. The unit column is likewise reserved to the
192
+ * longest unit (`/min`); standalone rows let each unit hug its value instead
193
+ * (see `Readout List Item → ColumnAlignment`, "without reservers").
194
+ */
195
+ const INLINE_SOURCE_ROWS: Row[] = [
196
+ {
197
+ label: 'HDG',
198
+ src: 'GPS1',
199
+ value: 355,
200
+ unit: '',
201
+ hasDegree: true,
202
+ priority: ReadoutListItemPriority.enhanced,
203
+ weight: ObcTextboxFontWeight.semibold,
204
+ },
205
+ {label: 'COG', src: 'POS1', value: 5, unit: '', hasDegree: true},
206
+ {label: 'ROT', src: 'GYRO2', value: 10, unit: '/min', hasDegree: true},
207
+ {
208
+ label: 'SOG',
209
+ src: 'LOG1',
210
+ value: 9.8,
211
+ unit: 'kn',
212
+ fractionDigits: 1,
213
+ priority: ReadoutListItemPriority.enhanced,
214
+ weight: ObcTextboxFontWeight.semibold,
215
+ },
216
+ {label: 'STW', src: 'GPS1', value: 10.2, unit: 'kn', fractionDigits: 1},
217
+ {label: 'DPTH', src: 'ECHO1', value: 534, unit: 'm'},
218
+ {label: 'Pitch', value: 5.3, unit: '', hasDegree: true, fractionDigits: 1},
219
+ {label: 'Draft fore', value: 3.78, unit: 'm', fractionDigits: 2},
220
+ ].map((row) => ({
221
+ ...row,
222
+ size: ReadoutListItemSize.medium,
223
+ stacking: ReadoutListItemStacking.leadingSrcInline,
224
+ labelSize: ObcTextboxSize.s,
225
+ }));
226
+
227
+ export const LeadingSrcInline: Story = {
228
+ args: {showDebugOverlay: false},
229
+ render: (args) => renderList(INLINE_SOURCE_ROWS, args.showDebugOverlay),
230
+ };
231
+
172
232
  const WARNING_FRAME: AlertFrameConfig = {
173
233
  status: AlertType.Warning,
174
234
  mode: ObcAlertFrameMode.ackedActive,
@@ -550,6 +550,27 @@ obc-readout-block {
550
550
  gap: var(--global-size-spacing-target-padding);
551
551
  }
552
552
 
553
+ /* leading-src-inline: the source follows the label on one line, bottom-aligned
554
+ so the `xs` source and the label share a cap baseline. The label↔source gap
555
+ reuses the tier's content gap (8px, matching Figma 46596:102880). */
556
+ .stacking-leading-src-inline .label-stack {
557
+ flex-direction: row;
558
+ align-items: flex-end;
559
+ }
560
+ .size-small.stacking-leading-src-inline .label-stack {
561
+ gap: var(
562
+ --instrument-components-readout-new-list-item-regular-content-container-gap
563
+ );
564
+ }
565
+ .size-medium.stacking-leading-src-inline .label-stack {
566
+ gap: var(
567
+ --instrument-components-readout-new-list-item-medium-content-container-gap
568
+ );
569
+ }
570
+ .size-large.stacking-leading-src-inline .label-stack {
571
+ gap: var(--instrument-components-readout-new-list-item-large-content-gap);
572
+ }
573
+
553
574
  /* ---------- Data quality ----------
554
575
  * Orthogonal to the alert frame — both can apply at once. */
555
576
  .root.data-low-integrity .surface,
@@ -726,6 +726,42 @@ export const LeadingSrc: Story = {
726
726
  ]),
727
727
  };
728
728
 
729
+ /**
730
+ * `leading-src-inline` keeps the source on the label's line, so a row with a
731
+ * source stays one line high (Figma 46596:102880 pairs an `s` label with the
732
+ * `xs` source; the dense default label stays `xs` unless `labelOptions.size`
733
+ * says otherwise).
734
+ */
735
+ export const LeadingSrcInline: Story = {
736
+ render: () =>
737
+ renderShowcase([
738
+ {
739
+ title: 'Stacking: leading-src-inline',
740
+ columns: 3,
741
+ cases: stackingCases(ReadoutListItemStacking.leadingSrcInline),
742
+ },
743
+ {
744
+ title: 'Label s + source xs (Figma 46596:102880)',
745
+ columns: 3,
746
+ cases: SIZES.map((size) => ({
747
+ label: `${size} / label s`,
748
+ config: {
749
+ label: 'HDG',
750
+ src: 'GPS1',
751
+ value: 355,
752
+ unit: '',
753
+ options: {
754
+ size,
755
+ stacking: ReadoutListItemStacking.leadingSrcInline,
756
+ hasDegree: true,
757
+ label: {size: ObcTextboxSize.s},
758
+ },
759
+ },
760
+ })),
761
+ },
762
+ ]),
763
+ };
764
+
729
765
  export const SetpointFlipFlop: Story = {
730
766
  render: () =>
731
767
  renderShowcase([
@@ -1781,8 +1817,12 @@ export const MissingParts: Story = {
1781
1817
  * same width regardless of each row's own value length / `fractionDigits`, and
1782
1818
  * the columns line up.
1783
1819
  *
1784
- * (Source/stacking variations are exercised in the `LeadingSrc` / `LeadingUnit`
1785
- * stories; mixing them here would move the unit out of the rightmost column.)
1820
+ * The Heading row has no unit and carries its source inline
1821
+ * (`leading-src-inline`): in the aligned column it still renders the blank
1822
+ * reserved unit column, so its degree lines up with the rows that have a unit.
1823
+ * (`leading-src` and `leading-unit` are exercised in their own stories:
1824
+ * `leading-unit` would move the unit out of the rightmost column, and
1825
+ * `leading-src` would make the row two lines high.)
1786
1826
  *
1787
1827
  * The last two rows use `size=medium` / `size=large`. Their value digit edges do
1788
1828
  * NOT fully align with the small rows (~8px stagger): the `°` column scales with
@@ -1800,10 +1840,12 @@ export const MissingParts: Story = {
1800
1840
  */
1801
1841
  type AlignmentRow = {
1802
1842
  label: string;
1843
+ src?: string;
1803
1844
  value: number | string | null;
1804
1845
  valueType?: ReadoutValueType;
1805
1846
  unit: string;
1806
1847
  size?: ReadoutListItemSize;
1848
+ stacking?: ReadoutListItemStacking;
1807
1849
  hasDegree?: boolean;
1808
1850
  fractionDigits?: number;
1809
1851
  priority?: ReadoutListItemPriority;
@@ -1849,6 +1891,16 @@ const ALIGNMENT_ROWS: AlignmentRow[] = [
1849
1891
  // per-block advice low-integrity — the advice chip must not shift the columns
1850
1892
  adviceDataQuality: ReadoutListItemDataQuality.lowIntegrity,
1851
1893
  },
1894
+ // no unit + degree + inline source — the blank reserved unit column keeps
1895
+ // the degree glyph aligned with the unit rows
1896
+ {
1897
+ label: 'Heading',
1898
+ src: 'GPS1',
1899
+ value: 355,
1900
+ unit: '',
1901
+ hasDegree: true,
1902
+ stacking: ReadoutListItemStacking.leadingSrcInline,
1903
+ },
1852
1904
  // negative value + fraction + low-integrity data quality
1853
1905
  {
1854
1906
  label: 'Flow speed',
@@ -1987,7 +2039,7 @@ function renderAlignmentColumn(aligned: boolean, showDebugOverlay: boolean) {
1987
2039
  renderItem({
1988
2040
  label: row.label,
1989
2041
  unit: row.unit,
1990
- src: '',
2042
+ src: row.src ?? '',
1991
2043
  value: row.value,
1992
2044
  valueType: row.valueType,
1993
2045
  hasSetpoint: row.hasSetpoint,
@@ -1999,6 +2051,7 @@ function renderAlignmentColumn(aligned: boolean, showDebugOverlay: boolean) {
1999
2051
  showDebugOverlay,
2000
2052
  options: {
2001
2053
  size: row.size ?? ReadoutListItemSize.small,
2054
+ stacking: row.stacking,
2002
2055
  hasDegree: row.hasDegree ?? false,
2003
2056
  fractionDigits: row.fractionDigits ?? 0,
2004
2057
  priority: row.priority,