/* Shared styles for the portfolio site. One file so the pages cannot drift. */

:root {
  --ink: #1c2530; --soft: #5b6975; --line: #e2e0d9;
  --paper: #faf9f6; --card: #fff; --accent: #2f6f5e; --accent-ink: #fff;
  --veil: rgba(250, 249, 246, 0.94);
}
@media (prefers-color-scheme: dark) {
  :root {
    --ink: #e8e6e1; --soft: #a3aeb8; --line: #333b44;
    --paper: #171c22; --card: #1f262e; --accent: #6fb3a0; --accent-ink: #10161b;
    --veil: rgba(23, 28, 34, 0.94);
  }
}
* { box-sizing: border-box; margin: 0; }
body {
  font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
  background: var(--paper); color: var(--ink); line-height: 1.6;
  margin: 0 auto; padding: 2.5rem 1.25rem 5rem;
  -webkit-font-smoothing: antialiased;
}
body.page { max-width: 46rem; }

a { color: var(--accent); }
h1 { font-size: 1.55rem; letter-spacing: -0.01em; }
h2 { font-size: 1.08rem; margin: 2.2rem 0 0.6rem; }
h3 { font-size: 0.95rem; margin: 1.4rem 0 0.4rem; }
p, li { font-size: 0.95rem; }
p { margin-bottom: 0.8rem; }
ul { margin: 0 0 0.9rem; padding-left: 1.2rem; }
li { margin-bottom: 0.3rem; }

.back { font-size: 0.88rem; text-decoration: none; }
.back:hover { text-decoration: underline; }

/* --- landing: square tiles, one screenful --------------------------------- */
/* The tile is a SQUARE whose side is worked out from the viewport before
   anything is laid out — the smaller of "the height left after the heading
   divided by the rows" and "the width divided by the columns" — so the grid
   can never add up to more than the screen. Nothing here has an intrinsic
   height, and the screenshots are scaled to fit inside a tile rather than
   cropped by it.
   ⚠️ The column and row counts were hardcoded at 2 when there were four
   projects. They are variables now, because six tiles at the old arithmetic
   either overflowed the viewport or shrank the squares past the point where
   a screenshot reads. Change --cols/--rows to match the tile count and the
   sizing follows; nothing else needs touching. */
body.fleet {
  --gap: 1rem;
  /* heading + the page's own padding + the internal-only footer line.
     ⚠️ This is the allowance the tile size is computed against, so ANY
     chrome added to the page has to be added here too or the grid grows
     past the viewport. Adding the footer link without it made the page
     scroll at 1280x800. */
  /* 15 Aug: the bare <h1>Portfolio</h1> became a masthead (name + tagline)
     and a colophon line was added under the grid. Both are chrome, so both
     are paid for here — 8.8rem -> 12.4rem. The warning above is not
     decorative: the footer link alone once made this scroll at 1280x800. */
  --chrome: 12.4rem;
  /* 7 tiles: 4 x 2 leaves one empty slot on the second row. 3 x 3 would fit
     too, but a third row is height-constrained and shrinks every square past
     the point a screenshot reads — the failure this block warns about. */
  --cols: 4;
  --rows: 2;
  /* Height of .name — its padding plus one line at its font size. Keep in step
     with the .name rules; .choices reserves it so the open tile's buttons are
     not painted over. */
  --name-reserve: 2.5rem;
  --tile: min((100dvh - var(--chrome) - var(--gap) * (var(--rows) - 1)) / var(--rows),
              (100vw - 2.5rem - var(--gap) * (var(--cols) - 1)) / var(--cols),
              23rem);
  height: 100dvh; padding: 1.15rem 1.25rem 1.35rem;
  display: flex; flex-direction: column; gap: 0.9rem; align-items: center;
}
/* Squared off with the grid rather than the page, so the left edges line up
   whatever the tile size works out to be. */
body.fleet .masthead,
body.fleet h1 { flex: none;
                width: calc(var(--tile) * var(--cols)
                            + var(--gap) * (var(--cols) - 1)); }
