/* ===========================================================================
   Create your desk shelf
   Four steps, full screen, two sections.

   LEFT   a dark column carrying the four steps and, under them, the order as
          it is being built. It is the same column on every step, so what you
          have made and what it costs never leaves the screen.
   RIGHT  the step itself, with the step title and the two navigation buttons
          along the top, and the work under them.

   Everything is built on the tokens in styles.css so this reads as part of the
   same site: greige paper, ink, a warm accent, Fraunces and Inter.
   =========================================================================== */

:root {
  --st-panel:  #d5d1c6;   /* light panels inside the work area */
  --st-panel2: #c3bfb2;
  --st-well:   #98948a;   /* the drawing well */
  --st-sheet:  #f7f5ef;   /* the paper a drawing sits on */
  --st-line:   #918c7d;
  --st-line2:  #6f6a5d;
  --st-ink:    #100e0c;
  --st-soft:   #2a2620;
  --st-faint:  #453f36;
  --st-accent: #4c443a;
  /* The only cool colour in the studio, and it has one job: the frame round the
     sector being edited on the plan. Nothing we make comes in it, so it can
     never be mistaken for a part. */
  --st-focus:  #17646f;
  --st-err:    #7a2314;
  /* POINT 1. THE FILL OF A PIECE THAT IS CAUSING AN ERROR, which is a different
     colour from --st-err above and has to be.

     --st-err is a LINE colour. It is the oxblood the error stamps, the message
     numbers and the piece outlines are drawn in, it sits against paper, and at
     1.8px of stroke it is read as ink. This is a FIELD colour: it fills a whole
     part, it has to be told apart from the two things a part is otherwise made
     of, and it has to carry the part's own interior detail on top of it.

     #c62828, measured rather than picked by eye. CIEDE2000 from the three
     surfaces it has to be distinguished from: 37.1 from the dowel wood
     #cfa871, 35.3 from a black part #26221e, 50.9 from a white part #fdfdfa.
     Anything above about 5 is plainly a different colour, so all three are far
     past the question. Against the oxblood outline it keeps 15.3, which is
     what stops the silhouette dissolving into the fill.

     It is not in the palette and it is not meant to be. Nothing we make comes
     in red, so a red part cannot be mistaken for a part somebody chose, which
     is the same reason --st-focus is the one cool colour in the studio. */
  --st-err-fill:  #c62828;
  --st-err-fill2: #a81f1f;   /* the second fill, a step down, for soft shapes */
  --st-err-face:  #9c1c1c;   /* a recessed face, one step further down */
  --st-err-line:  #5c1212;   /* the silhouette, still darker than the field */
  --st-warn:   #5d4310;
  --st-ok:     #2c4a2b;
  --st-shadow: 0 18px 44px -20px rgba(25, 22, 19, .65);
  --st-r: 12px;

  /* the dark side column */
  --sd-bg:     #1f1c19;
  --sd-fg:     #d8d6ce;
  --sd-soft:   #9a948a;
  --sd-line:   #3a352f;
  --sd-accent: #b0a89b;

  /* Wood. Dowels are bought, not made, so they never take the finish. */
  --wood:      #cfa871;
  --wood-dark: #a37f4c;
  --wood-line: rgba(72, 48, 20, .68);
  --wood-hi:   rgba(255, 246, 228, .6);
}

[hidden] { display: none !important; }
html, body.flow-page { height: 100%; }
body.flow-page {
  margin: 0;
  overflow: hidden;
  background: var(--bg);
  color: var(--st-ink);
  font-family: 'Inter', sans-serif;
  -webkit-font-smoothing: antialiased;
}

.flow {
  height: 100%;
  display: grid;
  grid-template-columns: 340px minmax(0, 1fr);
}

/* ---- the dark side ------------------------------------------------------ */

.side {
  background: var(--sd-bg);
  color: var(--sd-fg);
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 26px 24px 22px;
}
.side-brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: inherit; }
.side-brand .bar { width: 4px; height: 30px; background: var(--sd-accent); flex: 0 0 auto; }
.side-brand .txt { font-family: 'Fraunces', serif; font-weight: 600; font-size: 23px; letter-spacing: -.025em; }
.side-brand .txt .d { color: var(--sd-accent); }
.side-task {
  font-size: 10.5px; letter-spacing: .19em; text-transform: uppercase;
  color: var(--sd-accent); font-weight: 700; margin: 10px 0 24px;
}
/* The one link out of a flow and into the other one. Quiet on purpose: it
   is for the person who arrived in the wrong place, not a second call to
   action competing with the step they are on. */
.side-cross {
  display: inline-block; margin: -6px 0 18px;
  font-size: 12.5px; color: var(--sd-soft);
  border-bottom: 1px solid currentColor; padding-bottom: 1px;
  transition: color .2s;
}
.side-cross:hover { color: var(--sd-fg); }

.fsteps { list-style: none; margin: 0 0 22px; padding: 0; position: relative; }
.fsteps::before { content: ""; position: absolute; left: 15px; top: 18px; bottom: 20px; width: 2px; background: var(--sd-line); }
.fstep { position: relative; padding: 0 0 18px 44px; cursor: pointer; }
.fstep:last-child { padding-bottom: 0; }
.fstep .dot {
  position: absolute; left: 0; top: 0; width: 32px; height: 32px; border-radius: 50%;
  background: var(--sd-bg); border: 2px solid var(--sd-line); z-index: 1;
  display: flex; align-items: center; justify-content: center;
  font-family: 'Fraunces', serif; font-size: 14px; color: var(--sd-soft);
  transition: background .18s, border-color .18s, color .18s;
}
.fstep .tx { display: block; padding-top: 4px; }
.fstep .tx b { display: block; font-size: 14px; font-weight: 600; color: var(--sd-soft); transition: color .18s; }
.fstep .tx em { display: block; font-style: normal; font-size: 12px; color: var(--sd-soft); opacity: .72; }
.fstep:hover .tx b { color: var(--sd-fg); }
.fstep.done .dot { background: var(--sd-accent); border-color: var(--sd-accent); color: var(--sd-bg); }
.fstep.done .tx b { color: var(--sd-fg); }
.fstep.on .dot { background: var(--sd-fg); border-color: var(--sd-fg); color: var(--sd-bg); }
.fstep.on .tx b { color: #fff; font-weight: 700; }
.fstep.on .tx em { opacity: 1; color: var(--sd-accent); }
.fstep.on::after { content: ""; position: absolute; left: -24px; top: 2px; bottom: 18px; width: 3px; background: var(--sd-accent); }

/* You are building. It has to hold a long order without pushing the total off
   the screen, so it is the only thing in the column that grows, and it scrolls
   inside itself. */
/* The order can run long, so this is the only thing in the column that grows.
   A dark panel with a thin scrollbar gives no clue that there is more below it,
   so it says so out loud: a count in the heading and a fading edge at the foot
   that appears only when there is something under it. */
.building { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-top: 1px solid var(--sd-line); padding-top: 16px; position: relative; }
.building-k { display: flex; align-items: baseline; gap: 8px; }
.building-k .count { margin-left: auto; font-size: 10px; letter-spacing: .1em; color: var(--sd-soft); font-weight: 600; }
.building.more::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 46px; pointer-events: none;
  background: linear-gradient(rgba(31, 28, 25, 0), var(--sd-bg) 78%);
}
.building.more .scroll-cue { display: flex; }
.scroll-cue {
  display: none; position: absolute; left: 50%; transform: translateX(-50%); bottom: 4px; z-index: 2;
  align-items: center; gap: 6px; padding: 4px 12px; border-radius: 100px;
  background: rgba(216, 214, 206, .14); border: 1px solid var(--sd-line);
  font: 600 10px 'Inter', sans-serif; letter-spacing: .12em; text-transform: uppercase; color: var(--sd-accent);
  cursor: pointer;
}
.scroll-cue:hover { background: rgba(216, 214, 206, .24); color: var(--sd-fg); }
.scroll-cue svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.building-k { font-size: 10.5px; letter-spacing: .18em; text-transform: uppercase; color: var(--sd-accent); font-weight: 700; margin-bottom: 10px; flex: 0 0 auto; }
.building-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-right: 4px; margin-right: -4px; }
.building-scroll::-webkit-scrollbar { width: 8px; }
.building-scroll::-webkit-scrollbar-track { background: rgba(216, 214, 206, .07); border-radius: 8px; }
.building-scroll::-webkit-scrollbar-thumb { background: var(--sd-accent); border-radius: 8px; }

.bgrp {
  font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--sd-bg);
  background: var(--sd-accent); font-weight: 700;
  margin: 14px 0 0; padding: 4px 8px; border-radius: 4px;
}
.bgrp:first-child { margin-top: 0; }
.bline { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--sd-line); font-size: 12.5px; color: var(--sd-soft); }
.bline > .n:last-child { grid-column: 3 / span 2; }
.bline .q { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--sd-accent); }
.bline .d { min-width: 0; color: var(--sd-fg); }
.bline .d em { display: block; font-style: normal; font-size: 11px; color: var(--sd-soft); line-height: 1.45; }
/* What the customer picked, under the name of the kit: the model and the leg
   height. It is their own choice read back to them, not the packing list under
   it, so it is set brighter than a note and closer to the name it belongs to. */
.bline .d em.spec { color: var(--sd-fg); font-size: 11.5px; margin-top: 1px; }
.bline .n { font-variant-numeric: tabular-nums; color: var(--sd-fg); font-weight: 500; white-space: nowrap; }
.bline.free .n { color: var(--sd-accent); }
.bline .rm { background: none; border: none; color: var(--sd-soft); cursor: pointer; font-size: 15px; line-height: 1; padding: 0 0 0 4px; }
.bline .rm:hover { color: #e59a86; }
.bempty { font-size: 12.5px; color: var(--sd-soft); line-height: 1.6; }

/* the shipping block, above the price, on every step */
.side-sum { flex: 0 0 auto; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--sd-line); }
.side-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12.5px; color: var(--sd-soft); }
.side-row + .side-row { margin-top: 5px; }
.side-row b { font-weight: 600; color: var(--sd-fg); }
.side-row b.is-free { color: #a8c8a0; letter-spacing: .06em; font-size: 11.5px; }
/* v70. The tax row before an address exists, which is a promise rather than a
   figure, so it is set the way the same sentence is set in the step 4 summary:
   lighter, unbolded, and smaller than a number. The moment there is a figure
   js/studio.js takes this class off and it reads like the two rows above it. */
.side-row b.soft { font-weight: 400; font-size: 11.5px; color: var(--sd-soft); }
/* AND IT HAS TO BE ABLE TO GO AWAY. `display: flex` on the class outranks the
   browser's own `[hidden] { display: none }`, which is a specificity rule and
   not an opinion: a class beats an attribute selector only when the attribute
   selector is the browser's, and here they are level and the later one in the
   cascade wins. So the row marked hidden was drawn anyway, empty, and cost the
   drawer 25px on every page at every width before anybody typed an address.
   Caught by tools/box-diff.py against before-v70, which is exactly the kind of
   thing it is for. The same rule for the step 4 summary is below. */
.side-row[hidden] { display: none; }
.side-ship-note { margin: 7px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--sd-accent); }

.side-total { flex: 0 0 auto; display: flex; align-items: baseline; justify-content: space-between; margin-top: 12px; padding-top: 12px; border-top: 2px solid var(--sd-line); }
.side-total span { font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--sd-soft); font-weight: 700; }
.side-total b { font-family: 'Fraunces', serif; font-size: 30px; font-weight: 500; }
/* The chosen colour, on the panel that stays on screen through the accessories
   flow. Quiet once it is answered and marked while it is not, because it is the
   one thing on that order nobody can guess for the customer. */
