/* ── Tiling / poster module ──────────────────────────────────────────────── */
/* Tiling-only styles, split out of app.css so app.css stays app.html's file,
   untouched. Loaded after app.css in tiling.html — same-named selectors here
   win over any leftover copy in app.css purely by source order (no !important
   needed). Every property the legacy app.css block also sets is restated
   explicitly (not just the ones this refactor changed): border/border-radius
   are shorthands expanding into per-side longhands, so overriding only
   border-top would have left the old right/bottom/left/radius showing
   through from app.css's still-present (pre-deletion) copy. */

/* Same separator app.html uses between .image-rows — a thin border, no boxed
   card — so the loaded image reads as part of the same list as the drop zone
   above it, not as an independent panel. */
.tiling-image-card {
  display: grid;
  grid-template-columns: 48px minmax(0, 1fr);
  grid-template-rows: auto auto;
  column-gap: 12px;
  row-gap: 10px;
  align-items: start;
  margin-top: 14px;
  padding: 14px 20px;
  border: none;
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  position: relative;
}

.tiling-image-card[hidden] { display: none; }

/* .thumb-wrap, .image-info and .image-controls are app.html's own classes
   (app.css, shared section) — reused verbatim, already know how to place
   themselves in a 48px-thumb / 2-row grid like this one. .btn-remove's base
   opacity:0 is restated here (not just the hover trigger) because app.css's
   still-present pre-deletion copy of this exact selector forces opacity:1
   unconditionally — without this, that rule wins over the plain ".btn-remove"
   default and the icon never hides. */
.tiling-image-card .btn-remove { opacity: 0; }
.tiling-image-card:hover .btn-remove,
.tiling-image-card .btn-remove:focus-visible { opacity: 1; }

/* Mirrors app.html's .image-section: the one elastic child of .panel-left
   (flex:1 1 ...; min-height:0; overflow-y:auto), so "2 Papel" and the footer
   (both flex-shrink:0 via .section / .panel-footer) get pinned to the bottom
   instead of trailing right under the image content with empty space below.
   basis is auto, not app.html's fixed 4-row calc() — tiling never shows more
   than one image, so there's no "reserve room for future rows" need. */
.tiling-image-section {
  padding: 0;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* The DPI badge + expandable detail panel (click to expand, progress bar,
   AI-upscale CTA) reuses app.css's .dpi-badge/.dpi-detail component verbatim
   — same clickable-badge-and-panel pattern as app.html's per-image rows,
   just with one static pair of elements instead of one per list row. No
   tiling-specific overrides needed. */

/* .panel-right no longer scrolls its own content in tiling — .preview-viewport
   below is the fixed-size, clipped region that zoom/pan operates inside, and
   h2 stays pinned above it (flex-shrink:0, unaffected). */
.panel-right { overflow: hidden; }

/* The pannable/zoomable region — bounded height (flex:1 1 auto inside
   .panel-right's column) with overflow:hidden so a zoomed-in or panned grid
   clips instead of pushing the layout around. display:flex + centering here
   replaces #preview-wrap's old margin:auto trick, so at the default zoom
   (1) / pan (0,0) the JS-applied transform below is the identity and the
   layout is pixel-identical to before zoom/pan existed. */
.preview-viewport {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  /* The viewport owns pan + pinch via Pointer Events (see tiling.js) — take
     the browser's own touch scrolling/zooming out of the way so a one-finger
     drag pans and a two-finger gesture pinches instead of scrolling the page
     or triggering page zoom. */
  touch-action: none;
}

.preview-viewport.panning { cursor: grabbing; }

/* Overrides #preview-wrap's flex layout (app.css's version) with an explicit
   grid, one cell per sheet, so the whole thing reads as the assembled poster.
   grid-template-columns is set inline by tiling.js (repeat(cols, 1fr)); the
   1px gap plus this background is what shows through as the sheet dividers —
   cheaper and crisper than a border on every .tile-preview. Centering is now
   .preview-viewport's job (flex, see above) — margin:0 here so it doesn't
   double up with that. transform-origin:center matches the flex-centered
   resting position, so tiling.js's scale() zooms into the middle of the grid
   rather than a corner. */
#preview-wrap.tiling-preview-wrap.has-content {
  display: grid;
  gap: 1px;
  background: #cbd5e1;
  border: 1px solid #cbd5e1;
  width: 100%;
  max-width: 880px;
  margin: 0;
  transform-origin: center center;
  /* Physical LTR, deliberately, and the one place in this file that has to say
     so out loud. Grid auto-placement runs along the INLINE axis, so under
     <html dir="rtl"> column 0 lands on the right and the whole poster comes out
     mirrored: B2 painted on the left, A1 on the right, each cell still holding
     the half of the image its own coordinates say it holds. Same call already
     made for .tile-label and the crop handles — a poster is a physical object
     the reader tapes together, and A1 is the top-left sheet of the printed
     image in every language. Those two only needed physical left/top because
     they are absolutely positioned, which never consulted `direction` in the
     first place; this container lays out in flow, so it needs the override.
     Scoped to the tiling grid on purpose: real-size's #preview-wrap is a
     numbered SEQUENCE of bin-packed pages ("Sheet 1", "Sheet 2"), i.e. reading
     order, and mirroring that one under RTL is correct.
     It is a layout override only — the localized copy inside the cells opts
     back out via unicode-bidi on .tile-exclude-hint below. */
  direction: ltr;
}

