*, *::before, *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/* The browser gives [hidden] `display: none`, but that lives in the user-agent
   sheet, so any author `display` at all outranks it. `.attr-row` and
   `.attr-fieldset` both set one, which silently made the attribute inert on
   every element carrying them — the Lay Tiles zone panel showed its Band,
   Rectangle and Offset rows all at once regardless of the zone, and the tile
   dialog's read-only notice never went away.

   !important because a class alone already outranks a bare attribute selector,
   and `hidden` has to win wherever it is put. */
[hidden] {
  display: none !important;
}

:root {
  /* Everything below is dark, and without saying so the browser keeps painting
     light-mode chrome over it: scrollbars, select popups, colour swatches,
     number spinners and the file button all come up white otherwise. */
  color-scheme: dark;

  --bg-page: #1d1d1d;
  --bg-panel: #303030;
  --bg-panel-hover: #3d3d3d;
  --bg-input: #232323;
  --border: #232323;
  --border-light: #4a4a4a;
  --text: #e6e6e6;
  --text-muted: #9a9a9a;
  --accent: #4772b3;
  --accent-hover: #5482c8;
  /* Props in the outliner. Teal rather than another blue, and deliberately not
     --active: that orange already means "hovered" everywhere in the viewport,
     and a row that looked hovered while it sat there would be a worse lie than
     no colour at all. */
  --accent-prop: #4a9e8f;
  --active: #e87d0d;
  --active-hover: #f58a1f;
  --danger: #c04a4a;
  --danger-hover: #d75a5a;
  --focus-glow: #4772b3;
  --viewport-bg: #1d1d1d;
  --radius: 3px;
  --toolbar-h: 44px;
  --statusbar-h: 24px;
  --outliner-w: 220px;
  --attributes-w: 260px;
}

html, body {
  height: 100%;
  background: var(--bg-page);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  font-size: 13px;
  line-height: 1.5;
}

a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }

/* Scrollbars, everywhere at once: the outliner and attributes panels, the cut
   editor's body, the tile grid and the tile form, and the document itself on
   the pages that are not height-clamped.
   `color-scheme: dark` above already gets them out of light mode; this is the
   part that matches them to the palette. Deliberately no `scrollbar-color`
   alongside it — Chrome gives the standard property precedence and switches
   ::-webkit-scrollbar customisation off entirely, so setting both would throw
   these rules away. Firefox keeps the native dark bar from color-scheme. */
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

/* Transparent, not a colour: --bg-input and --border are the same #232323, and
   the surfaces that scroll already sit on it, so a painted track would only
   show up as a seam. */
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: var(--border-light);
  border-radius: 5px;
  /* Inset through the border box, so the thumb reads narrower than the gutter
     it slides in rather than filling it edge to edge. */
  border: 2px solid transparent;
  background-clip: padding-box;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--text-muted);
  background-clip: padding-box;
}

/* ─── Auth pages ─── */
.auth-page {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.auth-form {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 40px;
  width: 360px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.auth-form h1 {
  font-size: 24px;
  font-weight: 700;
  color: var(--accent);
  text-align: center;
}

.auth-form h2 {
  font-size: 16px;
  font-weight: 500;
  color: var(--text-muted);
  text-align: center;
}

.auth-form label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 13px;
  color: var(--text-muted);
}

.auth-form input {
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  padding: 10px 12px;
  font-size: 14px;
  outline: none;
  transition: border-color 0.15s;
}

.auth-form input:focus {
  border-color: var(--focus-glow);
  box-shadow: 0 0 0 2px rgba(0, 180, 216, 0.15);
}

.auth-form button {
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: var(--radius);
  padding: 10px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;
}

.auth-form button:hover { background: var(--accent-hover); }

.auth-error {
  color: var(--danger);
  font-size: 13px;
  text-align: center;
}

.auth-link {
  text-align: center;
  font-size: 13px;
  color: var(--text-muted);
}

/* ─── Scenes page ─── */
.scenes-page {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.scenes-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
}

.scenes-header h1 {
  font-size: 20px;
  color: var(--accent);
}

.scenes-main {
  flex: 1;
  padding: 24px;
  max-width: 800px;
  margin: 0 auto;
  width: 100%;
}

.scenes-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
}

.scenes-toolbar h2 {
  font-size: 18px;
  font-weight: 600;
}

.btn-primary {
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: var(--radius);
  padding: 8px 16px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;
}

.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled:hover { background: var(--accent); }

.btn-danger {
  background: transparent;
  color: var(--danger);
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  padding: 8px 16px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.15s;
}

.btn-danger:hover {
  background: var(--danger);
  color: #fff;
}

.btn-link {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 13px;
  padding: 4px 8px;
}

.btn-link:hover { color: var(--text); }

.btn-icon {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 16px;
  padding: 4px 8px;
  border-radius: var(--radius);
}

.btn-icon:hover {
  background: var(--bg-panel-hover);
  color: var(--danger);
}

.scene-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px 16px;
  margin-bottom: 8px;
  transition: border-color 0.15s;
}

.scene-card:hover { border-color: var(--accent); }

.scene-card-link {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  color: var(--text);
}

.scene-card-name {
  font-size: 15px;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.scene-card-stats {
  font-size: 12px;
  color: var(--text-muted);
}

.scene-card-link:hover .scene-card-name { color: var(--accent); }

/* ─── The admin page ───
   The same page furniture as the scene list — header, toolbar, a column of cards
   — since it answers the same kind of question about a different noun. The row is
   a scene card with a stacked stat block instead of a single line. */
.admin-hint {
  font-size: 12px;
  color: var(--text-muted);
}

.admin-user {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px 16px;
  margin-bottom: 8px;
}

/* A superuser is the one row worth picking out of a list of thirteen. */
.admin-user.is-admin {
  border-color: var(--accent);
}

.admin-user-main {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.admin-user-head {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 2px;
}

.admin-user-email {
  font-size: 14px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.admin-user-stats {
  font-size: 12px;
  color: var(--text-muted);
}

/* No text-transform. The shape was drawn around "SUPERUSER"; the Russian for it
   is "суперпользователь", which is seventeen characters before anything is done
   to it, and shouting them at 10px made a badge as wide as the column it sits
   in. Uppercase is worth nothing here that weight and colour do not already do. */
.admin-tag {
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--text-muted);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  padding: 1px 5px;
}

.admin-tag-super {
  color: var(--accent);
  border-color: var(--accent);
}

.admin-user-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

/* A granted uploader is worth marking, but it is not the standing this page is
   really about — so it borrows the tag shape without the accent border. */
.admin-tag-upload {
  color: var(--active);
  border-color: var(--active);
}

/* The email is a link to the detail page now. Left looking like the heading it
   was: turning every row into blue text would make the list harder to read, not
   easier to use. */
a.admin-user-email { color: var(--text); }
a.admin-user-email:hover { color: var(--accent); }

/* Abuse signals. Flags, not blocks — nothing here refuses anything, so they are
   marked in the "look at this" colour rather than the destructive one. */
.admin-flags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 4px;
}

.admin-flag {
  font-size: 10px;
  font-weight: 600;
  color: var(--active);
  border: 1px solid var(--active);
  border-radius: var(--radius);
  padding: 1px 5px;
}

/* What the database weighs, beside the button that makes it weigh less. */
.admin-storage-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px 16px;
  margin-bottom: 12px;
}

/* A row on the detail page: one tile or one project. The user row without the
   coloured border, since these are items rather than accounts. */
.admin-item {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 8px 16px;
  margin-bottom: 6px;
}

.admin-item .admin-user-main { flex: 1; }

/* Small enough that a page of them is still one request each but no real
   traffic, and square regardless of the tile's format — this is an identity
   check, not a preview. */
.admin-thumb {
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  object-fit: cover;
  border-radius: var(--radius);
  background: var(--bg-input);
}

/* Keeps the column aligned when a tile has no image to show. */
.admin-thumb-empty {
  display: inline-block;
  border: 1px dashed var(--border-light);
}


/* ─── The admin page, as a table ───
   The accounts were a column of cards: an email, four composed stat lines, and
   an action column that would not shrink. That laid out for English and broke in
   Russian, where the button labels run half again to twice as wide — so the
   actions won the width fight and the email, the one thing that identifies the
   row, was what got ellipsised.

   A table instead, because every column is a number somebody wants sorted, and
   the actions in a popover, because that is what stops a label setting the width
   of a column. The card rules above are still in use: the detail page renders
   one of them for the account it is about. */

/* Ten columns want more than the 800px the projects page is comfortable in.
   Wide enough that the flags column — the one the page exists for — is on
   screen rather than the first thing over the horizontal scroll. */
.admin-main {
  max-width: 1500px;
}

/* The hint is a sentence — 118 characters of it in Russian — and the toolbar it
   used to share put space-between it and the heading. It gets its own line. */
.admin-page-head {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 16px;
}

.admin-page-head h2 {
  font-size: 18px;
  font-weight: 600;
}

/* ── The instance at a glance ──
   Above the table because these answer a question the table cannot: how many
   accounts arrived today. One row of an abuse page is one account; this is the
   shape of the whole list. */
.admin-summary {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 12px;
}

.admin-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 120px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px 14px;
}

