/* WattShip design system - adaptation layer for this app's existing markup.
   Not part of the upstream package: the package assumes its own Django partials
   (ds/_sidebar.html and friends), while this app has a grown SPA shell whose DOM
   and wiring (JS/shell.js, JS/shellNav.js) must stay exactly as it is. These
   rules dress that DOM in the system instead of replacing it. Loaded after components.css.
   Everything here is scoped to a concrete container (#sidebar, .ws-page), so a
   page that has not adopted the system is untouched. */

/* ── Shell sidebar ─────────────────────────────────────────────────────────
   The shell's named layer for the repeated part of the sidebar (#171): about 80
   page and category rows, their current and active state, the folds and the
   motion. One rule dresses every row alike, and the states are set by
   JS/shell.js and JS/shellNav.js rather than written into the markup, so they
   are said here once instead of in 80 class lists. The rows carry no colour
   utilities of their own, the icon paths only a tone (data-tone="deep" or
   "light"). The pieces that exist once (the collapse button, the wordmark,
   the two group headings) carry utilities in mosaicTheme.html. */
#sidebar{background:var(--ws-bg-sunken);border-right:1px solid var(--ws-line)}

/* Every sidebar link: the base layer underlines links, navigation must not be.
   Its colour fade is with the rest of the sidebar's motion below. */
#sidebar a{color:var(--ws-text-muted);border-bottom:0;text-decoration:none}
#sidebar a:hover{color:var(--ws-text);border-bottom:0}

/* Two-tone icons name each path's shade with data-tone; the lit icon a row
   swaps in has its own pair below. */
#sidebar svg:not([data-nav-icon="lit"]) [data-tone="deep"]{color:var(--ws-ink-400)}
#sidebar svg:not([data-nav-icon="lit"]) [data-tone="light"]{color:var(--ws-text-muted)}
/* On paper the pair turns over. ink-400 next to the muted ink-300 is only
   1.3:1 apart, so every icon read as one dark silhouette and its inner shapes
   (the chat bubble's dots, the gauge's bars, the grid's tiles) were lost. The
   deep shade takes the pale ink-100 instead, about as far from the paper as
   ink-400 is from the night sidebar, and the light shade keeps the labels'
   colour, as it does at night. The active rules below still win. */
:root:not(.dark) #sidebar svg:not([data-nav-icon="lit"]) [data-tone="deep"]{color:var(--ws-ink-100)}

/* Active item. Two mechanisms mark it and both have to be caught: the router
   adds .is-active to the anchor of a page row, while JS/shellNav.js adds
   .is-current to the <li> of a row with an icon that holds the page shown -
   a page row like Welcome AND a collapsible category row, which has no
   data-url at all. (Until #133 the latter was Alpine's literal bg-slate-900,
   a dark navy block in the middle of a light sidebar.) */
/* The marker is an inset line, not a border: a 2px border took width from the
   collapsed 70px rail and cut every icon by about 2px on the right (#144). */
#sidebar li.is-current,
#sidebar li[data-url]:has(> a.is-active){background-color:var(--ws-accent-quiet);box-shadow:inset 2px 0 0 var(--ws-accent)}
#sidebar li.is-current > a{color:var(--ws-text)}
#sidebar li[data-url] > a.is-active{color:var(--ws-accent)}
#sidebar li[data-url] > a.is-active svg:not([data-nav-icon="lit"]) [data-tone="deep"]{color:var(--ws-cyan-700)}
#sidebar li[data-url] > a.is-active svg:not([data-nav-icon="lit"]) [data-tone="light"]{color:var(--ws-accent)}
#sidebar li.is-current svg:not([data-nav-icon="lit"]) [data-tone="light"]{color:var(--ws-accent)}
/* The lit icon: the deep shade (body) takes the accent, the light one (roof
   or frame) a quieter accent. */
#sidebar [data-nav-icon="lit"] [data-tone="deep"]{color:var(--ws-accent)}
#sidebar [data-nav-icon="lit"] [data-tone="light"]{color:color-mix(in srgb,var(--ws-accent) 55%,var(--ws-bg-sunken))}

/* ── Sidebar motion (#165) ─────────────────────────────────────────────────
   Short and straight, on the motion tokens: a new current row fades in, the
   icon swap cross-fades, a category's pages unfold in height and its chevron
   turns, labels and pages fade with the rail's width, and the drawer slides on
   the same beat. Colour, opacity, height and position only, never scale.
   .ws-nav-still (in the markup until JS/shellNav.js lifts it after the first
   navigation) and reduced motion switch all of it off: the state restored on
   load is painted, not played. */

/* Both icons stay in the row, the second drawn over the first (the markup
   has them in either order), and .is-lit says which shows. */
#sidebar [data-nav-icon] + [data-nav-icon]{margin-left:-1.5rem}
#sidebar [data-nav-row]:not(.is-lit) [data-nav-icon="lit"],
#sidebar [data-nav-row].is-lit [data-nav-icon="idle"]{opacity:0}

/* A fold is a one-row grid running between 0fr and 1fr; its list is hidden
   and inert once closed (shellNav.js). The gap above a list sits inside the
   fold, or a closed fold would keep it. */
#sidebar [data-nav-fold]{grid-template-rows:0fr}
#sidebar [data-nav-fold].is-open{grid-template-rows:1fr}
#sidebar [data-nav-fold] > *{min-height:0}
#sidebar [data-nav-fold].is-moving > *{overflow:hidden}
#sidebar [data-nav-list] > li:first-child{margin-top:.25rem}
#nav-more-list > li:first-child{margin-top:.75rem}

#sidebar.ws-nav-still{transition:none}
#sidebar:not(.ws-nav-still){transition-property:width,transform;transition-duration:var(--ws-dur-slow);
  transition-timing-function:var(--ws-ease)}
#sidebar:not(.ws-nav-still) li{transition:background-color var(--ws-dur) var(--ws-ease),box-shadow var(--ws-dur) var(--ws-ease)}
#sidebar:not(.ws-nav-still) a{transition:color var(--ws-dur) var(--ws-ease),background-color var(--ws-dur) var(--ws-ease)}
#sidebar:not(.ws-nav-still) [data-nav-icon]{transition:opacity var(--ws-dur) var(--ws-ease)}
#sidebar:not(.ws-nav-still) [data-nav-chevron]{transition:transform var(--ws-dur-slow) var(--ws-ease-out)}
#sidebar:not(.ws-nav-still) [data-nav-fold]{transition:grid-template-rows var(--ws-dur-slow) var(--ws-ease-out)}
#sidebar:not(.ws-nav-still) [class~="lg:sidebar-expanded:opacity-100"]{transition:opacity var(--ws-dur-slow) var(--ws-ease)}
@media (min-width:1024px){
  /* A category's pages fade with the rail's width instead of popping in
     after it (display joins the fade where the browser allows it). */
  body:not(.sidebar-expanded) #sidebar [data-nav-row] > [data-nav-fold]{opacity:0}
  #sidebar:not(.ws-nav-still) [data-nav-row] > [data-nav-fold]{
    transition-property:grid-template-rows,opacity,display;transition-duration:var(--ws-dur-slow);
    transition-timing-function:var(--ws-ease-out);transition-behavior:allow-discrete}
  @starting-style{
    body.sidebar-expanded #sidebar:not(.ws-nav-still) [data-nav-row] > [data-nav-fold]{opacity:0}
  }
}
@media (prefers-reduced-motion: reduce){
  #sidebar,#sidebar *{transition:none !important}
}


/* Map tooltips (#140). The package sets them on ink-900 in both themes but
   inks the text with --ws-text, which is ink-800 in the light theme: dark on
   dark, unreadable. On the fixed dark ground the text is the dark theme's
   foam in either theme. leaflet-theme.css loads after this file, so the
   rule names the container twice to win. */
.leaflet-container.ws-map .leaflet-tooltip{color:var(--ws-foam-500)}


/* ── Page surface ──────────────────────────────────────────────────────────
   .ws-page is the per-page opt-in: dot grid ground, system type, 24px rhythm. */
/* The raster runs across the whole page, not just under the content column.
   That is only compatible with tiles sitting on it because the column's left
   margin is snapped to the module below: a centred column's auto margin is
   whatever is left over, which is what used to push every tile corner off the
   grid by an arbitrary amount. */
.ws-page{min-height:100%;display:flex;flex-direction:column;position:relative;isolation:isolate;
  background-color:var(--ws-bg)}

/* The raster. A sticky child of the content column, not a background on the
   page: sticky holds it still while the content scrolls over it, and sitting
   inside the column anchors it horizontally to the box whose track boundaries
   it has to meet. Its pitch comes from ds/app-raster.js, which derives it from
   the column's own width; the module is only the fallback. The dots sit ON the
   track boundaries, so a tile edge continues into the dot column above and
   below it - hence the half-pitch shift. isolation on the page keeps the
   negative z-index behind the content without escaping into the shell. */
.ws-page__raster{position:sticky;top:0;height:100vh;z-index:-1;
  pointer-events:none;
  /* Lifted to the scrollport's top edge so it is already at the stick
     position and never rides along; --ws-raster-top is that distance,
     measured in ds/app-raster.js. The bottom margin gives the lift back
     so the element still contributes no height to the column. */
  margin-top:calc(-1 * var(--ws-raster-top, 0px));
  margin-bottom:calc(var(--ws-raster-top, 0px) - 100vh);
  /* Bleed out to the page edges: the dots cover the full width while the
     element still begins at the column, which is what the margins undo and the
     background-position puts back. --ws-raster-left is the distance from the
     page edge to the column's content origin, measured in ds/app-raster.js. */
  margin-left:calc(-1 * var(--ws-raster-left, 0px));
  margin-right:calc(-1 * var(--ws-raster-right, 0px));
  background-image:var(--ws-grid);
  background-size:var(--ws-raster-pitch, var(--ws-grid-size)) var(--ws-raster-pitch, var(--ws-grid-size));
  background-position:calc(var(--ws-raster-left, 0px) - var(--ws-raster-pitch, var(--ws-grid-size)) / 2) 0}

/* A page with an animated backdrop shows that instead; two fields of dots on
   top of each other is one too many. */
