@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.121 → 2.0.0-next.123

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 (52) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +252 -53
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +66 -17
  4. package/dist/automation/automation-tank/automation-tank.css.js +94 -14
  5. package/dist/automation/automation-tank/automation-tank.css.js.map +1 -1
  6. package/dist/automation/automation-tank/automation-tank.d.ts +53 -5
  7. package/dist/automation/automation-tank/automation-tank.d.ts.map +1 -1
  8. package/dist/automation/automation-tank/automation-tank.js +39 -20
  9. package/dist/automation/automation-tank/automation-tank.js.map +1 -1
  10. package/dist/building-blocks/readout-block/readout-block.d.ts +27 -1
  11. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  12. package/dist/building-blocks/readout-block/readout-block.js +31 -10
  13. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  14. package/dist/navigation-instruments/compass-sector/compass-sector.css.js +10 -0
  15. package/dist/navigation-instruments/compass-sector/compass-sector.css.js.map +1 -1
  16. package/dist/navigation-instruments/readout/readout-formatters.d.ts +61 -0
  17. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  18. package/dist/navigation-instruments/readout/readout-formatters.js +35 -2
  19. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  20. package/dist/navigation-instruments/readout/readout.d.ts +19 -2
  21. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  22. package/dist/navigation-instruments/readout/readout.js +18 -4
  23. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  24. package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
  25. package/dist/navigation-instruments/readout-list/readout-list.js +6 -3
  26. package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
  27. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +19 -2
  28. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  29. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +18 -4
  30. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  31. package/dist/navigation-instruments/thruster/thruster.d.ts.map +1 -1
  32. package/dist/navigation-instruments/thruster/thruster.js +1 -0
  33. package/dist/navigation-instruments/thruster/thruster.js.map +1 -1
  34. package/dist/navigation-instruments/watch/watch.css.js +4 -0
  35. package/dist/navigation-instruments/watch/watch.css.js.map +1 -1
  36. package/dist/navigation-instruments/watch-flat/watch-flat.css.js +4 -0
  37. package/dist/navigation-instruments/watch-flat/watch-flat.css.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/automation/automation-tank/automation-tank.css +70 -3
  40. package/src/automation/automation-tank/automation-tank.stories.ts +140 -1
  41. package/src/automation/automation-tank/automation-tank.ts +106 -27
  42. package/src/building-blocks/readout-block/readout-block.stories.ts +20 -0
  43. package/src/building-blocks/readout-block/readout-block.ts +56 -10
  44. package/src/navigation-instruments/compass-sector/compass-sector.css +10 -0
  45. package/src/navigation-instruments/readout/readout-formatters.spec.ts +205 -0
  46. package/src/navigation-instruments/readout/readout-formatters.ts +128 -2
  47. package/src/navigation-instruments/readout/readout.ts +38 -5
  48. package/src/navigation-instruments/readout-list/readout-list.ts +10 -2
  49. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +38 -5
  50. package/src/navigation-instruments/thruster/thruster.ts +1 -0
  51. package/src/navigation-instruments/watch/watch.css +4 -0
  52. package/src/navigation-instruments/watch-flat/watch-flat.css +4 -0
@@ -77,6 +77,39 @@
77
77
  min-width: 0;
78
78
  min-height: 0;
79
79
  }
80
+
81
+ /* Indefinite-axis fallback.
82
+ *
83
+ * `width`/`height: 100%` only resolve when the parent's corresponding axis is
84
+ * definite. In a flex/grid dashboard slot one axis is very often left to
85
+ * content — `align-self: center` makes the cross axis shrink-to-fit, and a
86
+ * parent sized with `min-height`/`max-height` (rather than `height`) leaves the
87
+ * block axis indefinite. In those cases the percentage computes to `auto` and
88
+ * the tank would size to its own content, which includes the chart cell — and
89
+ * the chart sizes itself from the cell it was measured in. That is a circular
90
+ * constraint, and it resolves as a collapsed cell, unbounded growth or
91
+ * pixel-level ringing depending on the loop gain (issue #1121).
92
+ *
93
+ * `aspect-ratio` supplies the missing axis from the design footprint the
94
+ * `point` variant uses above, so an indefinite axis lands on a definite value
95
+ * that no longer depends on the tank's own content. It is inert when the parent
96
+ * makes both axes definite, so the normal "fill my slot" behavior is unchanged.
97
+ */
98
+ :host([positioning="button"]) {
99
+ aspect-ratio: 256 / 376;
100
+ }
101
+ :host([positioning="button"][orientation="horizontal"]) {
102
+ aspect-ratio: 420 / 256;
103
+ }
104
+ :host([positioning="button"][compact]),
105
+ :host([positioning="button"][static]) {
106
+ aspect-ratio: 170 / 282;
107
+ }
108
+ :host([positioning="button"][orientation="horizontal"][compact]),
109
+ :host([positioning="button"][orientation="horizontal"][static]) {
110
+ aspect-ratio: 244 / 208;
111
+ }
112
+
80
113
  :host([positioning="button"]) .root {
81
114
  position: static;
82
115
  top: auto;
@@ -134,10 +167,30 @@
134
167
  display: flex;
135
168
  flex-direction: column;
136
169
  gap: 4px;
170
+ }
171
+
172
+ /* Interactive (`clickable`, the default): the full six-state flat mixin —
173
+ * enabled, activated, hover, pressed, focus-visible, disabled — painted on
174
+ * `.halo`. */
175
+ .root.clickable {
137
176
  cursor: pointer;
138
177
  /* prettier-ignore */
139
178
  @mixin style style=flat visibleWrapperClass=.halo;
140
179
  }