.admin-stat-value {
  font-size: 20px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.admin-stat-label {
  font-size: 11px;
  color: var(--text-muted);
}

/* Only when there is something in it. A permanently orange tile is furniture. */
.admin-stat-warn {
  border-color: var(--active);
}

.admin-stat-warn .admin-stat-value {
  color: var(--active);
}

/* ── The table ──
   The wrapper scrolls, not the page. Ten columns will not fit every window, and
   a page that shifts sideways under a horizontal scroll is worse than a table
   that does. Same containment .est-body uses for the estimate. */
.admin-table-wrap {
  overflow-x: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
}

.admin-table th {
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  color: var(--text-muted);
  white-space: nowrap;
  border-bottom: 1px solid var(--border);
  padding: 0;
}

/* The whole header cell is the sort control, so the click target is the cell
   rather than the few characters of text in it. */
.admin-table th a {
  display: block;
  padding: 10px 12px;
  color: inherit;
}

.admin-table th a:hover {
  color: var(--text);
  background: var(--bg-panel-hover);
}

/* Reserved whether or not this column is the sorted one, so switching columns
   does not shuffle every heading sideways by the width of an arrow. */
.admin-sort-marker {
  display: inline-block;
  width: 1em;
  text-align: center;
  color: var(--accent);
}

.admin-table td {
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}

.admin-table tbody tr:last-child td {
  border-bottom: none;
}

.admin-table tbody tr:hover {
  background: var(--bg-panel-hover);
}

/* A superuser is the one row worth picking out of a list of thirteen. A left
   edge rather than the card's full border, which a table row cannot have
   without breaking the grid it is part of. */
.admin-table tr.is-admin td:first-child {
  box-shadow: inset 2px 0 0 var(--accent);
}

/* Numbers line up so an outlier is visible without reading any of them. */
.admin-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.admin-table th.admin-num a {
  text-align: right;
}

.admin-when {
  white-space: nowrap;
  color: var(--text-muted);
}

/* The one column allowed to be wide, and the one that must never be cut: it is
   what says whose row this is. */
.admin-cell-email {
  min-width: 200px;
}

.admin-tags {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-left: 6px;
}

/* Badges wrap inside their own cell rather than pushing the row wider — but the
   cell needs a floor, or auto table layout hands its width to the nowrap columns
   beside it and a flagged account stacks four Russian badges one per line into a
   row three times the height of every other. A minimum, not a maximum: the point
   of the column is to be read. */
.admin-cell-flags {
  min-width: 260px;
}

.admin-cell-flags .admin-flag {
  display: inline-block;
  margin: 0 4px 4px 0;
}

.admin-cell-actions {
  width: 1%;
  white-space: nowrap;
}

/* ── The row menu ──
   A <details>, like the language switcher and the message log: it opens and
   closes without a line of script and keeps its keyboard access for free.

   This is the change that makes the table fit. Three buttons in the row set the
   column's width to the longest label in the language being read, which in
   Russian is "Назначить суперпользователем"; in a popover the same label sets
   the width of something that is not there most of the time. */
/* Deliberately not `position: relative`. The panel below is fixed, and a
   positioned ancestor here would only tempt someone into making it absolute
   again — which is the bug this replaced. */
.admin-menu {
  display: inline-block;
}

.admin-menu summary {
  list-style: none;
  cursor: pointer;
}

.admin-menu summary::-webkit-details-marker,
.admin-menu summary::marker {
  display: none;
}

.admin-menu-button {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 24px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-muted);
  font-size: 14px;
  line-height: 1;
}

.admin-menu-button:hover,
.admin-menu[open] .admin-menu-button {
  background: var(--bg-panel-hover);
  color: var(--text);
}

/* Fixed, and placed by static/js/admin.js rather than by CSS.
   
   It has to be: the table sits in a wrapper with `overflow-x: auto` so it can
   scroll sideways on a narrow screen, and a scroll container clips absolutely
   positioned descendants — worse, a box with `overflow-x: auto` has its
   `overflow-y` computed to `auto` as well, so the panel was clipped downward
   and the menu came out as two visible items and a scrollbar.

   `position: fixed` escapes that clip entirely, since nothing between here and
   the viewport has a transform to become its containing block. The price is
   that fixed coordinates mean nothing relative to the button, which is what the
   script supplies. */
.admin-menu-panel {
  position: fixed;
  z-index: 30;
  display: flex;
  flex-direction: column;
  min-width: 180px;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}

.admin-menu-item {
  background: none;
  border: none;
  color: var(--text);
  text-align: left;
  white-space: nowrap;
  font-family: inherit;
  font-size: 12px;
  padding: 8px 12px;
  cursor: pointer;
}

.admin-menu-item:hover {
  background: var(--bg-panel-hover);
  color: var(--text);
}

.admin-menu-danger {
  color: var(--danger);
}

.admin-menu-danger:hover {
  background: var(--danger);
  color: #fff;
}

/* Named for a screen reader and nothing else: the menu button is a glyph, and a
   glyph is not a label. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.scene-card-actions {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

.btn-rename-scene:hover { color: var(--accent); }

/* The rename form replaces the name+stats block while it is open. */
.scene-card.renaming .scene-card-link { display: none; }

.scene-rename-form { flex: 1; min-width: 0; }

.scene-rename-input {
  width: 100%;
  background: var(--bg-input);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
  font-family: inherit;
  font-size: 15px;
  font-weight: 500;
  padding: 4px 8px;
}

.scene-rename-input:focus {
  outline: none;
  border-color: var(--focus-glow);
}

/* ─── Editor layout ─── */
.editor {
  display: flex;
  flex-direction: column;
  height: 100vh;
  overflow: hidden;
}

.toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  height: var(--toolbar-h);
  padding: 0 12px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.toolbar-title {
  font-size: 15px;
  font-weight: 700;
  color: var(--text);
  margin-right: 8px;
}

/* Reads as the toolbar title until hovered/focused, then as an editable field.
   Sits at the right-hand end of the row now, so it takes the toolbar's own gap
   for spacing rather than carrying a margin of its own. */
.toolbar-title-input {
  width: 180px;
  text-align: right;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--text);
  font-family: inherit;
  font-size: 15px;
  font-weight: 700;
  padding: 3px 6px;
  transition: background 0.15s, border-color 0.15s;
}

.toolbar-title-input:hover {
  background: var(--bg-input);
  border-color: var(--border-light);
}

.toolbar-title-input:focus {
  outline: none;
  background: var(--bg-input);
  border-color: var(--focus-glow);
}

.toolbar-actions {
  display: flex;
  gap: 8px;
  align-items: center;
  flex: 1;
}

/* Still an <a>, so middle-click and right-click open it the way a link should,
   but wearing .btn-icon-tool so it reads as part of the tool row. */
.toolbar-back {
  flex-shrink: 0;
}

.toolbar-separator {
  width: 1px;
  height: 24px;
  background: var(--border);
  margin: 0 4px;
}

.btn-icon-tool {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  width: 32px;
  height: 28px;
  padding: 3px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.15s;
}

.btn-icon-tool:hover {
  background: var(--bg-panel-hover);
}

.btn-icon-tool.active {
  background: var(--active);
  border-color: var(--active-hover);
}

.btn-icon-tool.active:hover {
  background: var(--active-hover);
}

.btn-icon-tool img {
  width: 16px;
  height: 16px;
  display: block;
  filter: brightness(0) invert(1);
  opacity: 0.8;
  transition: opacity 0.15s;
}

.btn-icon-tool:hover img {
  opacity: 1;
}

.btn-icon-tool.active img {
  opacity: 1;
}

.btn-icon-tool:disabled {
  opacity: 0.3;
  cursor: default;
}

.btn-icon-tool:disabled:hover {
  background: transparent;
}

.btn-icon-tool:disabled img {
  opacity: 0.4;
}

/* The app's own mark rather than a tool glyph, so it loses the button chrome
   and stands as just the image — a box around a logo makes it read as one more
   tool. It still has to answer the pointer, which it does by coming up to full
   strength and lifting slightly instead of filling a box behind itself. */
#btn-tiles {
  border-color: transparent;
  background: transparent;
  width: auto;
  height: auto;
  padding: 2px 4px;
}

#btn-tiles:hover {
  background: transparent;
}

/* Every other glyph in the row is a single dark fill that the filter above
   turns white; this one is the real logo, and flattening it to a silhouette
   would throw the point of it away. Being the only colour in the row is what
   marks it as the important button. */
#btn-tiles img {
  filter: none;
  opacity: 0.85;
  width: 22px;
  height: 22px;
  transition: opacity 0.15s, transform 0.15s, filter 0.15s;
}

#btn-tiles:hover img {
  opacity: 1;
  transform: scale(1.12);
  filter: brightness(1.15);
}

/* Pressed: settle back down, so the click has somewhere to land. */
#btn-tiles:active img {
  transform: scale(1.02);
}

.editor-body {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.panel {
  background: var(--bg-panel);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  flex-shrink: 0;
}

.panel-title-row {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--border);
}

.panel-title {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-muted);
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
}

.panel-title-row .panel-title {
  border-bottom: none;
}

/* How many surfaces an edit in this panel will reach. Left empty — and so with
   no padding to give it away — whenever that is just the one on display. */
.panel-count {
  font-size: 10px;
  font-weight: 600;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  margin-right: 12px;
}

.panel-count:not(:empty) {
  background: var(--bg-input);
  border: 1px solid var(--border-light);
  border-radius: 10px;
  padding: 2px 8px;
}

.btn-danger-outliner {
  background: transparent;
  color: var(--danger);
  border: 1px solid var(--danger);
  border-radius: var(--radius);
  padding: 3px 8px;
  margin-right: 12px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.15s;
}

.btn-danger-outliner:hover {
  background: var(--danger);
  color: #fff;
}

.btn-danger-outliner:disabled {
  opacity: 0.3;
  cursor: default;
}

.btn-danger-outliner:disabled:hover {
  background: transparent;
  color: var(--danger);
}

.outliner-panel {
  width: var(--outliner-w);
}

.attributes-panel {
  width: var(--attributes-w);
  border-right: none;
  border-left: 1px solid var(--border);
}

.viewport {
  flex: 1;
  background: var(--viewport-bg);
  position: relative;
  overflow: hidden;
}

/* The pencil tool says so under the pointer as well as in the toolbar: it is the
   one tool where a left click does something other than select, and the cursor
   is where you are looking when you find that out.

   The hotspot is the nib rather than the middle, because that is the end that
   draws. Crosshair is the fallback for anywhere the SVG will not load. */
.viewport.tool-pencil {
  cursor: url("/static/icons/pencil_cursor.svg") 1 19, crosshair;
}

/* The slope tool aims at a line rather than at a point, so the crosshair is the
   right resting cursor; `over-edge` is set from the viewport's pointermove
   whenever an edge is actually within reach, which is the only feedback saying
   the click will land on something. */
.viewport.tool-slope {
  cursor: crosshair;
}

.viewport.tool-slope.over-edge {
  cursor: pointer;
}

/* ─── Transform HUD ───
   Floating readout that tracks the gizmo while dragging (translate/rotate/
   scale delta). Hidden by default; shown/positioned from hud.ts. */