.ws-page--backdrop .ws-page__raster{display:none}
/* Four modules tall, so the page header is the same block on every page whether
   or not it carries a subtitle. This began as an attempt to put the first row of
   tiles on the raster vertically; that is no longer the goal, since only the
   vertical raster lines have to meet the tiles. It stays for the consistency. */
.ws-page__head{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--ws-space-5);
  box-sizing:border-box;min-height:calc(4 * var(--ws-grid-size));
  margin-bottom:var(--ws-grid-size);padding-bottom:var(--ws-space-4);border-bottom:1px solid var(--ws-line)}
.ws-page__title{font-family:var(--ws-font-display);font-size:var(--ws-size-h1);line-height:var(--ws-lh-h1);
  letter-spacing:var(--ws-ls-h1);font-weight:var(--ws-weight-bold);color:var(--ws-text);margin:0}
.ws-page__sub{font-size:var(--ws-size-small);line-height:var(--ws-lh-small);color:var(--ws-text-muted);margin:4px 0 0}
/* The content column. Its padding is one grid module and the gaps between tiles
   are another, so the first tile's edges sit exactly on a cell boundary and
   every tile after it that is a whole number of modules wide follows.
   Reading pages (a landing page, a listing) keep the column; a dense workspace
   with a map and charts takes .ws-page--wide and uses the screen. */
/* The gap between tiles is the module, not whatever Tailwind's gap-* resolves to.
   This app sets a 14px root, so gap-6 is 1.5rem = 21px and only looks like the
   24px module. With 21px gaps a 12-track grid divides into tracks of 98.75px and
   every tile after the first sits a fraction of a module off the raster - the
   drift is small per tile and unmistakable across a row. Pinning the gap is what
   makes the track arithmetic below come out whole.

   Which grids: those that actually hold tiles, plus the gap-6 spelling the app
   uses most. Not every grid, because these pages also use gap-1 to gap-4 inside
   a tile, for an icon beside a label or a row of chips, and those are not module
   distances and must not become one. Matching on the tiles rather than on a
   utility name is what catches the pages laid out with gap-4, which were off by
   5 to 9 pixels while a rule on gap-6 alone looked like it had worked.

   The third selector is for pages that wrap each tile in a col-span element, so
   the tile is a grandchild and the first selector never sees it. Restricting it
   to a grid directly inside the content column keeps it off the grids that sit
   inside a tile. */
.ws-page .grid:has(> .ws-tile),
.ws-page__inner > .grid:has(> [class*="col-span-"]),
.ws-page .gap-6{gap:var(--ws-tile-gap, var(--ws-grid-size))}

/* The column width is a reading decision again, not an arithmetic one: the
   raster derives its pitch from whatever width comes out, so no width is wrong
   any more. Narrow for a landing page, the default for a listing, and the wide
   variant below for a workspace that wants the screen. */
.ws-page__inner{flex:1 1 auto;width:100%;
  max-width:calc(var(--ws-maxw-content) + 2 * var(--ws-grid-size));
  margin-inline:auto;padding:var(--ws-grid-size)}
.ws-page--narrow > .ws-page__inner{max-width:calc(50 * var(--ws-grid-size) + 2 * var(--ws-grid-size))}
/* A dense workspace uses the whole screen, and keeps its raster: the pitch is
   derived from the column, so full width costs nothing any more. */
.ws-page--wide > .ws-page__inner{max-width:none;width:100%}


.ws-page__section{margin-bottom:var(--ws-space-8)}
.ws-page__section-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--ws-space-3);margin-bottom:var(--ws-space-4)}

/* A tile the whole of which is a control. The package has no interactive tile;
   the app has plenty, and they must read as pressable without a shadow. */
.ws-tile--interactive{display:block;width:100%;text-align:left;cursor:pointer;
  transition:background-color var(--ws-dur) var(--ws-ease),border-color var(--ws-dur) var(--ws-ease)}
/* A tile that is also a flex box stays one. This file loads after the Tailwind
   build, so the block above beat the flex next to it: the welcome tiles stacked
   their plate above the title, the ship cards' columns did not fill them (#220). */
.ws-tile--interactive.flex{display:flex}
.ws-tile--interactive:hover{background:var(--ws-surface-raised);border-color:var(--ws-line-strong)}
.ws-tile--interactive:hover{--ws-cross-color:var(--ws-accent)}
.ws-tile--interactive:focus-visible{box-shadow:var(--ws-focus-ring)}

/* Direction of a change. Status hues, never decoration. */
.ws-tone-success{color:var(--ws-success)}
.ws-tone-danger{color:var(--ws-danger)}
.ws-tone-none{color:var(--ws-text-faint)}

/* Icon plate in front of a label; carries no meaning of its own, so it stays
   neutral and only picks up the accent when its tile is hovered. */
.ws-plate{flex:none;display:grid;place-items:center;width:40px;height:40px;
  border:1px solid var(--ws-line);color:var(--ws-text-muted);
  transition:color var(--ws-dur) var(--ws-ease),border-color var(--ws-dur) var(--ws-ease)}
.ws-tile--interactive:hover .ws-plate{color:var(--ws-accent);border-color:var(--ws-accent)}

/* Sparkline in an emission tile. The pollutants are told apart by the shape of
   their icon, not by a hue of their own: the page already carries one colour
   system in the status tile, and the only colour a sparkline earns is the
   direction of its change, which sits on the figure above it. */
.ws-spark{color:var(--ws-text-muted)}

/* Dropdown menu. The package has a dialog but no menu; these are the tile's
   edge and surface with rows that light up, never a rounded floating card. */
.ws-menu{background:var(--ws-surface);border:1px solid var(--ws-line-strong);box-shadow:var(--ws-shadow-overlay)}
.ws-menu__item{padding:10px 14px;color:var(--ws-text);background:transparent;border:0;
  transition:background-color var(--ws-dur) var(--ws-ease),color var(--ws-dur) var(--ws-ease)}
.ws-menu__item:hover{background:var(--ws-surface-raised)}
.ws-menu__item:hover .ws-muted{color:var(--ws-accent)}

/* A row of filters above a listing: hairline below, 24px rhythm. */
/* A row of filters above a listing: hairline below, one module of rhythm.
   No min-height here. Three, four and five modules were all tried to snap the
   first row of tiles onto the raster vertically, and none of them bound: the bar
   holds a row of chips beside a column of three switches and measures about
   126px on its own. A floor that the content always clears is dead weight, and
   raising it further would buy the alignment with empty space. Vertical
   alignment below a content-sized block is not something CSS can hold. */
.ws-filterbar{margin-bottom:var(--ws-grid-size);padding-bottom:var(--ws-space-4);
  border-bottom:1px solid var(--ws-line)}

/* Bare form controls. The package pairs .ws-input with a .ws-control wrapper and
   .ws-select with a wrapper around the <select>; this app has bare <input> and
   <select> elements throughout, so this class carries the same look on its own.
   The chevron is drawn from two gradients because a bare select has no ::after. */
.ws-field-control{height:var(--ws-control-h);padding:0 10px;background:var(--ws-bg-sunken);
  border:1px solid var(--ws-line);color:var(--ws-text);font:400 13px/1.2 var(--ws-font-ui);outline:none}
.ws-field-control:focus{border-color:var(--ws-accent);box-shadow:var(--ws-focus-ring)}
.ws-field-control::placeholder{color:var(--ws-text-faint)}
select.ws-field-control{appearance:none;-webkit-appearance:none;padding-right:26px;
  background-image:linear-gradient(45deg,transparent 50%,var(--ws-text-muted) 50%),
                   linear-gradient(135deg,var(--ws-text-muted) 50%,transparent 50%);
  background-position:calc(100% - 14px) calc(50% + 1px),calc(100% - 10px) calc(50% + 1px);
  background-size:4px 4px,4px 4px;background-repeat:no-repeat}
textarea.ws-field-control{height:auto;padding:8px 10px;line-height:1.45}

/* Dialog widths. The package's .ws-dialog is a fixed 480px; the app's modals
   sized themselves with Tailwind max-w-* utilities, which the design system
   would override from a later stylesheet. The widths are therefore named here
   instead, so one rule decides how wide a dialog is. */
.ws-dialog--sm{width:100%;max-width:28rem}
.ws-dialog--md{width:100%;max-width:32rem}
.ws-dialog--lg{width:100%;max-width:42rem}
.ws-dialog--xl{width:100%;max-width:56rem}
.ws-dialog--2xl{width:100%;max-width:72rem}
/* A dialog that takes the viewport (the energy management configuration). */
.ws-dialog--viewport{width:100%;max-width:1900px;height:100%}
/* The cap used to be 90vh minus a guessed header height, which left several
   dialogs a few dozen pixels short and made them scroll for no reason. One grid
   module of air above and below is enough, and the dialog only scrolls when it
   genuinely does not fit. */
.ws-dialog__body--scroll{max-height:calc(100vh - 2 * var(--ws-grid-size));overflow-y:auto}

/* A search field that carries its own icon and spinner, as on the public AIS
   check (#149): one hairlined control, the focus ring of the system. */
.ws-searchbox{display:flex;align-items:center;gap:var(--ws-space-2);background:var(--ws-bg-sunken);
  border:1px solid var(--ws-line-strong);transition:border-color var(--ws-dur) var(--ws-ease)}
.ws-searchbox:focus-within{border-color:var(--ws-accent);box-shadow:var(--ws-focus-ring)}
/* The field inside draws no second frame (Tailwind's forms plugin gives every
   text input a border and a focus ring). */
.ws-searchbox input, .ws-searchbox input:focus{border:0;background:transparent;box-shadow:none;outline:none}

/* A boxed term, e.g. a supported column name in an import dialog. */
.ws-chip{display:inline-flex;align-items:center;padding:4px 8px;background:var(--ws-bg-sunken);
  border:1px solid var(--ws-line-quiet);font-family:var(--ws-font-mono);font-size:12px;color:var(--ws-text)}

/* A boxed aside inside a dialog: help text, an import hint, a download offer. */
.ws-note{padding:var(--ws-space-4);background:var(--ws-bg-sunken);border:1px solid var(--ws-line-quiet);color:var(--ws-text)}
.ws-note--accent{border-color:var(--ws-accent)}

/* One option of a choice, the whole row being the control. */
.ws-choice{padding:var(--ws-space-3);background:var(--ws-bg-sunken);border:1px solid var(--ws-line);cursor:pointer;
  transition:border-color var(--ws-dur) var(--ws-ease),background-color var(--ws-dur) var(--ws-ease)}