180
+
181
+ /* Non-interactive (`clickable` is `false`, and `static`, which is display-only
182
+ * by definition): the mixin's `noClick` variant emits the enabled state ONLY, so
183
+ * the halo keeps exactly the same resting colors and the same 1px border box
184
+ * — no layout shift, nothing else in the tank changes — while hover, pressed
185
+ * and focus-visible disappear. The root is a `<div>` in this case, so there is
186
+ * no focus ring or tab stop to suppress either. Same split as
187
+ * `obc-elevated-card`'s `.not-clickable` / `:not(.not-clickable)` pair. */
188
+ .root:not(.clickable) {
189
+ cursor: default;
190
+ /* prettier-ignore */
191
+ @mixin style style=flat noClick visibleWrapperClass=.halo;
192
+ }
193
+
141
194
  .root:focus {
142
195
  outline: none;
143
196
  }
@@ -156,10 +209,12 @@
156
209
  top: auto;
157
210
  }
158
211
 
159
- /* Static is display-only: not focusable (rendered as <div>) and not
160
- * interactive disable :hover/:active visuals from the flat-mixin rules. */
212
+ /* Static is display-only, so it never gets the `.clickable` class and the
213
+ * hover/pressed visuals are already gone via the `noClick` rule above. Static
214
+ * additionally refuses pointer events outright — unlike a non-clickable tank,
215
+ * which still lets clicks reach the host so a consumer can opt into handling
216
+ * them. */
161
217
  :host([static]) .root {
162
- cursor: default;
163
218
  pointer-events: none;
164
219
  }
165
220
 
@@ -510,9 +565,21 @@
510
565
  overflow: hidden;
511
566
  }
512
567
 
568
+ /* The renderers are taken out of flow so the cell sizes them, never the other
569
+ * way round. Both of them derive a pixel size from the box they are measured
570
+ * in — `obc-gauge-trend` publishes an inline `--chart-height` computed from its
571
+ * own measured width, and `obc-bar-vertical` writes `height: 100%; width: auto`
572
+ * onto its own host — so while they are in flow the cell's content height is a
573
+ * function of the cell's own height. Absolute positioning severs that return
574
+ * path: `.bar-container` is `position: relative`, so `inset: 0` pins the
575
+ * renderer to the cell the tank's grid/flex layout already decided on, and a
576
+ * rounding difference between the two can no longer relayout the tank
577
+ * (issue #1121). */
513
578
  .bar-container > obc-gauge-trend,
514
579
  .bar-container > obc-bar-vertical {
515
580
  display: block;
581
+ position: absolute;
582
+ inset: 0;
516
583
  width: 100%;
517
584
  height: 100%;
518
585
  }