.transform-hud {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 5;
  padding: 3px 8px;
  background: rgba(24, 24, 24, 0.92);
  border: 1px solid var(--border-light);
  border-radius: 4px;
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 0.08s ease;
}

.transform-hud.visible {
  opacity: 1;
}

/* ─── Dimension labels ───
   The measurements on the viewport's drafting layer. Pooled divs, positioned in
   px from dimensions.ts by projecting each dimension line's midpoint. The opaque
   background is the same knockout drawLabel() paints on the cut editor's canvas:
   a number has to stay readable over the grid and over its own line. */
.dim-label {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  transform: translate(-50%, -50%);
  padding: 0 3px;
  background: var(--viewport-bg);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10px;
  line-height: 13px;
  font-variant-numeric: tabular-nums;
  color: rgba(230, 230, 230, 0.8);
  white-space: nowrap;
  pointer-events: none;
}

/* The overall-extents tier, matching COLOR_DIM_FAINT in blueprint.ts. */
.dim-label.faint {
  color: rgba(230, 230, 230, 0.45);
}

/* A label that stands for one surface's own width or height can be typed over.
   The extents tier cannot — it measures a box around everything in the scene,
   and there is no single number behind it — so it keeps the class off and stays
   click-through. */
.dim-label.dim-editable {
  pointer-events: auto;
  cursor: pointer;
}

/* The whole label lights up, not just the digits: it already paints a padded
   knockout box to stay readable over its own line, so recolouring that box is
   the highlight — nothing new has to be drawn to say "this one is a control". */
.dim-label.dim-editable:hover {
  background: var(--accent);
  color: #fff;
}

/* The box you type a dimension into, shared by the viewport and the cut editor
   (see src/dimedit.ts). Sized and centred like the label it replaces, so the
   number does not jump when it opens. */
.dim-edit {
  position: absolute;
  z-index: 4;
  transform: translate(-50%, -50%);
  /* Wide enough for an expression, not just its answer: "2400+200" is the
     point of the field. */
  width: 9ch;
  padding: 0 3px;
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-input);
  color: var(--text);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10px;
  line-height: 13px;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.dim-edit:focus {
  outline: none;
  box-shadow: 0 0 0 2px var(--focus-glow);
}

/* ─── Marquee (box) selection ───
   Rubber-band rectangle drawn while left-dragging in the Select tool. Positioned
   and sized from viewport.ts; hidden unless the .visible class is set. */
.marquee-box {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 4;
  border: 1px solid var(--accent);
  background: rgba(71, 114, 179, 0.15);
  pointer-events: none;
  display: none;
}

.marquee-box.visible {
  display: block;
}

/* ─── Status bar ─── */
.statusbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: var(--statusbar-h);
  padding: 0 12px;
  background: var(--bg-panel);
  border-top: 1px solid var(--border);
  font-size: 11px;
  color: var(--text-muted);
  flex-shrink: 0;
  gap: 12px;
}

.statusbar-coords {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.statusbar-message {
  flex: 1;
  text-align: right;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.statusbar-message .axis-disabled {
  color: var(--text-muted);
}

/* Severity, shared by the bar's message slot, the badge and the log rows. The
   same three colours the rest of the app already means these things with. */
.msg-error { color: var(--danger); }
.msg-warn { color: var(--active); }
.msg-info { color: var(--text-muted); }

/* ─── The message log ───
   A drawer at the end of the status bar. The bar shows one message at a time
   and gives the slot back to the tool hint; this keeps the ones that scrolled
   past. Shaped like .lang-switch, which is the app's other <details> popover —
   only it opens upward, being at the bottom of the window. */
.msg-log {
  position: relative;
  flex-shrink: 0;
}

.msg-log summary {
  list-style: none;
  cursor: pointer;
}

.msg-log summary::-webkit-details-marker,
.msg-log summary::marker {
  display: none;
}

.msg-log-button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 0 6px;
  color: var(--text-muted);
  transition: color 0.15s;
}

.msg-log-button:hover,
.msg-log[open] .msg-log-button {
  color: var(--text);
}

.msg-log-label {
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-size: 10px;
  font-weight: 600;
}

/* How much is waiting, coloured by the worst of it: a count that does not say
   whether it is three notes or three failures is not worth reading. */
.msg-log-badge {
  min-width: 15px;
  padding: 0 4px;
  border: 1px solid currentColor;
  border-radius: 7px;
  font-size: 9px;
  font-weight: 600;
  line-height: 13px;
  text-align: center;
}

/* Drawn in CSS rather than loaded as an icon, for the reason the language
   switcher's is: it has to turn when the drawer opens and take its colour from
   the label beside it, and an <img> can do neither. Points up when closed —
   that is where the panel comes from. */
.msg-log-chevron {
  width: 5px;
  height: 5px;
  margin-bottom: -2px;
  border-right: 1.4px solid currentColor;
  border-bottom: 1.4px solid currentColor;
  transform: rotate(225deg);
  transition: transform 0.15s;
}

.msg-log[open] .msg-log-chevron {
  margin-bottom: 2px;
  transform: rotate(45deg);
}

.msg-log-panel {
  position: absolute;
  bottom: calc(100% + 4px);
  right: 0;
  z-index: 60;
  width: 420px;
  max-width: 80vw;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.45);
  overflow: hidden;
}

.msg-log-panel .panel-title-row {
  justify-content: space-between;
}

.msg-log-clear {
  padding: 0 16px;
  font-size: 11px;
}

/* Capped rather than grown: the drawer opens over the viewport, and a session's
   worth of messages would otherwise cover the thing they are about. */
.msg-log-list {
  list-style: none;
  max-height: 40vh;
  overflow-y: auto;
  padding: 4px 0;
}

/* The outliner's row, with a severity stripe down its left edge. */
.msg-row {
  padding: 5px 12px 5px 9px;
  border-left: 3px solid transparent;
  font-size: 11px;
  color: var(--text);
  transition: background 0.1s;
}

.msg-row:hover { background: var(--bg-panel-hover); }
.msg-row.msg-error { border-left-color: var(--danger); }
.msg-row.msg-warn { border-left-color: var(--active); }

.msg-row-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
}

.msg-time {
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  flex-shrink: 0;
}

/* The message itself stays plain: at this length red text is harder to read
   than black-on-white, and the stripe down the row already says which kind it
   is. Stated rather than inherited, so it does not depend on this rule sitting
   after the .msg-error one. */
.msg-text {
  flex: 1;
  min-width: 0;
  color: var(--text);
  word-break: break-word;
}

/* The one thing a row offers: go to what it is about. Out of the way until the
   row is under the pointer, so a list of thirty messages reads as messages
   rather than as a column of buttons. Centred rather than on the baseline its
   flex parent aligns to, which a button sits badly on. */
.msg-action {
  flex-shrink: 0;
  align-self: center;
  width: 18px;
  height: 18px;
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-muted);
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.1s, background 0.1s, color 0.1s;
}

/* Focus as well as hover: reachable by keyboard, not only by pointer. */
.msg-row:hover .msg-action,
.msg-action:focus {
  opacity: 1;
}

.msg-action:hover {
  background: var(--bg-input);
  color: var(--text);
}

/* The long half of a message — what would not parse, what the server said. */
.msg-detail summary {
  font-size: 10px;
  color: var(--text-muted);
  cursor: pointer;
  padding: 2px 0 2px 26px;
}

.msg-detail-body {
  margin: 2px 0 2px 26px;
  padding: 4px 6px;
  background: var(--bg-input);
  border-radius: var(--radius);
  color: var(--text-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 10px;
  white-space: pre-wrap;
  word-break: break-word;
}

/* ─── Outliner ─── */
.outliner-tree {
  list-style: none;
  padding: 4px 0;
}

.outliner-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 12px;
  cursor: pointer;
  transition: background 0.1s;
  user-select: none;
}

.outliner-item:hover { background: var(--bg-panel-hover); }

.outliner-item.selected {
  background: rgba(71, 114, 179, 0.25);
  border-left: 3px solid var(--accent);
  padding-left: 9px;
}

/* The active (primary) surface within a multi-selection reads a touch brighter. */
.outliner-item.selected.active {
  background: rgba(71, 114, 179, 0.4);
}

.outliner-icon {
  font-size: 14px;
  color: var(--accent);
}

/* A fixture, not a face of the room. */
.outliner-icon-prop { color: var(--accent-prop); }

/* Fixtures hidden from the toolbar. The rows stay — the outliner is the scene —
   but they are plainly not on offer, and the click is taken off them so nothing
   can select a solid that is not on the screen. The other half of that is
   dropHiddenFixtures in src/selection.ts, which catches every route in that a
   pointer cannot take. */
.fixtures-hidden .outliner-item-prop {
  opacity: 0.45;
  pointer-events: none;
}

.outliner-label {
  font-size: 13px;
}

.panel-empty {
  color: var(--text-muted);
  font-size: 13px;
  padding: 24px 16px;
  text-align: center;
}

/* ─── Attributes form ─── */
.attr-form {
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.attr-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px;
}

.attr-row-3 {
  grid-template-columns: repeat(3, 1fr);
}

.attr-field {
  display: flex;
  flex-direction: column;
  gap: 3px;
  font-size: 11px;
  font-weight: 500;
  color: var(--text-muted);
}

.attr-field-axis {
  text-align: center;
}

.attr-field input, .attr-field select {
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
  color: var(--text);
  padding: 5px 7px;
  font-size: 12px;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
  width: 100%;
}

.attr-field-axis input {
  text-align: center;
  padding: 5px 4px;
}

.attr-field input:hover, .attr-field select:hover {
  border-color: var(--text-muted);
}

.attr-field input:focus, .attr-field select:focus {
  border-color: var(--focus-glow);
  box-shadow: 0 0 0 2px rgba(0, 180, 216, 0.15);
}

/* Checkbox + caption on one line, unlike the stacked default field. */
.attr-field-check {
  flex-direction: row;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--text);
  cursor: pointer;
}

.attr-field-check input[type="checkbox"],
.attr-field-check input[type="radio"] {
  width: auto;
  flex: 0 0 auto;
  margin: 0;
  accent-color: var(--accent);
  cursor: pointer;
}