.ws-choice:hover{border-color:var(--ws-accent);background:var(--ws-surface-raised)}

/* A drop target for a file: dashed hairline, sunken ground, accent on hover.
   The dashed border is the one place the system draws a broken line, because it
   is what tells a target apart from a panel. */
.ws-dropzone{background:var(--ws-bg-sunken);border:1px dashed var(--ws-line);color:var(--ws-text-muted);
  transition:border-color var(--ws-dur) var(--ws-ease),background-color var(--ws-dur) var(--ws-ease)}
.ws-dropzone:hover{border-color:var(--ws-accent)}
/* JS toggles this while a file hovers over the target. */
.ws-dropzone.drag-over{border-color:var(--ws-accent);background:color-mix(in srgb,var(--ws-accent) 8%,var(--ws-bg-sunken))}

/* Determinate progress. Square, hairlined, accent fill - no rounded pill. */
.ws-progress{width:100%;height:8px;background:var(--ws-bg-sunken);border:1px solid var(--ws-line-quiet)}
.ws-progress__bar{height:100%;background:var(--ws-accent);transition:width var(--ws-dur-slow) var(--ws-ease)}

/* An icon that should read as the page's one accent. */
.ws-accent-ink{color:var(--ws-accent)}

/* A colour a user configured for a fleet or a ship type. It identifies, so the
   swatch keeps the colour and only loses its radius; status hues stay reserved. */
.ws-swatch{flex:none;width:12px;height:12px;border:1px solid var(--ws-line-quiet)}
.ws-swatch--lg{width:16px;height:16px}
.ws-typetag{padding:3px 8px;font-family:var(--ws-font-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase}
.ws-grouphead{padding-bottom:var(--ws-space-2);margin-bottom:var(--ws-space-2);border-bottom:1px solid var(--ws-line)}

/* Ship card. The picture sits on the sunken ground so a logo placeholder reads
   as an empty berth rather than as a pale panel. */
.ws-shipcard__image{background-color:var(--ws-bg-sunken);border-bottom:1px solid var(--ws-line)}
.ws-shipcard__facts{font-family:var(--ws-font-mono);font-size:var(--ws-size-data);
  font-variant-numeric:tabular-nums;color:var(--ws-text-muted)}

/* Waiting: the reticle. The one loading mark of the app (#82), drawn from the
   brand's corner cross: four corner brackets around a small crosshair, the
   highlighted bracket stepping clockwise from corner to corner. Hard steps, no
   smooth turning, no pulsing, no scaling - it reads as an instrument acquiring,
   not as a toy. No radius, like everything else.

   Sizes are modifiers, not Tailwind w-/h- classes: this file loads after the
   Tailwind build and would override them. --ink follows the text colour, for a
   loader inside a button or next to a label; without it the highlight is the
   accent. Never a Tailwind animate-spin ring or SVG circle (a test enforces it). */
.ws-loader{--ws-loader-size:16px;--ws-loader-stroke:2px;--ws-loader-arm:32%;
  --ws-loader-rest:color-mix(in srgb,var(--ws-text-faint) 55%,transparent);--ws-loader-mark:var(--ws-accent);
  --ws-loader-center:var(--ws-loader-rest);
  display:inline-block;flex:none;vertical-align:middle;
  width:var(--ws-loader-size);height:var(--ws-loader-size);
  background:
    linear-gradient(var(--ws-loader-center),var(--ws-loader-center)) center/34% var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-center),var(--ws-loader-center)) center/var(--ws-loader-stroke) 34% no-repeat}
/* The marks hang off the box; a loader the page positions itself (absolute,
   inside an input) keeps that position, since this file loads after Tailwind. */
.ws-loader:not(.absolute):not(.fixed){position:relative}
.ws-loader::before,.ws-loader::after{content:"";position:absolute;inset:0;pointer-events:none}
/* The four brackets at rest. */
.ws-loader::before{background:
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 0 0/var(--ws-loader-arm) var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 0 0/var(--ws-loader-stroke) var(--ws-loader-arm) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 100% 0/var(--ws-loader-arm) var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 100% 0/var(--ws-loader-stroke) var(--ws-loader-arm) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 0 100%/var(--ws-loader-arm) var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 0 100%/var(--ws-loader-stroke) var(--ws-loader-arm) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 100% 100%/var(--ws-loader-arm) var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-rest),var(--ws-loader-rest)) 100% 100%/var(--ws-loader-stroke) var(--ws-loader-arm) no-repeat}
/* The marked bracket: the top-left one, turned a quarter at a time. A corner
   bracket turned by 90 degrees about the centre is exactly the next one. */
.ws-loader::after{background:
    linear-gradient(var(--ws-loader-mark),var(--ws-loader-mark)) 0 0/var(--ws-loader-arm) var(--ws-loader-stroke) no-repeat,
    linear-gradient(var(--ws-loader-mark),var(--ws-loader-mark)) 0 0/var(--ws-loader-stroke) var(--ws-loader-arm) no-repeat;
  animation:ws-loader-step 1s steps(4,end) infinite}
@keyframes ws-loader-step{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: reduce){.ws-loader::after{animation-duration:3.2s}}
.ws-loader--12{--ws-loader-size:12px;--ws-loader-stroke:1.5px}
.ws-loader--20{--ws-loader-size:20px}
.ws-loader--24{--ws-loader-size:24px}
.ws-loader--32{--ws-loader-size:32px;--ws-loader-arm:28%}
.ws-loader--48{--ws-loader-size:48px;--ws-loader-stroke:3px;--ws-loader-arm:26%}
.ws-loader--64{--ws-loader-size:64px;--ws-loader-stroke:3px;--ws-loader-arm:24%}
.ws-loader--ink{--ws-loader-mark:currentColor;--ws-loader-rest:color-mix(in srgb,currentColor 35%,transparent)}
.ws-loader--block{display:block;margin-inline:auto}
/* This file loads after Tailwind, so hide what the page hides. */
.ws-loader.hidden,.ws-loader[hidden]{display:none}

/* Animated backdrop behind a page (Vanta over the shell's Three.js). Fixed, so
   it does not scroll with the content, and behind everything the page draws.
   The page keeps its dot grid and only gives up its flat background colour, so
   the brand's raster still sits between the swell and the tiles. */
/* pointer-events:none is not cosmetic. The backdrop is position:fixed, so its
   containing block is the viewport and a wheel event over it scrolls the
   document, which the shell keeps at overflow:hidden. The page then would not
   move at all while the cursor sat beside the tiles. Letting events through
   hands them to the scroll container underneath. The effect runs without mouse
   controls anyway, so it needs no events of its own. */
.ws-backdrop{position:fixed;inset:0;z-index:0;pointer-events:none}
.ws-page--backdrop{position:relative;background-color:transparent;background-image:none}
.ws-page--backdrop > .ws-page__inner{position:relative;z-index:1}

/* Filter chip above a listing. A ship type's chip is filled with the colour the
   organisation gave that type, so the selected one is marked by an edge and not
   by a colour of its own. */
.ws-filterchip{display:flex;align-items:center;justify-content:center;gap:6px;
  padding:7px 10px;min-width:0;white-space:nowrap;overflow:hidden;cursor:pointer;
  background:var(--ws-bg-sunken);border:1px solid var(--ws-line);color:var(--ws-text-muted);
  font-family:var(--ws-font-label);font-size:var(--ws-size-label);letter-spacing:var(--ws-ls-label);
  text-transform:uppercase;
  transition:border-color var(--ws-dur) var(--ws-ease),color var(--ws-dur) var(--ws-ease)}
.ws-filterchip:hover{border-color:var(--ws-line-strong);color:var(--ws-text)}
.ws-filterchip.is-active{border-color:var(--ws-accent);color:var(--ws-text);box-shadow:inset 0 0 0 1px var(--ws-accent)}
.ws-filterchip--data{border-color:transparent}
.ws-filterchip--data.is-active{border-color:var(--ws-accent)}

/* A page's fixed backdrop paints at the root. On desktop the shell's sidebar is
   `lg:static`, and z-index does nothing on a static element, so the backdrop
   covered it: the nav went invisible on the welcome page. A stacking position
   puts it back on top without changing its place in the flow. Only from the lg
   breakpoint, because below it the sidebar is `absolute` and must stay so. */
@media (min-width: 1024px) {
  #sidebar { position: relative; z-index: 40; }
}

/* Square checkbox on a bare <input type="checkbox">, matching .ws-check without
   its label wrapper. */
.ws-checkbox{appearance:none;-webkit-appearance:none;width:16px;height:16px;flex:none;
  background:var(--ws-bg-sunken);border:1px solid var(--ws-line-strong);display:inline-grid;place-content:center}
.ws-checkbox:checked{background:var(--ws-accent);border-color:var(--ws-accent)}
.ws-checkbox:checked::after{content:"";width:8px;height:4px;
  border-left:1.5px solid var(--ws-accent-on);border-bottom:1.5px solid var(--ws-accent-on);
  transform:rotate(-45deg) translate(1px,-1px)}
.ws-checkbox:focus-visible{outline:none;box-shadow:var(--ws-focus-ring)}

/* A dialog section separated by a hairline above or below it. */
.ws-divider--t{border-top:1px solid var(--ws-line)}
.ws-divider--b{border-bottom:1px solid var(--ws-line)}
.ws-note--danger{border-color:var(--ws-danger);background:var(--ws-danger-quiet)}
.ws-note--warning{border-color:var(--ws-warning);background:var(--ws-warning-quiet)}
.ws-tone-warning{color:var(--ws-warning)}

/* Components that are sometimes an <a>. The base layer styles links inside a
   .ws-scope at (0,2,0), which outranks a plain component class, so a menu row,
   a button or a filter chip rendered as a link would come out cyan and
   underlined while its neighbours did not. These raise the specificity to match
   rather than reaching for !important. */