/* Empty state lives inside .preview-viewport now (still #preview-wrap's
   immediate sibling, so app.css's "#preview-wrap:empty + .preview-empty"
   rule is unaffected) — hide the zoom toolbar along with it since there's
   nothing to zoom. */
.preview-viewport:has(#preview-wrap:empty) .zoom-controls { display: none; }

.zoom-controls {
  position: absolute;
  inset-inline-end: 16px;
  bottom: 16px;
  display: flex;
  align-items: center;
  gap: 2px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
  padding: 2px;
  z-index: 5;
}

.zoom-controls button {
  width: 28px;
  height: 28px;
  padding: 0;
  border: none;
  background: none;
  border-radius: 5px;
  color: var(--muted);
  font: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, color 0.15s;
}

.zoom-controls button:hover { background: var(--primary-soft); color: var(--primary); }

/* Reset button reads its live percentage, so it's wider than the +/- squares. */
#zoom-reset {
  width: auto;
  min-width: 42px;
  padding: 0 6px;
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
}

/* aspect-ratio is set inline by tiling.js from computeGrid's own pw/ph, so
   every cell — regardless of position — has the exact shape of one printed
   sheet in the orientation actually chosen. Click-to-exclude is handled by
   the pan/click-threshold logic in tiling.js (a plain click listener here
   couldn't tell a real click apart from the end of a drag), so this is just
   the visual half: hover overlay + hint, and a permanent dimmed look once
   .excluded is toggled on. */
.tile-preview {
  position: relative;
  overflow: hidden;
  background: #fff;
  cursor: pointer;
}

.tile-preview canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.tile-preview::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(15, 23, 42, 0.55);
  opacity: 0;
  transition: opacity 0.15s;
  pointer-events: none;
}

.tile-preview:hover::after { opacity: 1; }

.tile-exclude-hint {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 8px;
  color: #fff;
  /* The grid above forces direction:ltr to keep the sheets physical, but this
     string is localized ("✕ استبعاد") and would then resolve its leading symbol
     against the wrong paragraph direction. plaintext re-derives the direction
     from the copy's own first strong character, so each language gets its own
     inside a frame that stays physical — the FSI/PDI idea from js/i18n.js, in
     CSS because here it's a whole element. (.tile-label needs none of it: "A1"
     is ASCII everywhere.) */
  unicode-bidi: plaintext;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-align: center;
  opacity: 0;
  transition: opacity 0.15s;
  pointer-events: none;
}

.tile-preview:hover .tile-exclude-hint { opacity: 1; }

/* Touch has no hover, so the hover-only exclude overlay/hint above never
   reveals itself on a phone. On coarse pointers, surface the same overlay +
   hint as a pressed-state affordance instead: while a finger is held on a
   tile it darkens and reads "Excluir"/"Incluir", giving the tap-to-exclude
   gesture a discoverable, immediate response without cluttering every tile at
   rest (which a big grid can't afford). Reuses the exact classes the hover
   rules drive, so no new markup or i18n strings. */
@media (pointer: coarse) {
  .tile-preview:active::after { opacity: 1; }
  .tile-preview:active .tile-exclude-hint { opacity: 1; }
}