/* The prop panel's two built-in-the-browser groups: a plain vertical stack of
   fields. It exists because those fields are generated from the Params schema
   (src/props/panel.ts) rather than written out in the template, so they have no
   hand-tuned row layout of their own to sit in. */
.attr-col {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Label left, value right on one line — the same flip .attr-field-check does
   for checkboxes. A colour needs a chip, not a full-width box. */
.attr-field-inline {
  flex-direction: row;
  align-items: center;
  gap: 8px;
}

.attr-field-inline .attr-swatch-hex {
  margin-left: auto;
  font-size: 11px;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  text-transform: uppercase;
}

.attr-field input[type="color"] {
  width: 44px;
  height: 22px;
  flex: 0 0 auto;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
}

/* Without these the UA draws its own inset border inside the box, so the chip
   reads as a bordered widget rather than as the colour itself. */
.attr-field input[type="color"]::-webkit-color-swatch-wrapper {
  padding: 0;
}

.attr-field input[type="color"]::-webkit-color-swatch {
  border: none;
  border-radius: 1px;
}

.attr-field input[type="color"]::-moz-color-swatch {
  border: none;
  border-radius: 1px;
}

.attr-fieldset {
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 1px);
  padding: 6px 8px 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* The panel's two actions, side by side and at the panel's own scale. They used
   to be one full-width .btn-primary at 13px/8px-16px, against fields at
   12px/5px-7px — it read as a foreign element dropped into the column. */
.attr-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px;
  margin-top: 4px;
}

.attr-action {
  background: transparent;
  border: 1px solid var(--border-light);
  border-radius: calc(var(--radius) - 2px);
  color: var(--text);
  font: inherit;
  font-size: 11px;
  padding: 6px 4px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.attr-action:hover {
  background: var(--bg-panel-hover);
}

.attr-action:disabled {
  opacity: 0.35;
  cursor: default;
}

.attr-action:disabled:hover {
  background: transparent;
}

/* With more than one surface selected, every field here writes to all of them —
   it always has. The tint is what stops that being a silent surprise. */
.attr-form.multi .attr-field {
  color: var(--active);
}

/* Merging needs something to merge and aligning needs something to align to, so
   those two are only there when the selection has more than one surface in it;
   pushing drags a face and what is attached to it, and a quarter turn is one
   face's own setout, so those need exactly one. Flipping wants neither and is
   always there. They sit above the fields, because they act on the selection
   rather than on any value in them. */
.attr-op-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.attr-combine,
.attr-align,
.attr-push,
.attr-flip,
.attr-rotate {
  display: none;
}

/* Whichever of them are showing share the row evenly, and wrap once three of
   them are up. */
.attr-form.multi .attr-combine,
.attr-form.multi .attr-align,
.attr-form.single .attr-push,
.attr-form.multi .attr-flip,
.attr-form.single .attr-flip {
  display: block;
  flex: 1;
  min-width: 0;
}

/* The two turns are the exception: a single glyph each, so they take only the
   width that glyph needs instead of a share of the row. Given one they would
   sit as wide as Push and Flip while saying a third as much, and the row would
   wrap for no reason. */
.attr-form.single .attr-rotate {
  display: block;
  flex: 0 0 auto;
  padding-inline: 10px;
  font-size: 15px;
  line-height: 1;
}

.attr-fieldset legend {
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-muted);
  padding: 0 4px;
}

/* ─── Room dialog ───
   Shared with the extrude dialog, which is the same object: a small centred
   panel with a short form in it. Two copies of these six declarations would
   only ever drift apart. */