@@ -99,6 +99,7 @@ const renderTankEl = (args: StoryArgs, richReadout: unknown = nothing) => html`
99
99
  .compact=${args.compact}
100
100
  .static=${args.static}
101
101
  ?activated=${args.activated}
102
+ .clickable=${args.clickable}
102
103
  .positioning=${args.positioning}
103
104
  .chartMode=${args.chartMode}
104
105
  .chartData=${args.chartData}
@@ -142,6 +143,7 @@ const meta: Meta<StoryArgs> = {
142
143
  compact: false,
143
144
  static: false,
144
145
  activated: false,
146
+ clickable: true,
145
147
  positioning: TankPositioning.point,
146
148
  chartMode: TankChartMode.bar,
147
149
  chartData: SAMPLE_DATA,
@@ -200,7 +202,12 @@ const meta: Meta<StoryArgs> = {
200
202
  activated: {
201
203
  control: {type: 'boolean'},
202
204
  description:
203
- 'Enables the activated background color, used to indicate that the tank is activated/selected.',
205
+ 'Enables the activated background color, used to indicate that the tank is activated/selected. Requires `clickable` — a non-clickable tank only paints the resting enabled state.',
206
+ },
207
+ clickable: {
208
+ control: {type: 'boolean'},
209
+ description:
210
+ 'Whether the tank is interactive. Default `true`. Set to `false` for a display-only tank that still shows live data: the resting appearance, chart, badges, readout, tag and alert frame are unchanged, but hover / pressed / focus states are removed and the tank leaves the tab order. Unlike `static`, the chart keeps rendering.',
204
211
  },
205
212
  hasAdvice: {
206
213
  control: {type: 'boolean'},
@@ -330,6 +337,32 @@ export const CompactActivated: Story = {
330
337
  },
331
338
  };
332
339
 
340
+ /**
341
+ * Display-only tank — `.clickable=${false}`. `clickable` is property-only
342
+ * (`attribute: false`), so it has to be set as a property; a
343
+ * `clickable="false"` attribute in plain HTML is not observed. The root
344
+ * renders as a `<div>`
345
+ * instead of a `<button>`, so there is no hover, pressed or focus-visible
346
+ * state and the tank is out of the tab order. Everything else is untouched:
347
+ * the resting surface keeps the same colors and the same 1px border box (the
348
+ * flat mixin's `noClick` variant paints the enabled state only), and the
349
+ * chart, badges, readout and tag all render exactly as on a clickable tank.
350
+ *
351
+ * Use this for a tank that aggregates the ones beside it — a row total that
352
+ * still shows live data but has nothing to open. Contrast with `static`,
353
+ * which represents "device present, current state unknown": that one also
354
+ * hides the chart, swaps the percent for capacity, and shrinks to the compact
355
+ * footprint.
356
+ */
357
+ export const NotClickable: Story = {
358
+ args: {
359
+ type: TankType.atmospheric,
360
+ chartMode: TankChartMode.graphAndBar,
361
+ tag: 'TOTAL',
362
+ clickable: false,
363
+ },
364
+ };
365
+
333
366
  export const CompactAtmospheric: Story = {
334
367
  args: {compact: true, type: TankType.atmospheric},
335
368
  };
@@ -523,6 +556,7 @@ export const WithFractionDigits: Story = {
523
556
  .orientation=${args.orientation}
524
557
  .compact=${args.compact}
525
558
  .static=${args.static}
559
+ .clickable=${args.clickable}
526
560
  .positioning=${args.positioning}
527
561
  .chartMode=${args.chartMode}
528
562
  .chartData=${args.chartData}
@@ -562,6 +596,7 @@ export const WithAlertAlarm: Story = {
562
596
  .orientation=${args.orientation}
563
597
  .compact=${args.compact}
564
598
  .static=${args.static}
599
+ .clickable=${args.clickable}
565
600
  .positioning=${args.positioning}
566
601
  .chartMode=${args.chartMode}
567
602
  .chartData=${args.chartData}
@@ -583,6 +618,24 @@ export const WithAlertAlarm: Story = {
583
618
  `,
584
619
  };
585
620
 
621
+ /**
622
+ * A non-clickable tank still raises its alert frame — `clickable` governs the
623
+ * interaction surface only, not what the tank is allowed to display. The chart
624
+ * keeps rendering too, which is the difference from `static`.
625
+ */
626
+ export const NotClickableWithAlert: Story = {
627
+ ...WithAlertAlarm,
628
+ args: {
629
+ type: TankType.atmospheric,
630
+ chartMode: TankChartMode.graphAndBar,
631
+ tag: 'TOTAL',
632
+ clickable: false,
633
+ alert: true,
634
+ alertFrameStatus: ObcAlertFrameStatus.Warning,
635
+ alertFrameType: ObcAlertFrameType.SmallSideFlip,
636
+ },
637
+ };
638
+
586
639
  /**
587
640
  * Warning-status alert on a horizontal atmospheric tank — demonstrates the
588
641
  * ring tracks the tank's host orientation correctly.
@@ -650,6 +703,91 @@ export const WithAlertLevelDiagnostic: Story = {
650
703
  * story, which gives the host fixed default dimensions and a P&ID
651
704
  * top-center anchor for placement on a pipe-grid coordinate.
652
705
  */