/* Permanent — stays dimmed without hover, so it reads at a glance (not just
   on mouseover) which sheets are skipped in the PDF. */
.tile-preview.excluded canvas { filter: grayscale(1) brightness(0.65); }
.tile-preview.excluded::after { opacity: 1; background: rgba(15, 23, 42, 0.35); }
.tile-preview.excluded .tile-label { opacity: 0.6; }

/* Physical top-LEFT, deliberately not logical: this labels a sheet in the tile
   grid, and that grid is the physical layout of the printed poster. It does not
   mirror under RTL — the sheet the reader tapes into the top-left corner is the
   top-left one in every language — so neither does its label. */
.tile-label {
  position: absolute;
  top: 4px;
  left: 4px;
  background: rgba(255, 255, 255, 0.85);
  color: var(--muted);
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  padding: 1px 5px;
  border-radius: 4px;
  pointer-events: none;
}

/* ── Image editor trigger (thumbnail overlay) ────────────────────────────── */
/* .thumb-wrap itself is app.css's shared class (no position set there) —
   scoped to tiling's own card so app.html's identical-looking thumbnail is
   untouched. Centered via inset:0 + margin:auto on a fixed-size circle,
   revealed on hover same as .btn-remove, but a separate trigger/position
   (overlaid on the thumbnail itself) so the two don't compete for the same
   hover target. */
.tiling-image-card .thumb-wrap { position: relative; }

.btn-edit-image {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(15, 23, 42, 0.65);
  color: #fff;
  font-size: 0.75rem;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s, background 0.15s;
}

.thumb-wrap:hover .btn-edit-image { opacity: 1; }
.btn-edit-image:hover { background: rgba(15, 23, 42, 0.85); }
.btn-edit-image:focus-visible {
  opacity: 1;
  outline: none;
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--primary);
}

/* ── Image editor modal (crop + real-scale calibration) ──────────────────── */
/* Reuses app.css's .modal-overlay for the dark backdrop verbatim — only the
   panel itself needs a size well beyond the fixed 420px .modal-box the
   upscale modal uses, so this is a new class rather than an override that
   would also resize that other modal. */
.editor-modal-box {
  background: #fff;
  border-radius: 16px;
  width: min(920px, calc(100vw - 48px));
  max-height: calc(100vh - 48px);
  display: flex;
  flex-direction: column;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
  overflow: hidden;
}

.editor-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}

.editor-tabs { display: flex; gap: 4px; }

.editor-tab {
  padding: 7px 14px;
  border: none;
  background: none;
  border-radius: 6px;
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
  transition: background 0.15s, color 0.15s;
}

.editor-tab:hover:not(:disabled) { background: var(--preview-bg); color: var(--text); }
.editor-tab.active { background: var(--primary-soft); color: var(--primary); }
.editor-tab:disabled { cursor: not-allowed; opacity: 0.7; }

.editor-close {
  width: 30px;
  height: 30px;
  padding: 0;
  border: none;
  background: none;
  border-radius: 6px;
  color: var(--muted);
  font-size: 0.9375rem;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, color 0.15s;
}

.editor-close:hover { background: #FEF2F2; color: #EF4444; }

/* display:flex WITHOUT align-items/justify-content:center — centering an
   overflowing flex item via align-items/justify-content is a well-known
   footgun: browsers clip/misalign the overflow instead of scrolling to it
   cleanly, which is exactly what caused the crop mask to visually detach
   from the crop box while scrolling (both are position:absolute children of
   .crop-stage, so they were never out of sync with EACH OTHER — the whole
   .crop-stage was being mispainted relative to the scrollable area). The
   margin:auto trick on .editor-tab-panel below is the standard fix: it
   still centers when the content is smaller than the body, but degrades to
   a normal, correctly-scrollable 0-margin flow once the content overflows. */
.editor-modal-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow: auto;
  display: flex;
  padding: 28px;
  background: var(--preview-bg);
}

.editor-tab-panel {
  margin: auto;
  display: flex;
}

.editor-tab-panel[hidden] { display: none; }

/* Crop's own panel only ever had one child (.crop-stage) before the adjust-
   warning banner existed, so row-vs-column made no visual difference — this
   override just makes the (usually-hidden) banner stack above the stage
   instead of sitting beside it. */
#editor-panel-crop {
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

/* Unlike the crop panel (a single .crop-stage filling the row), the scale
   panel stacks the stage, hint and distance form vertically. */