.room-dialog,
.extrude-dialog,
.drain-dialog,
.linear-drain-dialog,
.push-dialog,
.slope-dialog {
  width: 300px;
  padding: 0;
  /* The `* { margin: 0 }` reset kills the UA's `margin: auto`, which is what
     centers a modal dialog in its inset: 0 box. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

.room-dialog::backdrop,
.extrude-dialog::backdrop,
.drain-dialog::backdrop,
.linear-drain-dialog::backdrop,
.push-dialog::backdrop,
.slope-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.room-dialog-title,
.extrude-dialog-title,
.drain-dialog-title,
.linear-drain-dialog-title,
.push-dialog-title,
.slope-dialog-title {
  margin: 0;
}

.room-dialog-body,
.extrude-dialog-body,
.drain-dialog-body,
.linear-drain-dialog-body,
.push-dialog-body,
.slope-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
}

.room-dialog-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 8px;
  margin-top: 2px;
}

/* ─── Cut editor dialog ─── */
.cut-dialog {
  /* Nearly the whole window: this is a drawing surface, not a form. The canvas
     below flexes into whatever the chrome leaves, so no scrollbar appears. */
  width: min(1600px, calc(100vw - 32px));
  height: calc(100vh - 32px);
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  /* Same reason as .room-dialog: the margin reset would pin it to the corner. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

/* Only when open: a closed <dialog> is hidden by the UA's `display: none`, and a
   plain `display: flex` here would override it and pin the editor to the page. */
.cut-dialog[open] {
  display: flex;
}

.cut-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.cut-dialog-title {
  margin: 0;
  padding: 10px 12px 0;
}

.cut-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  /* The dimension editor is placed against this box, at the canvas's own offset
     inside it — the canvas is a direct child, so no wrapper is needed. */
  position: relative;
  /* min-height: 0 lets the canvas shrink; the overflow is the last resort on a
     window too short even for the canvas's minimum. */
  flex: 1;
  min-height: 0;
  overflow: auto;
}

.cut-tools {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
}

.cut-tool {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 11px;
  padding: 5px 9px;
  cursor: pointer;
  transition: all 0.15s;
}

/* Matches .btn-danger-outliner:disabled. A button that does nothing has to look
   like it — the built-in tile library greys several of these out at once, and
   without this they sat there at full contrast inviting the click. */
.cut-tool:disabled,
.btn-primary:disabled {
  opacity: 0.3;
  cursor: default;
}

.cut-tool:disabled:hover {
  background: transparent;
  border-color: var(--border);
}

.cut-tool:hover {
  background: var(--bg-panel-hover);
}

.cut-tool.active {
  background: var(--active);
  border-color: var(--active-hover);
}

/* Pushes the history/delete buttons away from the drawing tools. */
.cut-tools-gap {
  flex: 1;
}

.cut-canvas {
  width: 100%;
  /* Takes the height left over after the toolbar, fields and buttons, down to a
     floor that still leaves room for the drafting margins outside the face. */
  flex: 1 1 0;
  min-height: 260px;
  display: block;
  background: var(--viewport-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* The editor drives the pointer itself; touch scrolling would fight it. */
  touch-action: none;
}

/* Shares the footer row with the fields and buttons, taking whatever width they
   leave. min-width:0 lets it wrap rather than shove them off the right edge. */
.cut-hint {
  flex: 1;
  min-width: 0;
  font-size: 11px;
  color: var(--text-muted);
  min-height: 14px;
}

.cut-dialog-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 8px;
  margin-top: 2px;
}

/* ─── Selected cut's X/Y/W/H ───
   A compact strip in the dialog's footer, left of Cancel/Apply. It stays a
   fieldset purely so `disabled` still covers all four inputs at once when
   nothing is selected — the border and legend are dropped. */
.cut-fields {
  border: 0;
  padding: 0;
  margin: 0 6px 0 0;
}

.cut-fields:disabled {
  opacity: 0.4;
}

/* The flex row lives on a child: fieldsets have their own layout quirks. */
.cut-fields-row {
  display: flex;
  align-items: center;
  gap: 5px;
}

.cut-field {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 500;
  color: var(--text-muted);
}

.cut-field input {
  width: 66px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
  color: var(--text);
  padding: 4px 6px;
  font-size: 12px;
  text-align: center;
  font-variant-numeric: tabular-nums;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s;
}

.cut-field input:hover {
  border-color: var(--text-muted);
}

.cut-field input:focus {
  border-color: var(--focus-glow);
  box-shadow: 0 0 0 2px rgba(0, 180, 216, 0.15);
}

.cut-fields-unit {
  font-size: 11px;
  color: var(--text-muted);
}

/* Moved to the attributes block, where these two buttons actually live. */

/* ─── Tile library ───
   Two columns: the set picker over its swatch grid on the left, and the repeat
   preview taking the slack on the right with the selected tile's fields banded
   underneath it. Same dialog shell as the cut editor. */
.tile-dialog {
  width: min(1200px, calc(100vw - 32px));
  /* The whole window height, like .cut-dialog: the preview is a viewport you
     pan and zoom now, not a thumbnail, and it flexes into whatever is left. */
  height: calc(100vh - 32px);
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  /* Same reason as .room-dialog: the margin reset would pin it to the corner. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

/* Only when open — a plain `display: flex` would defeat the UA's `display: none`
   and pin the dialog to the page. Same trap as .cut-dialog. */
.tile-dialog[open] {
  display: flex;
}

.tile-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.tile-dialog-title {
  margin: 0;
  padding: 10px 12px 0;
}

.tile-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  flex: 1;
  min-height: 0;
}

/* Sits in the list column now, which is too narrow for the picker and its three
   buttons side by side — so the picker takes the first row on its own and the
   buttons wrap onto the second. */
.tile-sets {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 6px;
}

/* .cut-hint carries `flex: 1` so it can take the slack in the cut editor's
   footer row. Here it is a row in a column, where growing would hand it all the
   spare height and squash the panes above it. */
.tile-dialog-body .cut-hint {
  flex: 0 0 auto;
}

.tile-set-picker {
  flex: 1 1 100%;
}

/* Why the form beside this is greyed out, and the way round it. Sits under the
   set picker rather than beside the form, because the set is what makes it
   read-only and that is the control you change to get out of it. */
.tile-readonly {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.tile-readonly .cut-hint {
  flex: 1;
  margin: 0;
}

.tile-readonly button {
  flex: 0 0 auto;
  white-space: nowrap;
}

/* The rename box stands exactly where the picker was, so the row does not jump. */
.tile-set-picker input[type="text"] {
  background: var(--bg-input);
  border: 1px solid var(--focus-glow);
  border-radius: calc(var(--radius) - 2px);
  color: var(--text);
  padding: 5px 7px;
  font-size: 12px;
  outline: none;
  width: 100%;
}

/* The three panes split the height the chrome leaves; min-height:0 is what lets
   the grid scroll instead of stretching the dialog. */
.tile-main {
  display: flex;
  gap: 12px;
  flex: 1;
  min-height: 0;
}

/* The left column, so the row reads the way the work does: pick a tile, edit its
   fields, watch the preview.

   360, not less: .tile-grid is `repeat(auto-fill, minmax(104px, 1fr))` with an
   8px gap, so three swatches need 328px of content box. At 320px — once the
   grid's own 8px padding and 1px border come off — only two would fit, and the
   column would quietly go two-across. */
.tile-grid-pane {
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex: 0 0 360px;
  min-width: 0;
}

.tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  align-content: start;
  gap: 8px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 8px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.tile-add {
  align-self: flex-start;
}

.tile-swatch {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 5px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}

.tile-swatch:hover {
  background: var(--bg-panel-hover);
}

.tile-swatch.selected {
  background: var(--bg-panel-hover);
  border-color: var(--active);
}

/* A fixed-height row so the labels under every swatch line up, whatever shape
   the tile inside it is. The face itself is sized in tilecard.ts — shared with
   the Lay Tiles picker — because keeping a plank's proportions while fitting it
   into the column needs both axes scaled together and CSS can only clamp one of
   them at a time. */
.tile-swatch-face {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 74px;
}

.tile-swatch-shape {
  display: block;
  max-width: 100%;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
  background-position: center;
  /* Every tile is a solid sitting proud of its joint, so every swatch gets the
     drop. The chamfer is a per-tile property and rides on .bevelled below. */
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}

/* The chamfer the preview canvas draws, as the two insets — same light from the
   upper left, so a swatch and the preview below it read as the same tile. Hard
   inset edges, because a flat chamfer meets the face along a hard line. */
.tile-swatch-shape.bevelled {
  box-shadow:
    inset 1px 1px 0 rgba(255, 255, 255, 0.3),
    inset -1px -1px 0 rgba(0, 0, 0, 0.3),
    0 1px 2px rgba(0, 0, 0, 0.4);
}

/* A bullnose instead: the insets blur into the face rather than stopping at it,
   and the silhouette loses its corners, matching the canvas clip. */
.tile-swatch-shape.bevelled.rounded {
  border-radius: 5px;
  box-shadow:
    inset 2px 2px 3px rgba(255, 255, 255, 0.32),
    inset -2px -2px 3px rgba(0, 0, 0, 0.32),
    0 1px 2px rgba(0, 0, 0, 0.4);
}

.tile-swatch-label {
  font-size: 11px;
  line-height: 1.25;
  /* Two lines, then ellipsis: catalogue names run long. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.tile-swatch-size {
  font-size: 10px;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* The right column: the canvas takes the slack, the caption rides under it. The
   canvas has to live out here rather than inside .tile-form, because nothing
   inside an `overflow-y: auto` box can flex to the available height. */
.tile-preview-col {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-height: 0;
}

/* Positioned only so the reset-view button can sit in its corner. */
.tile-preview-pane {
  position: relative;
  display: flex;
  flex: 1 1 0;
  /* Low, because the band under it is what must never be squeezed: on a short
     window the preview gives up the height rather than pushing the footer out
     of a dialog that clips. */
  min-height: 200px;
}

/* A fieldset only so `disabled` greys the form out when no tile is selected —
   same trick as .cut-fields. The preview is deliberately not inside it: a
   dimmed-out preview is harder to read, and it is blank anyway with no tile.
   In a 300px column these fields could not be seen at once and the column grew
   a scrollbar of its own. Across the preview's width they fit in two rows, so
   the band is exactly as tall as its contents and never scrolls. */
.tile-form {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 0;
  /* Flush with the preview above it, and on the dialog's own surface rather than
     in a --bg-input well: --border is --bg-input, so a field sunk into a well of
     the same colour has neither edge nor contrast and the row reads as bare
     labels. On the panel colour every input is a visible recess, exactly as in
     the attributes panel. */
  padding: 8px 0 0;
  background: none;
  border: 0;
  border-top: 1px solid var(--border-light);
  border-radius: 0;
}

.tile-form:disabled {
  opacity: 0.4;
}

/* flex-end, not stretch: the checkbox and the two buttons have no label line
   above them, and only aligning on the bottom puts them on the inputs' line. */
.tile-form-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 6px 8px;
}

/* Each field as wide as its own label rather than a share of a grid track — a
   grid would give the 44px colour chip the same column as the name. `nowrap`
   because a label that wraps to two lines (Толщина (мм), Grout colour) would
   lift its input off the row's line. */
.tile-form-row .attr-field {
  flex: 0 0 auto;
  white-space: nowrap;
}

/* The width is set on the box, not on the field around it — wide enough for a
   four-digit price, and the field itself still grows to whatever its label
   needs, so a longer translation widens the field instead of clipping it.
   Left to itself a number input claims its 20-character default, which is twice
   what any figure here is. */
.tile-form-row .attr-field-axis input {
  width: 78px;
}

/* Level with the boxes beside it rather than four pixels short of them. */
.tile-form-row input[type="color"] {
  height: 26px;
}

/* The one field that takes the slack, so both rows justify to the band. */
.tile-form-row .tile-field-name {
  flex: 1 1 150px;
  white-space: normal;
}

/* Its caption sits beside the box, so the field is a line shorter than the rest
   — the padding drops the box back onto their line. */
.tile-form-row .attr-field-check {
  padding-bottom: 5px;
}

.tile-preview {
  width: 100%;
  /* Takes whatever height the caption and fields leave, down to the pane's
     floor — the same deal .cut-canvas gets. */
  flex: 1 1 0;
  display: block;
  background: var(--viewport-bg);
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 1px);
  /* The preview drives the pointer itself; touch scrolling would fight it. */
  touch-action: none;
}

/* Reset view. Absent until the view has actually been moved — there is nothing
   to go back to before that — then faint, so it never competes with the tiles,
   and full strength once the pointer is over the preview. */
.tile-preview-home {
  position: absolute;
  right: 8px;
  bottom: 8px;
  background: var(--bg-panel);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s;
}

.tile-preview-pane.moved .tile-preview-home {
  opacity: 0.35;
  pointer-events: auto;
}

.tile-preview-pane.moved:hover .tile-preview-home,
.tile-preview-home:focus-visible {
  opacity: 1;
}

.tile-preview-caption {
  font-size: 11px;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  min-height: 14px;
  margin-top: -4px;
}

/* Pushes Replace image and Delete tile to the far end of the price row. */
.tile-form-gap {
  flex: 1;
}

/* ─── Lay Tiles dialog ───
   The same shell as the cut editor — a full-window drawing surface — with a
   fixed rail down the right for the zones and their tile mixes. The canvas takes
   everything the rail and the chrome leave. */
.lay-dialog {
  width: min(1600px, calc(100vw - 32px));
  height: calc(100vh - 32px);
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  /* Same reason as .room-dialog: the margin reset would pin it to the corner. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

/* Only when open — a plain `display: flex` would defeat the UA's `display: none`
   and pin the dialog to the page. Same trap as .cut-dialog. */
.lay-dialog[open] {
  display: flex;
}

.lay-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.lay-dialog-title {
  margin: 0;
  padding: 10px 12px 0;
}

.lay-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  flex: 1;
  min-height: 0;
}

/* min-height: 0 so the canvas can shrink inside the column rather than pushing
   the footer off the bottom of the dialog. */
.lay-main {
  display: flex;
  gap: 10px;
  flex: 1;
  min-height: 0;
}

.lay-canvas {
  flex: 1 1 0;
  min-width: 0;
  min-height: 260px;
  display: block;
  background: var(--viewport-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* The editor drives the pointer itself; touch scrolling would fight it. */
  touch-action: none;
}

.lay-rail {
  width: 260px;
  flex: 0 0 260px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow-y: auto;
}

.lay-section {
  display: flex;
  flex-direction: column;
  gap: 6px;
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
}

/* The zone form is a fieldset only so `disabled` covers every control at once
   when no zone is selected; the border and padding are dropped. */
.lay-zone-form[disabled] {
  opacity: 0.5;
}

.lay-section-head {
  display: flex;
  align-items: center;
  gap: 6px;
}

.lay-section-title {
  flex: 1;
  margin: 0;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

.lay-mini {
  padding: 2px 6px;
  font-size: 11px;
}

.lay-zones {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.lay-zone {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  font: inherit;
  font-size: 12px;
  text-align: left;
  color: var(--text);
  background: var(--bg-input);
  border: 1px solid transparent;
  border-radius: var(--radius);
  cursor: pointer;
}

.lay-zone:hover {
  border-color: var(--border-light);
}

.lay-zone.active {
  border-color: var(--accent);
}

.lay-zone-swatches {
  display: flex;
  gap: 2px;
  margin-left: auto;
}

/* The tile's own colour under its face image, so a tile whose image has not
   decoded yet still reads as the right colour rather than as a hole.
   `cover` here, unlike .tile-swatch-shape's `100% 100%`, and deliberately: this
   is a 14px chip answering "what colour and texture", not a format — stretching
   a 2:1 wood photo into a square would distort the grain at a size where no
   proportion reads anyway. Where the format is the point — the picker's
   thumbnails, and the mix rows below — the face goes through tileShape instead
   and stretches to its own proportions. */
.lay-swatch {
  width: 14px;
  height: 14px;
  flex: 0 0 14px;
  border-radius: 2px;
  border: 1px solid var(--border);
  background-size: cover;
  background-position: center;
}

.lay-mix {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* A fixed box for a face of any format, so a mix holding both a plank and a
   square still reads as a list rather than as a ragged edge. */
.lay-mix-face {
  flex: 0 0 56px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.lay-mix-row {
  display: flex;
  align-items: center;
  gap: 6px;
  /* Taller than the zone rows beside it: the face is 40px now, and a row that
     hugged it would put four of them in a solid block. */
  padding: 5px 6px;
  font-size: 12px;
  background: var(--bg-input);
  border: 1px solid transparent;
  border-radius: var(--radius);
  cursor: pointer;
  /* The row answers to a double-click, which would otherwise select the tile's
     name and leave it highlighted behind the picker it just opened. */
  user-select: none;
}

/* Which tile the swap tool paints with — the same orange the editor uses for
   "the thing you are about to act on" everywhere else. */
.lay-mix-row.active {
  border-color: var(--active);
}

.lay-mix-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lay-mix-weight {
  width: 44px;
  flex: 0 0 44px;
  font-variant-numeric: tabular-nums;
}

/* One remove control, shared by the two lists that have one: a zone row and a
   mix row are the same shape, so they get the same affordance in the same
   place. */
.lay-mix-remove,
.lay-zone-remove {
  background: transparent;
  border: 0;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  padding: 0 2px;
}

.lay-mix-remove:hover,
.lay-zone-remove:hover {
  color: var(--danger);
}

/* The label takes the slack so the swatches and the × stay pinned right, and a
   long zone name is trimmed rather than pushing them out of the rail. */
.lay-zone-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.lay-warn {
  color: var(--active);
}

/* ─── The tile picker ───
   A thumbnail grid rather than a dropdown, because choosing a tile is a visual
   decision and a list of catalogue names is not one.

   Its own dialog rather than a panel in the Lay rail: the library is dozens of
   tiles, and that grid in a 260px column had to be capped into a scroller of its
   own, which pushed Start from, Band and Rectangle somewhere you had to go
   looking for them. Sized as a picker, not a workspace — wide enough for five or
   six columns, and no taller than the window. */
.pick-dialog {
  width: min(760px, calc(100vw - 32px));
  max-height: calc(100vh - 64px);
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  /* Same reason as .room-dialog: the margin reset would pin it to the corner. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

/* Only when open — a plain `display: flex` would defeat the UA's `display: none`
   on a closed dialog. */
.pick-dialog[open] {
  display: flex;
}

/* Darker than the other dialogs': this one opens on top of Lay Tiles, which has
   already spent one backdrop, and at 0.5 the two stack into near-black. */
.pick-dialog::backdrop {
  background: rgba(0, 0, 0, 0.35);
}

.pick-dialog-title {
  margin: 0;
  padding: 12px 12px 0;
}

/* .cut-dialog-actions carries no padding of its own — in the cut and lay dialogs
   it sits inside a padded body and takes theirs. Here it is a direct child of a
   dialog with `padding: 0`, so the hint ran into the left edge and Done into the
   right. The rule above it separates the footer from the grid the way the two
   panes of the other dialogs are separated. */
.pick-dialog .cut-dialog-actions {
  gap: 10px;
  padding: 10px 12px 12px;
  border-top: 1px solid var(--border);
}

/* Fixed chrome, one scrolling pane — the shape the cut editor and the tile
   library both use. The body does not scroll; the grid does. Scrolling the whole
   body instead and pinning the controls with `position: sticky` left a strip of
   the body's own top padding above them, and rows slid up through it in full
   view of the dialog title. A sticky bar only ever masks its own box. */
.pick-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
  flex: 1;
  min-height: 0;
}

/* Three .attr-fields, each stacking its label over its control, exactly as the
   attributes panel and the Tile Library's set row do. flex-end keeps the controls
   themselves on one line even if a label wraps on a narrow window.

   The flex sizes below are on the labels, not the controls: .attr-field is a
   column with the default `stretch`, so each input and select fills the width its
   label was given. */
.pick-controls {
  display: flex;
  align-items: flex-end;
  gap: 6px;
}

/* One height for all three. Left to themselves a select and a text input differ
   by a pixel or two — same padding, same border, different intrinsic content box
   — which nobody notices until they are sitting in a row together. */
.pick-controls input,
.pick-controls select {
  height: 28px;
}

/* Wide enough for a set name without crowding the search box. */
.pick-set {
  flex: 0 0 200px;
  min-width: 0;
}

/* The one that takes the slack — it is the control you type into. */
.pick-search {
  flex: 1;
  min-width: 0;
}

.pick-format {
  flex: 0 0 160px;
  min-width: 0;
}

.pick-note {
  margin: 0;
  font-size: 11px;
  color: var(--text-muted);
}

/* The only thing in the dialog that scrolls. min-height: 0 so it can shrink
   inside the column rather than pushing the footer out through the bottom. */
.pick-grid {
  display: grid;
  /* minmax rather than a column count, so the grid reflows on a narrow window
     instead of clipping. */
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  align-content: start;
  gap: 4px;
  padding: 6px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* So bottoming out the grid does not then scroll the page behind the dialog. */
  overscroll-behavior: contain;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.pick-tile {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 3px;
  position: relative;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s, opacity 0.15s;
}

.pick-tile:hover {
  background: var(--bg-panel-hover);
}

/* Already in the mix. The accent blue, not the orange `--active` — that is
   reserved for .lay-mix-row.active, which is the one entry the Swap tool paints
   with, and the two must not read as the same state. */
.pick-tile.in-mix {
  border-color: var(--accent);
}

.pick-tile.in-mix::after {
  content: "✓";
  position: absolute;
  top: 1px;
  right: 3px;
  font-size: 10px;
  line-height: 1;
  color: var(--accent);
}

/* What the next click will do, said before the click. A tick that turns into a
   cross under the pointer is the only thing telling you the grid is a toggle at
   all — without it, "click again to remove" is a sentence in the footer that
   nobody reads twice. */
.pick-tile.in-mix:hover::after {
  content: "✕";
  color: var(--danger);
}

/* A different format from the one setting the mix's lattice. Dimmed rather than
   hidden: the constraint stays legible, but clicking the first tile into an
   empty mix does not make fifty thumbnails vanish under the pointer.

   Only just dimmed, though. A mix led by a 300×600 puts 54 of the 56 tiles in
   here, and at the 0.45 this started on the whole grid read as disabled rather
   than as de-emphasised. The job is to let the handful that do share the lattice
   step forward, not to grey out the library. */
/* A size this zone's lattice cannot hold. The cell is `disabled` in markPicker,
   so this only has to look the part — and it must not brighten on hover the way
   it used to, back when the click still went through. */
.pick-tile.off-format {
  opacity: 0.38;
  cursor: not-allowed;
}

/* Follows PICK_BOX_PX in tilepicker.ts, plus a little air: the face is measured
   there, in both axes, so a plank keeps its proportions instead of being capped
   by CSS into the same box as a square. */
.pick-face {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 66px;
}

.pick-label {
  font-size: 11px;
  line-height: 1.2;
  /* Two lines, then ellipsis: catalogue names run long. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.pick-meta {
  font-size: 10px;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.pick-empty {
  grid-column: 1 / -1;
}

/* Sits in the footer between the hint and the buttons: the running count of what
   the wall costs in tiles, which is the question the dialog exists to answer. */
.lay-readout {
  font-size: 11px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ─── Estimate dialog ───
   A document rather than a workspace: a column of tables that scrolls, with the
   estimator's own figures pinned above it and the total in the footer. */
.est-dialog {
  width: min(1000px, calc(100vw - 32px));
  max-height: calc(100vh - 32px);
  flex-direction: column;
  overflow: hidden;
  padding: 0;
  /* Same reason as .room-dialog: the margin reset would pin it to the corner. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

/* Only when open — a plain `display: flex` would defeat the UA's `display: none`
   and pin the dialog to the page. Same trap as .cut-dialog. */
.est-dialog[open] {
  display: flex;
}

.est-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.est-dialog-title {
  margin: 0;
  padding: 10px 12px 0;
}

.est-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  min-height: 0;
}

.est-settings {
  display: flex;
  flex-direction: column;
  gap: 4px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 8px;
  margin: 0;
}

/* The tables are the part that grows, so they take the scroll rather than the
   dialog — the settings and the total stay put while the list moves. */
.est-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  overflow-y: auto;
  min-height: 0;
}

.est-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

.est-table th {
  text-align: right;
  font-weight: 600;
  font-size: 11px;
  color: var(--text-muted);
  padding: 4px 6px;
  border-bottom: 1px solid var(--border-light);
  white-space: nowrap;
}

.est-table td {
  text-align: right;
  padding: 3px 6px;
  border-bottom: 1px solid var(--border);
}

/* The first column is a name, not a number. */
.est-table th:first-child,
.est-table td:first-child {
  text-align: left;
}

.est-table tfoot td {
  font-weight: 600;
  border-bottom: 0;
  border-top: 1px solid var(--border-light);
}

.est-unpriced {
  display: inline-block;
  padding: 0 5px;
  font-size: 10px;
  border-radius: 8px;
  color: var(--active);
  border: 1px solid var(--active);
  white-space: nowrap;
}

.est-details summary {
  font-size: 11px;
  color: var(--text-muted);
  cursor: pointer;
  padding: 2px 0;
}

.est-note {
  color: var(--active);
}

.est-total {
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The printable sheet: a plain white copy of the estimate, filled in just
   before printing and thrown away after.

   Printing the dialog itself does not work. It is opened with showModal(), so it
   lives in the browser's top layer under a ::backdrop that covers the page, and
   it is nested inside .editor rather than sitting at the top of the body — so
   any rule broad enough to hide the editor hides the dialog with it. A separate
   sheet sidesteps both: nothing in the top layer, nothing to except out. */
.est-print {
  display: none;
}

@media print {
  /* The app is dark; paper is not. Browsers drop background colours by default,
     but "Background graphics" in the print dialog puts them back — and a quote
     printed on a black page wastes a cartridge. */
  html,
  body {
    background: #fff !important;
  }

  /* Hidden, not display:none — the sheet is nested, and its ancestors have to
     stay laid out for it to have anywhere to be. visibility is inherited, so a
     blanket hide here can still be undone on the sheet's own subtree below. */
  body * {
    visibility: hidden !important;
  }

  #estimate-print,
  #estimate-print * {
    visibility: visible !important;
  }

  #estimate-print {
    display: block !important;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    padding: 0;
    background: #fff;
    color: #000;
    font: 12px system-ui, sans-serif;
  }

  .est-print-head {
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin-bottom: 10px;
    border-bottom: 1px solid #000;
    padding-bottom: 6px;
  }

  .est-print-title {
    font-size: 16px;
    font-weight: 600;
    flex: 1;
  }

  .est-print-total {
    font-size: 15px;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
  }

  /* On paper the estimate is one document, so the collapsible per-surface
     breakdown is opened rather than left for someone to click. */
  #estimate-print details {
    display: block;
  }

  #estimate-print summary {
    font-weight: 600;
    margin: 12px 0 4px;
    list-style: none;
  }

  #estimate-print .est-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 11px;
    font-variant-numeric: tabular-nums;
    margin-bottom: 14px;
    /* A table split across a page break loses its header; these are short. */
    break-inside: avoid;
  }

  #estimate-print .est-table th {
    text-align: right;
    font-size: 10px;
    color: #444;
    padding: 3px 5px;
    border-bottom: 1px solid #000;
    white-space: nowrap;
  }

  #estimate-print .est-table td {
    text-align: right;
    padding: 3px 5px;
    border-bottom: 1px solid #ccc;
  }

  #estimate-print .est-table th:first-child,
  #estimate-print .est-table td:first-child {
    text-align: left;
  }

  #estimate-print .est-table tfoot td {
    font-weight: 600;
    border-bottom: 0;
    border-top: 1px solid #000;
  }

  #estimate-print .est-unpriced {
    border: 1px solid #666;
    color: #444;
    border-radius: 8px;
    padding: 0 4px;
    font-size: 9px;
  }

  #estimate-print .est-note {
    color: #444;
    font-size: 10px;
  }

  @page {
    margin: 14mm;
  }
}