706
+ /**
707
+ * A dashboard row of tanks in a flex container — the layout family that
708
+ * `positioning="button"` exists for, and the one that regressed in issue #1121.
709
+ *
710
+ * Each cell is a flex item sized with `min-height` / `max-height` rather than
711
+ * `height`, and the tank inside it is centered on the cross axis. Both of those
712
+ * leave an axis indefinite, so the tank cannot take its size from the parent
713
+ * alone; it falls back to the design aspect ratio instead of sizing to its own
714
+ * content. Sizing to content would be circular — the chart derives its pixel
715
+ * size from the cell it was measured in — and used to surface as tanks that
716
+ * collapsed, grew without bound, or oscillated by a few pixels forever.
717
+ *
718
+ * Pin both axes on the parent whenever the exact footprint matters; the
719
+ * fallback keeps the layout stable, it does not guess the size you wanted.
720
+ */
721
+ export const DashboardRow: Story = {
722
+ args: {
723
+ type: TankType.generic,
724
+ chartMode: TankChartMode.graphAndBar,
725
+ positioning: TankPositioning.button,
726
+ showTrendSymbol: false,
727
+ percentFractionDigits: 1,
728
+ },
729
+ decorators: [],
730
+ render(args) {
731
+ const tanks = [
732
+ {tag: 'FO TOT', value: 61.4, max: 76.21},
733
+ {tag: 'FO SERV SB', value: 0.82, max: 1.06},
734
+ {tag: 'FO SERV PS', value: 0.41, max: 1.06},
735
+ {tag: 'OVERFLOW', value: 2.3, max: 17.4},
736
+ {tag: 'AFT SB FO', value: 14.9, max: 17.4},
737
+ {tag: 'UREA', value: 3.9, max: 5.5},
738
+ ];
739
+ // Each tank has its own capacity, so the series has to be built on that
740
+ // tank's scale — a shared fixture would render off-axis.
741
+ const series = (value: number, max: number, seed: number) =>
742
+ Array.from({length: 16}, (_, i) => ({
743
+ label: String(i).padStart(2, '0'),
744
+ value: Math.min(
745
+ max,
746
+ Math.max(0, value * (0.55 + 0.03 * ((i + seed) % 15)))
747
+ ),
748
+ }));
749
+ // `position: static` opts out of the shared `crossDecorator`'s
750
+ // `position: absolute; top: 50%; left: 50%`, which would push a full-width
751
+ // row into the bottom-right quadrant.
752
+ return html`
753
+ <div
754
+ style="position: static; display: flex; align-items: stretch; gap: 8px;"
755
+ >
756
+ ${tanks.map(
757
+ (tank, index) => html`
758
+ <div
759
+ style="
760
+ display: flex;
761
+ flex-direction: column;
762
+ align-items: center;
763
+ flex: 1 1 auto;
764
+ min-width: 0;
765
+ min-height: 340px;
766
+ max-height: 340px;
767
+ border: 1px solid var(--border-divider-color);
768
+ "
769
+ >
770
+ <obc-automation-tank
771
+ style="align-self: center; flex: 1 1 auto; min-width: 0;"
772
+ .value=${tank.value}
773
+ .max=${tank.max}
774
+ .tag=${tank.tag}
775
+ .type=${args.type}
776
+ .positioning=${args.positioning}
777
+ .chartMode=${args.chartMode}
778
+ .chartData=${series(tank.value, tank.max, index)}
779
+ .showTrendSymbol=${args.showTrendSymbol}
780
+ .percentFractionDigits=${args.percentFractionDigits}
781
+ .priority=${args.priority}
782
+ ></obc-automation-tank>
783
+ </div>
784
+ `
785
+ )}
786
+ </div>
787
+ `;
788
+ },
789
+ };
790
+
653
791
  export const Responsive: Story = {
654
792
  args: {
655
793
  type: TankType.atmospheric,
@@ -684,6 +822,7 @@ export const Responsive: Story = {
684
822
  .orientation=${args.orientation}
685
823
  .compact=${args.compact}
686
824
  .static=${args.static}
825
+ .clickable=${args.clickable}
687
826
  .positioning=${args.positioning}
688
827
  .chartMode=${args.chartMode}
689
828
  .chartData=${args.chartData}
@@ -82,6 +82,22 @@ export enum TankOrientation {
82
82
  * slot — the parent controls the footprint and the tank renders
83
83
  * responsively inside it, just like a regular button. Compact / static
84
84
  * inner layout still applies; only the host box is changed.
85
+ *
86
+ * If the parent leaves *one* axis indefinite — a flex/grid slot sized with
87
+ * `min-height`/`max-height` rather than `height`, or a cross axis freed by
88
+ * `align-self: center` — the corresponding `100%` computes to `auto` and the
89
+ * tank derives that axis from the other one through the design aspect ratio
90
+ * of the matching `point` footprint (256×376 vertical, 420×256 horizontal,
91
+ * 170×282 compact/static, 244×208 horizontal compact/static). The size then
92
+ * does not depend on the chart cell, which takes its own size from the cell
93
+ * it was measured in and would otherwise make the constraint circular
94
+ * (issue #1121).
95
+ *
96
+ * If *both* axes are indefinite (a shrink-to-fit parent, e.g. an unsized
97
+ * `inline-block`) there is no axis left to derive from, so the host falls
98
+ * back to its text content. That is stable and non-circular, but much
99
+ * smaller than the design footprint — give the parent a definite size on at
100
+ * least one axis, and on both whenever the exact footprint matters.
85
101
  */
86
102
  export enum TankPositioning {
87
103
  point = 'point',
@@ -122,7 +138,7 @@ export enum TankChartMode {
122
138
  * @slot alert-icon - Custom icon for the alert frame.
123
139
  * @slot alert-label - Label for the alert frame.
124
140
  * @slot alert-timer - Timer for the alert frame.
125
- * @fires click - Fired when the tank is clicked. In `static` mode the tank renders a `<div role="img">` instead of a `<button>`, so it is not focusable or keyboard-activatable; pointer clicks still reach the host.
141
+ * @fires click - Fired when the tank is clicked. When `clickable` is `false` the tank renders a plain `<div>`, and in `static` mode a `<div role="img">`, instead of a `<button>` in both cases it is not focusable or keyboard-activatable; pointer clicks still reach the host.
126
142
  * @beta
127
143
  */
128
144
  @customElement('obc-automation-tank')
@@ -137,9 +153,10 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
137
153
  @property({type: Boolean, reflect: true}) compact: boolean = false;
138
154
  /**
139
155
  * Host positioning model — see `TankPositioning` for details. Defaults to
140
- * `button` (host fills parent container, 100% × 100%, no anchor offset).
141
- * Set to `point` for the legacy P&ID canvas mode (fixed default dimensions
142
- * + top-center anchor offset).
156
+ * `button` (host fills parent container, 100% × 100%, falling back to the
157
+ * design aspect ratio on any axis the parent leaves indefinite, no anchor
158
+ * offset). Set to `point` for the legacy P&ID canvas mode (fixed default
159
+ * dimensions + top-center anchor offset).
143
160
  */
144
161
  @property({type: String, reflect: true}) positioning: TankPositioning =
145
162
  TankPositioning.button;
@@ -156,7 +173,38 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
156
173
  * activatable controls.
157
174
  */
158
175
  @property({type: Boolean, reflect: true}) static: boolean = false;
159
- /** Enables the activated background color, used to indicate that the tank is activated/selected. */
176
+ /**
177
+ * Whether the tank is interactive. `true` (default) renders the root as a
178
+ * `<button>` with the full flat-mixin interaction surface. `false` renders a
179
+ * non-interactive `<div>` — the resting appearance is unchanged (same
180
+ * enabled-state colors and the same 1px border box, via the mixin's
181
+ * `noClick` variant), but the hover / pressed / focus-visible states are
182
+ * gone and the tank leaves the tab order.
183
+ *
184
+ * Everything else keeps rendering: the chart / bar, badges, readout, tag and
185
+ * the `alert` frame all behave exactly as they do on a clickable tank. Use
186
+ * this for a display-only tank that still shows live data — e.g. a row total
187
+ * aggregating the tanks beside it. For "device present, current state
188
+ * unknown" use `static` instead, which also hides the chart and shrinks to
189
+ * the compact footprint.
190
+ *
191
+ * `static` is already non-interactive, so this has no effect there.
192
+ *
193
+ * Property-only (`attribute: false`, per the repo's positive-default-true
194
+ * boolean convention — a `true` default cannot round-trip through an HTML
195
+ * boolean attribute). Set it as a property: `el.clickable = false`,
196
+ * `.clickable=${false}` in a Lit template, or the equivalent binding in the
197
+ * React / Vue / Angular / Svelte wrappers. A `clickable="false"` attribute in
198
+ * plain HTML is **not** observed and leaves the tank interactive.
199
+ */
200
+ @property({type: Boolean, attribute: false}) clickable: boolean = true;
201
+ /**
202
+ * Enables the activated background color, used to indicate that the tank is
203
+ * activated/selected. Requires an interactive tank — the `noClick` mixin
204
+ * variant used when `clickable` is `false` only paints the enabled state, so
205
+ * a non-clickable tank ignores this (matching `obc-elevated-card`).
206
+ * @availableWhen clickable==true
207
+ */
160
208
  @property({type: Boolean}) activated: boolean = false;
161
209
  @property({type: String}) tag: string = '';
162
210
 
@@ -1001,7 +1049,18 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
1001
1049
  // The `activated` class goes on the interactive `.root` so the shared
1002
1050
  // `flat` style mixin paints the activated background/border on `.halo`
1003
1051
  // (its `visibleWrapperClass`), same as the mixin's hover/pressed states.
1004
- const rootClasses = classMap({root: true, activated: this.activated});
1052
+ //
1053
+ // `.clickable` selects between the two flat-mixin variants in CSS: the
1054
+ // full six-state one, or the `noClick` one that paints only the resting
1055
+ // enabled state. `static` is already display-only, so it never counts as
1056
+ // clickable. Same shape as `obc-elevated-card`'s `.not-clickable` split
1057
+ // and `obc-readout-list-item`'s `.root.clickable`.
1058
+ const isClickable = this.clickable && !this.static;
1059
+ const rootClasses = classMap({
1060
+ root: true,
1061
+ activated: this.activated,
1062
+ clickable: isClickable,
1063
+ });
1005
1064
 
1006
1065
  // `aria-live="polite"` + `aria-atomic="true"` on the root so the
1007
1066
  // slotted alert label (and any state change of the alert frame) is
@@ -1010,27 +1069,47 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
1010
1069
  // TODO(a11y): the rest of the automation component family still lacks
1011
1070
  // this live-region announcement; consolidate when alert support is
1012
1071
  // factored into a shared mixin.
1013
- return html`
1014
- ${this.static
1015
- ? html`<div
1016
- class=${rootClasses}
1017
- role="img"
1018
- aria-label=${this.tag || 'Tank'}
1019
- aria-live="polite"
1020
- aria-atomic="true"
1021
- >
1022
- ${halo}
1023
- </div>`
1024
- : html`<button
1025
- class=${rootClasses}
1026
- type="button"
1027
- aria-label=${this.tag || 'Tank'}
1028
- aria-live="polite"
1029
- aria-atomic="true"
1030
- >
1031
- ${halo}
1032
- </button>`}
1033
- `;
1072
+ // Three root shapes:
1073
+ // - static: <div role="img"> — an opaque graphic standing in
1074
+ // for a device whose state is unknown, named by its tag.
1075
+ // - clickable false: a plain <div>. Deliberately no `role="img"` and no
1076
+ // `aria-label` here: unlike a static tank this one still shows live
1077
+ // data, and both would collapse the readout into a single opaque name
1078
+ // and hide the percent / value / tag from screen readers. The visible
1079
+ // content is the accessible content. (Same reasoning as the non-
1080
+ // clickable branch of `obc-readout-list-item`.)
1081
+ // - default: <button>.
1082
+ // The live region stays on all three so an `alert` label is announced
1083
+ // regardless of interactivity.
1084
+ if (this.static) {
1085
+ return html`<div
1086
+ class=${rootClasses}
1087
+ role="img"
1088
+ aria-label=${this.tag || 'Tank'}
1089
+ aria-live="polite"
1090
+ aria-atomic="true"
1091
+ >
1092
+ ${halo}
1093
+ </div>`;
1094
+ }
1095
+ if (!isClickable) {
1096
+ return html`<div
1097
+ class=${rootClasses}
1098
+ aria-live="polite"
1099
+ aria-atomic="true"
1100
+ >
1101
+ ${halo}
1102
+ </div>`;
1103
+ }
1104
+ return html`<button
1105
+ class=${rootClasses}
1106
+ type="button"
1107
+ aria-label=${this.tag || 'Tank'}
1108
+ aria-live="polite"
1109
+ aria-atomic="true"
1110
+ >
1111
+ ${halo}
1112
+ </button>`;
1034
1113
  }
1035
1114
 
1036
1115
  static override styles = unsafeCSS(compentStyle);
@@ -481,6 +481,19 @@ const UNAVAILABLE_CASES: {
481
481
  label: 'null · no fraction digits',
482
482
  args: {value: null, maxDigits: 3},
483
483
  },
484
+ // The knobs, not the value: a `fractionDigits` written by a failed runtime
485
+ // (`NaN`) must not silently format with zero decimals — a critical 0.4
486
+ // printed as a plausible-looking "0" reads as healthy. The reading dashes
487
+ // instead. A missing `maxDigits` dashes too, for consistency; with the
488
+ // precision still known, the placeholder keeps its fraction shape.
489
+ {
490
+ label: 'value 0.4 · fractionDigits NaN — precision failed to arrive',
491
+ args: {value: 0.4, maxDigits: 3, fractionDigits: Number.NaN},
492
+ },
493
+ {
494
+ label: 'value 0.4 · maxDigits NaN — same, for consistency',
495
+ args: {value: 0.4, maxDigits: Number.NaN, fractionDigits: 1},
496
+ },
484
497
  ];
485
498
 
486
499
  // Stacked in one column under identical settings so the decimal points and the
@@ -509,6 +522,13 @@ const ALIGNMENT_CASES: Partial<BlockArgs>[] = [
509
522
  * runtime data condition (sensor dropout, `0/0`, a bad parse) rather than a
510
523
  * programmer error, so they resolve to the dash rather than throwing.
511
524
  *
525
+ * 4. **A digit knob that never arrived dashes the reading too.** `NaN`,
526
+ * `null` or `undefined` in `fractionDigits` / `maxDigits` is a runtime
527
+ * failure of the writing system, and formatting with a default the author
528
+ * never chose would let a critical `0.4` pass for a healthy `0`. Finite
529
+ * out-of-range values are different: `fractionDigits` throws (programmer
530
+ * error), `maxDigits` clamps (width-only).
531
+ *
512
532
  * Hinted zeros are suppressed for an unavailable value, so the two "not
513
533
  * available" rows are identical and nothing reads `----Na.N`.
514
534
  *
@@ -15,6 +15,9 @@ import {
15
15
  formatNumericValue,
16
16
  readoutFormattedInteger,
17
17
  assertReadoutValueType,
18
+ assertReadoutFractionDigits,
19
+ isReadoutDigitCountMissing,
20
+ resolveReadoutDigitCount,
18
21
  resolveReadoutNumericValue,
19
22
  resolveReadoutTextValue,
20
23
  READOUT_UNAVAILABLE_DASH,
@@ -152,13 +155,23 @@ export class ObcReadoutBlock extends LitElement {
152
155
  @property({type: Boolean}) hasIcon = false;
153
156
 
154
157
  /**
155
- * Number of fraction digits.
158
+ * Number of fraction digits. Must be between 0 and 100 — the range
159
+ * `Number.prototype.toFixed` accepts; outside it throws a `RangeError`.
160
+ * A fractional count truncates (`2.7` → `2`). A count that never arrived
161
+ * (`NaN`, `null` or unset) renders the reading as the unavailable dash —
162
+ * formatting with a precision the author never chose would let a critical
163
+ * `0.4` pass for a healthy `0`.
156
164
  * @availableWhen valueType==number
157
165
  */
158
166
  @property({type: Number}) fractionDigits = 0;
159
167
 
160
168
  /**
161
169
  * Integer digits to reserve / hint (independent of `fractionDigits`).
170
+ * Bounded before use: a fractional count
171
+ * truncates (`2.7` → `2`), anything above 100 — including `Infinity` —
172
+ * caps at 100, and a negative count reserves nothing. A count that never
173
+ * arrived (`NaN`, `null` or unset) renders the reading as the unavailable
174
+ * dash, consistent with `fractionDigits`.
162
175
  * @availableWhen valueType==number
163
176
  */
164
177
  @property({type: Number}) maxDigits = 0;
@@ -214,24 +227,54 @@ export class ObcReadoutBlock extends LitElement {
214
227
  }
215
228
  }
216
229
 
230
+ /**
231
+ * `maxDigits` bounded to a usable count. Every width calculation below goes
232
+ * through this rather than the raw property, so an `Infinity` (or absurdly
233
+ * large) value cannot reach `String.prototype.repeat`.
234
+ */
235
+ private get resolvedMaxDigits(): number {
236
+ return resolveReadoutDigitCount(this.maxDigits);
237
+ }
238
+
239
+ /**
240
+ * Whether a digit knob failed to arrive (`NaN` / `null` / `undefined`).
241
+ * The reading then renders as the unavailable dash rather than silently
242
+ * formatting with a default the author never chose — a critical `0.4`
243
+ * formatted with a failed `fractionDigits` would otherwise print as a
244
+ * plausible-looking `0`. A missing `maxDigits` dashes too, for consistency:
245
+ * either knob arriving broken means the row's configuration cannot be
246
+ * trusted.
247
+ */
248
+ private get digitCountsMissing(): boolean {
249
+ return (
250
+ isReadoutDigitCountMissing(this.fractionDigits) ||
251
+ isReadoutDigitCountMissing(this.maxDigits)
252
+ );
253
+ }
254
+
217
255
  private get numericFormatOptions(): ReadoutNumericFormatOptions {
218
256
  return {
219
257
  // The unavailable placeholder stays short (`\u2012.\u2012\u2012`) rather than
220
258
  // spelling out every reserved digit position — `maxDigits` already
221
259
  // reserves the width, so it simply sits at the right edge of it.
222
260
  showZeroPadding: false,
223
- minValueLength: this.maxDigits,
224
- fractionDigits: this.fractionDigits,
261
+ minValueLength: this.resolvedMaxDigits,
262
+ // A missing precision shapes the placeholder as zero fraction digits (a
263
+ // single dash) — and keeps a runtime `undefined`/`null` out of the
264
+ // options object, which is typed `number` throughout the formatters.
265
+ fractionDigits: isReadoutDigitCountMissing(this.fractionDigits)
266
+ ? 0
267
+ : this.fractionDigits,
225
268
  };
226
269
  }
227
270
 
228
271
  /** Widest possible value string for width reservation (e.g. `"000.0"`). */
229
272
  private get reserverText(): string {
230
- const maxDigits = this.maxDigits;
273
+ const maxDigits = this.resolvedMaxDigits;
231
274
  if (maxDigits <= 0) {
232
275
  return '';
233
276
  }
234
- const integer = '0'.repeat(Math.max(maxDigits, 1));
277
+ const integer = '0'.repeat(maxDigits);
235
278
  return this.fractionDigits > 0
236
279
  ? `${integer}.${'0'.repeat(this.fractionDigits)}`
237
280
  : integer;
@@ -322,16 +365,19 @@ export class ObcReadoutBlock extends LitElement {
322
365
  // `changed`, skip the check, and render the invalid value as a plain dash:
323
366
  // exactly the silent failure this assertion exists to prevent.
324
367
  assertReadoutValueType('obc-readout-block', this.value, this.valueType);
368
+ assertReadoutFractionDigits('obc-readout-block', this.fractionDigits);
325
369
  }
326
370
 
327
371
  override render() {
328
372
  const valueSize = this.resolvedValueSize;
329
373
  const formatOptions = this.numericFormatOptions;
330
374
  const isTextMode = this.valueType === ReadoutValueType.text;
331
- const valueForFormat = resolveReadoutNumericValue(
332
- this.value,
333
- this.valueType
334
- );
375
+ // A missing digit knob makes the reading untrustworthy, so it resolves to
376
+ // "no reading" and renders the dash (see `digitCountsMissing`). Text mode
377
+ // is unaffected — it ignores the numeric knobs entirely.
378
+ const valueForFormat = this.digitCountsMissing
379
+ ? undefined
380
+ : resolveReadoutNumericValue(this.value, this.valueType);
335
381
  const textValue = resolveReadoutTextValue(this.value, this.valueType);
336
382
  // Text mode renders verbatim and ignores the numeric format options; a
337
383
  // blank / missing text value still falls back to the unavailable dash.
@@ -351,7 +397,7 @@ export class ObcReadoutBlock extends LitElement {
351
397
  valueForFormat === undefined ||
352
398
  valueForFormat < 0
353
399
  ? 0
354
- : Math.max(this.maxDigits - readoutFormattedInteger(text), 0);
400
+ : Math.max(this.resolvedMaxDigits - readoutFormattedInteger(text), 0);
355
401
  const hinted = hintCount > 0 ? '0'.repeat(hintCount) : '';
356
402
  // Hinted zeros own the width — they already fill to `maxDigits` — so when
357
403
  // `hintedZeros` is enabled an explicit `spaceReserver` is ignored (it has
@@ -27,4 +27,14 @@
27
27
  display: block;
28
28
  width: 100%;
29
29
  height: 100%;
30
+ /* Clips the rotated overlay <svg transform="rotate(...)">, whose corners
31
+ swing outside the host and leak arc pixels in wide layouts (same
32
+ protection obc-watch has had since #994). Known trade-off: the HDG/COG
33
+ arrow apexes previously painted 1-2px past the host's top edge and are
34
+ now shaved flat at the box edge — imperceptible at normal viewing.
35
+ TODO(designer): if the shave is unwanted, the frame needs headroom so
36
+ the arrow tips fit inside the box — see "Host clipping & the arrow-apex
37
+ shave" in docs/agents/watch-radial-instruments.md before changing
38
+ (interacts with the #1021/#1049 label-reserve geometry). */
39
+ overflow: hidden;
30
40
  }