.side-finish { font-size: 12px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--st-darksoft, #9a948a); margin: 10px 0 0; }
.side-finish.is-missing { color: var(--st-warn, #b0873a); font-weight: 600; }

.side-flag { font-size: 12px; margin: 6px 0 0; display: flex; align-items: center; gap: 7px; }
.side-flag:empty { display: none; }
.side-flag::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
.side-flag.e { color: #e59a86; }
.side-flag.w { color: #e0c184; }
.side-flag.k { color: #a8c8a0; }
/* v108 point 1. The one destructive control on the page, so it is the quietest
   thing in the column: the same soft ink as the credit line under it, no border
   and no fill, and it only takes the warning colour when somebody is already on
   it. Full width and 44 high because it is a real target on a phone, where this
   panel is the drawer. */
.side-reset {
  display: block; width: 100%; min-height: 44px; margin: 14px 0 0;
  padding: 11px 0; background: none; border: 0; border-top: 1px solid var(--sd-line);
  font: inherit; font-size: 12px; letter-spacing: .04em; color: var(--sd-soft);
  text-align: left; cursor: pointer; transition: color .2s;
}
.side-reset:hover, .side-reset:focus-visible { color: var(--st-err); }

.side-foot { font-size: 10.5px; color: var(--sd-soft); line-height: 1.6; margin: 14px 0 0; }

/* ---- the work side ------------------------------------------------------ */

.work { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); }
.work-head {
  flex: 0 0 auto; display: flex; align-items: flex-end; gap: 20px;
  padding: 24px 34px 18px; border-bottom: 1px solid var(--st-line);
}
.work-title { min-width: 0; }
.work-head .k { display: block; font-size: 11px; letter-spacing: .2em; text-transform: uppercase; color: var(--st-accent); font-weight: 700; margin-bottom: 6px; }
.work-head h1 { font-family: 'Fraunces', serif; font-weight: 400; font-size: 32px; letter-spacing: -.02em; line-height: 1.1; margin: 0; }
.work-nav { margin-left: auto; display: flex; gap: 10px; flex: 0 0 auto; }
.nav-total { display: none; }

.btn {
  background: var(--st-ink); color: var(--bg); padding: 12px 26px; border-radius: 100px;
  font: 600 14px 'Inter', sans-serif; border: 1.5px solid var(--st-ink); cursor: pointer; white-space: nowrap;
  transition: background .16s, color .16s, opacity .16s;
}
.btn:hover:not(:disabled) { background: #000; }
.btn.ghost { background: transparent; color: var(--st-ink); }
.btn.ghost:hover:not(:disabled) { background: var(--st-ink); color: var(--bg); }
.btn.small { padding: 8px 16px; font-size: 12.5px; }
.btn:disabled { opacity: .3; cursor: not-allowed; }

.work-body { flex: 1 1 auto; min-height: 0; overflow: hidden; display: flex; }
.pane { display: none; flex: 1 1 auto; min-width: 0; min-height: 0; overflow-y: auto; padding: 26px 34px 40px; }
.pane.on { display: block; }
.pane.pane-bench.on { display: flex; overflow: hidden; padding: 0; }
.pane-lead { font-size: 14.5px; color: var(--st-soft); max-width: 74ch; margin: 0 0 22px; line-height: 1.6; }

/* ---- cards, step 1 and step 4 ------------------------------------------- */

/* `align-items: start` let each column take its own height, so the desk column
   grew past the shelf column as soon as the L fields opened. They are a pair of
   panels side by side and they read as one band, so they stretch to match. */
.cols2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: stretch; }
.cart-cols { grid-template-columns: 1fr 1.05fr; }
.blk { background: var(--st-panel); border: 1.5px solid var(--st-line); border-radius: var(--st-r); padding: 22px 24px; }
.blk + .blk, .blk-wide { margin-top: 18px; }
/* v72: h2 and h3 both, because the block headings on a step moved from h3 to
   h2 to stop the outline skipping a level under the page's one h1. Same rule,
   same pixels. */
.blk h2, .blk h3 { font-family: 'Fraunces', serif; font-weight: 400; font-size: 21px; letter-spacing: -.02em; margin: 0 0 3px; }
.blk-note { font-size: 13px; color: var(--st-soft); margin: 0 0 18px; line-height: 1.55; }
.fld-h { font: 700 10.5px 'Inter', sans-serif; letter-spacing: .16em; text-transform: uppercase; color: var(--st-accent); margin: 20px 0 10px; }

/* The base kit reads as one line: six counted things across the card, with the
   heading beside them rather than above. It used to wrap to three rows of
   sentences, which made a fixed and unchangeable kit look like a decision. */
.kit { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
.kit-head { flex: 0 0 auto; max-width: 260px; }
.kit-head h2, .kit-head h3 { margin-bottom: 2px; }
.kit-head .blk-note { margin: 0; }
.kitlist {
  list-style: none; margin: 0; padding: 0;
  flex: 1 1 380px; min-width: 0;
  display: flex; gap: 0; align-items: stretch;
}
.kitlist li {
  flex: 1 1 0; min-width: 0; padding: 2px 12px;
  border-left: 1px solid var(--st-line); text-align: center;
}
.kitlist li:first-child { border-left: none; padding-left: 0; }
.kitlist li:last-child { padding-right: 0; }
.kitlist b { display: block; font-family: 'Fraunces', serif; font-size: 24px; font-weight: 400; line-height: 1.1; }
.kitlist span { display: block; font-size: 11.5px; color: var(--st-soft); line-height: 1.35; margin-top: 3px; }

/* reopening a saved design, on step 1 where it belongs */
/* the two columns of step 1 must start level. `.blk + .blk` adds a top margin
   to any block following another, which inside the grid pushed the right column
   down by 18px on its own. Inside `.cols2` they are columns, not a stack. */
.cols2 > .blk + .blk { margin-top: 0; }
.blk-reopen { margin-top: 0; margin-bottom: 26px; }
#load-msg:empty, #run-len-note:empty { display: none; }
.devnote { font-size: 11.5px; color: var(--st-soft); line-height: 1.6; margin: 12px 0 0; }

.ship-service { margin: 2px 0 0; font-size: 13.5px; color: var(--st-ink); font-weight: 500; }
.save-row { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--st-line); }
.save-row .fld-note { display: block; margin-top: 8px; }
/* v108 point 2. Two buttons side by side, wrapping rather than shrinking: both
   labels are sentences and neither reads if it is cut. */
.save-btns { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }

.reopen { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.reopen .num { flex: 0 0 200px; }
.reopen .num input { font-family: 'Fraunces', serif; font-size: 19px; letter-spacing: .1em; text-transform: uppercase; }
#load-msg.bad { color: var(--st-err); }

/* ---- step 2, the bench --------------------------------------------------
   The two docks are gone. The catalog is a strip across the top, the drawing
   takes the whole width under it, and the piece you picked up opens over the
   right hand edge of the drawing rather than in a column of its own, so the
   sheet keeps the room when nothing is selected. */

.bench { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; }
.bench-bar { flex: 0 0 auto; background: var(--st-panel); border-bottom: 1px solid var(--st-line); }
.parts-strip { display: flex; gap: 9px; overflow-x: auto; padding: 12px 22px; }
.parts-head { display: none; }
.parts-strip::-webkit-scrollbar { height: 7px; }
.parts-strip::-webkit-scrollbar-thumb { background: var(--st-line); border-radius: 7px; }

.bench-tools {
  flex: 0 0 auto; display: flex; align-items: center; gap: 12px;
  padding: 9px 22px; background: var(--st-panel); border-bottom: 1px solid var(--st-line2);
}
.stage-tools { margin-left: auto; display: flex; gap: 4px; }
.tool {
  width: 33px; height: 33px; border-radius: 8px; border: 1.5px solid transparent; background: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--st-soft); transition: background .16s, color .16s, border-color .16s;
}
.tool-wide { display: none; width: auto; padding: 0 13px; font: 600 12.5px 'Inter', sans-serif; }
.tool svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tool:hover:not(:disabled) { background: #fbfaf5; border-color: var(--st-line); color: var(--st-ink); }
.tool:disabled { opacity: .32; cursor: not-allowed; }
.tool-warn:hover:not(:disabled) { color: var(--st-err); }
.zoomer { display: flex; align-items: center; gap: 2px; }
.zoom-pct {
  min-width: 48px; text-align: center; background: none; border: 1.5px solid transparent; cursor: pointer;
  font: 600 11.5px 'Inter', sans-serif; font-variant-numeric: tabular-nums;
  color: var(--st-soft); padding: 8px 4px; border-radius: 7px;
}
.zoom-pct:hover:not(:disabled) { border-color: var(--st-line); background: #fbfaf5; color: var(--st-ink); }
.zoom-pct:disabled { opacity: .5; cursor: default; }

.run-tabs { display: flex; gap: 4px; }
.run-tab { font: 500 12px 'Inter', sans-serif; padding: 7px 13px; border-radius: 7px; cursor: pointer;
  color: var(--st-soft); background: #fbfaf5; border: 1.5px solid var(--st-line); }
.run-tab:hover { border-color: var(--st-ink); color: var(--st-ink); }
.run-tab.on { background: var(--st-ink); border-color: var(--st-ink); color: var(--bg); font-weight: 600; }
.run-tab-wrap { display: inline-flex; align-items: stretch; }
.run-tab-wrap .run-tab { border-right-width: 0; border-radius: 7px 0 0 7px; }
.run-tab-x { font: 400 15px 'Inter', sans-serif; padding: 0 9px; cursor: pointer;
  border: 1.5px solid var(--st-line); border-radius: 0 7px 7px 0; background: #fbfaf5; color: var(--st-faint); }
.run-tab-x:hover { color: var(--st-err); border-color: var(--st-err); background: #fff; }
.run-tab-wrap.on .run-tab-x { background: var(--st-ink); border-color: var(--st-ink); color: rgba(226, 223, 214, .75); }

.stage { flex: 1 1 auto; min-height: 0; min-width: 0; position: relative; background: var(--st-well); display: flex; flex-direction: column; }

/* The dock is always there, and the paper always leaves room for it.

   It used to appear on selection and push the drawing over, which read well in
   a screenshot and badly under a pointer: pressing on a leg resized the sheet
   the leg was on, so the piece moved out from under the finger that had just
   grabbed it, and a drag that followed measured against a scale that had
   changed mid gesture. Reserving the space costs about 310px of drawing width
   and nothing ever moves. The drawing still has far more room than it had when
   there were two docks. */
.piece-dock {
  position: absolute; top: 0; right: 0; bottom: 0; width: 372px; z-index: 6;
  background: var(--st-panel); border-left: 2px solid var(--st-line2);
  display: flex; flex-direction: column;
}
.piece-dock-head {
  flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 14px 22px;
  border-bottom: 1px solid var(--st-line);
  font: 700 10.5px 'Inter', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--st-accent);
}
.piece-x { margin-left: auto; background: none; border: none; cursor: pointer; font-size: 21px; line-height: 1; color: var(--st-faint); }
.piece-x[hidden] { display: none; }
.piece-x:hover { color: var(--st-ink); }
.piece-dock-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* The pane had no padding of its own, so every control in it started against
   the border it shares with the drawings. The dock is 20px wider to pay for the
   gutters rather than taking them out of the controls, and the paper gives up
   that width. */
.pane-piece { padding: 18px 22px 26px; }
.stage .stage-paper { padding-right: 384px; }

/* ---- step 3, the add-ons ------------------------------------------------ */

.addon-layout { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 22px; height: calc(100% - 4px); align-items: start; }
.addon-side { display: flex; flex-direction: column; min-height: 0; max-height: 100%; }
.cat-pills { display: flex; flex-wrap: wrap; gap: 7px; flex: 0 0 auto; margin-bottom: 18px; }
.cat-pill { padding: 8px 15px; border-radius: 100px; border: 1.5px solid var(--st-line); background: var(--st-panel);
  font: 500 13px 'Inter', sans-serif; cursor: pointer; color: var(--st-soft); }
.cat-pill:hover { border-color: var(--st-ink); color: var(--st-ink); }
.cat-pill.on { background: var(--st-ink); border-color: var(--st-ink); color: var(--bg); font-weight: 600; }
.cat-pill i { font-style: normal; opacity: .6; margin-left: 5px; font-size: 11.5px; }

.addon-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(252px, 1fr)); gap: 10px; align-content: start; }
.addon-scroll::-webkit-scrollbar { width: 9px; }
.addon-scroll::-webkit-scrollbar-thumb { background: var(--st-line); border-radius: 9px; }

/* The card is a 252px grid cell, so the three things in it are fighting over
   about 220px. It used to be one row, which worked while the button said
   "Add" and stopped working the moment it said "Add to order": the button is
   `flex: 0 0 auto`, so it took its 103px and left the name 54, and a name like
   "MagSafe and wireless charger mount" wrapped to five lines and ran out of
   the card into the one above it.

   Two rows fixes it at the cause rather than by making the words shorter
   again. The picture and the name share the top row, and the control gets its
   own underneath, where it can be as wide as its label needs and the name can
   have the whole width of the card. */
.addon {
  display: grid; grid-template-columns: 52px minmax(0, 1fr);
  gap: 6px 11px; align-items: center; text-align: left; cursor: pointer;
  background: var(--st-panel); border: 1.5px solid var(--st-line); border-radius: 11px; padding: 11px;
  /* No min-height. It was here to stop a one-row card being shorter than its
     own thumbnail, which two rows can no longer be, and left in place it
     fought the nested grid: the outer track resolved to the old 78px while the
     card's own rows added up to 97, so every button hung 16px out of the
     bottom of its card. */
  transition: border-color .16s, background .16s;
}
.addon:hover { border-color: var(--st-line2); }
.addon.picked { border-color: var(--st-ink); background: #e4e1d8; }
.addon.sel { border-color: var(--st-ink); box-shadow: 0 0 0 2px rgba(31, 28, 25, .18); }
.addon-art { width: 52px; height: 40px; flex: 0 0 auto; display: block; }
.addon-art svg { width: 100%; height: 100%; display: block; }
.addon-mid { min-width: 0; }

/* POINT 8. This had no colour of its own, so it inherited its way to pure
   black, rgb(0, 0, 0), which is not in the palette: the ink is #1f1c19. It is
   the only element on /accessories, in the sheet, and in step 3 of the studio
   that computed to pure black, and the same name in the detail panel has always
   been ink, so the card and the panel disagreed about one string.

   Fred sees it as the browser's default link blue rather than as black. Nothing
   in Chromium renders it blue at any of the three widths or on the desktop, and
   there is no anchor anywhere in the add-on list or the detail panel to be an
   unstyled one. What does turn text that colour on a phone is iOS Safari
   deciding a string looks like a phone number or a date and linking it itself,
   which it paints in its own blue and which no rule here can override. Both are
   answered: the colour is declared, and index.html, accessories.html and
   studio.html tell iOS not to detect. */
.addon-name { display: block; font-size: 13px; font-weight: 600; line-height: 1.28; color: var(--st-ink); }
.addon-price { display: block; font-size: 12.5px; color: var(--st-soft); }
/* Second row of the card, under both columns, pushed right so the control
   sits where the eye already expects it from the old one-row layout. Also used
   inside the detail panel, where it is not in a grid and the grid-column is
   simply ignored. */
.addon-qty { display: flex; align-items: center; gap: 3px; flex: 0 0 auto;
  grid-column: 1 / -1; justify-content: flex-end; }
.addon-add { border: 1.5px solid var(--st-ink); border-radius: 100px; padding: 6px 13px; background: none; cursor: pointer;
  font: 600 12.5px 'Inter', sans-serif; color: var(--st-ink); }
.addon-add:hover { background: var(--st-ink); color: var(--bg); }
.addon-n { min-width: 17px; text-align: center; font-variant-numeric: tabular-nums; font-size: 13px; font-weight: 600; }

.addon-detail { background: var(--st-panel); border: 1.5px solid var(--st-line); border-radius: var(--st-r); padding: 20px; position: sticky; top: 0; }
/* The add-on picture. A square, always, whatever the file happens to be.

   Photographs come off a phone at whatever shape the phone was held, and the
   drawings are wider than they are tall. Letting each one set its own height
   made the pane resize every time somebody clicked a different add-on. The
   square is `aspect-ratio`, so it holds at any pane width.

   A photograph covers it: filled edge to edge, cropped if it has to be, never
   stretched. A drawing is contained inside it, because a drawing cropped is a
   part with its end cut off. tools/prep-addon-photo.py outputs 1200 by 1200, so
   a photograph prepared that way is never cropped here at all. */
.addon-photo {
  background: var(--st-sheet); border: 1px solid var(--st-line);
  border-radius: 10px; margin-bottom: 16px; position: relative;
  aspect-ratio: 1 / 1; width: 100%; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}
.addon-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.addon-photo svg { display: block; width: 100%; height: 100%; object-fit: contain; padding: 18px; }
/* The reference note, under the photograph of a family that shares one picture.

   The frame below it already carries `margin-bottom: 16px`, so this takes that
   space back with a negative top margin and keeps its own gap to the name. It
   reads as a caption on the picture rather than as a second heading: small, in
   the faint grey, and well away from the not-included line, which sits at the
   very bottom under the Add button. */
.addon-detail .addon-photo-note {
  font-size: 11.5px; line-height: 1.5; color: var(--st-faint);
  margin: -8px 0 14px;
}

/* ---- the notice at the top of the accessories page ----------------------
   Who the page is for, in the block the studio already uses for a titled note
   with body copy under it. No new component and no new colour: it is a `.blk`
   with a tint, so it reads as a note rather than as a warning. It is not a
   warning. */
/* The notice is orientation, not content, so it is tighter than a `.blk` that
   holds fields: less padding, and no dead margin under its last paragraph. It
   also has to STOP, which it did not: the layout underneath began immediately
   at its border, so the pills sat against it and the first row of cards was
   already cut off by the fold.

   The two gaps below are the site's own. 22px is what `.pane-lead` puts
   between a section and what follows it, and 18px is what `.blk + .blk` puts
   between blocks. Nothing new was invented for this. */
.blk-notice {
  background: var(--st-panel2); border-color: var(--st-line2);
  padding: 12px 18px; margin-bottom: 22px;
}
.blk-notice h2, .blk-notice h3 { font-size: 17px; margin-bottom: 4px; }
.blk-notice .blk-note { margin-bottom: 0; line-height: 1.5; }
.blk-notice .blk-note + .blk-note { margin-top: 6px; }
.blk-notice a { color: var(--st-ink); text-decoration: underline; text-underline-offset: 2px; }
.blk-notice a:hover { color: var(--st-accent); }

/* ---- the custom request panel -------------------------------------------
   The four steps of how it works, and the box. Numbered because they happen
   in that order and somebody deciding whether to type wants to know where it
   ends. Same type scale as the rest of the panel. */
.custom-steps {
  margin: 0 0 16px; padding: 0 0 0 18px;
  font-size: 13px; line-height: 1.6; color: var(--st-soft);
}
.custom-steps li { margin: 0 0 6px; padding-left: 3px; }
.custom-steps li::marker { color: var(--st-faint); }
.custom-field {
  width: 100%; resize: vertical; min-height: 120px;
  font: inherit; font-size: 13.5px; line-height: 1.6;
}
.custom-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; margin: 10px 0 14px;
  min-height: 30px;   /* so the row does not collapse when Remove is hidden */
}
.custom-count { font-size: 12px; color: var(--st-faint); }
/* While the box is short of the minimum the counter is saying WHY the button
   below it cannot be pressed, so it stops being a quiet tally and reads as the
   thing standing in the way. Same weight as a field note, not an error: the
   customer has done nothing wrong, they have just not finished. */
.custom-count.short { color: var(--st-soft); font-weight: 600; }
/* The row in the list, for a thing that is asked for rather than counted. */
.addon-on {
  font-size: 11.5px; font-weight: 600; color: var(--st-ink);
  border: 1.5px solid var(--st-ink); border-radius: 100px; padding: 5px 11px;
  white-space: nowrap;
}
.addon-extra .addon-price { font-style: italic; }

/* ---- the second picture, in the corner of the frame ---------------------

   This replaced a strip of two squares UNDER the frame. The strip read well and
   cost 198px on the tallest panel in the studio, which pushed the Add button
   off the bottom of the screen. Height wins here, so the switch sits on the
   photograph instead of below it and costs nothing.

   The chip shows the OTHER picture with its name on it, so the panel says there
   are two things here without being read, and one tap swaps them. 76px, well
   past the 44px a thumb needs. */
.addon-flip {
  position: absolute; right: 10px; bottom: 10px; z-index: 2;
  width: 76px; height: 76px; padding: 0; border: 0; cursor: pointer;
  border-radius: 9px; overflow: hidden; background: var(--st-sheet);
  box-shadow: 0 0 0 2px var(--st-panel), 0 0 0 3.5px rgba(31, 28, 25, .55),
              0 8px 18px -8px rgba(31, 28, 25, .55);
  transition: transform .16s, box-shadow .16s;
}
.addon-flip img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* The name, over a scrim so it reads on a light photograph and a dark one. */
.addon-flip-t {
  position: absolute; left: 0; right: 0; bottom: 0; padding: 8px 4px 3px;
  font-size: 10px; font-weight: 600; letter-spacing: .02em; color: #fff;
  text-align: center; line-height: 1.2;
  background: linear-gradient(0deg, rgba(20, 18, 16, .82), rgba(20, 18, 16, 0));
}
.addon-flip:hover { transform: translateY(-1px); box-shadow: 0 0 0 2px var(--st-panel), 0 0 0 3.5px var(--st-ink), 0 10px 20px -8px rgba(31, 28, 25, .6); }
.addon-flip:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 3px; }

/* ---- the price, and the option beside it --------------------------------
   One row. The price sat alone with an empty right-hand half, and the option
   that changes it was a 141px block further down. Putting the control next to
   the number it moves costs no height and reads better: the price changing is
   in the same glance as the click. */
.addon-priceline {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap; margin: 0 0 10px;
}

/* A pill, not a bare checkbox: this is the one control on the panel that adds
   money by itself, so it has to look like something you press. The whole label
   is the hit area, which is 38px tall and the full width of the pill. */
.addon-opt {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 8px 12px; border-radius: 100px;
  border: 1.5px solid var(--st-line); background: var(--st-sheet);
  cursor: pointer; transition: border-color .18s, background .18s;
}
.addon-opt:hover { border-color: var(--st-accent); }
.addon-opt.on { border-color: var(--st-ink); background: var(--st-panel2); }
.addon-opt:focus-within { border-color: var(--st-accent); }
.addon-opt input { width: 16px; height: 16px; margin: 0; accent-color: var(--st-ink); cursor: pointer; flex: 0 0 auto; }
.addon-opt-t { font-size: 12.5px; font-weight: 600; color: var(--st-ink); white-space: nowrap; }
.addon-opt-p { font-family: 'Fraunces', serif; font-size: 13.5px; font-weight: 500; color: var(--st-soft); white-space: nowrap; }

.addon-detail h2, .addon-detail h3 { font-family: 'Fraunces', serif; font-weight: 400; font-size: 23px; letter-spacing: -.02em; margin: 0 0 4px; }
.addon-detail .pr { font-family: 'Fraunces', serif; font-size: 26px; font-weight: 500; display: block; }
.addon-detail p { font-size: 13.5px; color: var(--st-soft); line-height: 1.6; margin: 0 0 14px; }
.addon-detail dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; font-size: 12.5px; margin: 0 0 18px; }
.addon-detail dt { color: var(--st-faint); }
.addon-detail dd { margin: 0; color: var(--st-ink); font-weight: 500; }
.addon-detail .none { text-align: center; padding: 40px 10px; color: var(--st-faint); font-size: 13.5px; line-height: 1.65; }
/* What is in the photographs but not in the box. One line under the card,
   quieter than the copy that sells the part above it. */
.addon-detail .addon-gear-note { font-size: 12px; color: var(--st-faint); line-height: 1.55; margin: 14px 0 0; text-align: center; }

/* ---- step 4, the cart --------------------------------------------------- */

.cart-lines { border-top: 1px solid var(--st-line); }
.cline { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: baseline; padding: 11px 0; border-bottom: 1px solid var(--st-line); font-size: 13.5px; }
.cline .q { color: var(--st-faint); font-weight: 700; font-variant-numeric: tabular-nums; }
.cline .d em { display: block; font-style: normal; font-size: 12px; color: var(--st-faint); line-height: 1.5; margin-top: 2px; }
.cline .d em.spec { color: var(--st-soft); font-size: 12.5px; margin-top: 1px; }
.cline .n { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
/* The group headings sit on the same rhythm as the rows under them, and they
   read as headings rather than as another line of the order. */
.cgrp {
  display: flex; align-items: center; gap: 10px;
  font: 700 10.5px 'Inter', sans-serif; letter-spacing: .16em; text-transform: uppercase;
  color: var(--st-ink); background: var(--st-panel2);
  margin: 0; padding: 8px 10px;
  border-top: 1px solid var(--st-line); border-bottom: 1px solid var(--st-line);
}
.cgrp::after { content: ""; flex: 1 1 auto; height: 1px; background: var(--st-line); }
.cgrp + .cline { border-top: none; }
.cline { padding: 11px 10px; }
.cart-sum { margin: 18px 0 0; padding-top: 14px; border-top: 2px solid var(--st-line2); }
.cart-sum > div { display: flex; justify-content: space-between; align-items: baseline; padding: 5px 0; font-size: 14px; color: var(--st-soft); }
/* v70. See .side-row[hidden] above. This row is visible on a page nobody has
   typed an address into, so the bug did not show here, but it is the same
   markup with the same override and it hides the moment the tax is zero. */
.cart-sum > div[hidden] { display: none; }
.cart-sum dt, .cart-sum dd { margin: 0; }
.cart-sum dd { font-variant-numeric: tabular-nums; color: var(--st-ink); font-weight: 500; }
.cart-sum dd.soft { color: var(--st-faint); font-weight: 400; font-size: 13px; }
.cart-sum .grand { margin-top: 8px; padding-top: 11px; border-top: 1px solid var(--st-line); }
.cart-sum .grand dt { font-weight: 600; color: var(--st-ink); }
.cart-sum .grand dd { font-family: 'Fraunces', serif; font-size: 27px; font-weight: 500; }

.ofld { display: block; margin-bottom: 13px; font: 700 10.5px 'Inter', sans-serif; letter-spacing: .13em; text-transform: uppercase; color: var(--st-faint); }
.ofld input, .ofld textarea {
  display: block; width: 100%; margin-top: 6px; background: var(--st-sheet);
  border: 1.5px solid var(--st-line); border-radius: 9px; padding: 11px 13px;
  font: 400 14.5px 'Inter', sans-serif; color: var(--st-ink); letter-spacing: 0; text-transform: none;
}
.ofld input:focus, .ofld textarea:focus { outline: none; border-color: var(--st-ink); }

/* ---- 16px ON TOUCH, OR SAFARI ZOOMS THE PAGE ---------------------------
   iOS Safari magnifies the whole page when a form control it is focusing has
   a font smaller than 16px, and it does not reliably zoom back out: the page
   is left wider than the screen with the right hand side cut off. These fields
   were 14.5px, so every one of them did it.

   The trigger is `pointer: coarse` and not a width. Width is the wrong test
   twice over: an iPad in landscape is wider than any phone breakpoint and
   Safari zooms there too, and a narrow desktop window has no auto zoom to
   prevent. A coarse pointer is exactly the set of devices this happens on, so
   a mouse never sees this rule and the desktop is untouched by it.

   NOT FIXED WITH `maximum-scale` OR `user-scalable=no`. That takes pinch zoom
   away from everybody, including readers who need it, and modern iOS ignores
   it anyway.

   16px is a floor here, not a size: everything below is about keeping the field
   looking the same at the larger text. */
@media (pointer: coarse) {
  .ofld input, .ofld textarea { font-size: 16px; padding: 10px 12px; }
  .ofld textarea { line-height: 1.45; }
}
.ofld input.bad { border-color: var(--st-err); background: #fbf0ec; }
.fld-err { display: block; margin-top: 5px; font: 500 11.5px 'Inter', sans-serif; letter-spacing: 0; text-transform: none; color: var(--st-err); font-style: normal; }
/* The section label above these sits at the same size and weight as the field
   labels inside them, so without this the block opens on two lines of small
   caps with nothing between them and reads as one list rather than a heading
   over a form. */
.billing-fields { display: block; margin-top: 9px; }
.billing-fields .fld-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.stripe-block { margin-bottom: 15px; }
.stripe-k { display: block; font: 700 10.5px 'Inter', sans-serif; letter-spacing: .13em; text-transform: uppercase; color: var(--st-faint); margin-bottom: 6px; }
.stripe-mount { min-height: 54px; }
.stub { background: var(--st-sheet); border: 1.5px dashed var(--st-line); border-radius: 10px; padding: 14px 16px; }
.stub b { display: block; font-size: 13px; font-weight: 700; margin-bottom: 4px; }
.stub p { font-size: 12.5px; color: var(--st-soft); line-height: 1.6; margin: 0; }
.stub code { background: rgba(25, 22, 19, .08); padding: 1px 5px; border-radius: 4px; font-size: 11.5px; }
.ck { display: flex; align-items: center; gap: 9px; font-size: 13.5px; color: var(--st-soft); margin: 0 0 15px; cursor: pointer; }
.ck input { width: 17px; height: 17px; accent-color: var(--st-ink); }
.btn-pay { width: 100%; margin-top: 6px; padding: 15px; font-size: 15px; }
/* v72 point 8b. The line under the pay button, on both surfaces that take a
   card. Small, centred and faint so it reads as a footnote to the button
   rather than as a second thing to press, and placed above .pay-note, which
   is where every payment error already goes. */
.pay-secure { font-size: 11.5px; color: var(--st-faint); text-align: center; line-height: 1.5; margin: 9px 0 0; }
.pay-note { font-size: 12px; color: var(--st-faint); line-height: 1.6; margin: 10px 0 0; }
.devtools { margin-top: 20px; border-top: 1px solid var(--st-line); padding-top: 14px; }
.devtools summary { cursor: pointer; font: 600 12.5px 'Inter', sans-serif; color: var(--st-soft); }
.devtools p { font-size: 12.5px; color: var(--st-soft); line-height: 1.6; margin: 10px 0; }
.devrow { display: flex; flex-wrap: wrap; gap: 8px; }
.load-error { color: var(--st-err); font-size: 12.5px; margin-top: 8px; }

.dowel-note { margin: 18px 0 0; padding: 13px 14px 14px; background: var(--st-panel2); border: 1.5px solid var(--st-line); border-radius: var(--st-r); }
.dowel-note:empty { display: none; }
.dowel-note h3, .dowel-note h4 {
  display: flex; align-items: center; gap: 9px;
  font: 700 11.5px 'Inter', sans-serif; letter-spacing: .06em; color: var(--st-ink); margin: 0 0 12px;
}
.dw-tag {
  font: 700 9.5px 'Inter', sans-serif; letter-spacing: .14em; text-transform: uppercase;
  background: var(--st-ink); color: var(--bg); padding: 3px 8px; border-radius: 100px;
}
.dw-buy { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.dw-buy > div { border-left: 2px solid var(--st-line); padding-left: 11px; }
.dw-buy b { display: block; font-family: 'Fraunces', serif; font-size: 17px; font-weight: 500; line-height: 1.2; }
.dw-buy span { display: block; font-size: 11.5px; color: var(--st-soft); line-height: 1.4; margin-top: 2px; }
.dw-k { font: 700 9.5px 'Inter', sans-serif; letter-spacing: .15em; text-transform: uppercase; color: var(--st-accent); margin: 0 0 7px; }
.cutlist {
  list-style: none; margin: 0 0 12px; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 6px;
}
.cutlist li {
  display: flex; align-items: baseline; gap: 6px;
  background: var(--st-sheet); border: 1px solid var(--st-line); border-radius: 7px; padding: 7px 10px;
  font-variant-numeric: tabular-nums;
}
.cutlist b { font-size: 12.5px; font-weight: 700; color: var(--st-accent); }
.cutlist span { font-size: 13px; font-weight: 600; color: var(--st-ink); }
.dowel-note p { font-size: 12px; line-height: 1.55; color: var(--st-soft); margin: 0; }
.cut-total { font-size: 11.5px; color: var(--st-faint); }

/* ---- fields and chips --------------------------------------------------- */

.fld { display: block; margin-bottom: 16px; }
.fld-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.fld-label { display: block; font: 700 10.5px 'Inter', sans-serif; letter-spacing: .13em; text-transform: uppercase; color: var(--st-faint); margin-bottom: 7px; }
.fld-note { display: block; font-size: 12.5px; color: var(--st-soft); line-height: 1.55; margin-top: 7px; }
.num { display: flex; align-items: center; gap: 8px; background: var(--st-sheet); border: 1.5px solid var(--st-line); border-radius: 9px; padding: 10px 13px; }
.num input { flex: 1 1 auto; min-width: 0; border: none; background: none; font: 500 16px 'Inter', sans-serif; color: var(--st-ink); }
.num input:focus { outline: none; }
/* The bench puts the ring back on everything inside it, but the desk and the
   design code sit above the bench, so these four fields lost the outline and
   got nothing in its place. Same ring as the bench draws, so a keyboard only
   ever sees one shape on this page. The mouse still sees none. */
.num input:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 2px; border-radius: 6px; }
.num input::-webkit-outer-spin-button, .num input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.num input[type=number] { -moz-appearance: textfield; }
.unit-tag { font-style: normal; font-size: 12px; color: var(--st-faint); flex: 0 0 auto; }

/* Every photograph on this page is served WebP first with the JPEG behind it, which
   needs a picture around the img. A picture is only a wrapper for picking the file,
   so it is taken out of the layout and the img is the thing that lays out. The add-on
   frames build theirs in js/studio.js.

   The source has to be hidden alongside it. display:contents hands ALL of the
   picture's children to the parent, not just the img, and a bare <source> computes to
   inline in the browser rather than to none, so it comes back as a second, zero-height
   item in whatever grid or flex row the picture sat in. That is what broke the two
   column story rows on the home page. Hiding it is what makes display:contents mean
   "the img is the item". It does not affect which file is chosen: the picture element
   picks its source from the DOM, not from the box tree. */
picture { display: contents; }
source { display: none; }

.chips { display: flex; flex-wrap: wrap; gap: 7px; }
/* Two fields in step one are empty in the HTML and filled by render() a moment later:
   the leg heights depend on the shelf size, and the dowel row count is computed. Empty
   they measure zero, so when the script filled them everything under them jumped down.
   Holding one row of chips and one line of note keeps the step still. 36px is the
   measured height of a .chip row: 13.5px text, 9px of padding and 1.5px of border top
   and bottom. The note is one line of its own 1.55 line-height. Both are floors, so a
   row that wraps or a note that runs to two lines still grows past them. */
#chips-leg { min-height: 36px; }
#size-note { min-height: 1.55em; }
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 100px; cursor: pointer;
  border: 1.5px solid var(--st-line); background: var(--st-sheet); font: 500 13.5px 'Inter', sans-serif; color: var(--st-ink); }
.chip:hover:not(:disabled) { border-color: var(--st-ink); }
.chip.on { background: var(--st-ink); border-color: var(--st-ink); color: var(--bg); font-weight: 600; }
.chip:disabled { opacity: .38; cursor: not-allowed; }
.chip .unit-tag { padding: 0; font-size: 10.5px; font-weight: 400; color: var(--st-faint); }
.chip.on .unit-tag { color: rgba(226, 223, 214, .8); }
.chips-view .chip { padding: 7px 14px; font-size: 12px; }
.sw { width: 13px; height: 13px; border-radius: 50%; border: 1px solid rgba(25, 22, 19, .4); }
.sw-white { background: #f4f2ec; }
.sw-black { background: #1b1917; }

/* ---- narrow ------------------------------------------------------------- */

.side-open { display: none; }
@media (max-width: 1180px) {
  .flow { grid-template-columns: 288px minmax(0, 1fr); }
  .addon-layout { grid-template-columns: minmax(0, 1fr) 300px; }
  .cols2 { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
  .flow { grid-template-columns: minmax(0, 1fr); }
  .side { position: fixed; inset: 0 auto 0 0; width: 300px; z-index: 40; transform: translateX(-100%); transition: transform .24s; }
  .side.open { transform: none; }
  .side-open { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 9px;
    border: 1.5px solid var(--st-line); background: var(--st-sheet); cursor: pointer; margin-right: 4px; }
  .side-open .bars, .side-open .bars::before, .side-open .bars::after { display: block; width: 16px; height: 2px; background: var(--st-ink); position: relative; }
  .side-open .bars::before { content: ""; position: absolute; top: -5px; }
  .side-open .bars::after { content: ""; position: absolute; top: 5px; }
  .work-head { flex-wrap: wrap; }
  /* Stacked, the add-on list stops being its own scroller and the pane scrolls
     instead. Left as it is on a desktop, the list is `flex: 1 1 auto` inside a
     column measured against `height: calc(100% - 4px)`, and once the grid goes
     to one column that height resolves to nothing: the list collapsed to zero
     and the detail card, taller and later in the DOM, painted straight over the
     buttons. They were visible and every tap landed on the card behind them,
     which made this step unusable on a phone. */
  .addon-layout { grid-template-columns: minmax(0, 1fr); height: auto; }
  .addon-side { max-height: none; }
  .addon-scroll { flex: 0 0 auto; overflow-y: visible; padding-right: 0; }
  .addon-detail { position: static; }
  /* ---- POINT 1, THE DOCK STOPS BEING A COLUMN ---------------------------
     It was `position: absolute` down the right of the stage at 320px, and the
     paper reserved 332px for it whether or not anything was picked up. On a
     390px screen that is 82% of the width for a panel that is empty most of
     the time, and it left the drawing 46px wide.

     It is the shared bottom sheet now, js/sheet.js, the same component
     /accessories and step 3 use. What it needs from this block is only the
     three things `.piece-dock` sets for the desktop column: its width, its
     border and the space it reserved. The sheet rules further down do the
     rest, and they are not repeated here. */
  .std .piece-dock {
    width: auto; border-left: none;
    max-height: 76vh; max-height: 76dvh;
    padding: 0 0 env(safe-area-inset-bottom);
  }
  /* The reserve is CONDITIONED, not deleted: the desktop rule above is
     untouched and still reads padding-right: 384px. */
  .stage .stage-paper { padding-right: 12px; }
  .std .piece-dock .piece-dock-head { position: sticky; top: 0; z-index: 2; background: var(--st-panel); }
  /* ---- POINT 4, THE WAY INTO A PIECE'S OPTIONS -------------------------
     Tapping a piece picks it up to move it, so it no longer opens the panel,
     and there has to be another way in. This is it: a button that appears next
     to the piece that is selected, inside the drawing, and only while one is.

     Why here and not somewhere else. It is discoverable without being told,
     because it turns up the moment you touch a piece and it is next to the
     thing you just touched. It costs no permanent height, because it is over
     the drawing and only exists while something is selected. It is wherever
     the piece is, which on a phone is under the thumb rather than in a corner.
     And it cannot fire during a drag: it is hidden for the whole gesture and
     placed again only after the finger comes up. */
  .std .piece-open {
    position: absolute; z-index: 7;
    min-width: 44px; min-height: 44px; padding: 0 14px;
    border-radius: 100px; border: 1.5px solid var(--st-line2);
    background: var(--st-ink); color: var(--bg);
    font: 600 12.5px 'Inter', sans-serif; cursor: pointer;
    box-shadow: 0 6px 18px -6px rgba(20, 18, 16, .55);
  }
  .std .piece-open[hidden] { display: none; }
  .std.is-carrying .piece-open { display: none; }

  /* ---- POINT 1a, WHERE THE SHELF SITS ON THE DESK -----------------------
     A layer over the top of the drawing, not a row under it, for the reason
     every other control on this stage is a layer: the drawing is the expensive
     thing and anything in the flow takes height from it whether it has
     anything to do or not. Measured: the stage is the same height with this on
     screen as without it.

     At the TOP because the bottom of this stage already has the message bar and
     the total strip on it, and a third thing down there is how a message ends
     up unreadable. Only while the top view is showing, which is the view that
     draws the desk this moves the shelf along. */
  .std .slide-shelf {
    position: absolute; z-index: 7; top: 8px; left: 50%; transform: translateX(-50%);
    display: flex; align-items: center; gap: 2px;
    padding: 3px; border-radius: 100px;
    border: 1.5px solid var(--st-line2);
    background: color-mix(in srgb, var(--bg) 92%, transparent);
    backdrop-filter: blur(10px);
    box-shadow: 0 6px 18px -8px rgba(20, 18, 16, .5);
  }
  .std .slide-shelf[hidden] { display: none; }
  .std.is-carrying .slide-shelf { display: none; }
  .std .slide-t {
    font: 600 12.5px 'Inter', sans-serif; color: var(--st-ink);
    padding: 0 4px; white-space: nowrap;
  }
  .std .slide-b {
    min-width: 44px; min-height: 44px; border-radius: 100px; border: 0;
    background: transparent; color: var(--st-ink);
    font: 400 21px 'Inter', sans-serif; line-height: 1; cursor: pointer;
  }
  .std .slide-b:active { background: rgba(20, 18, 16, .08); }
  .std .slide-b:disabled { opacity: .3; cursor: not-allowed; }

  /* The toolbar controls that open the two sheets, phone only. */
  .std #btn-piece, .std #btn-parts { display: inline-flex; }

  /* ---- POINT 2, THE PARTS STRIP LEAVES THE BENCH ------------------------
     It was a bar across the top of the bench costing 163px of height at every
     width, and it held 1051px of cards in 390px of room: 661px hidden, two of
     the eight reachable without a sideways swipe on a strip nothing announced
     as scrollable.

     It is the second bottom sheet now, the same component, opened by Add. Out
     of the flow it costs the bench nothing, and with the room a sheet has, all
     eight cards are on screen at once in two columns with no scrolling at all. */
  .std .bench-bar {
    border-bottom: none;
    max-height: 76vh; max-height: 76dvh;
    padding: 22px 16px calc(16px + env(safe-area-inset-bottom));
  }
  .std .parts-head {
    display: block; margin-bottom: 14px;
    font: 700 10.5px 'Inter', sans-serif; letter-spacing: .14em;
    text-transform: uppercase; color: var(--st-accent);
  }
  .std .parts-strip {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 10px; overflow: visible; padding: 0;
  }
  .std .parts-strip .pcard { width: auto; }
  .pane-piece { padding: 16px 16px 22px; }
}
/* ---- part cards --------------------------------------------------------- */

.pcard {
  flex: 0 0 auto; width: 118px;
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  padding: 9px 9px 10px;
  background: var(--st-sheet);
  border: 1px solid var(--st-line);
  border-radius: var(--st-r);
  text-align: left;
  cursor: grab;
  transition: border-color .16s, box-shadow .16s, transform .16s, background .16s;
  touch-action: none;
}
.pcard:hover:not(:disabled) {
  border-color: var(--st-accent);
  box-shadow: 0 6px 16px -10px rgba(31, 28, 25, .5);
  transform: translateY(-1px);
}
.pcard:active:not(:disabled) { cursor: grabbing; transform: translateY(0); }
.pcard:disabled { opacity: .42; cursor: not-allowed; }
.pcard-art {
  height: 48px; display: flex; align-items: center; justify-content: center;
  background: linear-gradient(180deg, #f6f4ee, #e8e5db);
  border-radius: 6px; border: 1px solid rgba(25, 22, 19, .14);
}
.pcard-art svg { width: 100%; height: 100%; display: block; }
.pcard-text { display: block; min-width: 0; }
.pcard-name { display: block; font-size: 12.5px; font-weight: 600; line-height: 1.25; color: var(--st-ink); }
.pcard-sub { display: block; font-size: 11px; line-height: 1.4; color: var(--st-faint); margin-top: 2px; }

/* The ghost that follows the pointer while a card is carried onto the stage.
   Named carry-ghost, not ghost: the site's own .btn.ghost modifier owns that
   word already, and the two collided. */
.carry-ghost {
  position: fixed; z-index: 900; pointer-events: none;
  width: 108px; padding: 7px; border-radius: var(--st-r);
  background: var(--st-sheet); border: 1px solid var(--st-accent);
  box-shadow: 0 14px 30px -14px rgba(31, 28, 25, .7);
  transform: translate(-50%, -50%) rotate(-2deg);
}
.carry-ghost svg { width: 100%; height: 40px; display: block; }
.carry-ghost .carry-name { font-size: 10.5px; font-weight: 600; text-align: center; margin-top: 4px; }
body.is-carrying, body.is-carrying * { cursor: grabbing !important; }

/* ---- paper -------------------------------------------------------------- */

.stage-paper {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; gap: 10px;
  padding: 10px 12px;
  overflow: auto;
}
.sheet {
  flex: 1 1 0; min-height: 120px;
  display: flex; flex-direction: column;
  background: var(--st-sheet);
  border: 1.5px solid var(--st-line2);
  border-radius: 12px;
  box-shadow: var(--st-shadow);
  overflow: hidden;
  position: relative;
}
.sheet figcaption {
  flex: 0 0 auto;
  display: flex; align-items: baseline; gap: 9px;
  padding: 8px 14px 7px;
  border-bottom: 1px solid rgba(25, 22, 19, .16);
  background: rgba(25, 22, 19, .05);
}
.sheet figcaption b {
  font: 700 11px/1 'Inter', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--st-ink);
}
.sheet-canvas { flex: 1 1 auto; min-height: 0; position: relative; }
.sheet-canvas { overscroll-behavior: contain; }
.sheet-canvas svg {
  position: absolute; inset: 0; width: 100%; height: 100%;
  display: block; touch-action: none; user-select: none; -webkit-user-select: none;
}
.sheet.is-hidden { display: none; }
/* One sheet is on the stage at a time and it fills it. */
#sheet-front { min-height: 148px; }
.chip .unit-tag { padding: 0; font-size: 10.5px; font-weight: 400; color: var(--st-faint); }
.chip.on .unit-tag { color: rgba(226, 223, 214, .8); }
.sheet.is-sliding { cursor: grabbing; }

/* Once a drawing is magnified there is somewhere to move it to, so the paper
   offers a hand. Pieces keep their own cursor: they are still draggable. */
/* the view is not dragged any more: zoom is the two buttons and nothing else */
/* ---- the drawing -------------------------------------------------------- */

/* paper grid */
.grid-fine  { stroke: rgba(25, 22, 19, .07);  stroke-width: .6; fill: none; }
.grid-bold  { stroke: rgba(25, 22, 19, .12);  stroke-width: 1; fill: none; }

/* the desk itself */
.desk-slab { fill: #a9a496; stroke: rgba(25, 22, 19, .55); stroke-width: 1.3; }
.desk-edge { fill: rgba(25, 22, 19, .22); }
.desk-plan { fill: #c0bbac; stroke: rgba(25, 22, 19, .55); stroke-width: 1.4; cursor: grab; }
.desk-plan:active { cursor: grabbing; }

/* The sector being edited, on the plan.

   A dashed frame drawn round it and nothing else. Nothing is washed over, faded
   or recoloured: every sector stays exactly as dark, as wooden and as legible as
   it was, and one of them is ringed. That is the whole idea, and it is why the
   frame is the only thing on this drawing in a colour the shelf itself never
   uses. Everything made is black or white, the dowels are wood and the paper is
   warm; a cool ink reads instantly as a mark about the drawing rather than a
   part of it, at any zoom and on either finish.

   It rides outside the sector's own footprint, so it never sits on a piece, and
   inside the sector's rotated frame, so it lands square on a sector at any
   angle. The name rides on the frame in the same ink, which is what ties the
   ring to the tab that was clicked. */
.run-focus {
  fill: none;
  stroke: var(--st-focus);
  stroke-width: 2.1;
  stroke-dasharray: 10 6;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;
}
.run-focus-name { fill: var(--st-focus); font-weight: 700; letter-spacing: .07em; }
/* parts. Fills come from the finish, set on the svg as .fin-white or .fin-black */
/* Two line colours, not one. --pl draws the silhouette, which always sits
   against the paper and is always dark. --pi draws everything inside a part,
   which sits against the part's own fill and has to flip with the finish. */
.part-fill  { fill: var(--pf); stroke: var(--pl); stroke-width: 1.4; stroke-linejoin: round; }
/* Dowels. Always wood, in both finishes, because they are wood. */
.dowel-body  { fill: var(--wood); stroke: var(--wood-line); stroke-width: 1.4; }
.dowel-hi    { stroke: var(--wood-hi); stroke-width: 1.4; fill: none; stroke-linecap: round; }
.dowel-grain { stroke: var(--wood-dark); stroke-width: .9; fill: none; stroke-linecap: round; opacity: .55; }
.dowel-line  { stroke: var(--wood-line); stroke-width: 1; fill: none; stroke-linecap: round; }
.pc.dowel:hover .dowel-body { stroke: var(--st-ink); stroke-width: 1.9; }
.pc.dowel.is-error .dowel-body   { stroke: var(--st-err);  stroke-width: 1.9; }
.pc.dowel.is-caution .dowel-body { stroke: var(--st-warn); stroke-width: 1.7; }
.part-shade { fill: var(--psh); stroke: none; }
.part-cut   { fill: var(--pcut); stroke: var(--pi); stroke-width: .7; stroke-linejoin: round; }
.part-line  { stroke: var(--pi); stroke-width: 1; fill: none; stroke-linecap: round; }

/* Interior detail that has to survive a black finish.

   A recessed face used to be a fill on a fill, which on black is one black
   rectangle: a drawer read as a solid block with no drawer in it. A face now
   carries its own outline in the interior line colour, which flips with the
   finish, and the fill is a shade off the body rather than the same value. The
   pull and the divider are drawn in the same colour so a black unit shows its
   fronts, its divider and its handles at a glance. */
.part-face {
  fill: var(--pfd);
  stroke: var(--pi);
  stroke-width: 1.15;
  stroke-linejoin: round;
}
.part-pull    { fill: var(--pi); stroke: none; }
.part-divider { stroke: var(--pi); stroke-width: 1.5; fill: none; stroke-linecap: round; }
.part-thin  { stroke: var(--pi); stroke-width: .85; fill: none; stroke-linecap: round; }
/* a cable is not part of the part, so it is drawn thinner and softer */
.part-cable { stroke: var(--pi); stroke-width: 1.6; fill: none; stroke-linecap: round; opacity: .55; }
.part-hi    { stroke: var(--ph); stroke-width: 1.4; fill: none; stroke-linecap: round; }
.part-soft  { fill: var(--ps); stroke: var(--pi); stroke-width: 1.1; stroke-linejoin: round; }

/* The post a corner stands on. It is a leg, drawn as one, but it belongs to the
   corner, so it takes the corner's hover cue rather than a leg's drag cursor. */
.pc.cornerpost { cursor: pointer; }
.pc.cornerpost .part-fill { stroke-dasharray: none; }

svg {
  --pf: #fdfdfa; --ps: #e5e1d6;
  --pl: rgba(16, 14, 12, .88);
  --pi: rgba(16, 14, 12, .62);
  --pfd: #dfdbd0;                    /* a recessed face, one step off the body */
  --pcut: rgba(25, 22, 19, .3);
  --psh: rgba(25, 22, 19, .13);
  --ph: rgba(255, 255, 255, .95);
}
svg.fin-black {
  --pf: #26221e; --ps: #3f3a31;
  --pl: rgba(8, 7, 6, 1);
  --pi: rgba(245, 242, 234, .72);
  --pfd: #35312c;                    /* a recessed face, lifted off the black body */
  --pcut: rgba(240, 236, 226, .42);
  --psh: rgba(0, 0, 0, .45);
  --ph: rgba(255, 255, 255, .16);
}

.dim-line { stroke: rgba(25, 22, 19, .45); stroke-width: .9; stroke-dasharray: 4 3; }
.dim-tick { stroke: rgba(25, 22, 19, .45); stroke-width: .9; }
/* Lettering on a drawing holds its size on screen however far the drawing is
   zoomed, because a label is there to be read, not to be measured. `--zk` is the
   zoom of the sheet, set on the svg when it is drawn, and it is 1 everywhere
   else, including on the catalog thumbnails. */
.lbl {
  font-family: 'Inter', sans-serif; font-size: calc(15px / var(--zk, 1)); fill: var(--st-faint);
  letter-spacing: .03em; paint-order: stroke; stroke: rgba(242, 240, 233, .85);
  stroke-width: calc(4px / var(--zk, 1));
}
.lbl-strong { fill: var(--st-ink); font-weight: 600; }
.lbl-tiny { font-size: calc(12.5px / var(--zk, 1)); fill: rgba(16, 14, 12, .74); }
.lbl-on-part { font-size: calc(13px / var(--zk, 1)); fill: rgba(16, 14, 12, .8); font-weight: 500; stroke: none; }
svg.fin-black .lbl-on-part { fill: rgba(248, 246, 240, .92); }

/* Names written on the top view. They are drawn in their own layer, last, so a
   dowel can never sit on top of one, and they carry a halo the colour of the
   paper so they read over a dowel, a cover or the desk without changing colour
   with the finish. */
.plan-labels { pointer-events: none; }
/* The letter on a leg. All three legs are the same part, so this is the only
   thing on the drawing that says which one somebody is looking at: L and R at
   the ends, M in the middle, C for a post that belongs to a corner. It holds its
   size when the drawing is zoomed, like every other piece of lettering. */
.leg-mark {
  font: 700 10px/1 'Inter', sans-serif;
  font-size: calc(10px / var(--zk, 1));
  letter-spacing: .04em;
  fill: var(--pi);
  paint-order: stroke fill;
  stroke: var(--pf);
  stroke-width: calc(2.6px / var(--zk, 1));
  stroke-linejoin: round;
  pointer-events: none;
}

.lbl-plan {
  font: 600 12.5px/1 'Inter', sans-serif;
  font-size: calc(12.5px / var(--zk, 1));
  fill: var(--st-ink);
  paint-order: stroke fill;
  stroke: var(--st-sheet);
  stroke-width: calc(3.6px / var(--zk, 1));
  stroke-linejoin: round;
  stroke-linecap: round;
}

/* selectable piece groups */
.pc { cursor: grab; }
.pc[data-drag=""] { cursor: pointer; }
.pc .hit { fill: transparent; stroke: none; }
.pc:hover .part-fill, .pc:hover .part-soft { stroke: var(--st-ink); stroke-width: 1.9; }
.pc.is-dragging { cursor: grabbing; }
.pc.is-dragging .part-fill { stroke: var(--st-ink); }

.sel-tag-bg { fill: var(--st-ink); }
.sel-tag-tx { font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 600; fill: var(--bg); letter-spacing: .01em; }

/* validation stamps. There were two halo rules here, one of them dashed. Nothing
   has drawn them since validation moved onto the pieces' own outlines, and a
   dashed frame is exactly what the drawing is not supposed to have, so they are
   gone rather than sitting here waiting to come back. */
/* Selection is drawn on the piece, following its own silhouette. There is no
   dashed rectangle anywhere on the drawing: a marquee around a corner or an L
   sector boxes in a shape that is not a box. Error and caution come after this
   on purpose, so a selected piece that is also wrong still reads as wrong. */
.pc.is-selected .part-fill, .pc.is-selected .part-soft { stroke: var(--st-ink); stroke-width: 2.4; }
.pc.is-selected .dowel-body { stroke: var(--st-ink); stroke-width: 2.2; }

/* POINT 1. A PIECE THAT IS CAUSING AN ERROR IS PAINTED, NOT OUTLINED.

   It used to be an outline and nothing else, 1.8px of oxblood on a part that
   went on looking exactly like every other part. The message bar said something
   was wrong and the drawing did not say which piece, so the customer read the
   sentence and then went looking. On a phone, at the size a shelf is drawn
   there, a stroke a millimetre and a half wide is not a thing you find by
   glancing.

   IT IS DONE WITH THE FILL TOKENS, not with a rule per shape. Every part in
   js/draw.js is built out of .part-fill, .part-soft, .part-face, .part-cut and
   the rest, and all of those take their colour from custom properties set on
   the <svg>. Setting the same properties on the piece's own group re-colours
   the whole part, interior detail included, in one place: a leg, a cover, a
   sub-shelf, a drawer with its fronts and its pulls, a hanging wall and every
   one of the twenty seven add-ons, without this stylesheet knowing what any of
   them are made of. A twenty eighth add-on is red on the day it is added.

   THE INTERIOR LINES FLIP TO WHITE. --pi is the interior line colour and it is
   dark, because it normally sits on a pale part; on a red field it would be
   mud. White at 82% keeps the grooves, the drawer fronts and the cable runs
   readable on the fill, and the contrast of white on #c62828 is 5.6 to 1.

   ERRORS ONLY, and the caution rule below is untouched on purpose. An error
   stops the order and a caution does not, and painting both the same colour
   would tell a customer they cannot buy a shelf they can buy.

   NOTHING HERE TOUCHES POINTER EVENTS. The fill was transparent on none of
   these shapes before and is opaque on none of them now that was not: the
   invisible .hit rectangle is what a finger lands on either way, so a painted
   piece is selected and dragged exactly like an unpainted one. */
.pc.is-error {
  --pf:   var(--st-err-fill);
  --ps:   var(--st-err-fill2);
  --pfd:  var(--st-err-face);
  --pl:   var(--st-err-line);
  --pi:   rgba(255, 255, 255, .82);
  --pcut: rgba(255, 255, 255, .3);
  --psh:  rgba(60, 6, 6, .3);
  --ph:   rgba(255, 255, 255, .5);
  /* A dowel is wood in both finishes, because it is wood. A dowel that is
     itself the piece in error is the exception, and it is the same exception
     the outline already made. */
  --wood:      var(--st-err-fill);
  --wood-dark: var(--st-err-face);
  --wood-line: var(--st-err-line);
  --wood-hi:   rgba(255, 255, 255, .45);
}
.pc.is-error .part-fill, .pc.is-error .part-soft   { stroke: var(--st-err-line); stroke-width: 1.8; }
/* On the plan a sub-shelf is a ghost, 35% of its fill, because it is under
   something. One that is wrong is put in over the cover instead of under it,
   see js/draw.js, and at 35% it would tint rather than read. This is the only
   number in the drawing that changes with the error state. */
.pc.under.is-error .part-fill { fill-opacity: .62; }
.pc.is-caution .part-fill, .pc.is-caution .part-soft { stroke: var(--st-warn); stroke-width: 1.6; }
.stamp-err  { fill: var(--st-err); }
.stamp-warn { fill: var(--st-sheet); stroke: var(--st-warn); stroke-width: 1.5; }
.stamp-tx   { font-family: 'Inter', sans-serif; font-size: 13px; font-weight: 700; text-anchor: middle; }
.stamp-err + .stamp-tx, .stamp-tx.on-err { fill: #f4f2ec; }
.stamp-tx.on-warn { fill: var(--st-warn); }
/* ---- the ledger --------------------------------------------------------- */

/* ---- the message bar ------------------------------------------------------
   A LAYER, NOT A ROW. It is positioned inside the stage rather than stacked
   under it, so the drawing keeps every pixel it had: a clean build renders no
   bar at all and gets the whole stage, and a build with eight things wrong
   gets exactly the same drawing area as one with none.

   It carries ONE message at its full size. The thing it replaced held them all
   in a box about a hundred pixels tall, which meant a scrollbar inside a
   scrollbar; stepping is both roomier and more useful, because each step also
   selects the piece the message is about and the drawing highlights it.

   Red for an error, which blocks the next step, and amber for a warning, which
   does not. Same two colours the strip used and the drawing uses. */
.msgbar {
  position: absolute; left: 18px; right: 18px; bottom: 18px; z-index: 5;
  display: flex; align-items: center; gap: 14px;
  padding: 12px 14px 12px 16px;
  border-radius: 12px;
  background: #262014;
  border: 1.5px solid var(--st-line2);
  box-shadow: 0 10px 30px rgba(10, 9, 8, .28), 0 2px 6px rgba(10, 9, 8, .18);
}
/* The dock is always there and always 372px wide, and it paints over the stage,
   so the bar stops where the paper stops. The same number .stage-paper uses,
   for the same reason. */
.msgbar { right: 390px; }
.msgbar[hidden] { display: none; }

/* BOTH STATES ARE DARK, and that is the point of them: the bar floats over a
   pale drawing on a pale sheet, so a pale bar would read as part of the paper.
   A dark panel reads as a layer sitting on top, which is what it is. The two
   grounds are the error red and the warning amber taken most of the way to
   black, so the colour still says which it is from across the room while the
   text stays legible.

   The first version left the warning on the default panel colour, which is
   light, with the light text the error state needs. It was almost unreadable. */
.msgbar.is-e { border-color: #a33b26; background: #2a1512; }
.msgbar.is-w { border-color: #9a7a24; background: #262014; }

.msgbar-k {
  flex: 0 0 auto; font: 700 10.5px 'Inter', sans-serif; letter-spacing: .13em;
  text-transform: uppercase; padding: 5px 9px; border-radius: 6px; white-space: nowrap;
}
.msgbar.is-e .msgbar-k { background: #a33b26; color: #fff; }
.msgbar.is-w .msgbar-k { background: #d3a63c; color: #241d0f; }

/* The message itself is the button, so the whole sentence is the target that
   takes you to the piece, rather than a small link at the end of it. */
.msgbar-t {
  flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: 0;
  font: 400 14px/1.5 'Inter', sans-serif; color: #f0ece2;
  cursor: pointer; padding: 0;
}
.msgbar-t:hover { text-decoration: underline; text-underline-offset: 3px; }
.msgbar-t[disabled] { cursor: default; text-decoration: none; }

.msgbar-nav { flex: 0 0 auto; display: flex; align-items: center; gap: 2px; }
.msgbar-nav[hidden] { display: none; }
.msgbar-n {
  font: 500 12px 'Inter', sans-serif; color: #b7b0a2;
  min-width: 54px; text-align: center; font-variant-numeric: tabular-nums;
}
.msgbar-step {
  width: 32px; height: 32px; border-radius: 8px; border: 1px solid rgba(240, 236, 226, .3);
  background: none; color: #f0ece2; font-size: 17px; line-height: 1;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.msgbar-step:hover { border-color: #f0ece2; }
.msgbar-all {
  flex: 0 0 auto; font: 600 12px 'Inter', sans-serif; letter-spacing: .04em;
  padding: 8px 13px; border-radius: 8px; cursor: pointer;
  border: 1px solid rgba(240, 236, 226, .34); background: none; color: #f0ece2;
  white-space: nowrap;
}
.msgbar-all:hover { border-color: #f0ece2; background: rgba(240, 236, 226, .08); }
.msgbar :focus-visible { outline: 2px solid #f0ece2; outline-offset: 2px; }

/* ===========================================================================
   POINT 5 AND POINT 7, THE MESSAGE BAR ON A PHONE
   ---------------------------------------------------------------------------
   Measured after the dock came out of the stage, at 390px: the bar was 120px
   tall with one caution and 172px with two, over a drawing 474px tall. That is
   25.1% and 36.1% of the drawing covered, and the ceiling is 15%, which is
   71px. The drawing's own top never moved, because the bar has always been
   absolutely positioned over the stage rather than in the flow above it, so
   the second rule was already satisfied and stayed that way.

   ALL OF THAT HEIGHT WAS FURNITURE, NOT MESSAGE. At 390px the badge, the
   previous and next arrows with "1 of 2" between them, and See all could not
   share a line, so they took three rows and the sentence took the fourth.

   WHAT WENT, AND WHY.

   The badge, "FIX TO CONTINUE" or "WORTH A LOOK", is gone from the bar. The bar
   is already dark red for an error and dark olive for a caution, across its
   whole width, so the badge was saying a second time what the colour says
   first. The full box still carries it.

   The arrows and the counter are gone from the bar. Stepping one at a time
   through messages is a thing to do in the list, and the list is one tap away.
   The count is not lost: See all reads "See all 2" when there is more than one.

   WHAT STAYED. The sentence, in full, at the size it was, and See all.

   WHAT WAS REJECTED. Clamping the sentence to two lines and leaving the badge:
   the caution is 95 characters and the error 92, both of which need three lines
   at this width, so a two line clamp would have cut the end off every real
   message and the third rule says a caution has to be legible without opening
   anything. Moving the bar out of the stage and into the flow under the
   drawing: it would cover nothing, but the stage already ends 16px above the
   fixed bar at the bottom of the screen, so the message would have been off
   screen unless the drawing gave up 150px, and giving the drawing's height to
   the bar is what the first rule exists to prevent.
   =========================================================================== */
@media (max-width: 900px) {
  /* ---- POINT 3, THE MESSAGE GOES ABOVE THE TOTAL, NOT UNDER IT ----------
     The bar was absolutely positioned inside the stage, 10px off its bottom,
     and the total strip is fixed to the bottom of the window. Those two agree
     with each other on exactly one phone and disagree on every shorter one,
     because the stage is `66dvh` while the chrome above it is a constant 253px:
     253 + 0.66H + the strip fits inside H only while H is at least 844.

     Measured, with a caution on screen, the area of the bar hidden behind the
     strip:

        390 x 932   0        390 x 800     740
        390 x 882   0        390 x 760   5,770
        390 x 844   0        390 x 745   7,654
                             390 x 714  11,470

     844 is an iPhone with nothing else on the screen. 745 is the same iPhone
     with Safari's own toolbar showing, which is how it is held most of the
     time, and that is the report: the first half of the sentence readable, the
     price across the middle of it, and the rest below the edge of the screen.

     So the bar stops being measured from the stage and is measured from the
     same edge the strip is: fixed, one gap above it, whatever the phone is. It
     is still a layer over the drawing, so the drawing keeps every pixel of its
     height and does not move when a message appears. */
  .msgbar {
    position: fixed;
    left: 10px; right: 10px; top: auto;
    bottom: calc(var(--bench-total-h, 34px) + 8px + env(safe-area-inset-bottom));
    flex-wrap: nowrap; align-items: center; gap: 10px;
    padding: 7px 11px;
  }
  /* POINT 6. THE BADGE COMES BACK ON A PHONE, ON A LINE OF ITS OWN.

     On a desktop the bar carries FIX TO CONTINUE in solid red, or WORTH A LOOK
     in solid amber, and the side column says "1 error" underneath. On a phone
     neither was there: the customer got a sentence and nothing else, so nothing
     said that the red one stops the order and the amber one does not. They
     found out at step 4, when they were bounced.

     The badge was taken off in v55 because five things could not share a line
     at 390px and the bar grew to four rows. Four of those five are still off:
     the arrows and the counter stay hidden and See all still carries the count.
     What comes back is the badge alone, on its own row, which is one row and
     not three.

     WHY THE BADGE AND NOT JUST A COLOUR. The two bar grounds are #2a1512 and
     #262014, which is 12.3 apart in CIEDE2000: enough to see side by side and
     not enough on its own, and on its own is the only way it is ever seen,
     because one bar is on the screen at a time and there is nothing to compare
     it against. The two badges are #a33b26 and #d3a63c, 40.5 apart, and they
     carry words as well as a colour, so they still work for somebody who cannot
     use the colour at all.

     IT COSTS THE DRAWING NOTHING. The bar is `position: fixed` and anchored to
     its bottom edge, above the total strip, so it grows upward: the stage keeps
     every pixel it had and the drawn shelf does not change size. It covers
     about 22px more of a drawing that is mostly empty paper. */
  /* A GRID, NOT A WRAPPED FLEX ROW. A flex item told to take a whole line is
     a whole line wide, so the badge came out stretched across the bar like a
     heading, and See all fell onto a third line under the sentence. Two columns
     and three placements says exactly what was wanted: the badge on its own
     row, sized to its own words, and the sentence and See all sharing the row
     under it. */
  .std .msgbar { display: grid; grid-template-columns: minmax(0, 1fr) auto;
                 gap: 6px 10px; align-items: center; }
  .std .msgbar-k { grid-column: 1 / -1; justify-self: start; }
  .std .msgbar-t { grid-column: 1; }
  .std .msgbar-all { grid-column: 2; }
  .std .msgbar-nav { display: none; }
  .msgbar-t { flex: 1 1 auto; font-size: 13.5px; line-height: 1.35; }
  .std .msgbar-all { flex: 0 0 auto; padding: 0 12px; font-size: 12px; }
}

.note {
  display: grid; grid-template-columns: 21px 1fr; gap: 10px; align-items: start;
  width: 100%; text-align: left; padding: 8px 0; border-top: 1px solid var(--st-line);
  font-size: 13px; line-height: 1.5; color: var(--st-soft);
}
.note:first-child { border-top: 0; }
.note-n {
  width: 19px; height: 19px; border-radius: 50%; display: inline-flex;
  align-items: center; justify-content: center; font-size: 10px; font-weight: 700; margin-top: 1px;
}
.note-e .note-n { background: var(--st-err); color: #f4f2ec; }
.note-w .note-n { background: transparent; color: var(--st-warn); border: 1.5px solid var(--st-warn); }
.note.clickable { cursor: pointer; }
.note.clickable:hover { color: var(--st-ink); }
.note-ok { padding: 8px 0 10px; font-size: 13px; color: var(--st-ok); }
.note-ok b { font-weight: 600; }
/* While the module graph is still arriving. Step 1 is already drawn by the
   HTML, so this is a status line and not a curtain: nothing is hidden, and the
   moment boot() finishes the class comes off and the strip goes with it. */
.booting-note {
  display: none; margin: 0 auto 0 0; padding: 6px 14px; border-radius: 999px;
  font-size: 11.5px; letter-spacing: .1em; text-transform: uppercase; font-weight: 700;
  color: var(--st-accent); background: var(--st-panel); border: 1.5px solid var(--st-line);
}
body.is-booting .booting-note { display: inline-block; }
/* the controls that only mean something once the studio is running */
body.is-booting .work-nav .btn { opacity: .45; pointer-events: none; }

/* ---- the counters, and the messages in full ----------------------------- */

/* The counter is a control now, not a label, so it looks like one and it is
   loud enough to be seen from the drawing. */
.flagbtn {
  font: inherit; font-weight: 700; font-size: 12px; letter-spacing: .02em;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 5px 12px 5px 10px; border-radius: 999px; cursor: pointer;
  border: 1.5px solid currentColor; background: transparent;
}
.flagbtn::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
.flagbtn.e { color: var(--st-err); background: rgba(122, 35, 20, .10); }
.flagbtn.w { color: var(--st-warn); background: rgba(93, 67, 16, .12); }
.flagbtn:hover { filter: brightness(.88); }
.flagbtn:focus-visible { outline: 2px solid var(--st-ink); outline-offset: 2px; }

.side-flag.is-btn { cursor: pointer; font-weight: 700; }
.side-flag.is-btn:hover { text-decoration: underline; }
.side-flag.is-btn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 4px; }

.msgback {
  position: fixed; inset: 0; z-index: 1300; display: flex;
  align-items: center; justify-content: center; padding: 24px;
  background: rgba(16, 14, 12, .5);
}
.msgbox {
  /* WIDER AND AS TALL AS THE SCREEN ALLOWS. This is the surface for reading
     several at once, and it was capped at 560 in both directions: on a tall
     screen it left most of the height unused and scrolled anyway, and at 560
     wide every message wrapped to three lines, which made the scrolling worse.
     Wider lines mean fewer of them, and 86vh means the cap is the screen rather
     than a number. */
  width: min(720px, 92vw); max-height: 86vh; display: flex; flex-direction: column;
  background: var(--st-sheet); color: var(--st-ink);
  border: 2px solid var(--st-line2); border-radius: 14px;
  box-shadow: 0 24px 60px rgba(16, 14, 12, .35);
}
.msgbox-head {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 16px 18px 12px; border-bottom: 1.5px solid var(--st-line);
}
.msgbox-head b { font-family: 'Fraunces', serif; font-size: 19px; font-weight: 500; letter-spacing: -.01em; }
.msgbox-x {
  width: 30px; height: 30px; flex: 0 0 auto; border-radius: 50%; cursor: pointer;
  border: 1.5px solid var(--st-line); background: transparent; color: var(--st-soft);
  font-size: 17px; line-height: 1;
}
.msgbox-x:hover { color: var(--st-ink); border-color: var(--st-ink); }
.msgbox-list { overflow-y: auto; padding: 4px 18px; }
.msgrow {
  display: grid; grid-template-columns: 22px 1fr; gap: 11px; align-items: start;
  width: 100%; text-align: left; font: inherit; font-size: 13.5px; line-height: 1.55;
  color: var(--st-soft); background: transparent; border: 0;
  border-top: 1px solid var(--st-line); padding: 13px 0;
}
.msgrow:first-child { border-top: 0; }
.msgrow-n {
  width: 20px; height: 20px; border-radius: 50%; display: inline-flex;
  align-items: center; justify-content: center; font-size: 10.5px; font-weight: 700; margin-top: 2px;
}
.msgrow-e .msgrow-n { background: var(--st-err); color: #f4f2ec; }
.msgrow-w .msgrow-n { background: transparent; color: var(--st-warn); border: 1.5px solid var(--st-warn); }
.msgrow-k {
  display: block; font-style: normal; font-size: 10px; font-weight: 700;
  letter-spacing: .14em; text-transform: uppercase; margin-bottom: 3px;
}
.msgrow-e .msgrow-k { color: var(--st-err); }
.msgrow-w .msgrow-k { color: var(--st-warn); }
.msgrow-go { display: block; font-style: normal; font-size: 12px; font-weight: 600; margin-top: 5px; color: var(--st-ink); }
.msgrow.clickable { cursor: pointer; }
.msgrow.clickable:hover { color: var(--st-ink); }
.msgrow.clickable:hover .msgrow-go { text-decoration: underline; }
/* A note is prose rather than a list, so it gets a wider box: at 560px the
   second sentence broke onto a third line for one word. */
.msgbox-note { width: min(680px, 100%); }
.msgbox-note .msgnote { max-width: none; }
.msgnote { margin: 0 0 12px; font-size: 13.5px; line-height: 1.6; color: var(--st-soft); }
.msgnote:last-child { margin-bottom: 4px; }
.msgbox-note .msgbox-list { padding-top: 14px; padding-bottom: 8px; }

.msgbox-foot {
  display: flex; align-items: center; gap: 14px;
  margin: 0; padding: 12px 18px 16px; border-top: 1.5px solid var(--st-line);
  font-size: 12.5px; line-height: 1.55; color: var(--st-soft);
}
.msgbox-foot-t { margin: 0; flex: 1 1 auto; }
.msgbox-mail { color: var(--st-ink); font-weight: 600; border-bottom: 1px solid currentColor; }
.msgbox-mail:hover { color: var(--st-err); }
.msgbox-foot .btn { flex: 0 0 auto; white-space: nowrap; }
/* v108 point 1. A footer that is only buttons has no paragraph to push them
   over, so it says so itself. The dialogs that carry a sentence are untouched. */
.msgbox-ask { justify-content: flex-end; }

/* ---- the piece pane ----------------------------------------------------- */

.piece-head { display: flex; gap: 11px; align-items: center; margin-bottom: 16px; }
.piece-art {
  width: 54px; height: 46px; flex: 0 0 auto; border-radius: 8px;
  background: linear-gradient(180deg, #f4f2ec, #e7e4dc); border: 1px solid rgba(31, 28, 25, .08);
  display: flex; align-items: center; justify-content: center; padding: 4px;
}
.piece-art svg { width: 100%; height: 100%; }
.piece-title { display: block; font-family: 'Fraunces', serif; font-size: 20px; font-weight: 500; letter-spacing: -.01em; line-height: 1.15; }
.piece-where { display: block; font-size: 11.5px; color: var(--st-faint); margin-top: 3px; }

.piece-empty { padding: 26px 4px; text-align: center; color: var(--st-faint); }
.piece-empty svg { width: 54px; height: 54px; margin: 0 auto 14px; display: block; opacity: .5; stroke: currentColor; fill: none; stroke-width: 1.2; }
.piece-empty p { font-size: 13px; line-height: 1.65; color: var(--st-soft); }
.piece-empty b { display: block; color: var(--st-ink); font-size: 14px; font-weight: 600; margin-bottom: 6px; }
/* how to get a closer look, said where somebody with nothing selected will read it */
.piece-empty-tip {
  margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--st-line);
  font-size: 12.5px !important; color: var(--st-faint) !important;
}
.piece-empty-tip b { font-size: 12.5px; letter-spacing: .01em; }

.hint { font-size: 12.5px; line-height: 1.6; color: var(--st-soft); margin-top: 10px; }
.hint b { color: var(--st-ink); font-weight: 600; }

/* position slider */
.slider { display: flex; align-items: center; gap: 8px; }
.slider input[type="range"] { flex: 1; min-width: 0; accent-color: var(--st-accent); height: 20px; }
.step {
  width: 30px; height: 30px; flex: 0 0 auto; border-radius: 7px; border: 1.5px solid var(--st-line);
  background: #fbfaf5; color: var(--st-soft); font-size: 15px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
}
.step:hover:not(:disabled) { border-color: var(--st-ink); color: var(--st-ink); }
.step:disabled { opacity: .35; cursor: not-allowed; }
.readout { font-size: 12.5px; font-weight: 600; color: var(--st-ink); font-variant-numeric: tabular-nums; min-width: 58px; text-align: right; }

.piece-acts { display: flex; gap: 7px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--st-line); }
.pact {
  flex: 1; padding: 10px; border-radius: 8px; font: 500 12.5px/1 'Inter', sans-serif;
  border: 1.5px solid var(--st-line); background: #fbfaf5; color: var(--st-soft);
  transition: border-color .16s, color .16s, background .16s;
}
.pact:hover { border-color: var(--st-ink); color: var(--st-ink); }
.pact-del:hover { border-color: var(--st-err); color: var(--st-err); background: rgba(157, 59, 42, .06); }
/* ---- the crash banner --------------------------------------------------- */

.crash {
  position: fixed; left: 50%; bottom: 92px; transform: translateX(-50%); z-index: 1100;
  display: flex; align-items: center; gap: 12px; padding: 12px 18px; border-radius: 10px;
  background: var(--st-ink); color: var(--bg); font-size: 13px; box-shadow: 0 20px 40px -18px rgba(0, 0, 0, .7);
}
.crash button { color: var(--darkaccent); text-decoration: underline; font-size: 13px; }
.crash button:hover { color: var(--bg); }

/* ---- the toast ---------------------------------------------------------- */

.toast {
  position: fixed; left: 50%; bottom: 92px; transform: translate(-50%, 8px); z-index: 1200;
  padding: 11px 20px; border-radius: 100px; background: var(--st-ink); color: var(--bg);
  font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
}
.toast.on { opacity: 1; transform: translate(-50%, 0); }

/* ===========================================================================
   NARROWER SCREENS
   The invariant: when a piece is selected, its options are on screen.

   This block used to carry the two docks and the price rail of the studio
   that came before the stepper, which turned into bottom sheets here. That
   shell is gone and so are its rules; what is left is the bench dropping a
   column at a time. Written down because the comment outlived the code once
   already.
   =========================================================================== */

@media (max-width: 1180px) {
  .bench { grid-template-columns: 258px minmax(0, 1fr) 296px; }
}

@media (max-width: 1080px) {
  .bench { grid-template-columns: 250px minmax(0, 1fr); }
}

@media (max-width: 760px) {
  .bench { grid-template-columns: minmax(0, 1fr); }
  .stage-tools { margin-left: auto; }
  .stage-paper { padding: 10px; }
}

@media (max-width: 520px) {
  .chips-view .chip { padding: 7px 11px; }
}

/* Short screens: let the whole thing scroll rather than crushing the drawing. */
@media (max-height: 560px) {
  .bench { min-height: 540px; }
}

/* ---- keyboard focus ------------------------------------------------------
   Everything on the bench is reachable by tab, so everything needs a ring the
   mouse never shows. */
.bench :focus { outline: none; }
.bench :focus-visible {
  outline: 2px solid var(--st-accent);
  outline-offset: 2px;
  border-radius: 6px;
}
.chip:focus-visible { border-radius: 100px; }
.pc:focus-visible { outline: none; }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}

/* ---- phone-only furniture, off by default --------------------------------
   Three things exist in the markup at every width and are only ever shown on a
   phone: the stepper row inside an order line, and the title bar's close button
   and the Checkout button that the accessories cart drawer builds into the dark
   column. They are rendered unconditionally so there is ONE renderer for the
   desktop column and the phone drawer, which is what stops the two drifting.

   They need this rule to be off, and they needed it badly: without it the
   stepper span became a fifth grid item in every .bline and the two buttons
   drew into the desktop sidebar, which moved 22,260 pixels of a page that was
   not allowed to move.

   The sheet's grip and close button are here for the same reason and were the
   last five pixels of it. They sit inside .addon-layout, which is a grid, so
   on a desktop they became a second row of it at y=1108, below the fold. The
   only thing that showed above the fold was the detail panel landing on a
   different subpixel offset, which changed the antialiasing along two edges of
   the photograph by one unit on one channel. Five pixels out of 1.6 million,
   invisible, and still not zero. Caught by the pixel comparison, not by
   looking, which is the whole reason the comparison is run. */
.bstep, .side-x, .cart-go, .sheet-x, .sheet-grip, .pill-nav { display: none; }

/* ===========================================================================
   THE ACCESSORIES PAGE ON A PHONE
   ---------------------------------------------------------------------------
   Everything below is inside a media query and scoped to `.acc`, a class on the
   body of accessories.html only. Both halves matter: the query keeps the
   desktop untouched, and the scope keeps this off studio.html, which shares
   this stylesheet, the .side-open button and the whole .flow shell. The studio
   is part 3 and nothing here reaches it.

   THE SHELL STOPS BEING A SHELL. On a desktop this page is an app: the document
   never scrolls, .flow is 100% tall, and the scrolling happens inside .pane.
   That is what buried the detail panel 3,671px down a scroller with no way to
   know it was there. On a phone the document scrolls instead, so the site
   header can be sticky the way it is on the home page, the footer can sit at
   the end where a footer goes, and the product sheet can be fixed to the
   bottom of the viewport rather than to the bottom of a box.
   =========================================================================== */
@media (max-width: 900px) {

/* ---- the document scrolls ------------------------------------------------ */
/* Only the body. `html, body.flow-page { height: 100% }` further up is not the
   thing that stops this page scrolling, `overflow: hidden` on the body is, and
   a root that is exactly viewport tall does not clip a body that overflows it.
   Written as `html:has(body.acc), body.acc` at first, which also worked; this
   says the same thing without putting a :has() on the root element. */
body.acc { height: auto; overflow: visible; }
.acc .flow { display: block; height: auto; }
.acc .work { display: block; min-height: 0; }
.acc .work-body { display: block; overflow: visible; }
.acc .pane { overflow: visible; padding: 16px 14px 30px; }
.sheet-host .addon-layout { display: block; height: auto; }

/* ---- the two strips ------------------------------------------------------
   The site header is the home page's, unchanged, and it sticks at the top. The
   page's own strip sticks directly under it and carries the three controls that
   have to stay reachable: the order, the step, and the way forward. Together
   they are shorter than the single strip this page used to have. */
/* ---- THE LAYER ORDER, ONCE, IN ONE PLACE -------------------------------
   The site header was given z-index 60 in v48, to clear the page strip at 50.
   The menu's own backdrop is 90, so the backdrop covered the header AND the
   open panel inside it: every link in the menu had a scrim on top of it and a
   tap on one hit the scrim, which closes the menu. The links were inert on
   this page from the moment it got a menu, on both steps, not just checkout.

   So the whole stack is written out here rather than each overlay picking a
   number near its own rule. Every overlay sits ABOVE the header, because each
   one is modal: while it is open the header is not meant to be tappable.

     50   the page strip
     90   the menu backdrop
    100   the site header, the same value the home page uses
    105   the product sheet's backdrop
    110   the product sheet, its grip and its close button
    115   the cart backdrop
    120   the cart drawer                                                   */
.acc .nav { position: sticky; top: 0; z-index: 100; }
.acc .work-head {
  position: sticky; top: var(--acc-nav-h, 57px); z-index: 50;
  display: flex; align-items: center; flex-wrap: nowrap; gap: 10px;
  padding: 7px 14px; min-height: 52px;
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: blur(14px);
}
/* The step and the title shrink into one line. They are context, not controls,
   so they give up their room first. */
.acc .work-head .work-title { flex: 1 1 auto; min-width: 0; }
.acc .work-head .k { font-size: 9.5px; letter-spacing: .16em; margin-bottom: 1px; }
.acc .work-head h1 { font-size: 19px; line-height: 1.15; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis; }
.acc .work-head .booting-note { display: none; }
.acc .work-nav { gap: 8px; }
.acc .work-nav .btn { padding: 0 16px; min-height: 44px; font-size: 13.5px; }
/* Back is meaningless on step 1, where it is already disabled, and the room it
   takes is the room the title needs. It comes back on step 2. */
.acc .work-nav #btn-back { display: none; }
.acc[data-step="2"] .work-nav #btn-back { display: inline-flex; align-items: center; }
.acc[data-step="2"] .work-nav #btn-next { display: none; }

/* ---- the cart button, with its count ------------------------------------- */
/* 5. The cart sits at the right end of the strip. The button is first in the
   markup, where it was the menu, so it is moved with `order` rather than by
   moving it in the DOM: studio.html has the same button in the same place and
   part 3 has not run yet. */
.acc .work-head .work-title { order: 1; }
.acc .work-head .work-nav { order: 2; }
.acc .work-head .side-open { order: 3; }
.acc .side-open { width: 44px; height: 44px; border-radius: 11px; position: relative; margin: 0; }
.acc .side-open .bars { display: none; }
.acc .cart-ico { display: block; width: 21px; height: 21px; color: var(--st-ink); }
.acc .cart-count {
  position: absolute; top: -5px; right: -5px; min-width: 19px; height: 19px; padding: 0 5px;
  border-radius: 99px; background: var(--st-ink); color: var(--bg);
  font: 700 11px/19px 'Inter', sans-serif; text-align: center;
  font-variant-numeric: tabular-nums;
}
.acc .cart-count[data-n="0"] { display: none; }

/* ---- the cart drawer -----------------------------------------------------
   The dark column is already the order: the lines, the totals and the flag. On
   a phone it comes in from the right, because that is the side the cart button
   is on, and it drops the parts that are not the order. */
.acc .side {
  inset: 0 0 0 auto; width: min(88vw, 380px); transform: translateX(100%);
  padding: 18px 18px 22px; z-index: 120;
}
.acc .side.open { transform: none; }
.acc .side-brand, .acc .side-task, .acc .side-cross, .acc .fsteps, .acc .side-foot { display: none; }
.acc .cart-scrim {
  display: block; position: fixed; inset: 0; z-index: 115; background: rgba(20,18,16,.5);
  opacity: 0; pointer-events: none; transition: opacity .24s ease;
}
.acc .cart-scrim.on { opacity: 1; pointer-events: auto; }
/* A title and a way out, drawn by the drawer rather than added to the markup,
   so the desktop column gains nothing. */
.acc .side .building-k { position: relative; padding-right: 46px; }
.acc .side-x {
  position: absolute; top: 10px; right: 12px; width: 44px; height: 44px; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  border-radius: 11px; color: var(--sd-fg); font-size: 26px; line-height: 1; background: none;
}
/* ---- THE CART LINES SCROLL INSIDE THEIR OWN BOX, AS THEY DO ON A DESKTOP ---
   This is where the overlap came from, and it is worth writing down because the
   same three properties appear on four boxes in this file.

   `.side` is a flex column and `.building` is `flex: 1 1 auto; min-height: 0`
   inside it. `min-height: 0` is what lets a flex item shrink BELOW its content,
   and it is there on purpose: it is what makes `.building-scroll` scroll rather
   than push the totals off the panel. v48 gave the drawer `overflow-y: auto` on
   `.side` and turned the inner scroller off with `overflow: visible`, so the
   box still shrank to the leftover space, 522px, while holding 2,701px of
   lines, and nothing clipped the difference. Twenty three lines painted straight
   over Subtotal, Shipping, the total, the Checkout button and the two status
   lines that follow it in the column.

   The fix is not to unshrink the box, it is to stop the drawer scrolling as a
   whole. It is a fixed height panel now, exactly like the desktop column: the
   lines scroll in the middle and the money stays pinned to the bottom, which is
   also the better cart. Nothing here is a phone rule any more, so the two
   overrides are gone rather than corrected. */
.acc .side { overflow-y: hidden; }
/* The steppers only exist on a phone. They are rendered on every width and
   hidden here rather than added conditionally, so there is one renderer. */
.acc .bline { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 10px 0; font-size: 13px; }
.acc .bline .q { display: none; }
.acc .bline .d { grid-column: 1; }
.acc .bline .n { grid-column: 2; text-align: right; }
/* The desktop's little x is replaced by the Remove button in the stepper row,
   so it goes. It was absolutely positioned to take it out of the grid, which
   left it drawn on top of the product name. */
.acc .bline .rm { display: none; }
.acc .bline .bstep { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.acc .bline .bstep .step {
  width: 44px; height: 44px; border-radius: 10px; border: 1.5px solid var(--sd-line);
  background: transparent; color: var(--sd-fg); font-size: 18px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
}
.acc .bline .bstep .addon-n { min-width: 26px; text-align: center; font-weight: 700; color: var(--sd-fg);
  font-variant-numeric: tabular-nums; }
.acc .bline .bstep .brm {
  margin-left: auto; min-height: 44px; padding: 0 14px; border-radius: 10px;
  border: 1.5px solid var(--sd-line); background: transparent; color: var(--sd-soft); font-size: 12.5px;
}
.acc .side .side-total b { font-size: 26px; }
.acc .cart-go { display: block; width: 100%; margin-top: 16px; min-height: 48px; text-align: center;
  line-height: 48px; padding: 0; }

/* ---- the list ------------------------------------------------------------ */
.acc .blk-notice { margin-bottom: 14px; }
/* 4. The row scrolls, and it has to LOOK like it scrolls.
   THE v49 FADE IS GONE. It was a mask that dissolved the right hand edge of
   the row, and on a phone it did nothing, for two reasons that only show on a
   screenshot. A mask fades to TRANSPARENT, and transparent here is the page
   behind it, which is --bg #d8d6ce, against pills of --st-panel #d5d1c6: three
   values apart on each channel, so it faded one greige into another greige.
   And at 390px the third pill ends 4px from the edge, so the 26px of fade
   landed on the gap after it rather than across a pill: there was no cut pill
   to soften, and nothing to read as "continues".

   A CONTROL INSTEAD OF AN EFFECT. A chevron in a filled circle at the end of
   the row, in its own gutter rather than floating over the pills. It is a
   thing rather than a treatment, so it survives any background and any set of
   labels, and it scrolls the row when tapped, which is the second half of what
   the reader needs. It turns around at the end of the row and comes back, so
   there is always exactly one button and it always means something. */
.sheet-host .addon-side { position: relative; }
.sheet-host .cat-pills { gap: 6px; margin-bottom: 12px;
  flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
  scroll-behavior: smooth;
  /* Bleeds off the left edge of the screen, stops short of the right one. The
     button gets real space rather than sitting on top of a pill: `padding` on
     a scroller only pads the CONTENT, so it scrolls away with it and the
     button ends up over whatever pill is passing underneath. A margin takes
     the room out of the scroll port itself, so nothing ever goes under it. */
  margin-left: -14px; padding-left: 14px;
  margin-right: 40px; padding-right: 2px; }
.sheet-host .pill-nav {
  display: flex; align-items: center; justify-content: center;
  position: absolute; top: 0; right: -4px; width: 44px; height: 44px; z-index: 3;
  border-radius: 50%; background: var(--st-sheet);
  border: 1.5px solid var(--st-ink); color: var(--st-ink);
  box-shadow: 0 2px 10px -2px rgba(20, 18, 16, .45);
  transition: transform .22s cubic-bezier(.4, 0, .2, 1);
}
.sheet-host .pill-nav svg { width: 18px; height: 18px; display: block; }
/* At the end of the row it points back the way it came. */
.sheet-host .cat-pills.at-end + .pill-nav { transform: scaleX(-1); }
.sheet-host .pill-nav:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 3px; }
.sheet-host .cat-pills::-webkit-scrollbar { display: none; }
.sheet-host .cat-pill { flex: 0 0 auto; min-height: 44px; padding: 0 15px; display: inline-flex; align-items: center; }
/* `overflow: visible` on a `flex: 1 1 auto; min-height: 0` box is the shape that
   broke the cart drawer above. It is safe here only because this one's flex
   container, `.addon-side`, has no bounded height on a phone, so there is no
   leftover space for it to shrink into. Pinned anyway, so it cannot become the
   same bug the day something gives that column a height. */
.sheet-host .addon-scroll { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px;
  overflow: visible; padding-right: 0; flex: 0 0 auto; min-height: auto; }
.sheet-host .addon { grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 10px; text-align: center; }
.sheet-host .addon-art { width: 100%; height: 74px; margin: 0 auto; }
.sheet-host .addon-mid { min-width: 0; }
.sheet-host .addon-name { font-size: 13px; line-height: 1.3; }
.sheet-host .addon-price { font-size: 12px; }
/* 3. NOTHING GOES ON THE ORDER FROM A CARD ON A PHONE.
   On a desktop the panel is always on screen beside the list, so Add on a card
   is a shortcut to something already visible. On a phone the panel is a sheet,
   so the same button sells somebody a photograph they have never seen. The
   controls come off the card and the whole card opens the sheet; adding happens
   in there, under the picture. What is left on the card is a count, so a card
   still says whether it is on the order. */
/* v54: this is a property of the sheet, not of /accessories, so it moved to
   the shared scope when /studio gained the sheet. The argument is the same on
   both pages and it would be the same on a third. */
.sheet-host .addon .addon-add,
.sheet-host .addon .addon-qty .step { display: none; }
.sheet-host .addon .addon-qty { justify-content: center; min-height: 0; }
.sheet-host .addon .addon-n {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 26px; height: 26px; padding: 0 8px; border-radius: 99px;
  background: var(--st-ink); color: var(--bg); font: 700 12.5px 'Inter', sans-serif;
}
.sheet-host .addon .addon-n::after { content: " on your order"; font-weight: 500; }
.sheet-host .addon .addon-on { font-size: 12px; color: var(--st-accent); font-weight: 600; }

/* 8. The stepper in the sheet is centred, not pushed to one edge. */
.sheet-host .addon-detail > .addon-qty { justify-content: center; }

}

/* ===========================================================================
   THE PRODUCT SHEET
   ---------------------------------------------------------------------------
   THIS IS THE SAME PANEL THE DESKTOP SHOWS. `#addon-detail` is not moved, not
   copied and not re-rendered: js/addons-ui.js goes on drawing the photograph,
   the flip chip, the price row, the option and the Add button into that one
   node exactly as it does on a desktop, and the only thing that changes is
   where the node is. That is why the picker needed no edit at all, and why the
   studio, which shares it, cannot have been affected.

   It is fixed to the viewport rather than to the pane, so it opens over
   whatever the reader was looking at and closes back onto it with the list
   still where they left it. Nothing scrolls underneath, nothing reflows, and
   the cycle of look, decide, carry on costs two taps however many times it is
   repeated.
   =========================================================================== */
@media (max-width: 900px) {

.sheet-host .sheet-panel {
  position: fixed; left: 0; right: 0; bottom: 0; top: auto; z-index: 110;
  max-height: 86vh; max-height: 86dvh; overflow-y: auto; overscroll-behavior: contain;
  margin: 0; border-radius: 20px 20px 0 0;
  padding: 26px 16px calc(18px + env(safe-area-inset-bottom));
  box-shadow: 0 -18px 46px -16px rgba(20, 18, 16, .55);
  transform: translateY(101%); visibility: hidden;
  transition: transform .28s cubic-bezier(.32, .72, 0, 1), visibility .28s;
}
.sheet-host .sheet-panel.is-open { transform: none; visibility: visible; }
/* While a finger is on it the sheet follows, so the drag has to be immediate. */
.sheet-host .sheet-panel.is-dragging { transition: none; }

/* Nothing to show is not a sheet. On the desktop this node always holds the
   "pick an add-on" line; here the sheet is simply shut. */
.sheet-host .addon-detail .none { display: none; }

.sheet-host .sheet-grip {
  display: block; position: fixed; left: 50%; transform: translateX(-50%);
  bottom: auto; z-index: 112; width: 42px; height: 4px; border-radius: 99px;
  background: var(--st-line); opacity: 0; pointer-events: none; transition: opacity .28s;
}
.sheet-host.sheet-on .sheet-grip { opacity: .9; }
.sheet-host .sheet-x {
  display: none; position: fixed; z-index: 112; width: 44px; height: 44px;
  border-radius: 50%; align-items: center; justify-content: center;
  background: var(--st-panel); border: 1.5px solid var(--st-line);
  color: var(--st-ink); font-size: 24px; line-height: 1;
}
.sheet-host.sheet-on .sheet-x { display: flex; }
.sheet-host .sheet-scrim {
  display: block; position: fixed; inset: 0; z-index: 105; background: rgba(20, 18, 16, .5);
  opacity: 0; pointer-events: none; transition: opacity .28s ease;
}
.sheet-host.sheet-on .sheet-scrim { opacity: 1; pointer-events: auto; }

/* The panel's own furniture, sized for a phone. */
.sheet-host .addon-detail .addon-photo { aspect-ratio: 4 / 3; border-radius: 14px; }
.sheet-host .addon-detail h2, .sheet-host .addon-detail h3 { font-size: 21px; margin-top: 12px; }
.sheet-host .addon-detail .addon-priceline { flex-wrap: wrap; gap: 10px; }
.sheet-host .addon-detail .addon-opt { min-height: 44px; }
/* The one control the sheet exists to reach stays on screen while the note and
   the table scroll under it.

   IT KEEPS ITS OWN FILL. The first version put a gradient here to mask the text
   sliding under it, which set `background` on the button itself: .btn is ink
   with a --bg label, so the label went pale on pale and "Add to order $16.95"
   was almost unreadable. The masking is done with a shadow spread instead,
   which paints the sheet's own colour above and below the control and leaves
   the control alone. */
/* NAMED BY ID, and the id is the reason this reads oddly next to its
   neighbours. `.std .pane .btn`, added for point 7, is four
   class level components deep because :not() carries the specificity of its
   argument, so it outranked `.sheet-host .addon-detail > .btn` and made the
   studio's Add to order 44px tall where /accessories had 50: one component,
   two sizes, and reordering the file does not fix a specificity contest. The
   panel is #addon-detail on both pages, so saying so puts the component back in
   charge of its own control without a second copy of the number. */
.sheet-host #addon-detail > .btn,
.sheet-host #addon-detail > .addon-qty {
  position: sticky; bottom: 8px; z-index: 3; margin-top: 16px;
  box-shadow: 0 -14px 0 0 var(--st-panel), 0 14px 0 0 var(--st-panel),
              0 10px 24px -12px rgba(20, 18, 16, .55);
}
.sheet-host #addon-detail > .addon-qty { background: var(--st-panel); }
.sheet-host #addon-detail > .btn { min-height: 50px; }
.sheet-host .addon-detail .addon-qty .step { width: 44px; height: 44px; }
/* AND THE SAME ARGUMENT ONE ROW ALONG. Remove sits beside those two steppers
   and is the component's own control, and it was 33px tall on /accessories and
   44 in the studio: `.std .pane .btn` gives every button in a studio pane the
   tap minimum on a phone and there is no such rule on the other page, so the
   shared component came out in two sizes again. Measured at 390px, every other
   control in the panel matched to the pixel and this one did not. The floor
   belongs to the component, so it is stated here for both consumers. */
.sheet-host .addon-detail .addon-qty .btn { min-height: 44px; }
.sheet-host .addon-detail .addon-flip { min-width: 44px; min-height: 44px; }

}

/* The backdrop is only given `display: block` inside the phone query, so above
   it a bare div would sit in the flow as an empty block. Hidden here, once, for
   every page that hosts the sheet, rather than in each page's own desktop list.
   =========================================================================== */
@media (min-width: 901px) {
  .sheet-host > .sheet-scrim { display: none; }
}

/* The sheet slides. Somebody who has asked their phone to stop animating gets
   it in place instead, and the drag still tracks the finger. */
@media (max-width: 900px) and (prefers-reduced-motion: reduce) {
.sheet-host .sheet-panel, .sheet-host .sheet-scrim, .sheet-host .sheet-grip { transition: none; }
}

/* ===========================================================================
   THE SITE CHROME IS A PHONE THING ON THIS PAGE, EXCEPT THE FOOTER
   ---------------------------------------------------------------------------
   accessories.html carries partials/site-header.html and site-footer.html so
   the phone gets the same header and footer as the home page. Up to v77 the
   desktop hid both, because the desktop was an app shell that could not scroll
   and there was nowhere for a footer to be.

   v78 gives the desktop a document to scroll, so the footer comes back. The
   marketing header does not: the dark column on the left already carries the
   brand and the way back to the home page, and a second header above it would
   be a new thing on the screen rather than a thing that was always in the
   markup and finally has somewhere to sit.

   The two scrims stay hidden. They are only given `display: block` inside the
   phone query, so outside it they would fall back to a div's default and sit in
   the flow as two empty blocks.
   =========================================================================== */
@media (min-width: 901px) {
  .acc > .nav,
  .acc > .nav-scrim,
  .acc > .cart-scrim { display: none; }

  /* THE MODAL COMES BACK BECAUSE THE FOOTER DID. The old comment said nothing
     at this width could open it "because the footer that does is hidden two
     lines up", and that is exactly the sentence v78 invalidates: Shipping and
     Returns is a footer link, the footer is on the screen now, and a link that
     does nothing is worse than no link.

     The reason it was worth hiding stands and is handled instead of ignored.
     It is invisible when shut, opacity 0 and pointer-events none, but a
     backdrop-filter makes a compositing layer whether the thing is visible or
     not, and on this page that changed four pixels of the photograph in the
     detail panel by one unit on one channel. So the filter is only applied
     once the dialog is actually open. */
  .acc > .modal-ov { backdrop-filter: none; }
  .acc > .modal-ov.on { backdrop-filter: blur(4px); }
}

/* ===========================================================================
   /accessories SCROLLS LIKE A PAGE, AND THE LIST STOPS SCROLLING BY ITSELF
   ---------------------------------------------------------------------------
   Everything here is `@media (min-width: 901px)` and scoped to `.acc`. The
   phone already worked and is not touched; studio.html shares this stylesheet,
   this shell and this whole add-on component, and `.std` never matches a line
   below.

   WHAT WAS WRONG, MEASURED, NOT ASSUMED. At 1440 the page had zero room to
   scroll: `scrollHeight 900` against `innerHeight 900`. The scrolling was done
   by two boxes nested inside it, `.pane` with 164px of room and `#addon-list`
   with 889px. So the wheel did something different depending on where the
   pointer happened to be: over the list it moved the list, over the detail
   panel or the margin it moved `.pane` its 164px and stopped, and over the
   header it did nothing whatsoever. Someone whose pointer was not on the list
   never saw 14 of the 28 accessories at 1440, 21 at 1280 and 22 at 1024.

   That is not a wheel bug to patch. It is what always happens when a page
   refuses to scroll and an inner box scrolls in its place, so the fix is to
   stop refusing.

   THE SAME FOUR LINES THE PHONE HAS USED SINCE v54. `html, body.flow-page
   { height: 100% }` is not what stops this page scrolling; `overflow: hidden`
   on the body is, and a root exactly one viewport tall does not clip a body
   that overflows it.
   =========================================================================== */
@media (min-width: 901px) {

  /* ---- the document scrolls --------------------------------------------- */
  body.acc { height: auto; overflow: visible; }

  /* `align-items: start` matters as much as `height: auto`. The grid's default
     is stretch, so the moment the row grew to the height of 28 cards the dark
     column would have been stretched to match it and the sticky rule below
     would have had nothing left to do. */
  .acc .flow { height: auto; min-height: 100vh; align-items: start; }
  .acc .work { min-height: 100vh; }
  .acc .work-body { display: block; overflow: visible; }
  .acc .pane { overflow: visible; }

  /* ---- the list stops being a scroller ----------------------------------- */
  /* `overflow: visible` on a `flex: 1 1 auto; min-height: 0` box is not enough
     on its own: the flex sizing still caps it at the parent's height and the
     content just spills. The three properties have to be undone together. */
  .acc .addon-layout { height: auto; }
  .acc .addon-side { max-height: none; min-height: auto; }
  /* `padding-right: 6px` STAYS, and it is now holding nothing back from a
     scrollbar that no longer exists. Dropping it was the obvious tidy and it
     made the card 3px wider here than the identical card on step 3 of the
     studio: 630px of column, minus 10px of gap, over two columns is 310 with
     the padding gone and 307 with it. The two surfaces are one component and
     the card is on the list of things that are not allowed to differ, so six
     pixels of empty margin is the cheaper of the two prices. */
  .acc .addon-scroll { overflow-y: visible; flex: 0 0 auto; min-height: auto; }

  /* ---- what has to stay on the screen ------------------------------------ */
  /* Three things were held in place for free by a shell that never moved, and
     each one needs saying out loud now that the page moves under them.

     1. THE ORDER COLUMN. It carries the running total and the count, which is
        the thing a person checks while they add. 100vh and sticky, so it is
        exactly as tall as it always was and stays where it always was.
     2. THE HEADER. It carries the only way forward, the Checkout button. Left
        in the flow it would scroll away and someone at the bottom of 28 cards
        would have to scroll back up to leave. It is opaque, or the cards would
        show through it.
     3. THE DETAIL PANEL, which the brief asks for by name. It was ALREADY
        `position: sticky; top: 0` further up this file; it just had almost
        nothing to stick to, because the scroller it lived in only had 164px of
        travel. Now that the document is the scroller the same declaration does
        the whole job, and all it needs is an offset that clears the header. */
  .acc .side { position: sticky; top: 0; height: 100vh; }
  .acc .work-head { position: sticky; top: 0; z-index: 30; background: var(--bg); }

  /* 102px is measured, not guessed: `.work-head` is 102px tall at 901, 1024,
     1280, 1440 and 1920, on both this page and the studio, because its height
     comes from its own padding and one line of title. `tools/v78-scroll.py`
     asserts it at every width and fails if it ever stops being true, so this
     number cannot rot quietly. */
  .acc { --acc-head: 102px; }
  .acc .addon-detail { top: calc(var(--acc-head) + 18px); }

  /* The panel is as tall as its photograph and its copy, and on a short window
     that can be taller than what is left under the header. Given a max height
     it scrolls inside itself only when it has to, which is the one inner
     scroller on this page that is still the right answer: it is the thing
     under the pointer when the pointer is on it.

     NO `overscroll-behavior: contain` HERE, and that is the whole point of the
     pass rather than a detail. It was written in out of habit and the wheel
     measurement caught it immediately: with it, standing on the detail panel
     at 1440 moved nothing at all, because Chrome treats the box as a scroll
     container that swallows the turn even on a width where the panel is 655px
     tall inside 762px of room and has nowhere to go. That is precisely the
     defect this pass exists to remove, reintroduced three lines after removing
     it. Without it the panel scrolls when it is too tall and hands the turn to
     the page when it is not. */
  .acc .addon-detail { max-height: calc(100vh - var(--acc-head) - 36px);
                       overflow-y: auto; }
}

/* A tablet is not a phone. Two cards across 768px makes each one 360px wide
   for a 74px drawing, which reads as a list of billboards. Three is the same
   card at the size the desktop grid gives it. */
@media (min-width: 600px) and (max-width: 900px) {
  .acc .addon-scroll { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* ===========================================================================
   THE CATEGORY ROW WITH MOTION TURNED OFF
   ---------------------------------------------------------------------------
   Last in the file, because `.acc .cat-pills` sets `scroll-behavior: smooth`
   further up and would otherwise win on source order at equal weight. Written
   inside the blanket reduced-motion block above it did nothing, which is the
   same trap the phone menu fell into in v47. `scroll-behavior` is motion and
   that blanket rule cannot reach it: it is neither a transition nor an
   animation. Read back as a computed value, not looked at.
   =========================================================================== */
@media (prefers-reduced-motion: reduce) {
.sheet-host .cat-pills { scroll-behavior: auto; }
}

/* ===========================================================================
   THE STUDIO ON A PHONE, POINTS 6 AND 8
   ===========================================================================
   The studio is an app shell: `body.flow-page { height: 100%; overflow: hidden }`
   near the top of this file, with a two column grid inside it. Nothing on the
   page scrolls except the boxes that were told to. That model is why a footer
   could not be added to this page at all: appended after </main> it sits below
   a body that is exactly one viewport tall with its overflow hidden, so it is
   clipped and no amount of scrolling reaches it.

   /accessories had the same shell and the same problem and solved it by letting
   the DOCUMENT scroll on a phone. The studio does the same here, with one
   exception that is the whole point of the exception: STEP 2 KEEPS THE FIXED
   SHELL, EXACTLY AS IT IS. Step 2 draws a shelf to scale on a stage that is
   measured against the viewport, and v54 is under instruction not to touch its
   layout, its CSS or its JS. So the switch is gated on which pane is open, with
   :has(), which needs no class from studio.js and therefore no change to step 2.

   v54 kept step 2 out of this, because it was not allowed to move step 2's
   stage. v55 is, so every step scrolls and every step carries the chrome. The
   stage is given an explicit height further down, since a scrolling document
   no longer constrains it.
   =========================================================================== */
@media (max-width: 900px) {

  /* ---- steps 1, 3 and 4: the document scrolls ---------------------------- */
  body.std { height: auto; overflow: visible; }
  .std .flow { display: block; height: auto; }
  .std .work { display: block; min-height: 0; }
  .std .work-body { display: block; overflow: visible; }
  .std .pane { overflow: visible; }

  /* POINT 7. The add-on step gets the same gutters /accessories has, so the
     shared list is laid out in the same room on both. The studio's other panes
     keep the padding they had: this is the one that holds the picker. Measured
     at 390px, the list was 322px wide here and 362px there, and the whole of
     that 40px was this. */
  .std .pane[data-p="3"] { padding: 16px 14px 30px; }

  /* ---- the two strips, the same arrangement /accessories uses ------------ */
  .std .nav { position: sticky; top: 0; z-index: 100; }
  .std .work-head {
    position: sticky; top: var(--site-nav-h, 57px); z-index: 50;
    background: color-mix(in srgb, var(--bg) 92%, transparent);
    backdrop-filter: blur(14px);
  }

  /* The steps drawer is fixed to the viewport and has to clear the header,
     which is at 100. It is modal in effect, so it goes above rather than
     below: sliding under a sticky header would cut its own brand off. */
  .std .side { z-index: 130; }

  /* ---- the page strip, compacted to one row ------------------------------
     EVERY RULE IN THIS GROUP IS GATED OFF STEP 2, and the gate was added after
     measuring rather than before. The strip is shared by all four steps, so
     compacting it from three rows to one moved step 2's stage from y=446.8 to
     y=341 and made it 106px taller. Nothing in step 2 was edited and step 2
     changed anyway, which is the whole reason the instruction is written as
     "do not touch its layout" and not "do not edit its files".
     Unwrapped it was three rows deep: the step, the title, and the two buttons
     below them. The title is context and gives up its room first, the same
     trade /accessories makes. */
  .std .work-head {
    flex-wrap: nowrap; align-items: center; gap: 10px;
    padding: 7px 14px; min-height: 52px;
  }
  .std .work-head .work-title { flex: 1 1 auto; min-width: 0; }
  /* POINT 3. "STEP 2 OF 4" comes off the strip. The four steps are listed in
     the hamburger drawer, with the current one marked, so the strip was
     spending a line on a thing the reader can already get to, and the title
     underneath already says which step it is. */
  .std .work-head .k { display: none; }
  .std .work-head h1 { font-size: 19px; line-height: 1.15; white-space: nowrap;
    overflow: hidden; text-overflow: ellipsis; }
  .std .work-head .booting-note { display: none; }
  .std .work-nav { gap: 8px; flex: 0 0 auto; }

  /* ---- POINT 7, the controls in the strip -------------------------------- */
  /* Rule 1: these have room, so they are simply made big enough. 43px was one
     pixel short of the target, which is the kind of number that only a census
     finds. */
  .std .work-nav .btn { min-height: 44px; padding: 0 16px; font-size: 13.5px; }
  .std .side-open { width: 44px; height: 44px; border-radius: 11px; margin: 0; }
  .std .side-open .bars { width: 18px; }

  /* ---- the steps drawer gets a backdrop ---------------------------------
     It had none. An overlay with no backdrop leaves every control behind it lit
     and tappable, which is what the overlap census found on all four steps: the
     drawer's own links sat on top of live buttons in the work area.

     It TAKES the pointer while it is open, and that is the half that matters.
     js/studio.js:2696 closes the drawer on any document click outside it, and
     the backdrop is outside it, so the drawer still closes on a tap anywhere
     off it, with no change to studio.js. What changes is that the tap stops
     there: before, a tap on a button behind the open drawer closed the drawer
     AND pressed the button, because nothing was in the way. Closed, the
     backdrop is `pointer-events: none` so it cannot swallow anything. */
  .std .side-scrim {
    position: fixed; inset: 0; z-index: 125; background: rgba(20, 18, 16, .5);
    opacity: 0; pointer-events: none; transition: opacity .24s ease;
  }
  .std:has(.side.open) .side-scrim { opacity: 1; pointer-events: auto; }

  /* ---- POINT 7, the controls inside the drawer --------------------------- */
  .std .side-brand { min-height: 44px; display: flex; align-items: center; }
  /* Rule 2: the visual size of these two is decided, so the hit area grows
     around them instead, by padding rather than by a taller line. */
  .std .side-cross { display: block; padding: 11px 0; min-height: 44px; box-sizing: border-box; }
  .std .scroll-cue { min-height: 44px; padding: 11px 12px; }
  .std .side-flag.is-btn { display: block; padding: 12px 0; min-height: 44px; }

  /* Back is disabled on step 1 and the room it takes is the room the title
     needs. /accessories makes the same trade. */
  .std:has(.pane[data-p="1"].on) .work-nav #btn-back { display: none; }

  /* ---- POINT 7, the controls inside the steps ---------------------------
     v54 wrote every selector here as `.pane:not([data-p="2"])`, because step 2
     was out of bounds for that pass and `.std .pane .chip` would have reached
     into it. v55 is allowed to touch step 2, so the exclusions are gone and the
     rules apply to all four panes. That is what brings the two view chips, 78
     by 32 and 86 by 32, up to the target along with everything else. */

  /* Rule 1, there is room: the fields simply become big enough to hit. They
     already carry the 16px floor from v50, so nothing here re-opens the iOS
     zoom question. */
  .std .pane .btn { min-height: 44px; }
  .std .pane input:not([type="checkbox"]):not([type="radio"]),
  .std .pane select,
  .std .pane textarea { min-height: 44px; }

  /* Rule 2, the visual size is decided and stays: the hit area grows around it.
     A pill of 36px is a deliberate shape and making it 44 would change how step
     1 reads. The pseudo element carries the extra 4px above and below, and the
     row gaps below keep two of them from touching. */
  .std .pane .chip,
  .std .pane .cat-pill { position: relative; }
  .std .pane .chip::after,
  .std .pane .cat-pill::after {
    content: ""; position: absolute; left: 0; right: 0;
    top: 50%; height: 44px; transform: translateY(-50%);
  }
  /* Rule 3, two hit areas may not overlap. The pills sit 36px and 34px tall in
     rows; grown to 44 they would meet, so the SPACING changes, not the area. */
  .std .pane .chips,
  .std .pane .cat-pills { row-gap: 12px; }

  /* The checkbox is 17px and cannot usefully be drawn at 44, so its label
     carries the hit area and the box keeps its size. */
  .std .pane label.ck { min-height: 44px; display: flex; align-items: center; gap: 10px; }
  .std .pane input[type="checkbox"] { position: relative; }
  .std .pane input[type="checkbox"]::after {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%);
  }

  /* ---- POINT 5, THE TOOLBAR, AND THE FOUR TOOLS THAT WERE OFF SCREEN ----
     The row held the two view chips, Add, Options, the zoom group and four
     stage tools, on one line, at 390px. It did not fit: `.stage-tools` ran from
     x=369.5 to x=513.5, so undo, redo, centre and start over were 123px past
     the right edge of the screen with nothing to scroll. They were not small,
     they were gone.

     It is two rows now. The break is a zero height pseudo element with
     `flex-basis: 100%` sitting between the two `order` groups, which is what
     forces a wrap at a chosen point rather than wherever the content happens to
     run out. Row one is what you reach for while deciding, row two is what you
     reach for while adjusting. */
  .std .bench-tools {
    flex-wrap: wrap; align-items: center;
    row-gap: 8px; column-gap: 8px; padding: 8px 14px;
  }
  .std .bench-tools::after {
    content: ""; flex-basis: 100%; height: 0; order: 2;
  }
  .std .chips-view, .std .run-tabs, .std #btn-parts, .std #btn-piece { order: 1; }
  .std .zoomer, .std .stage-tools { order: 3; }
  .std .stage-tools { margin-left: 0; }
  .std .chips-view .chip { white-space: nowrap; }

  /* Every control in the toolbar is a 44px target. The icons keep the size they
     were drawn at; the button around them grows. */
  .std .bench-tools .tool { width: 44px; height: 44px; }
  .std .bench-tools .tool-wide { width: auto; min-height: 44px; padding: 0 14px; }
  .std .zoom-pct { min-width: 52px; min-height: 44px; }

  /* POINT 6. The two nudge buttons on a slider row are how a piece is moved
     with a thumb, so they are targets like everything else. */
  .std .slider .step { width: 44px; height: 44px; flex: 0 0 auto; }
  .std .slider { gap: 6px; }

  /* The message bar's own two controls, measured at 70x34 and 33x33. */
  .std .msgbar-all { min-height: 44px; padding: 0 14px; }
  .std .msgbar-step { width: 44px; height: 44px; }

  /* POINT 5. AND THE SENTENCE ITSELF, WHICH IS THE THIRD CONTROL ON THIS BAR
     AND WAS THE ONE LEFT OUT.

     The whole message is a button: pressing it walks the highlight to the piece
     the message is about, which on a phone is the main way of finding it. Its
     height is however many lines the sentence wraps to, and at 430 the two
     sentences that matter fit on two lines of 18.2px and it comes out 36 tall.
     Measured across 552 controls in four builds, this is the ONLY control on
     the four steps and on /accessories that is genuinely under the floor, and
     it has been under it since before v59.

     Rule 2, not rule 1: the pseudo element carries the extra height and the bar
     does not change size. The bar's height was measured against the total strip
     in v62 and it is not free to grow. Nothing else on the bar is touched: this
     is one flex item with a 10px gap either side of it, so the widened area has
     nothing to run into. */
  .std .msgbar-t { position: relative; }
  .std .msgbar-t::after {
    content: ""; position: absolute; left: 0; right: 0;
    top: 50%; height: 44px; transform: translateY(-50%);
  }

  /* The fixed bar sits over whatever is at the bottom of the page, and at the
     bottom of the page is the footer. Reserving its height means the footer's
     last row clears it instead of sitting under it.

     v75. IT RESERVED THE TOTAL STRIP AND NOT THE MESSAGE BAR ABOVE IT, and
     the footer's last link was clearing that bar on the strength of the
     footer's own padding rather than on this line.

     v75 took a rule and 18px of padding off .foot .cr, which moved the whole
     footer up by that much, and tools/check-taps.py caught what had been true
     underneath all along: Contact drawn 349x44 with a finger reaching 141x27
     at 393, and 141x37 at 430, both under 44. Measured at maximum scroll with
     the old padding still on, the overlap was already only 17px at 393 and
     6px at 430, and at 390 it was already touching at -2px.

     So the reserve gets the 18px the copyright row gave up, and 6px more, and
     it goes here rather than in css/styles.css because this is the only
     surface with a bar fixed over its footer. Every other page keeps the
     tighter footer the brief asked for. */
  .std:has(.pane[data-p="2"].on) .foot { padding-bottom: calc(var(--bench-total-h) + 24px); }

  /* v83 point 3, AND IT IS THE SAME DEFECT AS THE PARAGRAPH ABOVE WITH A
     DIFFERENT CONTROL IN IT.

     The reserve above clears the total strip. It does not clear the MESSAGE
     BAR, which on a phone is fixed one gap above that strip and is the taller
     of the two by a long way. So the strip was cleared and the bar was not,
     and the last row of the footer, the copyright and the credit, has been
     sitting underneath it the whole time. Nothing said so because nothing in
     that row was a control: tools/check-taps.py walks controls, and a line of
     text that cannot be tapped is not one it has an opinion about.

     v83 made the credit a link, so the row grew a control, and the census
     found it at once: "The Workspace Hero" drawn 120x15 at 390, 393 and 430,
     with not one sampled point of its own box belonging to it. Every point
     landed on .msgbar. This rule is not what broke it; the link is what
     revealed it.

     MEASURED, at maximum scroll on step 2 with a warning showing:

       the bar          99px tall at 390, 100px at 393, 89px at 430
       its bottom edge  42px above the bottom of the window
                        (--bench-total-h 34px, plus the 8px gap, plus the
                        safe area, which is 0 on a desktop browser)
       the reserve      58px, so the bar's top edge was 141px up and the
                        footer only ever moved out of the last 58 of them

     144px is the tallest bar seen plus 44, which is a whole tap target of
     clearance rather than a hairline. The bar carries one message and one nav
     row, and the height moves with how far the sentence wraps; if a longer one
     ever appears, this is the number to remeasure, and check-taps.py is what
     will say so.

     ONLY WHILE THERE IS A BAR. `[hidden]` is what js/studio.js takes off the
     bar to show it, so with nothing to clear the footer keeps the tighter
     reserve above and the end of the page is not a screenful of nothing. */
  .std:has(.pane[data-p="2"].on):has(.msgbar:not([hidden])) .foot {
    padding-bottom: calc(var(--bench-total-h) + 8px + env(safe-area-inset-bottom) + 144px);
  }

  /* ---- POINT 4, THE TWO ACTIONS AND THE PRICE, WHERE THE THUMB IS -------
     Back and Add-ons were at the top of the screen, which on a phone is the one
     place a thumb does not reach. They are the same two buttons, moved by CSS
     rather than copied: no second pair, no second handler, and above 900px or
     on any other step this block does not apply and the row is what it was.

     The running total comes with them. It lived only inside the hamburger
     drawer, which means the number that most encourages somebody to keep going
     was behind a tap. It is read from `q.total`, the same value and the same
     call in js/studio.js that fills the drawer, so the two cannot disagree.

     THE BAR DOES NOT COVER THE DRAWING. The bench reserves its height as
     padding and the stage is sized against what is left, so the drawing stops
     above the bar rather than sliding under it. */
  /* ---- POINT 5, THE TWO BUTTONS GO BACK UP ------------------------------
     v55 moved Back and the next button to a fixed bar at the bottom of step 2,
     on the argument that a thumb does not reach the top of a phone. Steps 1, 3
     and 4 never moved, so the studio ended up asking for the same two actions
     in two different places depending on which step you were on. Measured at
     390px: y=80 on steps 1, 3 and 4, and y=791 on step 2.

     Taking the fixed positioning off is the whole change: the row goes back to
     the strip it is written into, beside the title, where the other three have
     it.

     ---- POINT 6, AND THE TOTAL STAYS DOWN THERE --------------------------
     A thin strip of its own, no buttons on it, aligned to the right. It is
     `position: fixed` over the drawing rather than in the flow under it, so the
     stage does not give up any height for it, and `pointer-events: none` so it
     can never take a tap meant for the drawing behind it. */
  .std { --bench-total-h: 34px; }
  .std .bench-total {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
    display: flex; justify-content: flex-end; align-items: center;
    padding: 5px 14px calc(5px + env(safe-area-inset-bottom));
    background: color-mix(in srgb, var(--bg) 94%, transparent);
    backdrop-filter: blur(14px);
    border-top: 1px solid var(--st-line);
    pointer-events: none;
  }
  .std .bench-total b {
    font: 700 17px 'Inter', sans-serif; color: var(--st-ink);
    font-variant-numeric: tabular-nums; letter-spacing: -.01em;
  }
  /* Only on step 2. The other three show the total in the drawer as they
     always have. */
  .std:not(:has(.pane[data-p="2"].on)) .bench-total { display: none; }
  .std .pane-bench { padding-bottom: var(--bench-total-h); }

  /* ---- POINT 3, step 2 has the same chrome as every other step -----------
     v54 gated all of the above off step 2, because compacting the strip moved
     step 2's stage and step 2 was not to be touched. It is now, so the gate is
     gone and step 2 gets the header, the footer, the one row strip and the
     44px controls like steps 1, 3 and 4.

     Letting the document scroll is what makes the footer reachable at all, and
     it is what costs the stage its height: nothing constrains it any more and
     `.sheet` would fall back to its 120px minimum. So the stage is given a
     height of its own here, in viewport units, and the drawing is sized against
     that instead of against whatever is left over. */
  .std .pane-bench .stage { height: 66vh; height: 66dvh; min-height: 320px; }

  /* ---- POINT 4, THE NOTE ABOUT A COMPUTER -------------------------------
     In the flow at the top of step 1, which is the step a phone opens on. It
     is a card, not a layer and not a modal: nothing is behind it, nothing has
     to be closed, and the build starts underneath it whether it is there or
     not. The close control is 44 by 44. */
  .std .pc-note {
    display: flex; align-items: flex-start; gap: 10px;
    margin: 0 0 16px; padding: 12px 8px 12px 14px;
    border: 1px solid var(--st-line2); border-radius: 14px;
    background: var(--st-panel);
  }
  .std .pc-note[hidden] { display: none; }
  .std .pc-note-t { flex: 1 1 auto; }
  .std .pc-note-t b {
    display: block; font: 600 14.5px 'Inter', sans-serif; color: var(--st-ink);
    margin-bottom: 3px;
  }
  .std .pc-note-t p { font-size: 13px; line-height: 1.45; color: var(--st-soft); margin: 0; }
  .std .pc-note-x {
    flex: 0 0 auto; min-width: 44px; min-height: 44px;
    border: 0; background: transparent; color: var(--st-soft);
    font: 400 22px 'Inter', sans-serif; line-height: 1; cursor: pointer;
  }
  .std .pc-note-x:active { color: var(--st-ink); }
}

/* ---- and the desktop studio is exactly what it was ----------------------- */
/* The shared chrome is a phone affordance here. On a desktop the studio has its
   own full height layout with the brand and the links in the dark column, and
   point 13 requires the desktop to come out of this pass pixel identical. */
@media (min-width: 901px) {
  /* Both of these are phone furniture that exists in the markup at every width.
     The total strip was left to render as a plain block on a desktop and it
     showed: 24,670 pixels of difference on step 2 at 1440x900, on a pass that
     is not allowed to touch the desktop at all. */
  .std .piece-open,
  .std .slide-shelf,
  .std .pc-note,
  .std .bench-total { display: none; }
  .std > .nav,
  .std > .nav-scrim,
  .std > .foot,
  .std > .side-scrim,
  .std > .modal-ov { display: none; }
}
