Skip to content
design

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
At anchor in Morgat, the phone in the cabin. The distance from the anchor swings with the gusts; 30 seconds in, Althea leaves the 35 m swing radius and an anchor drag alarm takes the top of the screen. Press Acknowledge, or the saloon display acknowledges it 35 seconds later; it clears when the anchor bites again. Set the swing radius to 50 m on the Anchor tab and the alarm does not fire.
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.

01The worst alert firstThe most urgent active alert is the first card on the screen, above the readings. With nothing unacknowledged, the top card is the worst acknowledged one.
02A thumb, not a pointerAcknowledge and the tab bar at the foot are large buttons, 48 px tall, for a thumb on a moving vessel. The weather bar opens its detail only when asked.
03One column, in order of useAlert, pills, weather, then tabs for the situation, the anchor and the crew. Nothing sits side by side that a phone would squeeze.

Built from

PartPageWhy it is there
AppShellApp shellThe 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.
HeaderBarHeader barThe 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.
LogoLogoThe small mark at the start of the header, without its tagline.
AlertZoneAlert zoneThe unacknowledged alarm in the header, with ACK, above everything that scrolls.
StatusStripStatus stripThe stream, the vessel at anchor, the sensors and the UTC clock.
AlertCardAlert cardThe most urgent alert, on top: the drag alarm with its distance, radius and advisory, or the wind warning.
ButtonButtonAcknowledge at the large size.
NavigationBarNavigation barHome, Chart, Alerts with the unacknowledged alarm counted, Chat, and More, which opens the other pages.
NavListNavigation listEvery 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, InstrumentPillGroupInstrument pill, Instrument pill groupDistance from the anchor, depth, true wind and barometer under Anchor watch, in as many even columns as the frame fits: two on a phone.
WeatherBarWeather barThe weather now on one line, with the forecast days behind its control.
TabsTabsSituation, Anchor and Crew, each tab naming the panel it shows.
DescriptionListDescription listWhere the anchor is, since when and how much rode is out.
ReadingReadingDepth and true wind speed with their sources, on the Situation tab; the distance on the Anchor tab.
AlertCardGrid, SectionDividerAlert card grid, Section dividerThe acknowledged alerts under Monitoring on the Situation tab.
SparklineSparklineThe distance from the anchor over the last 90 seconds, as wide as the panel, with the swing radius as an alarm line.
SettingToggle, SettingFieldSetting controlThe anchor watch and the swing radius, each saved to the vessel with its save chip.
DataTableData tableThe crew aboard, their anchor watches and their documents.
DueChipDue chipA crew document that falls due soon.
formatPositionPosition inputThe 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

  1. The alert zone in the header, with the alarm's ▲ shape, title, age and ACK, flashing until acknowledged.
  2. 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.
  3. 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.

Do
Anchor drag
Althea is 41 m from where the anchor was dropped, outside the 35 m swing radius.
Acknowledged
Give Acknowledge the large button on a phone, under the alert it names. The target is 48 px tall, for a thumb on a moving vessel.
Don't
Anchor drag
Althea is 41 m from where the anchor was dropped, outside the 35 m swing radius.
Acknowledged
Don't shrink the action to fit the card. A small button is hard to hit with a thumb and invites a second, wrong tap.
  • 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.