/* The preview surfaces on a file page: image/PDF/text previews, the CodeMirror viewer and editor,
   the repository banner, the commit box and staging groups, file history, the 3D viewer, the
   Measure view and the LightBurn settings panel. docs/invariants/measure-view.md and
   files-preview.md govern the behaviour behind these classes; this file only paints them. */
@layer pages {
  /* MIGRATED VERBATIM (old application.css 85-92, 94-133, 136-239, 241-307, 605-620),
     substitution table applied, in file order. Keep every comment that travels with a rule —
     several explain source-order dependencies (.measure-contour--marked after --hover) that
     the tests pin by computed style. Measure's literal layer colours (#2b7, #d40, the rgba tints,
     #888) STAY literal: they are the file's own colours, not the theme's. */
  .preview { border: 1px solid var(--line); border-radius: var(--radius-panel); padding: 16px; }
  .preview-image { max-width: 100%; height: auto; display: block; border-radius: 6px; }
  .preview-pdf { width: 100%; height: 80vh; border: 1px solid var(--line); border-radius: 6px; }
  .preview-text {
    margin: 0; white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono);
    font-size: 13px; max-height: 480px; overflow: auto;
  }
  .preview-fallback { text-align: center; padding: 24px; }

  /* --- A file page's picture, opened big and zoomable (files/viewers/_zoomable,
     image_zoom_controller.js; 2026-10-25). `.zoomable` qualifies `.preview-image`, which is never
     restyled bare (the addon bundles build markup with it). The focus ring sits INSIDE the picture
     because the card around a poster is `overflow: hidden` and would clip one drawn outside. */
  .preview-image.zoomable { cursor: zoom-in; }
  .preview-image.zoomable:focus-visible { outline-offset: -2px; }
  /* THE WHOLE WINDOW, NOT A BOX IN IT. `display` goes on `[open]` only: an author `display` on the
     bare selector would outrank the UA's `dialog:not([open]) { display: none }` and draw it shut.
     `inset: 0` with auto sizes fills the viewport the dialog is fixed to, where 100vw would reach
     under a classic scrollbar. Nothing in it can push the page sideways at 375px (rule 11): the
     bar wraps, the name breaks anywhere, and the stage is the window's own size. */
  dialog.image-zoom {
    position: fixed; inset: 0; width: auto; height: auto; max-width: none; max-height: none;
    margin: 0; padding: 0; border: 0; overflow: hidden;
    background: var(--bg); color: var(--fg);
  }
  dialog.image-zoom[open] { display: flex; flex-direction: column; }
  dialog.image-zoom::backdrop { background: var(--scrim); }
  .image-zoom-bar {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2);
    padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--line); background: var(--surface);
  }
  .image-zoom-name { min-width: 0; overflow-wrap: anywhere; font: 500 14px/1.3 var(--font-ui); }
  .image-zoom-actions { display: flex; flex-wrap: wrap; gap: var(--space-1); }
  .image-zoom-stage { flex: 1; min-height: 0; background: var(--bg); }
  /* A RENDER STAYS INK ON THE CARD AT ANY SIZE (design-system.md rule 5): the stage takes the
     card's ground and isolation, and the picture multiplies onto it as `.render-card img` does. */
  .image-zoom-stage--card { background: var(--thumb-stage); isolation: isolate; }
  .image-zoom-stage--card image { mix-blend-mode: multiply; }
  /* `touch-action: none` IS WHAT LETS A SECOND FINGER REACH lib/svg_pan_zoom's PINCH, as on the
     board's stage; without it the browser keeps the gesture for its own page zoom. */
  .image-zoom-stage svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
  .image-zoom-stage svg.is-panning { cursor: grabbing; }

  /* --- Source view, read-only AND editable — CodeMirror 6 --- */
  /* `.preview-code` IS BOTH MOUNTS, and the comment said "codeview_controller.js" alone until the
     editable sibling shipped. files/show.html.erb picks between two controllers on one class:
     `codeview` where the file is outside a working tree, `editor` where it is inside one and under
     the size cap. Every rule here has to hold for both, which is why the class is the selector and
     neither controller's name is. */
  /* The editor needs a bounded height of its own: .cm-scroller only scrolls inside a box with
     one, so without this the viewer grows to the length of the file and the PAGE scrolls, which
     is the one thing the 480px cap on .preview-text above was already avoiding. Matching that
     number keeps a file's page the same height before and after the bundle mounts. */
  .preview-code .cm-editor { max-height: 480px; border-radius: 6px; }
  /* No `.cm-focused { outline: none }` here or below (2026-10-21): CodeMirror's unlayered CSS beat
     those layered rules, so they never applied; vendor-overrides.css owns the focus treatment now. */
  .preview-code .cm-scroller { font-family: var(--font-mono); font-size: 13px; }

  /* The editor's Save bar (editor_controller.js builds it in #addSaveBar) and the commit box's
     own footer share this class: both are "one action plus a word about what just happened",
     and giving the commit page a second name for that would be two definitions of one thing. */
  .editor-bar {
    display: flex; align-items: center; gap: 10px;
    margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line);
  }
  .editor-bar button, .editor-bar input[type=submit] {
    font: inherit; padding: 5px 12px; border: 1px solid var(--line); border-radius: 6px;
    background: var(--bg); color: inherit; cursor: pointer;
  }
  .editor-bar button:hover:not(:disabled),
  .editor-bar input[type=submit]:hover { background: var(--raised); }
  .editor-bar button:disabled { opacity: .55; cursor: default; }
  /* Empty until a save reports back, so it must take no space when it says nothing — otherwise
     the bar jumps a line the first time the editor answers. */
  .editor-status { color: var(--fg-muted); font-size: 13px; }

  /* The way INTO the commit box, on a file page inside a working tree. It sits above the editor
     rather than in the .editor-bar because it is a property of the REPOSITORY, not of this file's
     unsaved state, and it must still show on a file with nothing pending. */
  .repo-banner {
    margin: 0 0 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px;
    background: var(--raised); font-size: 13px;
  }
  .repo-banner-link { text-decoration: none; }
  .repo-banner-link:hover { text-decoration: underline; }

  /* --- Review and commit (repo_commits/show.html.erb) ---

     THE ROW, MESSAGE AND GROUP RULES ARE RESTATED AT THE FOOT OF THIS LAYER, under "the staging
     panel and the history list, restated". PR 4 tightened their metrics together with the
     history list they now share a vocabulary with, and one definition each is the rule
     (§14m rule 3): what is here is what is NOT restated there. */
  .commit-box { display: block; }
  /* Tabular figures so a column of +12 / −3 lines up rather than shimmering row to row. */
  .commit-row-head .plus, .commit-row-head .minus { font-variant-numeric: tabular-nums; font-size: 12px; }
  /* --danger is the palette's red and --ok its green: the addition colour was the literal #1a7f37
     until the 2026-09 design system gave the palette a success token, and the substitution table
     maps it to --ok. Both are theme tokens now, so neither is a literal to be matched by eye. */
  .commit-row-head .plus  { color: var(--ok); }
  .commit-row-head .minus { color: var(--danger); }
  /* The inline diff (diffview_controller.js). Bounded for .preview-code's reason — a repository
     with six edited files must not make the commit page six file-lengths tall. */
  .commit-diff .cm-editor { max-height: 320px; }
  .commit-diff .cm-scroller { font-family: var(--font-mono); font-size: 13px; }
  .commit-diff:empty { display: none; }

  /* --- The staging panel's three groups (repo_commits/show.html.erb) ---

     STYLED OFF `data-group` AND `data-state`, NEVER OFF CLASSES OF THEIR OWN. Those attributes are
     already there for the tests to select on, so a parallel set of class names would be a second
     vocabulary for one fact, free to disagree with the first (§14d's own warning about naming). */
  .commit-group-note { margin: 2px 0 10px; font-size: 13px; }

  /* "tinkerex never creates these." IS AN EXPLANATION, NOT A WARNING, and shares .muted with every
     other aside on the page on purpose — .history-note's rule, for its reason. OS clutter is the
     ordinary contents of a folder on a NAS; painting it in --danger would teach makers to read a
     normal library as a fault. The actual protection is that the group arrives UNCHECKED, which is
     a property of the markup and of commitgroup_controller's scope, never of a colour. */

  /* BUILT BY commitgroup_controller.js, so this is the only description of the control's shape a
     reader can find by looking at the page. It sits INSIDE the <details> and above the rows, which
     is what stops a collapsed group being bulk-selected unseen. */
  .commit-group-all {
    display: inline-flex; align-items: center; gap: 6px; margin: 0 0 10px;
    font-size: 13px; color: var(--fg-muted); cursor: pointer;
  }

  /* A ROW GIT WILL NOT COMMIT. The only non-committable state that reaches this page is `ignored` —
     a TRACKED path that .gitignore also covers — and it is listed rather than dropped so the panel
     and `git status` cannot disagree. It therefore has to READ as inert, or an unticked row with no
     checkbox looks like one the maker simply forgot. */
  .commit-rows > li[data-state="ignored"] { background: var(--raised); }
  .commit-rows > li[data-state="ignored"] code { color: var(--fg-muted); }

  /* A row with nothing under its head — an `ignored` row mounts neither a diff nor a "no diff" note
     — would otherwise carry .commit-row-head's separator with empty space beneath it: a rule drawn
     across the bottom of the card for no reason. */
  .commit-row-head:only-child { border-bottom: none; }

  /* --- File history (history_controller.js, mounted by files/show.html.erb) ---

     EVERY ROW IN HERE IS BUILT IN JS, so this block is the only description of the panel's shape
     that a reader can find by looking at the page. The controller writes no styles of its own and
     must not start: a rule that lives in a JS string is one no stylesheet search will ever turn up.

     .history-note IS THE HONEST-EMPTINESS SENTENCE and shares .muted with every other aside on the
     page ON PURPOSE. "No changes to this file in the last 250 commits" is not a warning and must not
     be dressed as one — it is the ordinary answer for a file in a busy repository, and styling it as
     an error would teach makers to read a normal result as a fault. */
  .history-panel { margin-top: 28px; }
  .history-panel h2 { margin-bottom: 8px; }
  /* The way to the repository-wide list, beside the heading rather than under the rows: it is about
     the repository, not about what this walk happened to find, so it must not move or disappear with
     the rows below it. */
  .history-repo-link { font-size: 13px; font-weight: 400; margin-left: 10px; }
  .history-list { list-style: none; margin: 0; padding: 0; }
  .history-list > li { border: 1px solid var(--line); border-radius: var(--radius-panel); margin-bottom: 8px; }
  .history-row-head {
    display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 8px 10px;
  }
  .history-oid { color: var(--fg-muted); }
  /* The subject takes the slack so the actions stay hard right on a wide row and wrap as a group on
     a narrow one; min-width:0 is what lets a long one-line commit message ellipsise instead of
     pushing the actions off the card. */
  .history-subject { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .history-meta { font-size: 12px; }
  .history-actions { display: inline-flex; gap: 10px; font-size: 13px; }
  /* Empty until View or Diff opens one, so it must take no space when it holds nothing — otherwise
     every row carries a blank strip and the list reads as loading forever. */
  .history-detail:empty { display: none; }
  .history-detail { border-top: 1px solid var(--line); padding: 8px 10px; }
  .history-blob {
    margin: 0; max-height: 320px; overflow: auto; background: var(--raised); border-radius: 6px;
    padding: 8px 10px; font-family: var(--font-mono); font-size: 13px;
  }
  /* Bounded for .commit-diff's reason — a row's diff must not make the file page ten screens tall. */
  .history-diff .cm-editor { max-height: 320px; }
  .history-diff .cm-scroller { font-family: var(--font-mono); font-size: 13px; }

  /* --- Interactive 3D viewer (modelviewer_controller.js) — every format Online3DViewer reads,
     Maker::FileAttachment::VIEWABLE_3D_EXTS --- */
  .model-viewer { position: relative; }
  /* THE STAGE IS THE VIEWER'S PARENT ELEMENT, AND ITS SIZE IS READ IN JS. Online3DViewer's
     EmbeddedViewer measures clientWidth/clientHeight in its constructor, so the controller adds
     --ready (which un-hides this) BEFORE booting — a 0x0 stage yields a canvas that never
     recovers. `background` is also read from here, by getComputedStyle, because the engine's
     renderer has no alpha channel and would paint black behind a transparent clear colour. */
  .model-viewer-stage {
    width: 100%; height: 60vh; min-height: 320px; border-radius: 6px;
    background: var(--raised); display: none; touch-action: none;
  }
  .model-viewer-stage canvas { display: block; border-radius: 6px; }
  /* The engine appends its own unclassed <div> ("Loading model…", "Importing model…", and the
     import error text) beside the canvas. We cannot give it a class, so it is selected
     structurally — without this it renders as unstyled black text in the stage's top-left. */
  .model-viewer-stage > div {
    display: flex; align-items: center; justify-content: center;
    height: 100%; color: var(--fg-muted); font-size: 14px; text-align: center; padding: 16px;
  }
  .model-viewer-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
  .model-viewer-source { font: inherit; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); max-width: 60%; }
  .mv-launch { display: inline-flex; gap: 8px; }
  /* WRAPS, because the 3D viewer's bar outgrew one line on a phone: "Use this view as preview"
     (2026-10-20) took the open viewer's buttons to 435px against a 375px viewport, and nowrap
     pushed the whole page sideways (design-system.md rule 11; o3dv_test.rb measures it). */
  .mv-actions { display: none; gap: 8px; flex-wrap: wrap; }
  .model-viewer-dims { color: var(--fg-muted); font-variant-numeric: tabular-nums; }
  /* "Use this view as preview"'s answer. Outside .mv-actions and never hidden by --ready: a status
     line must be readable while the viewer is open, which is when the button is pressed. */
  .mv-status { font-size: 12px; }
  .model-viewer-fallback { text-align: center; padding: 24px; }
  /* --ready MEANS "THE VIEWER IS OPEN", NOT "THE MODEL RENDERED" — reveal the canvas + bar, hide
     the poster. The controller adds it when the user launches (the stage must have a size before
     the engine reads it, above) and removes it again if the load fails, so the poster returns.
     Which of the three happened is on the root element as data-modelviewer-state. */
  .model-viewer--ready .model-viewer-stage { display: block; }
  .model-viewer--ready .model-viewer-fallback { display: none; }
  .model-viewer--ready .mv-launch { display: none; }
  .model-viewer--ready .mv-actions { display: inline-flex; }
  .model-viewer:fullscreen { background: var(--raised); padding: 12px; }
  .model-viewer:fullscreen .model-viewer-stage { height: calc(100vh - 64px); }

  /* --- Gerber/Excellon (tracespace) — the file page's :gerber branch WEARS THE 3D VIEWER'S CHROME
     (every .model-viewer* rule above: the stage hidden until --ready, the bar, the poster, the
     fullscreen height), and `.gerber-viewer` says only what an <svg> stage needs that a <canvas>
     one does not. The project pages' Fabrication panels, which mounted this viewer inline, went on
     2026-09-06 and took `.gerber-stage` with them: the stage IS `.model-viewer-stage` now, whose
     explicit height is what the controller's `height="100%"` SVG resolves against (a percentage
     height against an auto-height ancestor is 0px — the KiCanvas comment below). `color` because a
     tracespace render is `fill="currentColor"` line work on nothing and needs the page's ink over
     the stage's --raised surface. */
  .gerber-viewer .model-viewer-stage { color: var(--fg); }
  /* `cursor: grab` says the board can be dragged before anyone tries it; `is-panning` is the class
     lib/svg_pan_zoom adds for the length of a drag, so the cursor answers the gesture rather than
     the hover.

     `touch-action: none` IS WHAT LETS A SECOND FINGER REACH THE PINCH HANDLER AT ALL. Without it
     the browser claims a two-finger gesture for page scrolling and zooming and never delivers the
     second pointer, so the pinch is not merely degraded — it never fires. It also stops a
     one-finger drag scrolling the page under the board on a phone. */
  .gerber-viewer .model-viewer-stage svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
  .gerber-viewer .model-viewer-stage svg.is-panning { cursor: grabbing; }
  /* A board's bar carries Top/Bottom and one toggle per layer beside Fit, Fullscreen and ✕ Image —
     a dozen buttons on a six-layer board — and wraps them as every .mv-actions does (above).
     `.btn small` is the component (design-system.md rule 3); these only lay the groups out. */
  .gerber-sides, .gerber-tools { display: inline-flex; flex-wrap: wrap; gap: 6px; }

  /* --- Measure view (measure_controller.js) — .lbrn/.lbrn2, .mage, .dxf, .svg --- */
  .measure-view { position: relative; }
  /* SQUARE, AND NEVER TALLER THAN THE VIEWPORT. The stage was 100% of a 920px column; inside the
     1360px shell it grew to a ~1160px square that no longer fits a 1400px browser window, and
     WebDriver clicks at the centre of an element's VISIBLE part — measure_print_test's computed
     click landed on the hole instead of the line. min(100%, 78vh) keeps it square (the tests derive
     mm-per-pixel from the width) and on screen; on a phone 100% is the smaller term. */
  /* THE STAGE IS THE CARD, IN BOTH THEMES (design-system.md rule 5). The drawing is the FILE's own
     layer colours, and a LightBurn black or blue layer, a DXF's ACI 7 or a plain SVG's black stroke
     reads on no dark ground: on Graphite's page it was black on #141512. The card is light in both
     themes, so the drawing reads in both, and it is the same card the poster sits on, so launching
     swaps a picture for a live drawing on one ground. The ring is a box-shadow, NOT a border: a
     border would shrink the SVG inside by 2px, and the Measure tests derive mm-per-pixel from the
     stage's width. */
  .measure-stage {
    display: none; width: min(100%, 78vh); aspect-ratio: 1; margin: 0 auto;
    background: var(--thumb-stage); border-radius: var(--radius-panel); box-shadow: 0 0 0 1px var(--line);
  }
  .measure-stage svg { width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
  .measure-stage svg.is-panning { cursor: grabbing; }
  /* Seven controls since stage 4, S4-3 (paper, Print, Save sheet, Download SVG, Save SVG, Reset,
     ✕ Image), so the row WRAPS rather than squashing them: flex items shrink by default, and a
     nowrap row of seven at 375px pushes the page sideways (design-system rule 11). */
  .measure-bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
  .measure-launch { display: inline-flex; gap: 8px; }
  .measure-actions { display: none; gap: 4px; flex-wrap: wrap; }
  .measure-dims { flex: 1; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
  .measure-fallback { text-align: center; padding: 24px; }
  /* THE POSTER IS CENTRED BY ITS CARD (2026-10-21). It sat against the fallback's left padding
     until slice 7, because `.preview-image` is `display: block` and `text-align: center` does not
     move a block; a `margin-inline: auto` here fixed it. Every poster now sits in a `.render-card`
     (components.css), which centres it as a flex container, so that rule went: two mechanisms for
     one centring is how one of them stops being the one that works. files_layout_test.rb measures
     it. The bare `.preview-image` is still never restyled (shared with the addon bundles). */
  .measure-fallback .render-card, .model-viewer-fallback .render-card { margin-bottom: var(--space-3); }
  /* AN SVG RESTS ON THE FILE ITSELF, IN THE DRAWING'S FOOTPRINT (2026-10-26). Left to `.render-card
     img`, an SVG took its own declared size: a sheet declared in mm ran off the bottom of the
     screen, and a 50 mm part sat small in a wide card. This is `.measure-stage`'s box, square and
     never taller than the viewport, and `contain` scales the file to fit it, which a vector does
     without going soft. The zoom overlay reads this laid-out box and its <image> letterboxes the
     same way, so the picture keeps its shape when it opens big. */
  .measure-own-poster .render-card { width: min(100%, 78vh); aspect-ratio: 1; margin-inline: auto; }
  .measure-own-poster .render-card img { width: 100%; height: 100%; object-fit: contain; }
  /* The controller adds --ready on a successful render: reveal the outline stage, hide the poster. */
  .measure-view--ready .measure-stage { display: block; }
  .measure-view--ready .measure-fallback { display: none; }
  .measure-view--ready .measure-launch { display: none; }
  .measure-view--ready .measure-actions { display: inline-flex; }
  /* Outline geometry drawn from the /geometry payload — see measure_controller.js (Task 8). */
  /* NO `stroke` HERE ON PURPOSE: the layer colour arrives as a presentation attribute from
     measure_controller.js, and any class rule would outrank it and flatten every layer
     to one colour. The --hover/--marked rules below DO declare one, which is how they
     still win over the attribute. */
  .measure-contour { fill: transparent; stroke-width: 1; vector-effect: non-scaling-stroke; }
  .measure-contour--open { fill: none; }
  .measure-contour--hit { stroke: transparent; stroke-width: 8; fill: none; pointer-events: stroke; vector-effect: non-scaling-stroke; }
  .measure-contour--hover { stroke: #2b7; stroke-width: 2; }
  /* THE SELECTION, IN ONE COLOUR, SINCE 2026-09-03 AND AT THE USER'S REQUEST. There were two rules
     until then: `--marked` painted the print set blue (#06c) and `--selected` painted the contour
     being measured this red, because a mode gathered one while a plain click did the other. A click
     now does both, so this rule keeps --selected's colour and weight and --marked's tint, and
     `.measure-contour--selected` is gone rather than left as a second name for the same state.

     DECLARED AFTER --hover, AND THAT ORDER IS THE WHOLE "STAYS RED" CLAIM. Both are one class,
     (0,1,0), so source order alone decides a contour that is both — and a click leaves the pointer
     ON the contour it just selected, so with this rule first the shape you just picked would sit
     under the hover green until you moved the mouse away. Reverse the two and the computed-style
     assertions in lbrn_measure_test.rb and measure_print_test.rb go red with rgb(34, 187, 119). */
  .measure-contour--marked { stroke: #d40; stroke-width: 2.5; fill: rgba(221, 68, 0, .16); }
  /* An open contour has no interior, so the tint would paint a meaningless blob between its ends.
     Higher specificity than the rules above, so it wins regardless of source order. */
  .measure-contour--open.measure-contour--marked { fill: none; }
  .measure-bitmap { fill: rgba(128, 128, 128, .18); stroke: #888; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
  /* `.measure-view--selecting .measure-stage svg:not(.is-panning) { cursor: copy }` STOOD HERE and
     went with the mode on 2026-09-03. Its `:not(.is-panning)` was a specificity fix — both selectors
     were (0,2,1) and this one was later, so without it a drag while selecting showed `copy` instead
     of `grabbing`. There is no selecting class to key on now; the stage is `crosshair` at rest and
     `grabbing` while panning, and nothing else. */
  .measure-paper, .measure-page { font-size: 13px; }

  /* --- The laser / CNC recipe panel (files/_lbrn_settings, _mage_settings): the head of the file
     page's side column on a .lbrn/.lbrn2/.mage file. It sat beside the preview at half the main
     column until 2026-10-25; the side column is its home now, and `.workspace.has-recipe` below
     widens that column for these pages only. Below 1280px the column stacks and the panel takes
     the full width like every other side block. --- */
  .lbrn-settings { border: 1px solid var(--line); border-radius: var(--radius-panel); padding: 14px 16px; }
  .lbrn-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: 10px; }
  .lbrn-head h2 { margin: 0; font-size: 16px; }
  .lbrn-layers { width: 100%; border-collapse: collapse; font-size: 13px; }
  .lbrn-layers th { text-align: left; color: var(--fg-muted); font-weight: 600; padding: 5px 5px; border-bottom: 1px solid var(--line); white-space: nowrap; }
  .lbrn-layers td { padding: 5px 5px; border-bottom: 1px solid var(--raised); }
  .lbrn-layers .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
  /* The unit a numeric column is in, written once in its heading rather than in every cell. */
  .lbrn-unit { font-weight: 400; }
  /* MillMage's tool, as a second line under its operation: its own names run long, so it wraps
     (anywhere, for a name with no spaces) instead of truncating. */
  .lbrn-tool { display: block; font-size: 12px; overflow-wrap: anywhere; }
  .lbrn-swatch { display: inline-block; width: 11px; height: 11px; border-radius: 2px; vertical-align: middle; margin-right: 7px; }
  .lbrn-summary { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 12px 0 0; font-size: 13px; }
  .lbrn-summary dt { color: var(--fg-muted); }
  .lbrn-summary dd { margin: 0; font-weight: 600; }
  /* The text shapes' strings, closed until asked for. The summary keeps the browser's own
     disclosure triangle — `display: flex` on a <summary> removes it in Chrome (the releases
     panel's note) — so it is styled as a plain line. */
  .lbrn-text { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--line); font-size: 13px; }
  .lbrn-text > summary { cursor: pointer; font-weight: 600; }
  .lbrn-text > summary .muted { font-weight: 400; }
  .lbrn-text-list { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0 0; padding: 0; list-style: none; }
  .lbrn-text-list li { font: 400 12px/1.3 var(--font-mono); padding: 3px 6px; border-radius: var(--radius-control); background: var(--raised); overflow-wrap: anywhere; }

  /* --- The staging panel and the history list, restated (PR 4, spec §8) ---

     THE RULES THE "Review and commit" AND "three groups" SECTIONS ABOVE POINT AT. They moved here
     rather than being edited in place because the commit rows, the commit message and the
     repository history list were re-metricked as ONE vocabulary — the same border, the same
     radius, the same mono meta line — and a reader comparing them should not have to hold three
     screens apart. Each selector is still declared exactly once in this file. */
  /* The trash and repository pages' one-line explanation under the head. Capped in ch rather than
     px because it is prose: a measure that long stops being readable, whatever the viewport.
     WAS `.page-intro` UNTIL 2026-09-09 (content blocks), when the project page's own page-intro
     CARD — the cover and description, spec §3 — claimed that name as a component. Two meanings for
     one class name is the pre-2026-09 `.btn` bug with a new face, and this one would have been
     silent AND one-directional: `pages` is declared after `components`, so this rule's 72ch cap
     would have squeezed the card and nothing here would have changed at all. */
  .head-note { max-width: 72ch; margin: -8px 0 16px; font-size: 13px; }
  .commit-group + .commit-group { margin-top: 12px; }
  /* The disclosure triangle is drawn here, not left to the browser: `display: flex` on a <summary>
     drops the native marker in WebKit only, so a group would carry a triangle in Firefox and none
     in Safari. `list-style: none` plus the -webkit- pseudo-element removes both, and ::before is
     then the ONE marker, rotating on [open] in both engines. */
  .commit-group-body > summary { cursor: pointer; padding: 2px 0; font-weight: 600; list-style: none; display: flex; align-items: center; gap: 8px; }
  .commit-group-body > summary::-webkit-details-marker { display: none; }
  .commit-group-body > summary::before {
    content: ""; width: 6px; height: 6px; border-right: 1.5px solid var(--fg-muted); border-bottom: 1.5px solid var(--fg-muted);
    transform: rotate(-45deg); transition: transform 120ms ease;
  }
  .commit-group-body[open] > summary::before { transform: rotate(45deg); }
  /* The count beside a title is the ONLY thing a closed group says about how much is hiding inside
     it, so it keeps .muted's colour but not its weight. It is set in the mono face at 11px because
     it is a figure read against the other groups' figures, not a second title — the same reason
     .commit-meta below is mono. */
  .commit-group-body > summary .muted { font: 400 11px/1.4 var(--font-mono); }
  .commit-rows { list-style: none; margin: 8px 0 0; padding: 0; }
  .commit-rows > li { border: 1px solid var(--line); border-radius: var(--radius-panel); margin-bottom: 8px; background: var(--bg); }
  .commit-row-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 6px 10px; border-bottom: 1px solid var(--line); }
  /* `overflow-wrap: anywhere` because a path has no spaces to break at: without it a deep one is
     the single element that makes the commit page scroll sideways at 375px (§14m rule 11). */
  .commit-row-head code { font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
  .commit-row-state { font: 500 10px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .08em; }
  .commit-nodiff { margin: 0; padding: 6px 10px; font-size: 12px; }
  .commit-message { width: 100%; font: 13px/1.5 var(--font-ui); padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-control); background: var(--surface); color: inherit; }
  .commit-rename-note, .commit-truncated { font-size: 12px; }

  /* --- Repository history (repo_histories/show.html.erb) ---

     A THREE-COLUMN GRID, NOT A FLEX ROW, so every oid starts at the same x down the whole list —
     which is the point of a short oid you scan rather than read. .history-list on the FILE page is
     flex for the opposite reason: its rows carry actions that must sit hard right. */
  .commit-history { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
  .commit-history-row { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 4px 14px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--line); }
  .commit-oid { font-size: 12px; color: var(--fg-muted); }
  .commit-subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .commit-meta { font: 11px/1.5 var(--font-mono); white-space: nowrap; }
  /* The meta line cannot ellipsise (a date read half way is worse than one on its own line), so on
     a phone it drops under the subject instead of squeezing the two columns beside it. */
  @media (max-width: 719px) { .commit-history-row { grid-template-columns: 76px minmax(0, 1fr); } .commit-meta { grid-column: 2; } }

  /* --- KiCad schematics/boards (KiCanvas), stage 3 S3-2 — the file page's :kicanvas branch AND
     the project page's Schematics section share this class (kicanvas_controller.js builds the
     <kicanvas-embed> inside it either way). `height`, not just `min-height`: kicanvas-embed below
     is told `height: 100%`, and a percentage height resolves to nothing against an ancestor whose
     own height is auto — the box needs an explicit one or the embed renders at 0px regardless of
     what the custom element does internally. The project panel keeps the 320px default; the file
     page — the format's main stage — asks for more room. */
  .kicanvas-stage { min-height: 320px; height: 320px; background: var(--raised); border-radius: 6px; }
  .preview .kicanvas-stage { height: 70vh; }
  .kicanvas-stage kicanvas-embed { display: block; width: 100%; height: 100%; }
  /* The "Loading the viewer…" line occupies the box until connect() replaces it; keeps the page
     from jumping once the module resolves and the embed's own shadow DOM takes over painting. */
  .kicanvas-stage > .muted { padding: 24px; text-align: center; }

  /* --- Schematics section on the project page (projects/_schematics.html.erb) --- */
  .schematics-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
  .schematics-list li { display: flex; align-items: center; gap: 8px; }

  /* --- The library path row (stage 4, S4-3). `dl.meta dd` is already mono and already wraps
     anywhere (components.css), so this only lays the value and its button on one line and keeps
     the button from stretching. A page rule qualified inside `.meta`, never a redeclaration of
     `dl.meta` (design-system rule 3). --- */
  .meta .file-path { display: flex; align-items: baseline; gap: 8px; }
  .meta .file-path-value { min-width: 0; overflow-wrap: anywhere; }
  .meta .file-path .linkbtn { flex: 0 0 auto; }
  /* ---- END OF S4-3 TASK C's ADDITIONS. Task D (Save to library) appends below. ---- */

  /* THE WORKSPACE (structural redesign §5.3, slice 3): the viewer takes the main column and the
     file's facts sit beside it from 1280px; below that they stack under the viewer. NOT
     COLLAPSIBLE: the 3D, Gerber and Measure viewers measure their container when they are built
     (files-preview.md, "`--ready` means the viewer is open"), so a column that changes width after
     load would need a resize contract with each of them.

     The side column's own grid gap spaces its blocks, so the margins they carry on their own
     (dl.meta's bottom, .attach-to-project's, .notes-panel's and .connections-panel's tops) are
     zeroed here rather than added to it. */
  .workspace { display: grid; gap: var(--space-5); grid-template-columns: minmax(0, 1fr); }
  .workspace-side { display: grid; gap: var(--space-4); align-content: start; min-width: 0; }
  .workspace-side dl.meta { margin: 0; }
  .workspace-side .attach-to-project,
  .workspace-side .notes-panel,
  .workspace-side .connections-panel { margin-top: 0; }
  @media (min-width: 1280px) {
    .workspace { grid-template-columns: minmax(0, 1fr) 340px; }
    /* A laser or CNC file's recipe panel heads the side column, and its five-column table needs
       more than 340px; every other file page keeps the narrower column. */
    .workspace.has-recipe { grid-template-columns: minmax(0, 1fr) 380px; }
    /* `align-self: start` IS WHAT LETS STICKY WORK AT ALL: a grid item stretches to its row by
       default, and an element as tall as its containing block has nowhere to stick. A side column
       taller than the window and shorter than the viewer scrolls its foot into view as the main
       column ends, the ordinary sticky-sidebar trade. */
    .workspace-side { position: sticky; top: calc(var(--topbar-height) + var(--space-4)); align-self: start; }
    /* dl.meta's four-column layout (components.css) keys on a 900px VIEWPORT, which a 340px column
       is not; two columns here. Stacked below 1280px the side column is the full width again and
       the component's own rule is right, so this override lives only at this width. */
    .workspace-side dl.meta { grid-template-columns: max-content minmax(0, 1fr); }
  }
}