/* ─── The language switcher ───
   Shaped like Wikipedia's: the translate glyph, the language you are in, and a
   chevron. The label is what makes it self-explanatory — a bare icon in a corner
   is a guess, and the people who most need this control are the ones who cannot
   read the page it is on.

   A <details> because the app has no dropdown pattern and this needs none: no
   script, keyboard access for free, and it survives the innerHTML swaps htmx
   does around it. */
.lang-switch {
  position: relative;
}

.lang-switch summary {
  list-style: none;
  cursor: pointer;
}

.lang-switch summary::-webkit-details-marker,
.lang-switch summary::marker {
  display: none;
}

.lang-switch-button {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 10px;
  background: transparent;
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text-muted);
  font-size: 13px;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}

.lang-switch-button:hover,
.lang-switch[open] .lang-switch-button {
  background: var(--bg-panel-hover);
  border-color: var(--text-muted);
  color: var(--text);
}

/* The icons are <img>, which inherit no colour from the page, so they are drawn
   in dark ink and whitened here — the same treatment .btn-icon-tool img gets. */
.lang-switch-button img {
  width: 17px;
  height: 17px;
  display: block;
  filter: brightness(0) invert(1);
  opacity: 0.8;
}

.lang-switch-button:hover img,
.lang-switch[open] .lang-switch-button img {
  opacity: 1;
}