body.fleet h1 { font-size: 1.3rem; }
.tagline { font-size: 0.92rem; color: var(--soft); margin: 0.1rem 0 0; }

/* The click affordance. A typographic arrow rather than an emoji: it inherits
   the type colour, scales with the tile, and renders identically everywhere —
   an emoji is a different glyph on every platform and reads as informal on a
   page whose job is to look considered. aria-hidden, because it is decoration:
   the tile is already a link and says so to a screen reader. */
.cue { position: absolute; top: 0.55rem; right: 0.7rem; z-index: 2;
       font-size: 0.95rem; line-height: 1; color: var(--soft);
       opacity: 0.5; transition: opacity .15s, color .15s, transform .15s; }
a.tile:hover .cue, details.tile:hover .cue, .tile:focus-within .cue {
       opacity: 1; color: var(--accent); transform: translate(1px, -1px); }
@media (prefers-reduced-motion: reduce) { .cue { transition: none; } }

/* Sits under the grid, quiet, and is public — unlike .internal-only below. */
.colophon { flex: none; margin: 0.2rem 0 0; text-align: center;
            font-size: 0.72rem; color: var(--soft); opacity: 0.7; }

/* 17 Aug 2026: a row rather than a centred line, so the contact address can sit
   bottom-left and the © keep the other end.
   ⚠️ Deliberately NOT a new line of its own. --chrome above is the height the
   tile grid is sized against, and its comment records a footer line alone once
   making this page scroll at 1280x800. Sharing the colophon's existing line
   costs no height at all, so --chrome needed no change.
   Width matched to the grid the same way .masthead is, so the address lines up
   with the left edge of the first tile rather than with the page. */
body.fleet .colophon {
  width: calc(var(--tile) * var(--cols) + var(--gap) * (var(--cols) - 1));
  display: flex; justify-content: space-between; align-items: center;
  gap: 1rem; text-align: left;
  /* ⚠️ The base .colophon rule dims the whole line to 0.7. `opacity` applies to
     the entire subtree and a CHILD CANNOT UNDO IT — no value on .contact can
     make it brighter than its parent. So the dimming is cancelled here and
     re-applied to the © alone, which is the only part that wanted it. */
  opacity: 1;
}
body.fleet .colophon .copyright { opacity: 0.7; }
/* A bordered box rather than an underlined line (Andy, 17 Aug: "can we make the
   email contact a bit nicer a 'Contact' heading above it, or put it in a box").
   A box, not a heading above — a heading is a second line and --chrome above
   records what a second line does to this page.

   ⚠️ The box is sized so the row's height does not grow: 0.72rem type in a
   0.2rem/0.5rem box is shorter than the 1.15rem bottom padding body.fleet
   already reserves. Verified at 1280x800 after the change; if the type or
   padding here grows, re-measure before assuming --chrome is still right. */
/* FILLED, not outlined (17 Aug: "can you make contact a bit more visible").
   --accent/--accent-ink are already a matched pair with a dark-mode variant, so
   this reads as a button in both themes without inventing a colour.
   ⚠️ Its own font-size, not the colophon's 0.72rem. This is the one thing on the
   page a reader might act on rather than click through, and it was competing
   with a copyright line for attention at the same weight and the same dimming. */
body.fleet .colophon .contact {
  display: inline-flex; align-items: baseline; gap: 0.5rem;
  padding: 0.4rem 0.85rem; border-radius: 999px;
  font-size: 0.84rem; line-height: 1.2;
  background: var(--accent); color: var(--accent-ink);
  border: 1px solid var(--accent); text-decoration: none;
  transition: filter .15s, box-shadow .15s;
}
/* "Contact" says what the thing is; the address is what gets read and copied, so
   the address carries the weight. Uppercased in CSS rather than in the markup,
   so a reader who selects and pastes the line still gets "Contact". */