.ws-scope a.ws-menu__item,
.ws-scope a.ws-btn,
.ws-scope a.ws-filterchip{border-bottom:0;text-decoration:none}
.ws-scope a.ws-menu__item{color:var(--ws-text)}
.ws-scope a.ws-filterchip{color:var(--ws-text-muted)}
.ws-scope a.ws-filterchip.is-active{color:var(--ws-text)}
.ws-scope a.ws-filterchip--data{color:inherit}
.ws-scope a.ws-btn{color:var(--ws-accent-on)}
.ws-scope a.ws-btn--secondary,
.ws-scope a.ws-btn--ghost{color:var(--ws-text)}
.ws-scope a.ws-btn--danger{color:var(--ws-danger)}
/* A tile that is a link (the calculator's choice tiles, #215) keeps the tile's
   text and its hairline all round instead of the link's accent underline. */
.ws-scope a.ws-tile{color:var(--ws-text);border-bottom:1px solid var(--ws-line)}
.ws-scope a.ws-tile--interactive:hover{color:var(--ws-text);border-bottom-color:var(--ws-line-strong)}

/* Leaflet.draw is an app plugin, not part of the design system package, and its
   tool icons are a black raster sprite meant for a white control. CSS cannot
   recolour a background-image, so in dark mode the whole button is inverted and
   its own colours are given as their pre-image: inverting #fbe8e4 lands exactly
   on --ws-ink-900, #eec3bc on --ws-ink-600 and #dbb8b3 on --ws-ink-400. The
   button therefore comes out the same dark as the zoom control above it while
   the black sprite comes out light. Recompute these three if the ink scale
   moves. In light mode the sprite is already right and nothing is inverted. */