.lang-switch-current {
  white-space: nowrap;
}

/* Drawn in CSS rather than loaded as another icon: it has to turn when the menu
   opens and take its colour from the label beside it, and an <img> can do
   neither — it is its own document, so currentColor never reaches it. */
.lang-switch-chevron {
  width: 6px;
  height: 6px;
  margin: -3px 1px 0 -1px;
  border-right: 1.4px solid currentColor;
  border-bottom: 1.4px solid currentColor;
  transform: rotate(45deg);
  transition: transform 0.15s;
}

.lang-switch[open] .lang-switch-chevron {
  margin-top: 2px;
  transform: rotate(225deg);
}

.lang-switch-menu {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  z-index: 60;
  min-width: 150px;
  padding: 4px;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}

/* ─── The list of languages, inside every switcher's menu ─── */
.lang-options {
  list-style: none;
  margin: 0;
  padding: 0;
}

.lang-option {
  display: block;
  padding: 6px 10px;
  border-radius: calc(var(--radius) - 1px);
  color: var(--text);
  font-size: 13px;
  text-decoration: none;
  white-space: nowrap;
}

.lang-option:hover {
  background: var(--bg-panel-hover);
}

/* The UA's focus ring around a full-width link reads as an empty text field.
   Highlight it the way hovering does instead, with the accent picking out the
   edge. */
.lang-option:focus-visible {
  outline: none;
  background: var(--bg-panel-hover);
  box-shadow: inset 0 0 0 1px var(--accent);
}

/* The active one is marked rather than hidden: seeing which you are in is the
   point of opening the menu at all. */
.lang-option.active {
  color: var(--accent);
  font-weight: 600;
}

/* ─── On the auth cards ───
   Last thing in the card, centred under the link to the other page. The panel
   opens upward because there is nothing below it but the edge of the screen. */
.auth-lang {
  display: flex;
  justify-content: center;
  margin-top: 2px;
}

.auth-lang .lang-switch-menu {
  top: auto;
  bottom: calc(100% + 4px);
  right: auto;
  left: 50%;
  transform: translateX(-50%);
}

/* ─── On the projects and admin headers ───
   Grouped with the account actions so it sits at the right-hand end rather than
   stranded between the title and them. */
.scenes-header-right {
  display: flex;
  align-items: center;
  gap: 12px;
}

/* ─── The editor's settings dialog ───
   The language lives behind a gear here. The editor is a working surface rather
   than a page you pass through, so a permanent control would be competing with
   the tools for a choice made roughly once. */
.settings-dialog {
  width: 260px;
  padding: 0;
  /* The UA gives <dialog> `overflow: auto`, which would clip the language menu
     hanging out of the panel — and scroll the panel instead of showing it. */
  overflow: visible;
  /* The `* { margin: 0 }` reset kills the UA's `margin: auto`, which is what
     centers a modal dialog in its inset: 0 box. */
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

.settings-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

/* <details> is a block and stretches the body's full width, so the default
   `right: 0` would hang the menu off the far side of the panel from the button
   that opened it. Squared off against the button instead, and given its width so
   the two read as one control — the same re-anchoring .auth-lang does upward. */
.settings-dialog .lang-switch-menu {
  right: auto;
  left: 0;
  min-width: 100%;
}

.settings-dialog-title {
  margin: 0;
}

.settings-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 12px;
}

.settings-label {
  padding: 0 2px 2px;
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

/* A row rather than the language list's column: three short, fixed labels that
   read as one control, where the languages are a list of names of any length. */
.quality-options {
  display: flex;
  gap: 4px;
  list-style: none;
  margin: 0 0 4px;
  padding: 0;
}

.quality-option {
  background: transparent;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 1px);
  color: var(--text);
  font-size: 13px;
  padding: 5px 12px;
  cursor: pointer;
}

.quality-option:hover {
  background: var(--bg-panel-hover);
}

/* Marked the same way the current language is, for the same reason: which one
   you are on is what you opened the dialog to find out. */
.quality-option.active {
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 600;
}

/* The cost of the choice, which the label alone cannot carry. */
.settings-hint {
  padding: 0 2px 2px;
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1.4;
}

.settings-dialog-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: 4px;
}

/* ─── Share ───
   The link that hands a project to someone with no account here. Sized like the
   settings dialog and a little wider, because what it holds is a URL and a URL
   that wraps is a URL nobody trusts. */
.share-dialog {
  width: 340px;
  max-width: calc(100vw - 32px);
  padding: 0;
  margin: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border-light);
  border-radius: var(--radius);
  color: var(--text);
}

.share-dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.share-dialog-title {
  margin: 0;
}

.share-dialog-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
}

.share-explain {
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1.4;
}

/* A field rather than a line of text: on a phone that will not give the page a
   clipboard, selecting it by hand is the fallback, and a field is what can be
   selected. Monospace so a token is checkable character by character. */
.share-url {
  width: 100%;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 1px);
  color: var(--text);
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
  font-size: 11px;
  padding: 6px 8px;
}

.share-dialog-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 4px;
}

/* Pushes Copy and Close to the far end, so the destructive one is nowhere near
   the hand reaching for them. */
.share-actions-gap {
  flex: 1;
}

.share-stop {
  color: var(--danger);
}

/* ─── Shared, view-only ───
   A project opened through a share link. The same page and the same marker the
   compact layout uses below — anything that exists to change the scene carries
   data-edit-only — off a class instead of a media query, because this one is
   about who is looking rather than how wide their screen is.

   The other half is src/compact.ts: isViewOnly() reads the same state and
   freezes the fields, which cannot be hidden because reading them is the whole
   point of showing a project to someone. */
.view-only [data-edit-only] {
  display: none;
}

/* ─── Compact layout ───
   A phone, or a tablet held in portrait. The editor becomes a viewer there: the
   two panels have nowhere to stand beside a 390px viewport, and a 28px target is
   half of what a fingertip needs.

   Nothing here is a smaller version of the editor. Below the breakpoint the
   tools that change the scene are off the screen entirely and the attribute
   fields are frozen — see src/compact.ts, which reads the same width from
   matchMedia and does the half of this that CSS cannot. The number below is
   written twice, once there and once here; changing one means changing both.

   The two markers this block turns on:
     [data-edit-only]  anything that exists to change the scene — hidden here.
     .compact-only     chrome that only makes sense at this width — hidden
                       everywhere else, which is why it is a base rule. */

.compact-only {
  display: none;
}

/* The toolbar's overflow, and the words that go with it. Both are base rules for
   the same reason .compact-only is: above the breakpoint the panel is empty —
   src/compact.ts leaves every button in the row — and an icon button in the row
   is read by its tooltip, not by a label inside it. */
.toolbar-menu {
  display: none;
}

.btn-label {
  display: none;
}

/* Managed by src/compact.ts through the `hidden` attribute, but only meaningful
   under the drawer below. A window widened while the drawer was open must not be
   left with a sheet of grey over the editor. */
.panel-scrim {
  display: none;
}