#editor-panel-scale {
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

/* Adjustments keeps the default row layout (stage beside a controls
   sidebar, like a compact photo editor) — align-items: flex-start since the
   sidebar's natural height rarely matches the stage's. */
#editor-panel-adjust {
  align-items: flex-start;
  gap: 20px;
}

.editor-modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 14px;
  border-top: 1px solid var(--border);
  flex-shrink: 0;
}

/* ── Crop stage ───────────────────────────────────────────────────────────── */
/* Sized in JS (width/height in px, matching the display-scaled image) — the
   image itself renders once as a plain <img> and is never redrawn; dragging
   only moves/resizes the .crop-box and mask DIVs on top of it, which is far
   cheaper per-frame than re-rendering canvas pixels on every mousemove. */
.crop-stage {
  position: relative;
  user-select: none;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
  /* The stage owns the crop gestures (box drag in Model A, image pan/pinch in
     Model B) via Pointer Events — keep the browser's native touch scroll/zoom
     out of the way, same as the preview and scale viewports. */
  touch-action: none;
}

/* Clip ONLY in Model B, where the image is transformed larger than the stage
   and moves under the fixed frame. Model A deliberately keeps no clipping so
   its resize handles can still extend past the stage edge when the box is flush
   with it, exactly as before (this is why overflow isn't on .crop-stage). */
.crop-mode-fixed { overflow: hidden; }