body.fleet .colophon .contact-label { text-transform: uppercase;
                                      letter-spacing: 0.07em; font-size: 0.82em;
                                      opacity: 0.8; }
body.fleet .colophon .contact-addr { font-weight: 600; }
body.fleet .colophon .contact:hover { filter: brightness(1.12); }
/* A visible focus ring, because the pill's own border is the same colour as its
   fill and :focus-visible would otherwise change nothing a keyboard user sees. */
body.fleet .colophon .contact:focus-visible { outline: none; filter: brightness(1.12);
                                              box-shadow: 0 0 0 3px var(--paper),
                                                          0 0 0 5px var(--accent); }

/* Any other link on the line keeps the plain underline treatment. */
body.fleet .colophon a:not(.contact) { color: inherit; text-decoration: none;
                                       border-bottom: 1px solid currentColor; }

/* Auto margins centre the block in whatever height is left over — on a tall
   narrow screen the squares are width-bound and there is a lot of it. */
.tiles { flex: none; margin-block: auto; display: grid; gap: var(--gap);
         grid-template-columns: repeat(var(--cols), var(--tile));
         grid-auto-rows: var(--tile); }

.tile { position: relative; min-height: 0; overflow: hidden;
        display: flex; flex-direction: column;
        background: var(--card); border: 1px solid var(--line);
        border-radius: 14px; text-decoration: none; color: var(--ink);
        transition: border-color .15s; }
a.tile:hover, details.tile:hover { border-color: var(--accent); }
.tile:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } }

/* `contain`, not `cover`: the whole screenshot, shrunk — a cropped one reads
   as a page that carries on past the edge, which is what this is not. */
.tile img { flex: 1; min-height: 0; width: 100%;
            object-fit: contain; object-position: center;
            padding: 0.5rem 0.5rem 0;
            background: var(--paper); }
/* ⚠️ The title size is derived from --tile, not fixed, so that no name ever
   wraps at any window size.
   Why not a breakpoint: --tile is min(height-bound, width-bound, 23rem), so a
   short window shrinks the square just as a narrow one does. A width-only
   media query fixes half the cases and leaves the laptop-in-split-screen half
   broken.
   The constant: measured 15 Aug, the longest name ("Neighbourhood Explorer")
   renders 180px wide at a 15.2px font — 11.84x the font size — and .name adds
   1.7rem of horizontal padding. So the largest size that still fits on one
   line is (tile - 1.7rem) / 11.84, clamped so it never grows past the design
   size or shrinks past legible.
   ⚠️ The divisor is 12.4, not the measured 11.84. Using the exact ratio makes
   the text fit to the pixel, and at 900x700 that still wrapped — sub-pixel
   rounding was enough to push it over. 12.4 leaves about 5% slack, which is
   the difference between arithmetic that is right and arithmetic that works.
   ⚠️ If a longer tile name is ever added, re-measure that ratio. The failure
   is graceful — the name wraps as it used to — but the rule stops being true.
   Shortening the longest name would remove the need for this whole rule. */
.name { flex: none; padding: 0.55rem 0.85rem; font-weight: 650;
        font-size: clamp(0.72rem, calc((var(--tile) - 1.7rem) / 12.4), 0.95rem);
        line-height: 1.3; }
/* ⚠️ A one-line description under each title was added and removed on 15 Aug.
   Removed deliberately: every demo explains itself in its own header the
   moment it opens, so the line on the tile was a second copy of the same
   sentence — and this page's job is to be taken in at a glance, not read.
   The slot stays defined because it is cheap and the decision may turn; if it
   is still unused by the next redesign, delete it. A tile whose description
   wraps further than its neighbours' also needs a min-height on .name, or the
   titles come off their shared baseline — that was the fault this rule had to
   fix while the lines existed. */
.name small { display: block; font-weight: 400; font-size: 0.8rem;
              color: var(--soft); }

/* The slot above, revived for a URL rather than a description — a fact about
   where the tile points, written by tile-urls.js from the href itself.
   Monospace because it is an address and reads as one, and truncated rather
   than wrapped so a long host cannot push the titles off their shared
   baseline, which is the fault the comment above records. */