@media (max-width: 860px) {
  /* Every editing control at once. The alternative was a list of ids here, which
     would go stale the first time a button was added to the toolbar. */
  [data-edit-only] {
    display: none;
  }

  .btn-icon-tool.compact-only {
    display: flex;
  }

  .btn-link.compact-only {
    display: block;
  }

  /* The layout is already written in terms of these three, so overriding the
     tokens moves the toolbar, the status bar and the drawer edges together
     rather than re-stating every rule that reads them.

     The outliner is a drawer now, so its width is a share of the screen with a
     ceiling rather than a fixed column: 78% leaves a strip of the scene showing
     beside it, which is what says the drawer is over the scene rather than a
     page of its own. */
  :root {
    --toolbar-h: 52px;
    --statusbar-h: 28px;
    --outliner-w: min(78vw, 280px);
  }

  /* Not 100vh: on a phone that is the viewport with the URL bar *collapsed*, so
     with the bar showing, the status bar sits below the fold and the sheet's
     bottom edge goes with it. 100dvh is the space actually on screen and follows
     the bar as it comes and goes. The 100vh in .editor stays as the fallback for
     anything that does not know the unit. */
  .editor {
    height: 100dvh;
  }

  /* No double-tap-to-zoom on the chrome: 300ms of nothing happening after a tap
     on a toolbar button reads as the app having missed it. The viewport is
     handled separately, below. */
  .toolbar,
  .statusbar,
  .panel {
    touch-action: manipulation;
  }

  .toolbar {
    gap: 6px;
    padding: 0 8px;
  }

  /* 32×28 is a mouse target. This is the smallest a finger can be asked for. */
  .btn-icon-tool {
    width: 40px;
    height: 40px;
  }

  /* Only the view group is left in here, and it is a fixed pair — so it stops
     claiming the row's spare width and hands it to the project name. */
  .toolbar-actions {
    flex: 0 0 auto;
  }

  /* 180px of fixed width does not fit beside the buttons on a 390px screen. It
     takes what is left instead, and clips rather than pushing anything off. */
  .toolbar-title-input {
    width: auto;
    flex: 1;
    min-width: 60px;
  }

  .btn-link.compact-only {
    padding: 8px 12px;
    font-size: 15px;
    line-height: 1;
  }

  /* ── The toolbar, as a menu ──
     Everything the row cannot hold at this width, dropped from the button at its
     far end. Shaped after .msg-log-panel, the app's other drop-down, but fixed
     rather than absolute: this one hangs off a flex row and must not take a slot
     in it. Above the drawer's scrim, being the thing that was just tapped. */
  .toolbar-menu.open {
    display: flex;
    flex-direction: column;
    position: fixed;
    top: var(--toolbar-h);
    right: 8px;
    z-index: 25;
    min-width: 200px;
    max-width: calc(100vw - 16px);
    padding: 4px;
    background: var(--bg-panel);
    border: 1px solid var(--border-light);
    border-radius: var(--radius);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
  }

  /* A row rather than a 40px square: the icon keeps its size, the label takes
     what is left, and the whole width of it is the target. */
  .toolbar-menu .btn-icon-tool {
    width: auto;
    height: auto;
    justify-content: flex-start;
    gap: 10px;
    padding: 10px 12px;
    border-color: transparent;
    /* Back to Projects is an <a>, and the link colour it wears everywhere else
       would make one row of the menu the odd one out. */
    color: var(--text);
  }

  .toolbar-menu .btn-label {
    display: block;
    font-size: 15px;
    white-space: nowrap;
  }

  /* The lit orange of an active tool button is a small square's worth of colour.
     A row of it reads as the row being selected rather than the toggle being on,
     so what is on says so at the end of the line instead. */
  .toolbar-menu .btn-icon-tool.active,
  .toolbar-menu .btn-icon-tool.active:hover {
    background: var(--bg-panel-hover);
    border-color: transparent;
  }

  .toolbar-menu .btn-icon-tool.active::after {
    content: "✓";
    margin-left: auto;
    padding-left: 12px;
    color: var(--active);
  }

  /* The panels come out of the flex row and go over the viewport, which is what
     gives the viewport the whole width back. */
  .editor-body {
    position: relative;
  }

  /* ── The outliner, as a drawer ──
     Off the left edge until the handle in the toolbar pulls it out. Between the
     toolbar and the status bar rather than over them: the handle has to stay
     reachable to put it away again. */
  .outliner-panel {
    position: fixed;
    top: var(--toolbar-h);
    bottom: var(--statusbar-h);
    left: 0;
    z-index: 20;
    border-right: 1px solid var(--border);
    transform: translateX(-100%);
    transition: transform 0.2s ease;
  }

  .outliner-panel.open {
    transform: none;
  }

  /* A finger picks a row out of this list, so the rows are a finger apart. */
  .outliner-item {
    padding: 10px 12px;
    font-size: 14px;
  }

  .panel-scrim:not([hidden]) {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 18;
    background: rgba(0, 0, 0, 0.45);
  }

  /* ── The attributes panel, as a sheet ──
     Up from the bottom edge when a surface is selected, down again when nothing
     is — src/compact.ts drives the class off the selection change. Capped at
     45dvh so the surface you just tapped is still on screen above it, which is
     the whole reason you tapped it.

     No scrim behind this one, unlike the drawer: tapping past it at the scene is
     how you deselect, and a scrim would eat that tap. */
  .attributes-panel {
    position: fixed;
    left: 0;
    right: 0;
    bottom: var(--statusbar-h);
    width: auto;
    max-height: 45dvh;
    z-index: 15;
    border-left: none;
    border-top: 1px solid var(--border-light);
    transform: translateY(calc(100% + var(--statusbar-h)));
    transition: transform 0.2s ease;
  }

  .attributes-panel.open {
    transform: none;
  }

  /* Frozen by src/compact.ts, and saying so: a field that still looks like a
     field but will not take a keystroke reads as broken. The border and the
     inset go, and what is left is the number — which is what it is for here. */
  .attr-field input[readonly],
  .attr-field input:disabled {
    background: transparent;
    border-color: transparent;
    padding-left: 0;
    cursor: default;
  }

  .attr-field input[readonly]:hover,
  .attr-field input:disabled:hover {
    background: transparent;
    border-color: transparent;
  }

  /* ── The viewport ──
     OrbitControls sets this on the canvas itself, which is what makes one-finger
     orbit and two-finger pinch work at all. Stated here as well because the
     canvas sits inside a div that has never had a rule of its own, and a gesture
     that starts on the wrapper — over the HUD, or in the moment before the
     canvas is in the DOM — would otherwise scroll the page instead. */
  .viewport,
  .viewport canvas {
    touch-action: none;
  }

  /* A pointer readout with no pointer. The message slot beside it stays, and
     takes the whole bar. */
  .statusbar-coords {
    display: none;
  }

  /* ── Dialogs ──
     Estimate and settings are the two still reachable from here; the rest are
     edit-only and cannot be opened. Full-bleed rather than a card with a margin:
     at this width the margin is the only thing standing between the content and
     fitting. */
  .est-dialog,
  .settings-dialog {
    width: 100vw;
    max-width: 100vw;
    max-height: 100dvh;
    border-radius: 0;
    border-left: none;
    border-right: none;
  }

  /* The estimate is a table of numeric columns and will not narrow past a point.
     It scrolls sideways inside its own pane rather than taking the dialog — and
     the page behind it — along for the ride. */
  .est-body {
    overflow-x: auto;
  }

  /* ── The pages you pass through to reach a scene ──
     Both are already fluid; they are only padded for a desk. */
  .scenes-header {
    padding: 12px 16px;
  }

  .scenes-main {
    padding: 16px 12px;
  }

  /* ── The admin page ──
     The table scrolls sideways and that is the whole treatment. Ten columns of
     numbers do not become a phone layout by stacking; they become a phone
     layout by being a different page, and this one is a desk tool that should
     stay legible when it is opened from a phone rather than pretend to be one.

     What is worth fixing at this width is the furniture around it: the summary
     tiles stop insisting on 120px each, and the page loses the margin it does
     not have room for. */
  .admin-main {
    max-width: none;
  }

  .admin-stat {
    flex-basis: 40%;
    padding: 8px 10px;
  }

  .admin-stat-value {
    font-size: 16px;
  }

  /* Off the panel's rounded edge, so the scroll runs the full width of the
     screen rather than stopping inside a border two thirds of the way through
     a column. */
  .admin-table-wrap {
    border-left: none;
    border-right: none;
    border-radius: 0;
    margin: 0 -12px;
  }

  /* The detail page's rows are the one place cards survive, and they have the
     same non-wrapping problem the account rows had. */
  .admin-item,
  .admin-user {
    flex-wrap: wrap;
  }

  .admin-user-actions {
    flex-wrap: wrap;
  }
}

/* The drawer and the sheet both announce themselves by moving. Asked not to,
   they are simply there or not. */
@media (prefers-reduced-motion: reduce) {
  .outliner-panel,
  .attributes-panel {
    transition: none;
  }
}

/* ─── The drawer handle ───
   Three bars, drawn rather than fetched: static/icons is a set of tool glyphs
   and none of them means "the list of what is in the scene". Sized and coloured
   after .btn-icon-tool img, whose place in the button it takes. */
.hamburger {
  position: relative;
  display: block;
  width: 16px;
  height: 2px;
  background: var(--text);
  border-radius: 1px;
  opacity: 0.8;
}

.hamburger::before,
.hamburger::after {
  content: "";
  position: absolute;
  left: 0;
  width: 16px;
  height: 2px;
  background: inherit;
  border-radius: inherit;
}

.hamburger::before {
  top: -5px;
}

.hamburger::after {
  top: 5px;
}

.btn-icon-tool:hover .hamburger {
  opacity: 1;
}

/* ─── The menu handle ───
   Three dots, drawn for the same reason the three bars are: static/icons is a
   set of tool glyphs and none of them means "the rest of the toolbar". Stood
   opposite the hamburger at the far end of the row, and built the same way so
   the pair of them read as one family. */
.kebab {
  position: relative;
  display: block;
  width: 3px;
  height: 3px;
  background: var(--text);
  border-radius: 50%;
  opacity: 0.8;
}

.kebab::before,
.kebab::after {
  content: "";
  position: absolute;
  left: 0;
  width: 3px;
  height: 3px;
  background: inherit;
  border-radius: inherit;
}

.kebab::before {
  top: -6px;
}

.kebab::after {
  top: 6px;
}

.btn-icon-tool:hover .kebab {
  opacity: 1;
}