.crop-image {
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Model B (fixed-ratio): the box is a non-interactive frame — no handles, no
   move cursor — because all pointer input goes to the stage to move the image
   underneath. (Set by JS via .crop-mode-fixed on the stage.) */
.crop-mode-fixed .crop-box { pointer-events: none; cursor: default; }
.crop-mode-fixed .crop-handle { display: none; }

/* Darkens everything outside the selection via 4 bands around .crop-box
   (top/bottom span the full width; left/right span just the box's own
   height) rather than a clip-path cutout — simpler to keep in sync with the
   box's position/size on every drag frame, and doesn't depend on clip-path
   browser support. */
.crop-mask {
  position: absolute;
  background: rgba(15, 23, 42, 0.6);
  pointer-events: none;
}

.crop-mask-top, .crop-mask-bottom { left: 0; width: 100%; }

.crop-box {
  position: absolute;
  box-sizing: border-box;
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 12px rgba(0, 0, 0, 0.3);
  cursor: move;
}

.crop-handle {
  position: absolute;
  width: 12px;
  height: 12px;
  background: #fff;
  border: 2px solid var(--primary);
  border-radius: 50%;
  transform: translate(-50%, -50%);
}

/* Physical coordinates, deliberately NOT logical — do not "fix" these to
   inset-inline-*. The handles address corners of the crop rectangle over the
   image itself, and an image does not mirror because the UI language is RTL.
   They are also read back by js/tiling.js through [data-handle], which resolves
   "nw"/"se" as physical directions when resizing. */
.crop-handle[data-handle="nw"] { top: 0;   left: 0;   cursor: nwse-resize; }
.crop-handle[data-handle="n"]  { top: 0;   left: 50%; cursor: ns-resize; }
.crop-handle[data-handle="ne"] { top: 0;   left: 100%; cursor: nesw-resize; }
.crop-handle[data-handle="e"]  { top: 50%; left: 100%; cursor: ew-resize; }
.crop-handle[data-handle="se"] { top: 100%; left: 100%; cursor: nwse-resize; }
.crop-handle[data-handle="s"]  { top: 100%; left: 50%; cursor: ns-resize; }
.crop-handle[data-handle="sw"] { top: 100%; left: 0;   cursor: nesw-resize; }
.crop-handle[data-handle="w"]  { top: 50%; left: 0;   cursor: ew-resize; }

/* ── Scale stage (real-scale calibration) ────────────────────────────────── */
/* .scale-viewport is the fixed-size, clipped frame zoom/pan operates inside
   (same role as .preview-viewport) — .scale-stage is the thing that actually
   gets translate()+scale()'d by JS (same role as #preview-wrap), sized once
   via fitStageScale to fit the display-scaled image. It calibrates against
   the CURRENT working image (state.image.dataUrl), while crop always shows
   the pristine original, so the two stages can't share one <img> or one
   viewport/zoom instance. */
.scale-viewport {
  position: relative;
  width: 820px;
  height: 520px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
  /* Same reason as .preview-viewport: this frame owns pan + pinch + point
     drag via Pointer Events, so take the browser's native touch scroll/zoom
     out of the way — without this, a one-finger drag on touch is swallowed as
     a page scroll and never reaches the point-drag / pan handlers. */
  touch-action: none;
}

.scale-viewport.panning { cursor: grabbing; }

.scale-stage {
  position: relative;
  flex-shrink: 0;
  user-select: none;
}

.scale-image {
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Centering is a fixed CSS margin (half the fixed 20×20 size), deliberately
   NOT part of `transform` — JS uses this element's own `transform` purely
   for the 1/zoom counter-scale (see applyScaleViewTransform()), so there's
   no translate() to recombine with it on every zoom/pan frame. */
.scale-point {
  position: absolute;
  width: 20px;
  height: 20px;
  margin: -10px 0 0 -10px;
  background: var(--primary);
  border: 2px solid #fff;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 8px rgba(0, 0, 0, 0.35);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.6875rem;
  font-weight: 700;
  color: #fff;
  cursor: grab;
}

/* The [hidden] attribute alone isn't enough here: .scale-point's own
   `display: flex` above has the same specificity as the UA stylesheet's
   `[hidden] { display: none }` and comes later in the cascade, so it wins by
   source order — the marker would render at the browser's default static
   position (visually: the container's top-left corner) instead of actually
   disappearing before the user places it. This rule (class+attribute beats
   class alone) is required to make `hidden` win back. */
.scale-point[hidden] { display: none; }

.scale-point:active { cursor: grabbing; }

/* Positioned/rotated from its own left edge (transform-origin), stretched to
   the distance between the two points via `width` — same plain-div technique
   the rest of this file uses instead of introducing SVG for a single line. */
.scale-line {
  position: absolute;
  height: 2px;
  margin-top: -1px;   /* centers the line ON its top-left point, not below it */
  background: var(--primary);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
  transform-origin: 0 50%;
  pointer-events: none;
}

.scale-hint {
  font-size: 0.8125rem;
  color: var(--muted);
  text-align: center;
  max-width: 480px;
}

.scale-distance-row {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  justify-content: center;
}

.scale-distance-row label {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--text);
}

/* direction:ltr for the same reason as .stepper-value in app.css: what this box
   holds is a measurement the user types ("2,5 cm"), so quantity precedes unit in
   every language, and a flex row left to follow <html dir> would serve
   "cm 2,5". It also re-anchors .scale-distance-unit's physical padding, which
   is asymmetric (10px outer / 4px against the input) and reads backwards the
   moment the pair swaps sides. The label OUTSIDE the box is ordinary prose and
   still mirrors with .scale-distance-row, as it should. */
.scale-distance-field {
  display: flex;
  align-items: center;
  height: 34px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  overflow: hidden;
  transition: border-color 0.15s, box-shadow 0.15s;
  direction: ltr;
}

.scale-distance-field:focus-within {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--primary-ring);
}

/* type=text since the field formats its own decimal separator (js/i18n.js). */
.scale-distance-field input[type=text] {
  width: 70px;
  height: 100%;
  padding-inline: 10px 0;
  padding-block: 0;
  border: none;
  background: transparent;
  font: inherit;
  font-size: 0.8125rem;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  text-align: end;   /* keeps the digits against the unit label — see app.css */
}

.scale-distance-field input[type=text]:focus { outline: none; }

.scale-distance-unit {
  padding: 0 10px 0 4px;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--muted);
}

/* ── Adjust stage ("Ajustes" tab) ─────────────────────────────────────────── */
/* No zoom/pan here (not asked for, and not needed — the whole point is a
   fast-updating live preview, not pixel-precise placement) — just a fixed
   frame holding a plain <canvas> that JS repaints in place on every control
   change. */
.adjust-viewport {
  position: relative;
  width: 540px;
  height: 460px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fff;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
  flex-shrink: 0;
}

.adjust-canvas {
  display: block;
  max-width: 100%;
  max-height: 100%;
}

.adjust-controls {
  display: flex;
  flex-direction: column;
  gap: 16px;
  width: 220px;
  flex-shrink: 0;
}