:root.dark .ws-map .leaflet-draw-toolbar a{filter:invert(1);
  background-color:#fbe8e4;border-bottom-color:#dbb8b3}
:root.dark .ws-map .leaflet-draw-toolbar a:hover{background-color:#eec3bc}

/* ── Shell header ──────────────────────────────────────────────────────────
   It used to paint a blurred plate in a fixed slate (#182235) plus a grey tint
   at the lg breakpoint. That is a different hue family from the deep-sea ink
   the rest of the system uses, and it sat directly between the sidebar and the
   content, which is what made the sidebar look like it belonged to another
   scheme. The plate is now the sunken surface with a hairline under it: on
   #main-header as utilities in mosaicTheme.html, like its other single
   pieces (divider, badge, symbols), and here for the header of the
   standalone pages (standalone_base.html: AIS check, map credits) and the
   Value Calculator, which wear the same plate and controls (#149). */
.ws-app-header{background-color:var(--ws-bg-sunken);border-bottom:1px solid var(--ws-line)}

/* Round icon controls: square, hairlined, accent on hover. The shell's named
   layer for them (#171): the controls of three shells, two of them from the
   shared language and time zone partials, draw alike, so each carries
   data-header-control and no colour, corner or state classes of its own.
   The theme and currency toggles are <label>s for hidden checkboxes (#144). */
:is(#main-header, .ws-app-header) [data-header-control]{background-color:var(--ws-surface);border:1px solid var(--ws-line);
  color:var(--ws-text-muted);transition:color var(--ws-dur) var(--ws-ease),border-color var(--ws-dur) var(--ws-ease)}
:is(#main-header, .ws-app-header) [data-header-control]:hover{background-color:var(--ws-surface-raised);
  border-color:var(--ws-accent);color:var(--ws-accent)}
/* The active language in the shared picker, in every shell: the plain picker
   on the wall display (#226) takes it from here too, over the button look
   its bar gives the entries. */
#langDropdown [aria-current="true"]{background-color:var(--ws-accent-quiet);color:var(--ws-accent)}

/* Initials plate or photo of the signed-in user, instead of Mosaic's stock
   avatar. Square like everything else. */
.ws-avatar{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;object-fit:cover;
  background-color:var(--ws-surface-raised);border:1px solid var(--ws-line);color:var(--ws-text);
  font-family:var(--ws-font-mono);font-size:11px;letter-spacing:.06em}

/* ── Dialogs ───────────────────────────────────────────────────────────────
   .ws-scrim carries display:flex. Modals here are toggled by swapping the
   `hidden` and `flex` classes; `hidden` wins through the rule at the end of
   this file, `flex` needs its own line to stay flex after the swap. */
/* Pages that keep a separate dim layer: this one only centres. */
.ws-scrim--clear{background:transparent}
.ws-scrim.flex{display:flex}
/* A clear scrim has to stack above that dim layer, so the z utility its page
   gives it must count. At equal specificity .ws-scrim's 50 came later and
   won: on the HyDe page every dialog (z-[1000]) sat under #overlay
   (z-[999]), dimmed and not clickable. */
.ws-scrim--clear.z-\[1000\]{z-index:1000}

/* A dialog that is really a workspace: the ship detail sheet needs the width. */
.ws-dialog--full{width:100%;max-width:min(96vw, 1600px)}

/* A control sitting on a photograph, where neither surface nor hairline can be
   trusted to read. The only place the system allows a translucent plate. */
.ws-btn--on-image{background-color:color-mix(in srgb,var(--ws-ink-900) 62%,transparent);
  border-color:color-mix(in srgb,var(--ws-foam-500) 35%,transparent);color:var(--ws-foam-500)}
.ws-btn--on-image:hover{background-color:color-mix(in srgb,var(--ws-ink-900) 78%,transparent);
  border-color:var(--ws-accent);color:var(--ws-accent)}

/* ── Compatibility shim for leftover Tailwind neutrals ─────────────────────
   Inside an adopted page, map the slate/gray scale onto the system's ink and
   paper. Most of this app's markup is generated in JavaScript, and rewriting
   every class string by hand both misses cases and makes the diff unreadable;
   this catches the rest, including markup that does not exist until a modal is
   opened. It only maps neutrals: a status hue, a channel colour and a colour a
   user configured are never touched, because those carry meaning.

   One rule per token, not per utility, and both the plain and the `dark:`
   spelling resolve to the same token because the token itself already knows
   which mode it is in. The design system is loaded after the Tailwind build, so
   these win the specificity tie against the utilities they replace.

   This is a bridge, not a licence: new markup on an adopted page uses the ws-*
   classes. When a page is fully converted its entries here become dead weight.

   White text is the exception to "one rule per token". It is mostly the
   foreground of a coloured button or badge, and remapping it there put body
   text on blue, green or red. It is bridged only where the element's own
   background is one this bridge remaps as well (the list after
   text-white:is() names exactly those; tests/js/dsTailwindBridge.test.js keeps
   the two in step). */
.ws-scope .text-white:is(.bg-white, .bg-slate-50, .bg-slate-100, .bg-slate-700, .bg-slate-800,
  .bg-gray-50, .bg-gray-100, .bg-gray-700, .bg-gray-800, .bg-slate-200, .bg-slate-600,
  .bg-gray-200, .bg-gray-600, .bg-slate-900, .bg-gray-900),
.ws-scope .text-slate-100, .ws-scope .text-slate-200, .ws-scope .text-slate-300,
.ws-scope .text-slate-700, .ws-scope .text-slate-800, .ws-scope .text-slate-900,
.ws-scope .text-gray-100, .ws-scope .text-gray-200, .ws-scope .text-gray-300,
.ws-scope .text-gray-700, .ws-scope .text-gray-800, .ws-scope .text-gray-900,
.dark .ws-scope .dark\:text-slate-100, .dark .ws-scope .dark\:text-slate-200,
.dark .ws-scope .dark\:text-slate-300, .dark .ws-scope .dark\:text-gray-100,
.dark .ws-scope .dark\:text-gray-200, .dark .ws-scope .dark\:text-gray-300{color:var(--ws-text)}

.ws-scope .text-slate-400, .ws-scope .text-slate-500, .ws-scope .text-slate-600,
.ws-scope .text-gray-400, .ws-scope .text-gray-500, .ws-scope .text-gray-600,
.dark .ws-scope .dark\:text-slate-400, .dark .ws-scope .dark\:text-slate-500,
.dark .ws-scope .dark\:text-gray-400, .dark .ws-scope .dark\:text-gray-500{color:var(--ws-text-muted)}

.ws-scope .bg-white,
.ws-scope .bg-slate-50, .ws-scope .bg-slate-100, .ws-scope .bg-slate-700, .ws-scope .bg-slate-800,
.ws-scope .bg-gray-50, .ws-scope .bg-gray-100, .ws-scope .bg-gray-700, .ws-scope .bg-gray-800,
.dark .ws-scope .dark\:bg-slate-700, .dark .ws-scope .dark\:bg-slate-800,
.dark .ws-scope .dark\:bg-gray-700, .dark .ws-scope .dark\:bg-gray-800{background-color:var(--ws-surface)}

.ws-scope .bg-slate-200, .ws-scope .bg-slate-600, .ws-scope .bg-gray-200, .ws-scope .bg-gray-600,
.dark .ws-scope .dark\:bg-slate-600, .dark .ws-scope .dark\:bg-gray-600{background-color:var(--ws-surface-raised)}

.ws-scope .bg-slate-900, .ws-scope .bg-gray-900,
.dark .ws-scope .dark\:bg-slate-900, .dark .ws-scope .dark\:bg-gray-900{background-color:var(--ws-bg-sunken)}

.ws-scope .border-slate-200, .ws-scope .border-slate-300, .ws-scope .border-slate-600,
.ws-scope .border-slate-700, .ws-scope .border-gray-200, .ws-scope .border-gray-300,
.ws-scope .border-gray-600, .ws-scope .border-gray-700,
.dark .ws-scope .dark\:border-slate-600, .dark .ws-scope .dark\:border-slate-700,
.dark .ws-scope .dark\:border-gray-600, .dark .ws-scope .dark\:border-gray-700{border-color:var(--ws-line)}

/* Tailwind's font-mono has its own ui-monospace stack (the config defines no
   fontFamily.mono), so IP addresses, codes and clocks set with it rendered in
   the platform's monospace next to Sometype Mono (#145). Code-like elements
   follow, they fall back to the browser's monospace the same way. */
.ws-scope .font-mono, .ws-scope code, .ws-scope kbd, .ws-scope samp,
.ws-scope pre{font-family:var(--ws-font-mono)}

/* Radii and shadows the shim does not reach, because they are set as utilities
   on the element itself rather than as a colour. The reset in base.css already
   removes the radius inside a scope; this takes the shadows with it. */
.ws-scope .shadow-sm, .ws-scope .shadow, .ws-scope .shadow-md,
.ws-scope .shadow-lg, .ws-scope .shadow-xl, .ws-scope .shadow-2xl,
.ws-scope .hover\:shadow-sm:hover, .ws-scope .hover\:shadow:hover, .ws-scope .hover\:shadow-md:hover,
.ws-scope .hover\:shadow-lg:hover, .ws-scope .hover\:shadow-xl:hover,
.ws-scope .hover\:shadow-2xl:hover{box-shadow:none}
/* Mosaic's .btn classes and form controls carry shadow-sm through @apply, so
   no class name says so and the rule above missed them (58 visible shadows on
   12 pages). A focused control keeps its ring. */
.ws-scope :is(.btn, .btn-xs):not(:focus-visible),
.ws-scope :is(.form-input, .form-select, .form-textarea, .form-multiselect):not(:focus){box-shadow:none}
/* The one shadow the system keeps belongs to an overlay, and it has to survive
   the rule above on a dialog that still carries a shadow utility. */
.ws-scope .ws-dialog{box-shadow:var(--ws-shadow-overlay)}
.ws-scope .ws-menu{box-shadow:var(--ws-shadow-overlay)}

/* ── Gaps of the bridge (#137) ─────────────────────────────────────────────
   Each of these took a whole element off the system on several pages. */

/* A pale button or chip (bg-slate-100 / dark:bg-slate-700) was mapped onto the
   tile's surface with no border, and in dark mode read as loose text. A
   control keeps a raised surface and a hairline. */
.ws-scope :is(button, a, .btn, .btn-xs):is(.bg-slate-100, .bg-gray-100, .bg-slate-200, .bg-gray-200),
.ws-scope span.rounded-full:is(.bg-slate-100, .bg-gray-100){
  background-color:var(--ws-surface-raised);border:1px solid var(--ws-line)}
.ws-scope :is(button, a, .btn, .btn-xs):is(.bg-slate-100, .bg-gray-100, .bg-slate-200, .bg-gray-200):hover{
  border-color:var(--ws-accent)}

/* The bridge matches descendants of a scope. Where the scope element carries
   the page canvas itself, the canvas stayed Tailwind navy next to the shell:
   it is the page ground. */
.ws-scope:is(.bg-white, .bg-slate-50, .bg-slate-100, .bg-gray-50, .bg-gray-100),
.dark .ws-scope:is(.dark\:bg-slate-800, .dark\:bg-slate-900, .dark\:bg-slate-950,
  .dark\:bg-gray-800, .dark\:bg-gray-900){background-color:var(--ws-bg)}

/* Near-opaque panels (/80 to /95) are surfaces, not tints, and escaped the
   bridge's class names. Lower alphas are hover and row tints and stay. */
.ws-scope :is(.bg-white\/80, .bg-white\/90, .bg-white\/95,
  .bg-slate-800\/80, .bg-slate-800\/90, .bg-slate-800\/95),
.dark .ws-scope :is(.dark\:bg-slate-800\/80, .dark\:bg-slate-800\/90, .dark\:bg-slate-800\/95,
  .dark\:bg-slate-900\/80, .dark\:bg-slate-900\/90, .dark\:bg-slate-900\/95){
  background-color:var(--ws-surface)}

/* No glass: depth is surface and hairline. */
.ws-scope [class*="backdrop-blur"]{-webkit-backdrop-filter:none;backdrop-filter:none}

/* overflow-hidden on a tile, or on the grid holding it, cut the outer half of
   every corner cross. clip with a margin of half a cross keeps them; content
   that really overflows is still cut a few pixels further out. Browsers
   without overflow-clip-margin clip as before. */
.ws-scope .ws-tile.overflow-hidden,
.ws-scope .overflow-hidden:has(> .ws-tile){
  overflow:clip;overflow-clip-margin:calc(var(--ws-cross-size) / 2)}

/* Scrollbars follow the tokens: the native ones stayed white under a dark
   page in some browsers (the component sheet, scrolling form columns). */
.ws-scope, .ws-scope *{scrollbar-color:var(--ws-line-strong) var(--ws-bg-sunken)}

/* base.css resets h1-h4 only; h5 and h6 fell back to Material Design Lite's
   Roboto, which the shell still loads. */
.ws-scope h5, .ws-scope h6{font-family:var(--ws-font-ui)}

/* The same heading resets (0,1,1) beat .ws-label (0,1,0), so a heading styled
   as a label kept the display face and bold weight (#155). */
.ws-scope :is(h1,h2,h3,h4,h5,h6).ws-label{font-family:var(--ws-font-label);font-weight:400}

/* base.css zeroes the margins of h1-h4 as `.ws-scope h2` (0,1,1); the package
   rule it adapts is a bare `h2` (0,0,1). Scoped, it also beat the margin
   utility a heading carries (0,1,0), so `ws-h3 mb-4` sat flush on what
   follows on every adopted page (#160). The reset still takes the browser's
   default margins; a margin the page asks for on the heading applies. */
.ws-scope :is(h1,h2,h3,h4).mb-0{margin-bottom:0}.ws-scope :is(h1,h2,h3,h4).mt-0{margin-top:0}
.ws-scope :is(h1,h2,h3,h4).mb-1{margin-bottom:.25rem}.ws-scope :is(h1,h2,h3,h4).mt-1{margin-top:.25rem}
.ws-scope :is(h1,h2,h3,h4).mb-2{margin-bottom:.5rem}.ws-scope :is(h1,h2,h3,h4).mt-2{margin-top:.5rem}
.ws-scope :is(h1,h2,h3,h4).mb-3{margin-bottom:.75rem}.ws-scope :is(h1,h2,h3,h4).mt-3{margin-top:.75rem}
.ws-scope :is(h1,h2,h3,h4).mb-4{margin-bottom:1rem}.ws-scope :is(h1,h2,h3,h4).mt-4{margin-top:1rem}
.ws-scope :is(h1,h2,h3,h4).mb-5{margin-bottom:1.25rem}.ws-scope :is(h1,h2,h3,h4).mt-5{margin-top:1.25rem}
.ws-scope :is(h1,h2,h3,h4).mb-6{margin-bottom:1.5rem}.ws-scope :is(h1,h2,h3,h4).mt-6{margin-top:1.5rem}
.ws-scope :is(h1,h2,h3,h4).mb-8{margin-bottom:2rem}.ws-scope :is(h1,h2,h3,h4).mt-8{margin-top:2rem}

/* ── Accent (#138) ──────────────────────────────────────────────────────────
   One accent, cyan or the organisation's brand colour (see the shell's
   branding block). Tailwind's indigo and blue were the app's accent before the
   system and still fill primary buttons, active tabs, links and checked
   inputs on most pages; inside a scope they take the accent. This is a bridge
   like the neutral one above, not a licence: new markup uses ws-btn and
   ws-tabs. Status colours and user colours are not touched. */
.ws-scope{accent-color:var(--ws-accent)}

/* Checkboxes and radios: .form-checkbox/.form-radio are indigo-500 from the
   Tailwind build, bare inputs are the forms plugin's blue, and an unchecked
   native box stayed white in dark mode. */
.ws-scope :is(.form-checkbox, .form-radio, input[type="checkbox"], input[type="radio"]){color:var(--ws-accent)}
.ws-scope :is(.form-checkbox, .form-radio, input[type="checkbox"], input[type="radio"]):checked{
  background-color:var(--ws-accent);border-color:var(--ws-accent)}
.dark .ws-scope :is(.form-checkbox, .form-radio, input[type="checkbox"], input[type="radio"]):not(:checked){
  background-color:var(--ws-bg-sunken);border-color:var(--ws-line-strong)}

/* Focus rings: the forms plugin rings in blue, many controls in indigo. */
.ws-scope :is(input, select, textarea, button, a):focus{--tw-ring-color:var(--ws-focus)}
.ws-scope :is(.focus\:border-indigo-300, .focus\:border-indigo-500, .focus\:border-blue-300,
  .focus\:border-blue-500, input, select, textarea):focus{border-color:var(--ws-focus)}

/* Filled controls, links and outlines in indigo or blue. */
.ws-scope :is(.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-blue-500, .bg-blue-600, .bg-blue-700){
  background-color:var(--ws-accent);color:var(--ws-accent-on)}
.ws-scope :is(.hover\:bg-indigo-500, .hover\:bg-indigo-600, .hover\:bg-indigo-700,
  .hover\:bg-blue-500, .hover\:bg-blue-600, .hover\:bg-blue-700):hover{background-color:var(--ws-accent-hover)}
.ws-scope :is(.text-indigo-500, .text-indigo-600, .text-indigo-700, .text-blue-500, .text-blue-600, .text-blue-700),
.dark .ws-scope :is(.dark\:text-indigo-300, .dark\:text-indigo-400, .dark\:text-blue-300, .dark\:text-blue-400){
  color:var(--ws-accent)}
.ws-scope :is(.border-indigo-500, .border-indigo-600, .border-blue-500, .border-blue-600){border-color:var(--ws-accent)}

/* ── Maps (#140) ───────────────────────────────────────────────────────────
   ds/app-leaflet.js puts .ws-map on every map container, so the container is
   the .leaflet-container itself: the package's `.ws-map .leaflet-container`
   rule looks for a descendant and misses it. Leaflet sets its controls in
   Helvetica and the zoom glyphs in Lucida Console. */
.ws-map.leaflet-container{background:var(--ws-bg-sunken);font-family:var(--ws-font-ui)}
.ws-map :is(.leaflet-control-zoom-in, .leaflet-control-zoom-out){font-family:var(--ws-font-mono);font-weight:500}
.ws-map .leaflet-control-attribution{font-family:var(--ws-font-ui)}
/* positron-blue (dark theme) in the deep-sea tones; ds/app-leaflet.js puts
   the class on the layer and the filter into the document. */
.ws-basemap--deepsea{filter:url(#ws-map-deepsea)}
/* A permanent label in a colour the user gave its object (a fleet's colour).
   The coloured box is the whole label: inside the system's tooltip plate
   (ink-900 with padding) it sat in a dark frame on either basemap. The tip is
   drawn by the box, in the colour it gets as --ws-map-label-bg next to its
   background; Leaflet's own tip sits on the container, which the inline colour
   does not reach. Leaflet's 6 px margins per side still hold the tip's room. */
.ws-map .leaflet-tooltip.ws-map-label{padding:0;border:0;background:transparent;box-shadow:none}
.ws-map .leaflet-tooltip.ws-map-label::before{display:none}
.ws-map-label__box{position:relative;padding:3px 6px;line-height:14px}
.ws-map-label__box::after{content:"";position:absolute;border:6px solid transparent;pointer-events:none}
.leaflet-tooltip-top > .ws-map-label__box::after{top:100%;left:50%;margin-left:-6px;border-top-color:var(--ws-map-label-bg)}
.leaflet-tooltip-bottom > .ws-map-label__box::after{bottom:100%;left:50%;margin-left:-6px;border-bottom-color:var(--ws-map-label-bg)}
.leaflet-tooltip-left > .ws-map-label__box::after{left:100%;top:50%;margin-top:-6px;border-left-color:var(--ws-map-label-bg)}
.leaflet-tooltip-right > .ws-map-label__box::after{right:100%;top:50%;margin-top:-6px;border-right-color:var(--ws-map-label-bg)}
/* A label that found no free place keeps its size, so it can be measured, but
   stays out of sight until the pointer is on its object. */
.ws-map .leaflet-tooltip.ws-map-label--hidden{visibility:hidden}
/* A card in a map popup (the fleet map's ship card): a photo across the card,
   the name, the package's label and rows, and actions side by side. */
.ws-popup__photo{display:block;width:100%;height:128px;object-fit:cover;margin-bottom:10px;
  background:var(--ws-bg-sunken);border:1px solid var(--ws-line)}
.ws-popup__title{font-family:var(--ws-font-ui);font-size:15px;font-weight:600;line-height:1.3;color:var(--ws-text)}
.ws-popup__actions{display:flex;gap:var(--ws-space-2);margin-top:12px}
.ws-popup__actions .ws-btn{flex:1}

/* A side panel at the right edge of a view (the fleet pages' filters,
   theme/templates/partials/fleet_filter_panel.html). Collapsed it is a rail:
   icon, its name set upright and a short state, all one button. Open it is a
   column of sections. It takes its room from the view instead of lying over
   it, so a map's own controls stay free; on a narrow screen it lies over the
   view. It opens at once, without a slide. */
.ws-sidepanel{position:relative;flex:none;display:flex;height:100%;
  background:var(--ws-surface);border-left:1px solid var(--ws-line);color:var(--ws-text)}
.ws-sidepanel__rail{display:flex;flex-direction:column;align-items:center;gap:var(--ws-space-3);
  width:40px;padding:var(--ws-space-3) 0;background:transparent;border:0;color:var(--ws-text-muted);cursor:pointer;
  transition:background var(--ws-dur) var(--ws-ease),color var(--ws-dur) var(--ws-ease)}
.ws-sidepanel__rail:hover{background:var(--ws-surface-raised);color:var(--ws-text)}
.ws-sidepanel__rail:focus-visible{outline:2px solid var(--ws-focus);outline-offset:-2px}
.ws-sidepanel__railname{writing-mode:vertical-rl;font-family:var(--ws-font-label);font-size:var(--ws-size-label);
  letter-spacing:var(--ws-ls-label);text-transform:uppercase}
.ws-sidepanel__railcount,.ws-sidepanel__headcount{font-family:var(--ws-font-mono);font-size:11px;
  font-variant-numeric:tabular-nums;color:var(--ws-text-muted)}
.ws-sidepanel__railcount{writing-mode:vertical-rl}
.ws-sidepanel__body{display:none;flex-direction:column;width:288px;overflow-y:auto}
.is-open > .ws-sidepanel__rail{display:none}
.is-open > .ws-sidepanel__body{display:flex}
.ws-sidepanel__head{display:flex;align-items:center;gap:var(--ws-space-2);
  padding:var(--ws-space-3) var(--ws-space-3) var(--ws-space-3) var(--ws-space-4);border-bottom:1px solid var(--ws-line)}
.ws-sidepanel__title{flex:1;font:600 15px/1.3 var(--ws-font-ui)}
.ws-sidepanel__section{padding:var(--ws-space-4);border-bottom:1px solid var(--ws-line)}
.ws-sidepanel__sectionhead{display:flex;align-items:center;gap:var(--ws-space-3);margin-bottom:var(--ws-space-3)}
.ws-sidepanel__sectionhead > .ws-label{flex:1}
.ws-sidepanel__action{padding:0;background:transparent;border:0;cursor:pointer;color:var(--ws-text-muted);
  font-family:var(--ws-font-label);font-size:var(--ws-size-label);letter-spacing:var(--ws-ls-label);text-transform:uppercase}
.ws-sidepanel__action:hover,.ws-sidepanel__action[aria-pressed="true"]{color:var(--ws-text)}
.ws-sidepanel__action:focus-visible{outline:none;box-shadow:var(--ws-focus-ring)}
.ws-sidepanel__foot{padding:var(--ws-space-4);font-size:12px;line-height:1.45;color:var(--ws-text-muted)}
@media (max-width: 767px){
  .ws-sidepanel.is-open{position:fixed;top:64px;right:0;bottom:0;z-index:1001;max-width:calc(100% - 40px)}
}
/* Choices down a column: checkbox, the colour the user gave the choice, its
   name in full and how many it holds. */
.ws-filterlist{display:flex;flex-direction:column;gap:2px;margin:0;padding:0;list-style:none}
.ws-filterlist .ws-check{display:flex;width:100%;padding:5px 0}
.ws-filterlist__name{flex:1;min-width:0;overflow-wrap:anywhere}
.ws-filterlist__count{font-family:var(--ws-font-mono);font-size:11px;font-variant-numeric:tabular-nums;color:var(--ws-text-muted)}
.ws-filterlist__empty{font-size:12px;color:var(--ws-text-faint)}

/* ── Motion (#147) ──────────────────────────────────────────────────────────
   Live values never move by themselves and nothing scales: a pulsing status
   dot reads like an alarm on the bridge. A live state is a still square in
   its status colour. The one exception, the placed parts of the energy system
   designer growing under the pointer (user decision 2026-10-21), uses an
   arbitrary transform (ES_HOVER_GROW in EnergySysUtility.js) that the
   hover:scale rule below deliberately does not catch. */
.ws-scope :is(.animate-pulse, .animate-bounce, .animate-ping){animation:none}
@media (prefers-reduced-motion: no-preference){
  .ws-scope :is(.motion-safe\:animate-pulse, .motion-safe\:animate-bounce, .motion-safe\:animate-ping){animation:none}
}
.ws-scope [class*="hover:scale-"]:hover,
.ws-scope .group:hover [class*="group-hover:scale-"]{--tw-scale-x:1;--tw-scale-y:1}

/* A numbered or symbolled step in front of a section head (#148): a square
   hairlined mark in mono, not a coloured disc with an emoji. */
.ws-step{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;
  border:1px solid var(--ws-line-strong);color:var(--ws-text-muted);
  font-family:var(--ws-font-mono);font-size:11px;line-height:1}
.ws-step .material-icons{font-size:15px}

/* ── Numbers and labels (#141) ─────────────────────────────────────────────
   What is measured is set in mono, and uppercase belongs to mono labels. Pages
   mark their figures with Tailwind's tabular-nums and their captions with
   uppercase + tracking-wide(r) (about a hundred each), both in Archivo; the
   bridge gives them the system's mono. New markup uses .ws-data, .ws-metric
   and .ws-label. */
.ws-scope .tabular-nums{font-family:var(--ws-font-mono)}
.ws-scope .uppercase:is(.tracking-wide, .tracking-wider, .tracking-widest){
  font-family:var(--ws-font-mono);letter-spacing:var(--ws-ls-label)}

/* A control the page puts on top of the map, and the transient job status the
   page shows over its own content. Both used to be white rounded boxes with a
   drop shadow, set inline, which no reset can reach and no theme can follow. */
.ws-map-control{display:flex;align-items:center;gap:var(--ws-space-2);
  padding:var(--ws-space-2) var(--ws-space-3);
  background-color:var(--ws-surface);border:1px solid var(--ws-line-strong);color:var(--ws-text)}
.ws-map-control__label{font-family:var(--ws-font-label);font-size:var(--ws-size-label);
  letter-spacing:var(--ws-ls-label);text-transform:uppercase;color:var(--ws-text-muted);white-space:nowrap}
.ws-field-control--sm{height:var(--ws-control-h-sm);font-size:12px}

.ws-jobstatus{position:absolute;top:var(--ws-grid-size);left:50%;transform:translateX(-50%);z-index:1000;
  padding:var(--ws-space-2) var(--ws-space-4);
  background-color:var(--ws-surface);border:1px solid var(--ws-accent);
  color:var(--ws-text);font-family:var(--ws-font-data);font-size:var(--ws-size-data)}

/* Plotly's modebar. The app styles it in HyDe/static/css/plotly-modebar.css with
   slate colours and a 4px radius, and Plotly injects further rules at runtime,
   after every stylesheet in the head. Inside an adopted page the buttons take
   the system's tokens instead. The !important mirrors the file being overridden,
   which uses it throughout; without it the declarations there would win. */
.ws-scope .js-plotly-plot .modebar-group{border-radius:0;background:transparent !important}
.ws-scope .js-plotly-plot .modebar-btn .icon path{fill:var(--ws-text-faint) !important}
.ws-scope .js-plotly-plot .modebar-btn:hover .icon path,
.ws-scope .js-plotly-plot .modebar-btn.active .icon path{fill:var(--ws-accent) !important}

/* The backing line under Plotly's spike (hover cursor) line. Plotly draws it
   two pixels wider in the plot's background colour and offers no option to
   change it; with the transparent paper of every chart that colour is white,
   so in the dark theme the dashed cursor sat on a white band. In the tile's
   colour it read as a dark band over the bars, so it is hidden: the cursor
   stands on its own, in the muted text tone (app-plotly.js). It is the only
   spike line without a dash array. A rule over markup Plotly generates, hence
   CSS. */
.dark .js-plotly-plot line.spikeline:not([stroke-dasharray]){display:none}

/* No corner crosses on a dialog. They are the mark of a tile sitting on the
   page raster; an overlay floats above it and belongs to no grid. They also sat
   6px outside the box on each side, which pushed a wide dialog past the viewport
   and produced the horizontal scrollbar in the ship sheet. */
.ws-scope .ws-dialog::before,
.ws-scope .ws-dialog::after{display:none}

/* An organisation's own logo, shown in the page header in place of its name.
   Height is two modules so it sits in the header's rhythm whatever its aspect
   ratio; the width follows. */
.ws-orgbrand{display:block;height:calc(2 * var(--ws-grid-size));width:auto;max-width:200px;object-fit:contain}

/* Info tips (issue 186, app-infotip.js): an explanation behind a small (i)
   button, opened on hover, focus and tap. The text is placed with position:
   fixed by the script, so a clipping container cannot cut it off; inside a
   label it keeps running-text case and spacing. The open rule stays at
   (0,2,0) and before the hidden override below, which it must not beat. */
.ws-infotip{display:inline-flex;vertical-align:middle;text-transform:none;letter-spacing:normal}
.ws-infotip__icon{display:inline-flex;align-items:center;justify-content:center;flex:none;width:16px;height:16px;padding:0;
  border:1px solid var(--ws-line-strong);background:transparent;color:var(--ws-text-muted);
  font:600 10px/1 var(--ws-font-mono);text-transform:none;cursor:help}
.ws-infotip__icon:hover,.ws-infotip.is-open .ws-infotip__icon{color:var(--ws-accent);border-color:var(--ws-accent)}
.ws-infotip__icon:focus-visible{outline:none;box-shadow:var(--ws-focus-ring)}
.ws-infotip__text{display:none;position:fixed;z-index:70;width:max-content;max-width:min(320px,calc(100vw - 16px));
  padding:8px 12px;background:var(--ws-surface);border:1px solid var(--ws-line-strong);box-shadow:var(--ws-shadow-overlay);
  color:var(--ws-text);font:400 12px/1.45 var(--ws-font-ui);text-align:left;white-space:normal;overflow-wrap:anywhere}
.is-open>.ws-infotip__text{display:block}
/* ── `hidden` wins over every design-system display ─────────────────────────
   Thirty-eight .ws-* classes set a display, and this layer loads after the
   Tailwind build. At equal specificity the later rule wins, so both ways of
   hiding an element lost against them: the `hidden` utility class and the
   `hidden` attribute. The AI chat's Stop button stood permanently next to
   Send, three converted buttons could not be hidden, and the welcome page
   showed an organisation's logo and the name badge it replaces side by side.

   One rule for all of them instead of a line per class, so the next class
   cannot reopen the hole. An attribute selector on the class token plus
   `.hidden` or `[hidden]` is (0,2,0) and beats a single .ws-* class wherever
   that class is declared; tests/js/dsHiddenOverride.test.js keeps every
   display rule of the system below it.

   No design-system element pairs `hidden` with a responsive display such as
   `sm:flex` (the "hidden on mobile" idiom), in templates or in markup built by
   scripts - this rule would win against that too, so it is checked there. */
[class^="ws-"].hidden, [class*=" ws-"].hidden,
[class^="ws-"][hidden], [class*=" ws-"][hidden]{display:none}

/* ── Padding utilities on flush tiles ──────────────────────────────────────
   .ws-tile--flush sets padding:0 with one class, the weight of Tailwind's p-4,
   and this system loads after the Tailwind build, so the flush rule won. The
   rollout kept the padding utilities of the panels it made flush, and each of
   them lost its padding: fields and menus touched the tile's border, as on the
   AIS viewer's search panel. Two classes outrank both; the values are the
   Tailwind scale, in Tailwind's order (all sides, axes, single sides, then
   breakpoints), so combinations such as "p-4 pt-0" resolve as they would there.
   Plain tiles keep the system's padding. tests/js/dsFlushTilePadding.test.js
   fails when a flush tile gets a padding utility without a rule here. */
.ws-tile--flush.p-2{padding:.5rem}
.ws-tile--flush.p-3{padding:.75rem}
.ws-tile--flush.p-4{padding:1rem}
.ws-tile--flush.p-5{padding:1.25rem}
.ws-tile--flush.p-6{padding:1.5rem}
.ws-tile--flush.p-10{padding:2.5rem}
.ws-tile--flush.px-4{padding-left:1rem;padding-right:1rem}
.ws-tile--flush.px-5{padding-left:1.25rem;padding-right:1.25rem}
.ws-tile--flush.py-1{padding-top:.25rem;padding-bottom:.25rem}
.ws-tile--flush.py-3{padding-top:.75rem;padding-bottom:.75rem}
.ws-tile--flush.pt-0{padding-top:0}
.ws-tile--flush.pb-5{padding-bottom:1.25rem}
@media (min-width:768px){
  .ws-tile--flush.md\:p-5{padding:1.25rem}
}

/* ── Tabulator (Power Table, #152) ─────────────────────────────────────────
   The CDN theme paints the table body #888 with grey heads, and the old
   #tabulator rules in styles.css turned it slate in dark mode. The table reads
   like .ws-table instead: rows on the tile's surface, hairlines, the label
   face for the heads, the accent for sorting and editing. */
.ws-scope .tabulator{background:var(--ws-surface);border:1px solid var(--ws-line);color:var(--ws-text);font:400 13px/1.4 var(--ws-font-ui)}
.ws-scope .tabulator .tabulator-header{background:var(--ws-surface);color:var(--ws-text-muted);border-bottom:1px solid var(--ws-line);font-weight:400}
.ws-scope .tabulator .tabulator-header .tabulator-col{background:var(--ws-surface);border-right:1px solid var(--ws-line-quiet)}
.ws-scope .tabulator .tabulator-header .tabulator-col .tabulator-col-content{padding:8px 12px}
/* The label face is wider than Tabulator's bold heads, and the columns have
   fixed widths: a head wraps (and hyphenates) instead of ending in "…". */
.ws-scope .tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title{font:400 var(--ws-size-label)/1.3 var(--ws-font-label);letter-spacing:var(--ws-ls-label);text-transform:uppercase;
  white-space:normal;hyphens:auto;overflow-wrap:anywhere}
.ws-scope .tabulator .tabulator-header .tabulator-col.tabulator-sortable.tabulator-col-sorter-element:hover{background:var(--ws-surface-raised)}
.ws-scope .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter .tabulator-arrow{border-top-color:var(--ws-text-faint);border-bottom-color:var(--ws-text-faint)}
.ws-scope .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
.ws-scope .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow{border-top-color:var(--ws-accent);border-bottom-color:var(--ws-accent)}
.ws-scope .tabulator .tabulator-tableholder .tabulator-table{background:transparent;color:var(--ws-text)}
.ws-scope .tabulator-row,
.ws-scope .tabulator-row.tabulator-row-even{background:var(--ws-surface);border-bottom:1px solid var(--ws-line-quiet)}
.ws-scope .tabulator-row.tabulator-selectable:hover{background:var(--ws-surface-raised)}
.ws-scope .tabulator-row .tabulator-cell{padding:7px 12px;border-right:1px solid var(--ws-line-quiet)}
.ws-scope .tabulator-row .tabulator-cell.tabulator-editing{border:1px solid var(--ws-accent);padding:0}
.ws-scope .tabulator-row .tabulator-cell.tabulator-editing :is(input, select){background:var(--ws-bg-sunken);color:var(--ws-text);padding:6px 11px}
.ws-scope .tabulator .tabulator-tableholder .tabulator-placeholder :is(span, .tabulator-placeholder-contents){color:var(--ws-text-faint);font:400 13px/1.4 var(--ws-font-ui)}
.ws-scope .tabulator .tabulator-footer{background:var(--ws-surface);color:var(--ws-text-muted);border-top:1px solid var(--ws-line)}

/* A native file input brings the browser's grey "Browse…" button, and some
   pages dressed it in blue file: utilities; it is a secondary button. */
.ws-scope input[type="file"]{font:400 13px/1.4 var(--ws-font-ui);color:var(--ws-text-muted)}
.ws-scope input[type="file"]::file-selector-button{height:var(--ws-control-h-sm);margin-right:10px;padding:0 10px;
  background:transparent;color:var(--ws-text);border:1px solid var(--ws-line-strong);border-radius:0;font:400 12px/1 var(--ws-font-ui);cursor:pointer}
.ws-scope input[type="file"]::file-selector-button:hover{background:var(--ws-surface-raised)}

/* Range sliders (#153). The browser's own slider - a light rounded track,
   almost white in the dark theme, a blue fill and a round thumb - is a
   hairline with a square accent thumb, like the system's other marks. */
.ws-scope input[type="range"]{-webkit-appearance:none;appearance:none;height:16px;padding:0;
  background:transparent;border:0;border-radius:0;cursor:pointer;accent-color:var(--ws-accent)}
.ws-scope input[type="range"]::-webkit-slider-runnable-track{height:2px;background:var(--ws-line-strong);border:0}
.ws-scope input[type="range"]::-moz-range-track{height:2px;background:var(--ws-line-strong);border:0}
.ws-scope input[type="range"]::-moz-range-progress{height:2px;background:var(--ws-accent)}
.ws-scope input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;margin-top:-5px;
  background:var(--ws-accent);border:0;border-radius:0}
.ws-scope input[type="range"]::-moz-range-thumb{width:12px;height:12px;background:var(--ws-accent);border:0;border-radius:0}
.ws-scope input[type="range"]:focus-visible{outline:none}
.ws-scope input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:var(--ws-focus-ring)}
.ws-scope input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:var(--ws-focus-ring)}
.ws-scope input[type="range"]:disabled{cursor:not-allowed}
.ws-scope input[type="range"]:disabled::-webkit-slider-thumb{background:var(--ws-text-faint)}
.ws-scope input[type="range"]:disabled::-moz-range-thumb{background:var(--ws-text-faint)}
.ws-scope input[type="range"]:disabled::-moz-range-progress{background:var(--ws-text-faint)}

/* Component catalogue (#153): ComponentTableBase.js and BatteriesUtility.js
   draw the same sheet on eight pages. A missing source is a data-quality
   hint in the warning tone (it was the alarm red that turned whole rows
   pink), an assumed one a quieter mark; pinned cells stay opaque, so the
   tones are laid over the tile's surface. The doubled classes outrank the
   bridge's dark-mode background on the pinned cells.
   The comp-* pieces stay named helpers (#171): one sheet on eight pages,
   built by two scripts, and comp-action--danger and comp-indicator are
   shared by some twenty more files. The same utilities would repeat in
   every one of them. */
.ws-scope td.comp-cell--nosource.comp-cell--nosource,
.ws-scope .comp-swatch.comp-cell--nosource{background:linear-gradient(var(--ws-warning-quiet),var(--ws-warning-quiet)),var(--ws-surface)}
.ws-scope td.comp-cell--assumed.comp-cell--assumed,
.ws-scope .comp-swatch.comp-cell--assumed{background:var(--ws-bg-sunken)}
.ws-scope .comp-swatch{display:inline-block;width:14px;height:14px;border:1px solid var(--ws-line-strong);background:var(--ws-surface)}
.ws-scope tr.comp-row--selected.comp-row--selected > td{background:linear-gradient(var(--ws-accent-quiet),var(--ws-accent-quiet)),var(--ws-surface)}
.ws-scope .comp-owner{color:var(--ws-text-faint)}
.ws-scope .comp-owner--premium{color:var(--ws-accent)}
.ws-scope .comp-action--danger:hover{color:var(--ws-danger);border-color:var(--ws-danger)}
/* The system's square switch as a button, drawn from aria-checked: "Show in
   layout" in the component tables and the energy system designer's switches
   (#255). A switch around a checkbox is .ws-switch; both share the geometry. */
.ws-scope .comp-switch{position:relative;display:inline-block;width:40px;height:18px;padding:0;vertical-align:middle;cursor:pointer;
  background:var(--ws-bg-sunken);border:1px solid var(--ws-line-strong);border-radius:0;transition:background var(--ws-dur) var(--ws-ease)}
.ws-scope .comp-switch__knob{position:absolute;top:2px;left:2px;width:14px;height:12px;pointer-events:none;background:var(--ws-text-faint);
  transition:left var(--ws-dur) var(--ws-ease),background var(--ws-dur) var(--ws-ease)}
.ws-scope .comp-switch[aria-checked="true"]{background:var(--ws-accent-quiet);border-color:var(--ws-accent)}
.ws-scope .comp-switch[aria-checked="true"] .comp-switch__knob{left:22px;background:var(--ws-accent)}
.ws-scope .comp-switch:focus-visible{outline:none;box-shadow:var(--ws-focus-ring)}
/* The bar for a selection floats over the page as an overlay. It is a child
   of body that carries the scope itself (#143), hence no ancestor here. */
.comp-batchbar{background:var(--ws-surface-raised);color:var(--ws-text);border:1px solid var(--ws-line-strong);box-shadow:var(--ws-shadow-overlay)}
/* A text link in the accent, underlined so it is not told by colour alone. */
.ws-scope .ws-link{color:var(--ws-accent);text-decoration:underline;text-underline-offset:2px}
.ws-scope .ws-link:hover{color:var(--ws-accent-hover)}
/* "Gespeichert" / "Fehler" after an inline change: a raised chip with a
   status frame instead of a green or red pill. */
.comp-indicator{background:var(--ws-surface-raised);color:var(--ws-text);border:1px solid var(--ws-success);box-shadow:var(--ws-shadow-overlay)}
.comp-indicator--error{border-color:var(--ws-danger)}
/* Ink for the channels and for danger, where a word or an icon carries the
   meaning: charging cyan, discharging amber (readme, "Diagramme"). */
.ws-scope .ws-ink-charge{color:var(--ws-charge)}
.ws-scope .ws-ink-discharge{color:var(--ws-discharge)}
.ws-scope .ws-ink-danger{color:var(--ws-danger)}
/* A required field's star: part of the label, not an alarm. */
.ws-scope .ws-required{color:var(--ws-text-muted);margin-left:2px}
/* A field the form rejected. */
.ws-scope .comp-invalid.comp-invalid{border-color:var(--ws-danger);--tw-ring-color:var(--ws-danger)}
/* Whitelists: the selected list in the accent's quiet tone with the
   sidebar's mark, instead of indigo (#153).
   Stays a named helper (#171): shared by several pages and scripts, where
   the same utilities would repeat in every file. */
.ws-scope .wl-item--active{background:var(--ws-accent-quiet);color:var(--ws-text);box-shadow:inset 2px 0 0 var(--ws-accent)}
/* Consumption manager (#151) and friends. */
.ws-scope .ws-ink-accent{color:var(--ws-accent)}
.ws-scope .ws-ink-success{color:var(--ws-success)}
.ws-scope .ws-ink-warning{color:var(--ws-warning)}
.ws-note--success{border-color:var(--ws-success);background:var(--ws-success-quiet, var(--ws-bg-sunken))}
.ws-scope .ws-codechip{padding:0 4px;background:var(--ws-bg-sunken);border:1px solid var(--ws-line-quiet);font-family:var(--ws-font-mono)}
.ws-scope .ws-flag-warning{background:var(--ws-warning)}
.ws-scope .ws-dropzone--active{border-color:var(--ws-accent)}
/* A page's own dialogs sit inside .ws-page, which isolates its stacking for
   the raster, so even a z-50 dialog stayed under the sticky header (z-30):
   its title slid under the header and the header stayed undimmed (#151,
   #143). While a dialog is open the page rises above the shell; at any
   other time it stays put, so the header's menus still open over it. */
.ws-page:has(:is(.ws-scrim, .cm-scrim, .fixed.inset-0):not(.hidden, .hidden *, .opacity-0, .pointer-events-none)){z-index:45}
/* A full-width button whose label wraps grows instead of squeezing two
   lines into the control height. */
.ws-scope .ws-btn--full{height:auto;min-height:var(--ws-control-h);padding-block:6px;text-align:center}
/* A segmented choice (Flotte / Schiffstyp / Schiff): the chosen segment in
   the accent's quiet tone, so the page keeps one filled accent surface. */
.ws-scope .ws-seg-btn.is-active{background:var(--ws-accent-quiet);color:var(--ws-accent)}
/* Emission overview figures: a frame and the figure's ink in the status
   token, the caption a label (#151).
   Stays a named helper (#171): shared by several pages and scripts, where
   the same utilities would repeat in every file. */
.ws-scope .eov-stat{border:1px solid var(--ws-line);background:var(--ws-bg-sunken)}
.ws-scope .eov-stat__value{font-family:var(--ws-font-mono);font-weight:500;color:var(--ws-text)}
.ws-scope .eov-stat--good{border-color:var(--ws-success)}
.ws-scope .eov-stat--good .eov-stat__value{color:var(--ws-success)}
.ws-scope .eov-stat--bad{border-color:var(--ws-danger)}
.ws-scope .eov-stat--bad .eov-stat__value{color:var(--ws-danger)}
.ws-scope .eov-stat--warning{border-color:var(--ws-warning)}
.ws-scope .eov-stat--warning .eov-stat__value{color:var(--ws-warning)}
.ws-scope .eov-stat--goal{border-color:var(--ws-accent)}
.ws-scope .eov-stat--goal .eov-stat__value{color:var(--ws-accent)}
/* Suggestion cards of the Hybrid and the Simple Dashboard (#153): one raised
   card for every rule; the icon names the rule, a ws-badge the severity, and
   only a warning, critical or long-term card gets a frame in its tone. New
   cards pop in, removed ones fade out and collapse: state classes the
   scripts toggle, with keyframes.
   Stays a named helper (#171): two pages and two scripts
   (HybridDashboardUtility.js, SimpleDashboardUtility.js) draw the same cards,
   which each page carried as a copy of these rules. */
.hyb-suggestion{background:var(--ws-surface-raised);border:1px solid var(--ws-line)}
.hyb-suggestion--warning{border-color:var(--ws-warning)}
.hyb-suggestion--critical{border-color:var(--ws-danger)}
.hyb-suggestion--longterm{border-color:var(--ws-accent)}
.hyb-suggestion__icon{color:var(--ws-text-muted)}
.hyb-suggestion__title{color:var(--ws-text);font-size:13px;font-weight:600}
.hyb-suggestion__line{color:var(--ws-text-muted);font-size:12px}
.hyb-suggestion__term{margin-right:4px;color:var(--ws-text-faint);font:400 var(--ws-size-label)/1 var(--ws-font-label);
  letter-spacing:var(--ws-ls-label);text-transform:uppercase}
.suggestion-enter{animation:suggestionPopIn 0.14s linear forwards}
@keyframes suggestionPopIn{from{opacity:0}to{opacity:1}}
.suggestion-item{transition:transform 0.3s ease,opacity 0.25s ease;overflow:hidden}
.suggestion-exit{opacity:0;max-height:0 !important;padding-top:0 !important;padding-bottom:0 !important;
  margin-top:0 !important;margin-bottom:0 !important;border-width:0 !important;
  transition:opacity 0.2s ease,max-height 0.3s ease 0.1s,padding 0.3s ease 0.1s,margin 0.3s ease 0.1s,border-width 0.3s ease 0.1s;
  pointer-events:none}
/* A secondary button that switches a mode (Fleet Overview's "Eigene /
   Auswahl") shows the switched-on state in the accent's quiet tone. */
.ws-scope .ws-btn--secondary.is-active{background:var(--ws-accent-quiet);border-color:var(--ws-accent);color:var(--ws-accent)}
/* A state as a filled square or stripe in its tone (job status stripes and
   dots, data-quality markers; #154). Running is the accent: in progress is
   neither good nor bad. */
.ws-scope .ws-fill-success{background:var(--ws-success)}
.ws-scope .ws-fill-warning{background:var(--ws-warning)}
.ws-scope .ws-fill-danger{background:var(--ws-danger)}
.ws-scope .ws-fill-accent{background:var(--ws-accent)}
.ws-scope .ws-fill-muted{background:var(--ws-text-faint)}
/* Plyr (Crash Analyser video, #154): the accent instead of Plyr's blue, and
   square controls. */
.ws-scope .plyr{--plyr-color-main:var(--ws-accent);--plyr-control-radius:0;--plyr-range-thumb-shadow:none}
/* AppDialogs (#146): the toast's message line, as in the system's toast
   partial, and the confirm text keep the line breaks alert() and confirm()
   kept; on a phone the stack fits the screen. */
.ws-toast__message{margin:8px 0 0;font:400 13px/1.4 var(--ws-font-ui);color:var(--ws-text);white-space:pre-line;overflow-wrap:anywhere}
#appToastHost .ws-toast{max-width:calc(100vw - 48px)}
#appConfirmOverlay .ws-dialog__body{white-space:pre-line;overflow-wrap:anywhere}
