Skip to content
design
Components

Status strip

The global status strip, with the stream's state, the vessel's activity, the sensors' integrity, risk, do not disturb, theme and brightness, and the UTC clock.

On this page
The strip underway on a live script: the stream connects, runs and drops, the sensors span all four integrity states, the risk is elevated and worsening, the night theme is suggested and do not disturb counts what it holds back.
Code
import { StatusStrip, type SensorSummary, type StreamConnection } from "@bosunkit/components/status-strip";
import { BosunkitProvider, PresentationScope, useThemePreference } from "@bosunkit/provider";
import { DEFAULT_THEME_STATE, type Theme, type ThemeState, type ThemeStore } from "@bosunkit/tokens/theme";
import { useEffect, useState, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react";

export const description: string =
  "The strip underway at night on a live script: the stream connects, runs and drops, the sensors span all four " +
  "integrity states, the risk is elevated and worsening, the night theme is suggested, and do not disturb counts " +
  "what it holds back.";

export const readsClock: boolean = true;

/**
 * A theme store for this example alone: the night suggestion and the brightness menu change the example's theme and
 * brightness, not the page's. An application passes none, and the strip changes the application's theme.
 */
function createExampleThemeStore(): ThemeStore {
  let state: ThemeState = DEFAULT_THEME_STATE;
  const listeners: Set<() => void> = new Set<() => void>();
  function update(next: Partial<ThemeState>): void {
    state = { ...state, ...next };
    for (const listener of listeners) {
      listener();
    }
  }
  return {
    get: (): ThemeState => state,
    getServerSnapshot: (): ThemeState => DEFAULT_THEME_STATE,
    subscribe: (listener: () => void): (() => void) => {
      listeners.add(listener);
      return (): void => {
        listeners.delete(listener);
      };
    },
    setPreference: (preference: ThemeState["preference"]): void => {
      update({ preference });
    },
    setBrightness: (percent: number): void => {
      update({ brightness: Math.min(100, Math.max(0, percent)) });
    },
    setSizeClass: (sizeClass: ThemeState["sizeClass"]): void => {
      update({ sizeClass });
    },
  };
}

/** Where the example's stream is: connecting for two seconds, then live and reconnecting in turn. */
const CONNECTING_SECONDS: number = 2;
const LIVE_SECONDS: number = 12;
const RECONNECTING_SECONDS: number = 9;

/** How often an alert arrives while do not disturb is on, and how often a new unacknowledged one appears. */
const HELD_BACK_EVERY_SECONDS: number = 7;
const NEW_ALERT_EVERY_SECONDS: number = 30;

function connectionAt(seconds: number): StreamConnection {
  if (seconds < CONNECTING_SECONDS) {
    return "connecting";
  }
  return (seconds - CONNECTING_SECONDS) % (LIVE_SECONDS + RECONNECTING_SECONDS) < LIVE_SECONDS
    ? "live"
    : "reconnecting";
}

/** The second of the stream's last event: this one while live, the last live one while reconnecting. */
function lastEventSecond(seconds: number): number | null {
  if (seconds < CONNECTING_SECONDS) {
    return null;
  }
  const cycle: number = LIVE_SECONDS + RECONNECTING_SECONDS;
  const intoCycle: number = (seconds - CONNECTING_SECONDS) % cycle;
  return intoCycle < LIVE_SECONDS ? seconds : seconds - intoCycle + LIVE_SECONDS - 1;
}

function sensorsAt(seconds: number): readonly SensorSummary[] {
  return [
    { name: "gps", integrity: "fresh", ageSeconds: 1 },
    { name: "compass", integrity: "degraded", reason: "heading frozen" },
    { name: "depth", integrity: "stale", ageSeconds: 95 + seconds },
    { name: "wind", integrity: "invalid", reason: "no data from WS320" },
    { name: "speed", integrity: "fresh", ageSeconds: 1 },
    { name: "ais", integrity: "fresh", ageSeconds: 2 },
  ];
}

/** The example in the theme its store holds; auto leaves it in the page's theme. */
function ThemedByStore({ children }: { children: ReactNode }): ReactElement {
  const { preference }: ThemeState = useThemePreference();
  const theme: Theme | undefined = preference === "auto" ? undefined : preference;
  return <PresentationScope theme={theme}>{children}</PresentationScope>;
}

export default function Showcase(): ReactElement {
  const [themeStore]: [ThemeStore, Dispatch<SetStateAction<ThemeStore>>] =
    useState<ThemeStore>(createExampleThemeStore);
  const [startedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  const [seconds, setSeconds]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
  // The second do not disturb was turned on, or null while it is off.
  const [dndSince, setDndSince]: [number | null, Dispatch<SetStateAction<number | null>>] = useState<number | null>(
    null,
  );
  const [suggestNight, setSuggestNight]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  useEffect((): (() => void) => {
    const timer: ReturnType<typeof setInterval> = setInterval((): void => {
      setSeconds((current: number): number => current + 1);
    }, 1000);
    return (): void => {
      clearInterval(timer);
    };
  }, []);

  const connection: StreamConnection = connectionAt(seconds);
  const lastSecond: number | null = lastEventSecond(seconds);
  const missed: number = dndSince === null ? 0 : Math.floor((seconds - dndSince) / HELD_BACK_EVERY_SECONDS);
  // One alert is unacknowledged from the start, and another arrives every NEW_ALERT_EVERY_SECONDS.
  const alertIds: string[] = Array.from(
    { length: 1 + Math.floor(seconds / NEW_ALERT_EVERY_SECONDS) },
    (_: unknown, index: number): string => `alert-${String(index)}`,
  );

  return (
    <BosunkitProvider themeStore={themeStore}>
      <ThemedByStore>
        <div style={{ width: "100%", background: "var(--bg)" }}>
          <StatusStrip
            connection={connection}
            lastEventAt={lastSecond === null ? null : startedAt + lastSecond * 1000}
            activity="underway"
            sensors={sensorsAt(seconds)}
            risk={{ level: "elevated", trend: "worsening" }}
            doNotDisturb={{
              enabled: dndSince !== null,
              missed,
              onToggle: (): void => {
                setDndSince(dndSince === null ? seconds : null);
              },
            }}
            nightSuggested={suggestNight}
            onDismissSuggestion={(): void => {
              setSuggestNight(false);
            }}
            unacknowledgedAlertIds={alertIds}
          />
        </div>
      </ThemedByStore>
    </BosunkitProvider>
  );
}

The status strip is the app's global strip. It shows the live data stream's state, the vessel's activity, the sensors' integrity, the risk, do not disturb, the night suggestion, theme and brightness, and the UTC clock, as quiet chips whose chrome stays dim (HMI rule 6). While the stream is not live, it counts the seconds since the stream's last event from its own clock, the clock that also drives its UTC time, so the two tick together. Underway, it asks for a second tap before do not disturb silences alerts.

Examples

Live

Underway with the stream live and every sensor fresh, so no chip asks for attention.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string = "Underway with the stream live and every sensor fresh, so no chip asks for attention.";

export const readsClock: boolean = true;

const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
  (name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);

function ignore(): void {
  // The example changes nothing on a tap.
}

export default function Live(): ReactElement {
  const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  return (
    <StatusStrip
      connection="live"
      lastEventAt={lastEventAt}
      activity="underway"
      sensors={SENSORS}
      risk={{ level: "normal", trend: "stable" }}
      doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
      nightSuggested={false}
      onDismissSuggestion={ignore}
      unacknowledgedAlertIds={[]}
    />
  );
}

Reconnecting

The stream reconnecting since its last event 12 seconds ago, two sensors stale, and the risk high and worsening.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The stream reconnecting since its last event 12 seconds ago, two sensors stale, and the risk high and worsening.";

export const readsClock: boolean = true;

const SENSORS: readonly SensorSummary[] = [
  { name: "gps", integrity: "fresh", ageSeconds: 1 },
  { name: "compass", integrity: "fresh", ageSeconds: 1 },
  { name: "depth", integrity: "stale", ageSeconds: 190 },
  { name: "wind", integrity: "stale", reason: "no wind reading for 45 seconds", ageSeconds: 45 },
  { name: "speed", integrity: "fresh", ageSeconds: 1 },
];

function ignore(): void {
  // The example changes nothing on a tap.
}

export default function Reconnecting(): ReactElement {
  const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>(
    (): number => Date.now() - 12_000,
  );
  return (
    <StatusStrip
      connection="reconnecting"
      lastEventAt={lastEventAt}
      activity="underway"
      sensors={SENSORS}
      risk={{ level: "high", trend: "worsening" }}
      doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
      nightSuggested={false}
      onDismissSuggestion={ignore}
      unacknowledgedAlertIds={[]}
    />
  );
}

Do not disturb

Do not disturb on at anchor, with the count of the two alerts it has held back.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string = "Do not disturb on at anchor, with the count of the two alerts it has held back.";

export const readsClock: boolean = true;

const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
  (name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);

function ignore(): void {
  // The example changes nothing on a tap.
}

export default function DoNotDisturb(): ReactElement {
  const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  return (
    <StatusStrip
      connection="live"
      lastEventAt={lastEventAt}
      activity="anchored"
      sensors={SENSORS}
      risk={{ level: "normal", trend: "stable" }}
      doNotDisturb={{ enabled: true, missed: 2, onToggle: ignore }}
      nightSuggested={false}
      onDismissSuggestion={ignore}
      unacknowledgedAlertIds={[]}
    />
  );
}

On a narrow screen

The strip follows the width of the screen it sits in, not the window's. A page, an app's main area or a phone frame declares itself the screen with container: bk-screen / inline-size; the strip is as wide as its chips, so it cannot measure itself. On a tablet, a screen up to the 68.75em breakpoint (1,100 px at 16 px), it drops the theme caption, the date, the time zone and the risk chip, and keeps the brightness control and the clock. On a phone, up to the 40em breakpoint (640 px), it also wraps onto a second row and drops the words beside the stream's dot and do not disturb. Outside a declared screen it keeps its wide form. Its chips grow to the touch target under a coarse pointer, whatever the screen.

A 360 px screen: the strip drops its date, time zone, theme and stream captions, and wraps onto a second row.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A 360 px screen: the strip drops its date, time zone, theme and stream captions, and wraps onto a second row.";

export const readsClock: boolean = true;

const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed"].map(
  (name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);

function ignore(): void {
  // The example changes nothing on a tap.
}

export default function OnANarrowScreen(): ReactElement {
  const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  return (
    <div style={{ container: "bk-screen / inline-size", width: "100%", maxWidth: 360 }}>
      <StatusStrip
        connection="live"
        lastEventAt={lastEventAt}
        activity="anchored"
        sensors={SENSORS}
        risk={{ level: "normal", trend: "stable" }}
        doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
        nightSuggested={false}
        onDismissSuggestion={ignore}
        unacknowledgedAlertIds={[]}
      />
    </div>
  );
}

Without motion

Reset in the brilliance menu restores 50% brightness and keeps the theme, so at night the strip stays in Night. Holding the brightness chip restores full brightness. Both changes persist in the browser's local storage.

The sensors and brightness menus drop in from their chip over the medium motion duration, and only fade under reduced motion. They close at once: a new alert closes the brightness menu, which on a phone opens over the alert zone's row, and no exit may keep it covering the alert. motion={false} opens them at once too.

With `motion={false}` the sensors and brightness menus open at once; by default they drop in from their chip.
Code
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "With `motion={false}` the sensors and brightness menus open at once; by default they drop in from their chip.";

export const readsClock: boolean = true;

const SENSORS: readonly SensorSummary[] = [
  { name: "gps", integrity: "fresh", ageSeconds: 1 },
  { name: "compass", integrity: "fresh", ageSeconds: 1 },
  { name: "depth", integrity: "stale", ageSeconds: 42 },
];

function ignore(): void {
  // The example changes nothing on a tap.
}

export default function WithoutMotion(): ReactElement {
  const [lastEventAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  return (
    <StatusStrip
      connection="live"
      lastEventAt={lastEventAt}
      activity="underway"
      sensors={SENSORS}
      risk={null}
      doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
      nightSuggested={false}
      onDismissSuggestion={ignore}
      unacknowledgedAlertIds={[]}
      motion={false}
    />
  );
}

Props

PropTypeDefaultDescription
connectionrequiredStreamConnection–The live data stream's state.
lastEventAtrequirednumber | null–When the stream's last event arrived, in epoch milliseconds; null while connecting with nothing received yet. While the stream is not live, the strip counts the whole seconds since then from its own clock.
activityrequiredstring | null–The vessel's activity (underway, anchored, moored); null or unknown hides the chip.
sensorsrequiredreadonly SensorSummary[]–The data-integrity instruments, in the order the sensors list shows them.
riskrequiredStatusStripRisk | null–The vessel's risk and its trend; null when bosund has not reported one.
doNotDisturbrequiredStatusStripDoNotDisturb–Do not disturb.
nightSuggestedrequiredboolean–Whether to suggest the night theme: it is night and the crew have not dismissed the suggestion today. The strip also hides it while the night theme is on.
onDismissSuggestionrequired() => void–Dismisses the night suggestion for the rest of the day.
unacknowledgedAlertIdsrequiredreadonly string[]–The ids of the unacknowledged alerts; a new one closes the brightness menu, so no alert arrives under it.
classNamestring | undefined–Classes added to the strip's own.
motionboolean | undefinedtrueWhether the sensors and brightness menus drop in from their chips when they open: on by default; they only fade under reduced motion. They close at once, so an alert arriving under the brightness menu is never covered by an exit. false opens them at once.
refRef<HTMLDivElement> | undefined–The strip's root element.