.adjust-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.adjust-group-label {
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.adjust-btn-row {
  display: flex;
  gap: 6px;
}

.adjust-btn {
  flex: 1;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font: inherit;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
}

.adjust-btn:hover { background: var(--preview-bg); }

.adjust-btn.active {
  background: var(--primary-soft);
  border-color: var(--primary);
  color: var(--primary);
}

.adjust-btn-block { width: 100%; }

.adjust-radio-group {
  display: flex;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.adjust-radio {
  flex: 1;
  padding: 7px 6px;
  border: none;
  border-inline-end: 1px solid var(--border);
  background: var(--surface);
  font: inherit;
  font-size: 0.6875rem;
  font-weight: 500;
  color: var(--text);
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

.adjust-radio:last-child { border-inline-end: none; }
.adjust-radio:hover { background: var(--preview-bg); }
.adjust-radio.active { background: var(--primary-soft); color: var(--primary); }

.adjust-slider-label {
  display: flex;
  justify-content: space-between;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--text);
}

.adjust-slider-value {
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.adjust-controls input[type=range] { width: 100%; }

.adjust-reset-btn { margin-top: 4px; }

/* ── Magnifier / loupe (shared, see createMagnifier in tiling.js) ─────────── */
/* A floating circular canvas positioned in JS (position:fixed, left/top set per
   pointermove). The white ring is a box-shadow, not a border, so it sits
   OUTSIDE the SIZE×SIZE box — the canvas fills the circle edge-to-edge and the
   crosshair stays dead-center. pointer-events:none so it never intercepts the
   drag it's assisting. Used now by the scale-calibration point drag; the
   Libre-crop corner drag (step 4) reuses the same element. */
.pr-magnifier {
  position: fixed;
  /* Above .modal-overlay (z-index:1000) — the loupe assists a drag happening
     inside the editor modal, so it must float over it, not behind it. */
  z-index: 1100;
  border-radius: 50%;
  overflow: hidden;
  background: #fff;
  box-shadow: 0 0 0 3px #fff, 0 6px 18px rgba(15, 23, 42, 0.35);
  pointer-events: none;
}

.pr-magnifier[hidden] { display: none; }

.pr-magnifier canvas { display: block; }

/* ── Mobile (≤768px) ─────────────────────────────────────────────────────── */
/* Poster reuses real-size's mobile chrome from app.css (the .rs-tabs segmented
   control, the fixed .rs-mobile-bar, the #rs-sheet-options collapsible, and the
   generic State A/B show-hide keyed on .rs-has-images / .rs-show-preview — all
   of which app.css defines and this page loads). This block only adds the
   deltas app.css can't cover, because they target poster-specific DOM (the
   single-image .tiling-image-section instead of a list, the .preview-viewport
   grid instead of a flat #preview-wrap) or undo a real-size-specific rule that
   would otherwise leak onto poster. Loaded after app.css, so equal-specificity
   rules here win by source order. */
@media (max-width: 768px) {
  /* State A — no image: the image card container is real-size's job to hide via
     .image-section; poster's equivalent is .tiling-image-section, so hide it
     here too (app.css already hides the dropzone-less bits: #rs-sheet-options,
     .panel-footer, .panel-right). */
  .layout:not(.rs-has-images) .tiling-image-section { display: none; }

  /* State B — config tab: keep .tiling-image-section as the ONE elastic,
     internally-scrolling child (its desktop flex:1 1 auto / min-height:0 /
     overflow-y:auto carries over unchanged). That's the same fix real-size
     uses for its image list: the variable-height content (here the single
     image card) absorbs the slack and scrolls inside itself, so the
     collapsible "Papel" section + the fixed Generar bar below it stay pinned
     to the bottom, always visible, regardless of the card's content. */

  /* State B — preview tab: app.css flips .panel-right to display:block, but
     poster's .preview-viewport fills its parent via flex:1 (it's the clipped
     pan/zoom frame), so the parent has to stay a flex column here or the
     viewport collapses. Same specificity as app.css's rule, later in source. */
  .layout.rs-has-images.rs-show-preview .panel-right {
    display: flex;
    flex-direction: column;
  }

  /* app.css's mobile preview rule `#preview-wrap canvas { height:auto!important;
     max-width:100%!important }` is written for real-size's stacked sheet canvases;
     on poster it would break the tile canvases, which must fill their
     aspect-ratio grid cell. This id+class selector outranks app.css's id-only
     one (both !important), restoring the tile fill. */
  #preview-wrap.tiling-preview-wrap canvas {
    width: 100% !important;
    height: 100% !important;
    max-width: none !important;
  }

  /* ── Editor: fluid stages so nothing overflows a narrow modal ────────────── */
  /* Not the full-screen editor restructure (that's a later round) — just enough
     that the fixed-px desktop stages don't spill out of the modal on a phone.
     The stage sizes themselves are recomputed in JS from these boxes' real
     client size (see editorStageBox / initScaleStage / renderAdjustPreview), so
     the crop overlay math stays correct at any width. */
  .editor-modal-box {
    width: calc(100vw - 24px);
    max-height: calc(100vh - 24px);
  }
  .editor-modal-body { padding: 14px; }

  .crop-stage { max-width: 100%; }

  /* The fixed 820×520 / 540×460 clip frames become fluid; JS fits the stage/
     canvas to whatever these end up measuring. */
  .scale-viewport {
    width: 100%;
    max-width: 100%;
    height: min(56vh, 520px);
  }
  .adjust-viewport {
    width: 100%;
    max-width: 100%;
    height: min(46vh, 460px);
  }

  /* Adjust tab is a stage-beside-sidebar row on desktop (540 + 220 = 760px,
     far past a phone's width) — stack the controls below the stage instead. */
  #editor-panel-adjust {
    flex-direction: column;
    align-items: stretch;
  }
  .adjust-controls { width: 100%; }
}

/* ── Hybrid mobile crop: ratio chips (hidden on desktop) ──────────────────── */
/* The chips + the full-screen editor + the touch crop mechanisms are gated on
   pointer:coarse (below), NOT on width — per the combined-breakpoint decision.
   A narrow desktop window with a mouse keeps the current floating-box crop with
   no chips. */
.crop-ratios { display: none; }

/* ── Full-screen editor + touch crop chrome (pointer:coarse only) ─────────── */
/* Placed after the max-width:768px block so, on a coarse phone where BOTH
   queries match, this .editor-modal-box sizing wins by source order. */
@media (pointer: coarse) {
  /* The 920px floating box becomes a full-screen sheet: tabs on top, stage in
     the middle, Cancelar/Aplicar bar at the bottom — same flex-column
     structure, just filling the viewport. dvh (with a vh fallback) so the
     browser's collapsing toolbars don't leave a gap at the bottom. */
  .editor-modal-box {
    width: 100vw;
    max-width: 100vw;
    height: 100vh;
    height: 100dvh;
    max-height: 100vh;
    max-height: 100dvh;
    border-radius: 0;
  }
  .editor-modal-body { padding: 12px; }

  /* A single horizontally-scrollable row of pills (photo-app style). */
  .crop-ratios {
    display: flex;
    gap: 8px;
    width: 100%;
    padding-bottom: 2px;
    overflow-x: auto;
    flex-wrap: nowrap;
    justify-content: flex-start;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .crop-ratios::-webkit-scrollbar { display: none; }

  .crop-ratio-chip {
    flex: 0 0 auto;
    min-height: 40px;
    padding: 8px 16px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    font: inherit;
    font-size: 0.8125rem;
    font-weight: 600;
    color: var(--muted);
    cursor: pointer;
    white-space: nowrap;
    transition: background 0.15s, color 0.15s, border-color 0.15s;
  }
  .crop-ratio-chip.is-active {
    background: var(--primary-soft);
    border-color: var(--primary);
    color: var(--primary);
  }

  /* Libre (free) mode on touch: only the 4 corners, each a 44px transparent
     touch target with a 20px visible dot centered on the vertex. The edge
     handles (n/e/s/w) are dropped — resizing is corner-only on touch. In fixed
     mode all handles are already hidden by .crop-mode-fixed above. */
  .crop-handle[data-handle="n"],
  .crop-handle[data-handle="e"],
  .crop-handle[data-handle="s"],
  .crop-handle[data-handle="w"] { display: none; }

  .crop-box .crop-handle {
    width: 44px;
    height: 44px;
    background: transparent;
    border: none;
  }
  .crop-box .crop-handle::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 20px;
    height: 20px;
    transform: translate(-50%, -50%);
    background: #fff;
    border: 2px solid var(--primary);
    border-radius: 50%;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
  }
}