.tile-url { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
            font-size: 0.7rem; letter-spacing: -0.01em;
            white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* Which copy of the page this is. Sits inline in the masthead so it costs no
   height — --chrome is the allowance the whole grid is computed against, and
   its own comment warns that anything added to the page has to be paid for
   there or the tiles outgrow the viewport. */
.where-badge { display: inline-block; margin-left: 0.5rem; vertical-align: 0.1em;
               padding: 0.05rem 0.45rem; border-radius: 999px;
               font-size: 0.68rem; font-weight: 600; letter-spacing: 0.04em;
               text-transform: uppercase;
               color: var(--paper); background: var(--soft); }
.where-badge.is-deploy { background: var(--accent); }

/* ⚠️ Internal-only chrome. Anything carrying this class is for the operator
   of this machine and must not survive to a public deploy — grep for
   `internal-only` before shipping. Deliberately quiet: it sits under the
   grid, out of the way of the thing the page is actually for. */
.internal-only {
  flex: none;
  margin: 0.15rem 0 0;
  text-align: center;
  font-size: 0.78rem;
  line-height: 1.4;
}
.internal-only a { color: var(--soft); text-decoration: none; }
.internal-only a:hover { text-decoration: underline; color: var(--accent); }
.internal-only small {
  display: block;
  color: var(--soft);
  opacity: 0.65;
  font-size: 0.68rem;
}

/* Not-yet-built tiles. Muted and dashed so they read as a gap in the grid
   rather than as something broken, and not clickable — see the note in
   index.html about why these should not survive to a public page. */
.tile-soon { border-style: dashed; background: transparent;
             align-items: center; justify-content: center;
             color: var(--soft); cursor: default; }
.tile-soon .soon-mark { font-size: 2rem; opacity: 0.35; line-height: 1; }
.tile-soon .name { text-align: center; color: var(--soft); font-weight: 550; }

/* The choice tile is a <details>, so the reveal works with scripting off. */
details.tile > summary { display: flex; flex-direction: column;
                         height: 100%; min-height: 0; cursor: pointer;
                         list-style: none; }
details.tile > summary::-webkit-details-marker { display: none; }
/* ⚠️ The veil covers the whole tile, but the buttons must be centred in the
   part ABOVE the name, not in the tile. The name is raised over the veil (see
   below) with a solid background, so anything centred in the full square is
   painted over from the bottom: measured at 375px, the second button ended at
   124px and the name began at 115px, which clipped "Aberdeen" in half.
   --name-reserve is that name's height, declared next to the font-size that
   determines it at each breakpoint. Reserving the space fixes this at any
   tile size and any number of wrapped lines; shrinking the buttons alone only
   moved the collision. */
.choices { position: absolute; inset: 0; display: flex;
           flex-direction: column; align-items: center; justify-content: center;
           padding-bottom: var(--name-reserve);
           gap: 0.7rem; background: var(--veil); }
/* The veil covers the picture, not the tile's own name — an open tile still
   says what it is. Both are positioned, so the name needs the higher layer. */
details.tile[open] .name { position: relative; z-index: 2;
                           background: var(--card); }


/* --- buttons ------------------------------------------------------------- */
.btn { position: relative; z-index: 1; display: inline-block;
       font-size: 0.85rem; font-weight: 600; text-decoration: none;
       padding: 0.42rem 1.1rem; border-radius: 7px;
       border: 1px solid var(--accent);
       background: var(--accent); color: var(--accent-ink); }
.btn.ghost { background: var(--card); color: var(--accent); }
.btn:hover { filter: brightness(1.08); }

/* --- project page -------------------------------------------------------- */
.page h1 { margin: 1.2rem 0 0.3rem; }
.cap-line { color: var(--soft); margin-bottom: 1.2rem; }
.facts { font-size: 0.84rem; color: var(--soft); margin-bottom: 0; }
.stack { font-size: 0.78rem; color: var(--soft); margin-bottom: 0;
         font-family: ui-monospace, SFMono-Regular, monospace; }
.shot-full { width: 100%; border: 1px solid var(--line); border-radius: 10px;
             display: block; margin: 1.2rem 0; }
table { border-collapse: collapse; width: 100%; font-size: 0.88rem;
        margin-bottom: 1rem; display: block; overflow-x: auto; }
th, td { text-align: left; padding: 0.45rem 0.7rem;
         border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--soft); font-weight: 600; white-space: nowrap; }
