Mobile
A phone at the mast or in the cabin, about 390 px wide, with the most urgent alert on top, composed from kit parts.
On this page
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { AppShell } from "@bosunkit/components/app-shell";
import { Button } from "@bosunkit/components/button";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { DataTable, type Column } from "@bosunkit/components/data-table";
import { DescriptionList } from "@bosunkit/components/description-list";
import { DueChip } from "@bosunkit/components/due-chip";
import { HeaderBar } from "@bosunkit/components/header-bar";
import { InstrumentPill, type InstrumentPillPriority } from "@bosunkit/components/instrument-pill";
import type { ReadingTrend } from "@bosunkit/components/reading";
import { InstrumentPillGroup } from "@bosunkit/components/instrument-pill-group";
import { Logo } from "@bosunkit/components/logo";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { formatPosition } from "@bosunkit/components/position-input";
import { Reading } from "@bosunkit/components/reading";
import { SectionDivider } from "@bosunkit/components/section-divider";
import { SettingField, SettingToggle, type SaveState } from "@bosunkit/components/setting-control";
import { Sparkline } from "@bosunkit/components/sparkline";
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { NavigationBar, type NavigationBarItem } from "@bosunkit/components/navigation-bar";
import { Tabs, type TabsOption } from "@bosunkit/components/tabs";
import { WeatherBar, type WeatherDay } from "@bosunkit/components/weather-bar";
import { ReadoutDirection } from "@bosunkit/instruments";
import {
BookOpen,
Cloud,
House,
Map as MapIcon,
Menu,
MessageSquare,
Settings,
TriangleAlert,
Users,
Waves,
} from "lucide-react";
import {
useEffect,
useRef,
useState,
type Dispatch,
type ReactElement,
type ReactNode,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"The phone in the cabin at anchor in Morgat: the distance from the anchor swings with the gusts until it leaves " +
"the swing radius and raises an anchor drag alarm on top, which is acknowledged and clears when the anchor " +
"bites again, under instrument pills, the weather bar and tabs for the situation, the anchor and the crew.";
export const readsClock: boolean = true;
/** The anchor watch repeats every two and a half minutes. */
const LOOP_SECONDS: number = 150;
/** If nobody presses Acknowledge here, the saloon display acknowledges the alarm this long after it fires. */
const SALOON_ACKNOWLEDGES_AFTER: number = 35;
/** How far back inside the swing radius the vessel must come before the drag alarm clears, in meters. */
const CLEAR_MARGIN_M: number = 2;
/** The swing radius the crew set, in meters, until they change it on the Anchor tab. */
const DEFAULT_SWING_RADIUS_M: number = 35;
/** How long the example's save of a setting takes to come back. */
const SAVE_MS: number = 900;
const DRAG_ID: string = "anchor-drag";
const POSITION: { readonly latitude: number; readonly longitude: number } = {
latitude: 48.2236,
longitude: -4.4975,
};
const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind"].map((name: string): SensorSummary => ({
name,
integrity: "fresh",
ageSeconds: 1,
}));
const TABS: readonly TabsOption[] = [
{ id: "situation", label: "Situation" },
{ id: "anchor", label: "Anchor" },
{ id: "crew", label: "Crew" },
];
/**
* Every page: the phone's menu behind More, and the rail on a phone on its side, where the tab bar gives way to it.
*/
const PAGES: readonly NavGroup[] = [
{
id: "anchorage",
label: "At anchor",
items: [
{ href: "#mobile-home", label: "Home", icon: <House /> },
{ href: "#mobile-chart", label: "Chart", icon: <MapIcon /> },
{ href: "#mobile-most-urgent", label: "Alerts", icon: <TriangleAlert /> },
{ href: "#mobile-chat", label: "Chat", icon: <MessageSquare /> },
],
},
{
id: "passage",
label: "Passage",
items: [
{ href: "#mobile-weather", label: "Weather", icon: <Cloud /> },
{ href: "#mobile-tides", label: "Tides", icon: <Waves /> },
{ href: "#mobile-log", label: "Log", icon: <BookOpen /> },
],
},
{
id: "aboard",
label: "Aboard",
items: [
{ href: "#mobile-crew", label: "Crew", icon: <Users /> },
{ href: "#mobile-settings", label: "Settings", icon: <Settings /> },
],
},
];
const DAYS: readonly WeatherDay[] = [
{ day: "Sat 11", weatherCode: 61, windMinKn: 18, windMaxKn: 26, windFromDeg: 225, gustMaxKn: 33 },
{ day: "Sun 12", weatherCode: 3, windMinKn: 14, windMaxKn: 20, windFromDeg: 260, gustMaxKn: 26 },
{ day: "Mon 13", weatherCode: 2, windMinKn: 10, windMaxKn: 15, windFromDeg: 290, gustMaxKn: 19 },
];
const CREW_COLUMNS: readonly Column[] = [
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
{ key: "watch", label: "Anchor watch" },
{
key: "due",
label: "Documents",
render: (row: Record<string, unknown>): ReactNode =>
typeof row.due === "string" ? <DueChip priority="caution" label={row.due} /> : "In date",
},
];
const CREW: readonly Record<string, unknown>[] = [
{ name: "Mathilde Kerouac", role: "Skipper", watch: "00:00–02:00", due: null },
{ name: "Jonas Verbeek", role: "Mate", watch: "02:00–04:00", due: "Medical due in 12 days" },
{ name: "Sofia Marín", role: "Crew", watch: "04:00–06:00", due: null },
];
/**
* Meters from where the anchor was dropped: the vessel swings 22 to 30 m on the rode, and a gust drags her up to
* 17 m further before the anchor bites again.
*/
function distanceAt(second: number): number {
const loopSecond: number = ((second % LOOP_SECONDS) + LOOP_SECONDS) % LOOP_SECONDS;
const swing: number = 26 + 4 * Math.sin((2 * Math.PI * loopSecond) / 40);
const drag: number = loopSecond >= 5 && loopSecond <= 95 ? 17 * Math.sin((Math.PI * (loopSecond - 5)) / 90) : 0;
return swing + drag;
}
/** The loop seconds at which the vessel leaves the swing radius and comes back inside it by the clear margin. */
function dragWindow(radius: number): { readonly fires: number; readonly clears: number } {
let fires: number = LOOP_SECONDS;
let clears: number = LOOP_SECONDS;
for (let second: number = 0; second < LOOP_SECONDS; second++) {
const distance: number = distanceAt(second);
if (fires === LOOP_SECONDS && distance > radius) {
fires = second;
}
if (fires !== LOOP_SECONDS && clears === LOOP_SECONDS && distance <= radius - CLEAR_MARGIN_M) {
clears = second;
}
}
return { fires, clears };
}
function windAt(second: number): number {
return 22 + 3 * Math.sin(second / 13) + (second % 11 < 2 ? 5 : 0);
}
/** The depth under the keel on a falling tide: tenths below 10 m, rounded down so it never overstates the water. */
function depthAt(second: number): number {
return Math.floor((6.9 - second / 600 + 0.15 * Math.sin(second / 7)) * 10) / 10;
}
function barometerAt(second: number): number {
return 1004.6 - second / 900;
}
function trendOf(earlier: number, now: number, band: number): ReadingTrend {
if (now - earlier > band) {
return "rising";
}
return earlier - now > band ? "falling" : "steady";
}
function utcTime(epochMilliseconds: number): string {
return new Date(epochMilliseconds).toISOString().slice(11, 16);
}
function useSeconds(): number {
const [seconds, setSeconds]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
useEffect((): (() => void) => {
const timer: ReturnType<typeof setInterval> = setInterval((): void => {
setSeconds((current: number): number => current + 1);
}, 1000);
return (): void => {
clearInterval(timer);
};
}, []);
return seconds;
}
interface Saved<Value> {
readonly value: Value;
readonly state: SaveState;
}
/** A setting whose save comes back from the vessel after SAVE_MS; the timer stops when the screen unmounts. */
function useSetting<Value>(initial: Value): [Saved<Value>, (next: Value) => void] {
const [setting, setSetting]: [Saved<Value>, Dispatch<SetStateAction<Saved<Value>>>] = useState<Saved<Value>>({
value: initial,
state: "idle",
});
const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect((): (() => void) => {
return (): void => {
clearTimeout(timer.current);
};
}, []);
function save(next: Value): void {
setSetting({ value: next, state: "saving" });
clearTimeout(timer.current);
// Stands in for the PUT to the vessel's anchor settings.
timer.current = setTimeout((): void => {
setSetting({ value: next, state: "saved" });
}, SAVE_MS);
}
return [setting, save];
}
const SCREEN_STYLES: string = `
.mobile-screen {
width: min(100%, 390px);
margin-inline: auto;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
border: 1px solid var(--border-bright);
border-radius: var(--radius-xl);
overflow: clip;
}
/* On a declared screen (a device preview, or the hero narrowed) the frame is that screen, so a phone on its side
shows the app 852 px wide rather than a 390 px column in the middle of it. */
@container bk-screen (min-width: 0px) {
.mobile-screen {
width: 100%;
}
}
.mobile-screen__content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.mobile-screen__panel {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.mobile-screen__trend {
display: grid;
gap: var(--space-xs);
}
`;
export default function Showcase(): ReactElement {
const seconds: number = useSeconds();
const [openedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
const [tab, setTab]: [string, Dispatch<SetStateAction<string>>] = useState<string>("situation");
const [moreOpen, setMoreOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [radius, saveRadius]: [Saved<string>, (next: string) => void] = useSetting<string>(
String(DEFAULT_SWING_RADIUS_M),
);
const [watch, saveWatch]: [Saved<boolean>, (next: boolean) => void] = useSetting<boolean>(true);
// The loop and second at which the crew pressed Acknowledge here; bosund's next board snapshot confirms it.
const [acknowledgeRequest, setAcknowledgeRequest]: [
{ readonly loop: number; readonly second: number } | null,
Dispatch<SetStateAction<{ readonly loop: number; readonly second: number } | null>>,
] = useState<{ readonly loop: number; readonly second: number } | null>(null);
const loop: number = Math.floor(seconds / LOOP_SECONDS);
const loopSecond: number = seconds % LOOP_SECONDS;
const swingRadius: number =
Number.parseFloat(radius.value) > 0 ? Number.parseFloat(radius.value) : DEFAULT_SWING_RADIUS_M;
const dragTimes: { readonly fires: number; readonly clears: number } = dragWindow(swingRadius);
const distance: number = distanceAt(seconds);
const wind: number = windAt(seconds);
const depth: number = depthAt(seconds);
const barometer: number = barometerAt(seconds);
const dragActive: boolean = watch.value && loopSecond >= dragTimes.fires && loopSecond < dragTimes.clears;
const requestedThisLoop: boolean = acknowledgeRequest !== null && acknowledgeRequest.loop === loop;
const acknowledged: boolean =
(requestedThisLoop && loopSecond > (acknowledgeRequest?.second ?? LOOP_SECONDS)) ||
loopSecond >= dragTimes.fires + SALOON_ACKNOWLEDGES_AFTER;
const acknowledging: boolean = requestedThisLoop && !acknowledged;
const unacknowledged: boolean = dragActive && !acknowledged;
const firedAt: number = openedAt + (loop * LOOP_SECONDS + dragTimes.fires) * 1000;
function acknowledge(): Promise<void> {
// Stands in for the POST to the board: the card shows acknowledged once the next snapshot says so.
setAcknowledgeRequest({ loop, second: loopSecond });
return Promise.resolve();
}
const zoneAlert: AlertZoneAlert | null = unacknowledged
? { id: DRAG_ID, title: "Anchor drag", priority: "alarm", raisedAt: firedAt }
: null;
const windCard: ReactElement = (
<AlertCard
key="anchoring-wind"
priority="warning"
title="Wind above the anchoring limit"
acknowledged
time={utcTime(openedAt - 40 * 60_000)}
timeReference="UTC"
message="True wind is above 20 kn, the limit set for this anchorage, and the forecast holds it until 06:00."
figures={[{ label: "True wind", value: `${wind.toFixed(0)} kn` }]}
/>
);
const dragCard: ReactElement = (
<AlertCard
key={DRAG_ID}
priority="alarm"
title="Anchor drag"
immediate
acknowledged={acknowledged}
time={utcTime(firedAt)}
timeReference="UTC"
message={
`Althea is ${distance.toFixed(0)} m from where the anchor was dropped, outside the ` +
`${swingRadius.toFixed(0)} m swing radius.`
}
advisory={["Start the engine and take the strain off the rode.", "Wake the skipper."]}
figures={[
{ label: "From drop", value: `${distance.toFixed(0)} m` },
{ label: "Radius", value: `${swingRadius.toFixed(0)} m` },
]}
actions={
<Button
variant="primary"
size="lg"
disabled={acknowledged || acknowledging}
onClick={(): void => {
void acknowledge();
}}
>
{acknowledging ? "Acknowledging" : acknowledged ? "Acknowledged" : "Acknowledge"}
</Button>
}
/>
);
const distancePriority: InstrumentPillPriority | undefined = dragActive
? "alarm"
: distance > swingRadius - 5
? "warning"
: undefined;
return (
<div className="mobile-screen">
<style href="bosunkit-composition-mobile" precedence="default">
{SCREEN_STYLES}
</style>
<AppShell
header={
<HeaderBar
logo={<Logo size="sm" showTagline={false} />}
title="Home"
alertZone={
<AlertZone
count={unacknowledged ? 1 : 0}
alert={zoneAlert}
acknowledgeAction={acknowledge}
boardHref="#mobile-most-urgent"
/>
}
statusStrip={
<StatusStrip
connection="live"
lastEventAt={openedAt + seconds * 1000}
activity="anchored"
sensors={SENSORS}
risk={null}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={unacknowledged ? [DRAG_ID] : []}
/>
}
/>
}
navigation={<NavList groups={PAGES} currentHref="#mobile-home" headingLevel={2} />}
menuOpen={moreOpen}
onMenuOpenChange={setMoreOpen}
navigationBar={
<NavigationBar
current="home"
items={[
{ id: "home", label: "Home", icon: <House />, href: "#mobile-home" },
{ id: "chart", label: "Chart", icon: <MapIcon />, href: "#mobile-chart" },
{
id: "alerts",
label: "Alerts",
icon: <TriangleAlert />,
href: "#mobile-most-urgent",
...(unacknowledged
? { count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" as const } }
: {}),
},
{ id: "chat", label: "Chat", icon: <MessageSquare />, href: "#mobile-chat" },
{
id: "more",
label: "More",
icon: <Menu />,
expanded: moreOpen,
onSelect: (): void => {
setMoreOpen(!moreOpen);
},
} satisfies NavigationBarItem,
]}
/>
}
main={
<div className="mobile-screen__content">
<div id="mobile-most-urgent">{dragActive ? dragCard : windCard}</div>
<InstrumentPillGroup label="Anchor watch">
<InstrumentPill
label="From drop"
value={distance.toFixed(0)}
unit="m"
priority={distancePriority}
trend={trendOf(distanceAt(seconds - 3), distance, 0.5)}
/>
<InstrumentPill
label="Depth"
value={depth.toFixed(1)}
unit="m"
trend={trendOf(depthAt(seconds - 30), depth, 0.05)}
/>
<InstrumentPill
label="True wind"
value={wind.toFixed(0)}
unit="kn"
priority={wind > 25 ? "warning" : undefined}
trend={trendOf(windAt(seconds - 3), wind, 1)}
/>
<InstrumentPill label="Barometer" value={barometer.toFixed(0)} unit="hPa" trend="falling" />
</InstrumentPillGroup>
<WeatherBar
current={{
description: "Rain",
wind: `${wind.toFixed(0)} kn, gusts 31 kn`,
conditions: "falling barometer",
weatherCode: 61,
windFromDeg: 225,
}}
forecasts={[
{ period: "+6H", wind: "20–26 kn", conditions: "rain" },
{ period: "+12H", wind: "14–20 kn", conditions: "clearing" },
]}
temperature="14 °C"
pressure={`${barometer.toFixed(0)} hPa`}
seaStateText="0.6 m"
days={DAYS}
model={{ name: "ICON-EU", ageHours: 3, confidence: 0.8 }}
limits={{ reefWindKn: 20 }}
/>
<Tabs tabs={TABS} value={tab} onValueChange={setTab}>
<Tabs.Panel value="situation">
<div className="mobile-screen__panel">
<DescriptionList
layout="stacked"
items={[
{ term: "Anchored at", value: formatPosition(POSITION) },
{ term: "Since", value: `${utcTime(openedAt - 5 * 3_600_000)} UTC` },
{ term: "Rode out", value: "30 m of chain in 6 m" },
]}
/>
<Reading
direction={ReadoutDirection.horizontal}
label="Depth"
value={depth}
unit="m"
source="Sounder"
fractionDigits={1}
maxDigits={2}
/>
<Reading
direction={ReadoutDirection.horizontal}
label="TWS"
value={wind}
unit="kn"
source="Masthead"
fractionDigits={0}
maxDigits={2}
/>
<AlertCardGrid>
<SectionDivider key="monitoring" label="Monitoring" variant="monitoring" />
{dragActive ? windCard : null}
<AlertCard
key="house"
priority="caution"
title="House bank at 61%"
acknowledged
time={utcTime(openedAt - 95 * 60_000)}
timeReference="UTC"
message="The anchor light and the fridge draw 3.1 A; the bank reaches 50% at about 07:00."
/>
</AlertCardGrid>
</div>
</Tabs.Panel>
<Tabs.Panel value="anchor">
<div className="mobile-screen__panel">
<div className="mobile-screen__trend">
<Reading
direction={ReadoutDirection.horizontal}
label="From drop"
value={distance}
unit="m"
source="GPS"
fractionDigits={0}
maxDigits={2}
/>
<Sparkline
data={Array.from({ length: 90 }, (_: unknown, index: number): number =>
distanceAt(seconds - 89 + index),
)}
width="fill"
height={40}
area
unit="m"
referenceLines={[
{ value: swingRadius, label: `RADIUS ${swingRadius.toFixed(0)} M`, priority: "alarm" },
]}
label="Distance from the anchor, last 90 seconds"
/>
</div>
<SettingToggle
label="Anchor watch"
description="Raise the anchor drag alarm when Althea leaves the swing radius."
checked={watch.value}
state={watch.state}
onCheckedChange={saveWatch}
/>
<SettingField
label="Swing radius"
description="The rode out plus the vessel's length, measured from the drop position."
value={radius.value}
state={radius.state}
onSave={saveRadius}
type="number"
unit="m"
maxDigits={3}
/>
</div>
</Tabs.Panel>
<Tabs.Panel value="crew">
<div className="mobile-screen__panel">
<DataTable
columns={CREW_COLUMNS}
rows={CREW}
rowKey={(row: Record<string, unknown>): string => String(row.name)}
/>
</div>
</Tabs.Panel>
</Tabs>
</div>
}
/>
</div>
);
}
function ignore(): void {
// The screen changes nothing on these taps.
}
The mobile screen is the app on a phone carried to the mast or kept in the cabin at anchor: one column, about
390 px wide, read in short glances and worked with a thumb. It follows how ui/web lays out under 900 px
(packages/bosun/ui/web/src/app.css): the sidebar gives way to a tab bar at the foot, the status strip drops its
captions, and the alert card and the instrument pills fill the width. Its data is the same board stream as the
other screens: board and board_meta for the alert, situation for the pills and readings, weather for the
weather bar.
Built from
| Part | Page | Why it is there |
|---|---|---|
AppShell | App shell | The screen's frame and its bk-screen: the header bar at the top, the page, and at this width the tab bar at the foot, with the pages behind More opening above the content. |
HeaderBar | Header bar | The logo and the status strip on one row and the alert zone on its own row under them, the phone form of the bar; the page's name is its h1, kept for a screen reader. |
Logo | Logo | The small mark at the start of the header, without its tagline. |
AlertZone | Alert zone | The unacknowledged alarm in the header, with ACK, above everything that scrolls. |
StatusStrip | Status strip | The stream, the vessel at anchor, the sensors and the UTC clock. |
AlertCard | Alert card | The most urgent alert, on top: the drag alarm with its distance, radius and advisory, or the wind warning. |
Button | Button | Acknowledge at the large size. |
NavigationBar | Navigation bar | Home, Chart, Alerts with the unacknowledged alarm counted, Chat, and More, which opens the other pages. |
NavList | Navigation list | Every page, grouped At anchor, Passage and Aboard: the menu behind More on a phone, and the rail beside the page on a phone on its side. |
InstrumentPill, InstrumentPillGroup | Instrument pill, Instrument pill group | Distance from the anchor, depth, true wind and barometer under Anchor watch, in as many even columns as the frame fits: two on a phone. |
WeatherBar | Weather bar | The weather now on one line, with the forecast days behind its control. |
Tabs | Tabs | Situation, Anchor and Crew, each tab naming the panel it shows. |
DescriptionList | Description list | Where the anchor is, since when and how much rode is out. |
Reading | Reading | Depth and true wind speed with their sources, on the Situation tab; the distance on the Anchor tab. |
AlertCardGrid, SectionDivider | Alert card grid, Section divider | The acknowledged alerts under Monitoring on the Situation tab. |
Sparkline | Sparkline | The distance from the anchor over the last 90 seconds, as wide as the panel, with the swing radius as an alarm line. |
SettingToggle, SettingField | Setting control | The anchor watch and the swing radius, each saved to the vessel with its save chip. |
DataTable | Data table | The crew aboard, their anchor watches and their documents. |
DueChip | Due chip | A crew document that falls due soon. |
formatPosition | Position input | The anchor's position in chart notation. |
Layout
The frame is min(100%, 390px) wide in the page, and as wide as the screen in a device preview or a narrowed hero.
It holds an app shell, which is the screen's bk-screen and lays out its phone form at
390 px: the header bar, which stays at the top as the content scrolls, the content, and the tab bar at the foot. The
header bar puts the logo and the status strip on one row and the alert zone on a row of its own under them. More
opens every page above the content, and following one closes it. The content stacks the top alert card, the pill
group, the weather bar, the tabs and the selected tab's panel, with the spacing scale's middle step (--space-md)
between them.
On a phone on its side the screen is 852 px wide and 393 px tall. The shell takes its tablet form there: the tab bar gives way to a rail of every page beside the content, which keeps the screen's height for the content, and the pills stand four in a row.
ui/web switches to this layout on the window's width: under 900 px the sidebar hides and the status strip drops its brilliance control, date, time zone and risk chip; under 640 px it drops its connection and do-not-disturb words. The kit's components follow the width of the screen they sit in, so this 390 px frame shows their phone forms on any display: the status strip drops those captions and wraps onto a second row, the alert zone's ACK, the advisory toggle and every button are 48 px targets, and the card grid holds one column.
What the crew sees first
- The alert zone in the header, with the alarm's ▲ shape, title, age and ACK, flashing until acknowledged.
- The alarm's card, first in the content: what happened ("Althea is 41 m from where the anchor was dropped, outside the 35 m swing radius"), the figures, the response advisory behind its toggle, and Acknowledge. It shows Acknowledging until the board confirms, and the zone clears only then.
- The pills, where the distance from the anchor turns to the alarm state while the vessel is outside the radius, and the warning state within 5 m of it.
A cleared alarm leaves the top of the screen to the next worst alert, the acknowledged wind warning, so the top card always says what matters most now.
Theme and size
The screen takes the site's theme: Night, Dusk, Day or Bright from the theme switch. It sets no size class of its
own, so it renders at the default size, which is the phone's; the helm size is for a fixed display read from the
helm seat. Touch targets come from the components: on this narrow screen every button, the alert zone's ACK and the
advisory toggle are --target-touch-size tall, and the status strip's chips and the tabs grow to it under a coarse
pointer.
Kit gaps
The screen has none: every part of it is a kit component.
Related
- Alert card: the card on top of the screen, with its stats and advisory.
- Instrument pill: the pill's states and how it marks an update.
- Setting control: the save chip each anchor setting shows.
- Conning: the helm screen the phone shares its parts with.