blockquote { margin: 1rem 0; padding: 0.9rem 1.1rem; background: var(--card);
             border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; }
blockquote p:last-child { margin-bottom: 0; }

/* ⚠️ REVERSED 15 Aug 2026. This block used to force the same column count at
   every width, on the argument that "an app you have to scroll to find is an
   app you did not know was there", so the tiles shrank instead.

   That reasoning held at six tiles and three columns. At seven tiles and four
   columns it stopped holding, and a phone check showed why: 375px / 4 gives a
   ~110px square, in which the screenshot is a postage stamp and the title
   clips mid-word — "Neighbourhood Explorer" rendered as "Neighbourl". The
   trade was never "small tiles" against "scrolling"; it had quietly become
   "unreadable tiles" against "scrolling", and unreadable wins nothing.

   Two columns, four rows, and the page scrolls. The tile is width-bound only
   here — with scrolling allowed, viewport height is no longer a constraint on
   the square, so --chrome does not enter the arithmetic on this branch. The
   one-screenful design above is unchanged and still applies at every width
   over 38rem, which is where it was designed to work. */
/* ⚠️ The four-column grid stops at 48rem, not 38rem. Below 768px the
   width-bound square drops under ~170px, which is narrower than the longest
   name can be set at even the smallest size the clamp above allows — so no
   font arithmetic rescues it and the grid itself has to change. Two columns
   here, scrolling, which gives a 276px tile at the low end of the band. */
@media (max-width: 48rem) and (min-width: 38.01rem) {
  body.fleet {
    --gap: 0.6rem;
    --cols: 2;
    --rows: 4;
    --tile: calc((100vw - 2.5rem - var(--gap)) / var(--cols));
    --name-reserve: 2.6rem;
    height: auto;
    min-height: 100dvh;
  }
  .tiles { margin-block: 0.9rem 0; }
  .colophon { margin-top: 0.9rem; }
}

@media (max-width: 38rem) {
  body.fleet {
    --gap: 0.6rem;
    /* ⚠️ One column, not two. Two columns gave a 163px tile, and at that width
       the longest name — "Neighbourhood Explorer" — wrapped to two lines while
       the others sat on one, so the grid read as ragged. Full width fits every
       name on a single line with room to spare.
       The row is deliberately SHORTER than the tile is wide: seven full-width
       squares would be a 2,300px scroll, and the screenshots are landscape
       anyway, so a landscape tile wastes less of itself on the `contain`
       letterboxing than a square does. */
    --cols: 1;
    --rows: 7;
    --tile: calc(100vw - 2.5rem);
    --row-h: 13.5rem;
    --name-reserve: 2.6rem;
    height: auto;
    min-height: 100dvh;
  }
  .tiles { grid-auto-rows: var(--row-h); }
  body.fleet h1 { font-size: 1.15rem; }
  .tagline { font-size: 0.84rem; }
  /* The auto margins that centre the grid in leftover height have no leftover
     height to work with once the page scrolls, and would push the first row
     down the screen. */
  .tiles { margin-block: 0.9rem 0; }
  .name { padding: 0.4rem 0.55rem; font-size: 0.82rem; }
  .name small { font-size: 0.72rem; }
  .cue { top: 0.4rem; right: 0.5rem; font-size: 0.85rem; }
  .tile img { padding: 0.4rem 0.4rem 0; }
  .colophon { margin-top: 0.9rem; }
}
