/* Projects board.
   At rest and on hover: a Pinterest board of rounded tiles, image only, no
   text. Hover does one thing, a small lift, which is the whole promise a
   pinboard card makes, and under the picture the project's name and one line
   about it open as a caption. Clicking the tile goes to that project's
   write-up.

   Hover used to build a museum display in place, which meant crossing the
   board fired twelve of them on the way to reading one, and a label that
   overlapped its neighbours could not be read at leisure or dismissed. A
   torn-paper reveal and a modal polaroid were both built and dropped; all
   three are in the history if they are ever wanted back. */

/* The board.
   Flex columns, not CSS multi-column. Multi-column fragments a tile's box
   across the column break, which drags the absolutely positioned reveal with
   it: the caption ends up spanning two columns and rendering in the wrong
   place. Flex columns give each tile an unfragmented containing block, so
   the reveal lands where it is told.

   Tiles are authored in the board in reading order and distributed into
   columns by js/board.js. Without JavaScript the board falls back to a plain
   grid with every caption already showing. */
.board {
  display: grid;
  /* At most four across, the same cap `js/board.js` applies to the scripted
     layout. `auto-fill` alone would keep adding columns as the board widens,
     so the minimum track is whichever is larger: the 140px floor, or a
     quarter of the row once three gaps are taken out of it. */
  grid-template-columns: repeat(auto-fill, minmax(max(140px, (100% - 3 * var(--s3)) / 4), 1fr));
  gap: var(--s3);
  align-items: start;
  /* Scroll anchoring is what used to make a hover jump the board, and it is
     turned off for the whole document in `base.css` rather than for the board
     here: the anchor the browser picks is as often outside the board as in it,
     and either one moves the page. The comment on `html` there is the
     explanation. */
}

.board.is-columned {
  display: flex;
  gap: var(--s3);
  align-items: flex-start;
}

.board__col {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

/* A photographed tile is a link to that project's write-up. It was a button
   opening a dialog; the dialog is gone, and what it carried is split between
   the caption that opens under the picture and the write-up the link goes to. */
.tile {
  display: block;
  position: relative;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  text-align: left;
  color: inherit;
  cursor: pointer;
  /* The tile is a link, but nothing in it is drawn as one: the photograph is
     the affordance, and where the label drops back onto the tile on touch it
     is a caption under a picture rather than a line of underlined blue. */
  text-decoration: none;
}

.tile:hover,
.tile:focus-visible { z-index: 3; }

/* The lift is on the photograph, not on the tile. The tile also carries the
   caption the hover opens underneath, and a transform on the tile would scale
   that caption up with the picture: 13px text drawn at 1.04 and blurred by a
   fractional transform. Transform only, so the lift itself still costs no
   layout and the tiles beside it never move. 1.04 is the card coming forward
   under the pointer, not a reveal. */
.tile__pic {
  display: block;
  position: relative;
  transform: scale(1);
  transition: transform var(--dur-base) var(--ease);
}

.tile:hover .tile__pic,
.tile:focus-visible .tile__pic { transform: scale(1.04); }

.tile:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }


/* The keyline is `inset`, and that is the whole trick. Two of the five tiles
   now carry the page's own color out to their own edge: Sift's window chrome
   is `--ground` exactly and STL Review's viewport is too, which measures
   1.00:1 against the page. There is no edge there to see, and the resting
   shadow only ever spoke for the foot, so the left and right sides of a tile
   simply dissolved.

   Inside rather than outside, for two reasons. It follows `border-radius`
   exactly, where an outer ring at this radius shows its corners first; and it
   costs no layout, where a real `border` would add 2px to every tile and
   repack the board.

   A keyline and a shadow on one element is two definitions of the same edge,
   which is usually a thing to avoid. This note used to say the pairing was
   safe because the shadow was 2px of contact and nothing more, and that if it
   ever grew wider the question had to be reopened. It grew: `--shadow-tile`
   reaches about 6px past the card, because 2px of contact left the left and
   right sides with the keyline and nothing else, and the tile read as having
   no sides until the pointer arrived.

   Reopened, the pairing still holds, for a different reason than before. The
   two marks answer different questions. The shadow says the card sits above
   the page, and it says it everywhere except where the card is dark, which on
   this board is (Favorite Artists)² on a near-black ground. The keyline says
   where the card ends, and it says it everywhere, including there. Neither
   covers the whole board alone.

   What the rule is actually against is a hairline plus a shadow used as
   atmosphere, and the test for that is whether the shadow describes height or
   mood. This one is 14px of blur pulled back by 8px of spread at .14: it
   reaches far enough to find an edge and stops well short of ringing the card.
   If it is ever widened again, or its alpha raised, that is the point the
   keyline should go rather than both being kept. */
.tile img {
  position: relative;
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-tile);
  box-shadow: inset 0 0 0 1px var(--keyline), var(--shadow-tile);
  transition: box-shadow var(--dur-base) var(--ease);
}

/* The corners stay rounded. A card that changes shape under the pointer is
   promising something the hover is not going to deliver, and what the hover
   delivers is one line of writing underneath it. */
.tile:hover img,
.tile:focus-visible img {
  /* The keyline is carried into the hover. Dropping it would make the tile's
     own outline vanish at the moment it comes forward, which is backwards. */
  box-shadow: inset 0 0 0 1px var(--keyline), var(--shadow-lift);
}

/* Shared text. The name leads in the serif; the one-liner sits under it in
   the sans. The medium is not printed here: it lives in `data-tags` and only
   drives the filter.

   The name went sans, then sans 700, and came back on 2026-09-14. The argument
   for the sans was that a caption under a photograph should not be the same
   voice as a page title; it was not worth what it cost, which was the board
   losing the one serif that tied it to the write-ups it opens. */
.tile__name {
  display: block;
  font-family: var(--serif);
  /* `--text-sm`, where this was 13px. DESIGN.md sets 14px as the floor for
     interface text and names 12px as its one exception; 13px was under both,
     and it was the name of the project, which is the thing the board exists to
     tell you. It is also what the no-JavaScript layout was already using, so
     the two layouts stopped disagreeing about how big a caption is. */
  font-size: var(--text-sm);
  line-height: 1.3;
  /* 500, where DESIGN.md holds the serif at 400 everywhere else. This caption
     sits directly under a photograph, which is the busiest thing it could sit
     under, and at 400 the name did not separate from the picture above it.
     The write-up's title takes the same exception for the same reason. It was
     600 while the caption was 13px and needed the extra weight to hold; at
     16px it does not, and 600 read as shouting a one-word label. */
  font-weight: 500;
  /* The display brown, the same as every other title on the site takes. It was
     `--ink`, the body near-black, which read cold under a warm photograph on a
     warm ground. */
  color: var(--ink-display);
}
.tile__desc {
  display: block;
  /* The 14px floor, up from 11.5px. Same argument as the name above it. */
  font-size: var(--text-label);
  line-height: 1.5;
  color: var(--muted);
  /* The whole gap between the name and its one-liner, and it has to stay
     smaller than the plaque's 8px above the name. Until 2026-09-15 the reveal
     carried a second `--s1` on top of this one, so the pair measured 8px and
     the blurb sat as far from the name as the name sat from its photograph:
     two equal gaps, which say nothing about which side the blurb belongs to.
     On the leading it read further still, since this line adds 3.5px of
     half-leading above its cap and the name adds 2.4px below its baseline.
     4px puts the blurb nearer its name than the name is to the picture, which
     is the order the eye should read them in.

     It sits here rather than on the reveal so that it is inside the clipped
     row: at rest the row is `0fr` and this margin is clipped with the text, so
     there is still nothing to see under a closed caption. */
  margin-top: var(--s1);
}

/* Touch has no hover and no pointer to cross the board with, so there is
   nothing to open the caption: it is simply left open on every tile, which is
   the same layout the page has without JavaScript. The tap itself is the link,
   so nothing is one interaction further away than it is on a desktop.
   `:hover` is still guarded because a tap leaves it stuck on. */
@media (hover: none) {
  .tile:hover,
  .tile:focus-visible { transform: none; }
  /* Back to the resting pair, keyline included: this cancels the lift on
     touch, not the tile's own edge. */
  .tile:hover img { box-shadow: inset 0 0 0 1px var(--keyline), var(--shadow-tile); }
}

/* The label lives on the tile, and there are three ways it can be drawn.
   It was also mirrored into a box above the board for a day; the box is gone,
   because the caption under the picture says the same thing in the place the
   reader is already looking.

   Without JavaScript it simply sits under its tile in the flow: the page is
   still a board of projects with names, blurbs and working links. With the
   script running the board is columned and the label is closed at rest, and it
   opens for whichever tile is under the pointer, directly beneath the picture
   it belongs to.

   In place it is a caption opening inside one column, so only the tiles below
   it in that column move down. The columns beside it are separate flex boxes
   and do not feel it at all.

   It opens on a transition rather than a display switch, so the tiles below
   slide down instead of jumping. `display` cannot be animated and `height:
   auto` cannot be transitioned to, so the open is a one-row grid going from
   `0fr` to `1fr` with the caption inside it clipped: the row is the thing that
   animates and the text never reflows as it goes. The margin travels with it,
   so at zero height there is no gap under the picture. */
.tile__plaque {
  display: block;
  /* `--s2`, not `--s3`. The tiles are packed 12px apart, so at 12px the name
     sat exactly as far from its own photograph as from the tile below it and
     said nothing about which one it belonged to. 8px attaches it upward. The
     serif's half-leading adds about three more, so it reads nearer 11. */
  margin-top: var(--s2);
  transform: translateY(0);
  transition: transform var(--dur-base) var(--ease);
}

/* The 8px is measured against a picture at rest. On hover the picture scales
   to 1.04 from its own center, so its bottom edge travels down by 2% of the
   tile's height, which on a tall tile is most of the 8px: the name ends up
   sitting on the photograph it belongs to. So the plaque moves with it.
   `translateY`, not margin, so the tiles below do not take a second push on
   top of the one the reveal already gives them, and the gap under the board
   does not breathe every time the pointer crosses a column. */
.tile:hover .tile__plaque,
.tile:focus-visible .tile__plaque { transform: translateY(var(--s2)); }

/* The touch guard has to sit after the rule it undoes, not up with the rest of
   the `hover: none` block: same specificity, so source order is the whole
   tie-break, and from up there it lost. */
@media (hover: none) {
  .tile:hover .tile__plaque { transform: none; }
}

/* The name is not part of the reveal. It was, and the board was nine
   photographs with nothing written on it: the only way to learn what any of
   them was, was to put the pointer on it and wait, which made scanning the
   board nine separate interactions instead of one look. So the name sits under
   its picture at rest, always, and the one-liner is what the hover opens under
   it. The picture is still the thing you look at; the name is just no longer
   hiding behind it.

   Column packing measures this, rather than assuming the caption is zero high
   at rest. See js/board.js. */

/* Only the one-liner opens, and only where the script has columned the board.
   It opens on a transition rather than a display switch, so the tiles below
   slide down instead of jumping. `display` cannot be animated and `height:
   auto` cannot be transitioned to, so the open is a one-row grid going from
   `0fr` to `1fr` with the sentence inside it clipped: the row is the thing
   that animates and the text never reflows as it goes. The margin travels with
   it, so at zero height there is no gap under the name. */
.board.is-columned .tile__reveal {
  display: grid;
  grid-template-rows: 0fr;
  margin: 0;
  transition: grid-template-rows var(--dur-base) var(--ease);
}

.board.is-columned .tile__reveal-in {
  display: block;
  overflow: hidden;
  min-height: 0;
}

.board.is-columned .tile:hover .tile__reveal,
.board.is-columned .tile:focus-visible .tile__reveal {
  grid-template-rows: 1fr;
}

/* Without the script the board is tiles in flow and everything is simply
   written out: name, then one-liner, no reveal to open. */
.board:not(.is-columned) .tile__reveal,
.board:not(.is-columned) .tile__reveal-in { display: block; }

/* Reduced motion: the card still lifts by its shadow, it just does not grow,
   and the dialog arrives without sliding into place. */
@media (prefers-reduced-motion: reduce) {
  .tile__pic { transition: none; }
  .tile:hover .tile__pic,
  .tile:focus-visible .tile__pic { transform: none; }
  /* Nothing scaled, so there is nothing for the plaque to get out of the way
     of. */
  .tile__plaque { transition: none; }
  .tile:hover .tile__plaque,
  .tile:focus-visible .tile__plaque { transform: none; }
  .board.is-columned .tile__reveal { transition: none; }
}

/* ---------------------------------------------------------------------------
   The tag filter.

   Tags are not printed on the tiles. A board of photographs with a metadata
   chip on every card reads as a table with pictures in it, and the medium is
   written on the project's own write-up. So the tags surface once, here, as
   the control that uses them.

   The row is built by js/board.js from the tags on the tiles, so it does not
   exist when the script does not run and there is never a dead pill.

   No hue: selected is the ink ground inverted, which is the same no-accent
   rule the rest of the page follows. The pills carry the one place on the site
   that `--radius-full` is allowed. */
/* The row scrolls rather than wraps, at every width.

   Not a breakpoint: a wrapped linked list is a broken one and a wrapped filter
   is the same shape, and the width where the medium row stops fitting moves
   every time a tag is added. `nowrap` plus `auto` says the rule instead of a
   number. Where the row fits there is nothing to scroll and nothing looks
   different; where it does not, it scrolls, and on a 375px screen the medium
   row was three stacked lines.

   The pill at the right edge is left sliced by the wrap's own padding. That cut
   is the whole affordance: no arrows, no fading mask, nothing to theme. A strip
   that ends in half a word is a strip somebody swipes. The first and last pills
   are never the clipped ones, because the padding at each end is the wrap's.

   The scrollbar is hidden. It is a horizontal bar under a 44px row of targets,
   drawn by the platform in a color the page does not choose, and on touch,
   which is where this matters, the platform does not draw one anyway. */
.filters {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--s2);
  margin-top: var(--s4);
  overflow-x: auto;
  /* A swipe that runs off the end of the strip scrolls the strip, never the
     history. Without this, a flick left at the last pill is a back gesture. */
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* Focus moving to a pill off screen scrolls it into view, and the browser
     does not know `all` is sitting on top of the left edge, so it would park
     the focused pill underneath it (WCAG 2.2 AA 2.4.11). This is a floor, not
     a measurement: `all` is 50px and the gap is 8, and scrolling a little
     further than needed costs nothing, so the number does not have to track
     the pill. */
  scroll-padding-inline-start: 72px;
}
.filters::-webkit-scrollbar { display: none; }

/* Scoped to the row: `.pill` is shared with the write-ups, where the tags do
   wrap and should. */
.filters > .pill { flex: 0 0 auto; }

/* The reset holds the left edge while its row scrolls under it. `all` is the
   way out of a filter, and a way out that is two swipes off screen is a reader
   stuck in one. Selected, it is the ink ground and opaque already; unselected
   it is a transparent pill, so it takes the page's own fill here or the pills
   passing behind it would read straight through it.

   Selected by `data-tag=""`, which is what `js/board.js` writes on the reset
   and on nothing else, rather than by `:first-child`: the subject row has a
   first child too, and it is an ordinary tag. */
.filters > .pill[data-tag=""] {
  position: sticky;
  left: 0;
  z-index: 1;
}
/* The fill, and only while the reset is not the selected pill. Written flat, it
   outweighed `[aria-pressed="true"]` by one attribute and painted the selected
   `all` cream on cream: the ground won, the inverted text did not, and the
   control that says "nothing is filtered" became an empty ring. Everything
   compiled and every gate passed; it was caught by looking at the screen. */
.filters > .pill[data-tag=""]:not([aria-pressed="true"]) {
  background: var(--ground);
}
/* The fill again, carried one flex gap further out. Stopping it at the pill's
   own edge left the next tag emerging from directly behind the border, so at
   `macOS` the reader got the word `cOS` with nothing to explain the cut and it
   read as a rendering fault rather than as one control sitting over another.
   Eight more pixels of ground each side puts clear space between the two, so
   the neighbor comes out of a margin instead of out of a border. A spread with
   no blur and no offset: this is the pill's fill reaching past its edge, not a
   shadow, and it takes the ground rather than a tone of its own so it stays the
   page behind the strip. */
.filters > .pill[data-tag=""] {
  box-shadow: 0 0 0 var(--s2) var(--ground);
}

/* The two rows are one control, so they sit closer to each other than the pair
   sits to the heading above it. The gap between them still has to beat the gap
   inside them, though: a row that wraps onto a second line at 375px would
   otherwise be indistinguishable from the row below it. Twice the flex gap,
   here and on touch. */
.filters + .filters { margin-top: var(--s4); }

/* The pill's resting look moved to base.css on 2026-09-14, when a write-up's
   rail started showing the same tags. What is left here is everything that
   makes it a control rather than a label: it is a toggle on this page and a
   plain tag on that one. */
.pill {
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              background-color var(--dur-fast) var(--ease);
}

.pill:hover { color: var(--ink); border-color: var(--muted); }
.pill:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* Selected is the warm dark brown, the same color as the nav's current-page
   rule, not the body's near-black: the two "you have chosen this" marks on the
   site are one color. 8.9:1 on cream, so it clears AA at 14px. */
.pill[aria-pressed="true"] {
  color: var(--ground);
  background: var(--ink-display);
  border-color: var(--ink-display);
}

/* The count, announced to a screen reader and shown to everyone else. It is
   empty while the board is unfiltered, and an empty box still has a margin,
   so it is taken out of the flow rather than left as a gap under the pills. */
.filters__status {
  margin: var(--s3) 0 0;
  font-size: var(--text-label);
  color: var(--muted);
}
.filters__status:empty { display: none; }

/* Touch: the pill is a target, not just a label. 14px text in 5px of padding
   is 32px tall, which is fine under a pointer and small under a thumb. */
@media (hover: none) {
  .pill {
    min-height: 44px;
    padding-left: var(--s4);
    padding-right: var(--s4);
  }
  .filters { gap: var(--s3); }
  .filters + .filters { margin-top: var(--s5); }
}

@media (prefers-reduced-motion: reduce) {
  .pill { transition: none; }
}

/* Narrow screens. The pills stay 44px, because a thumb target is not the thing
   to trade away for room; what goes instead is the dead space around them. Two
   rows of pills plus the heading were putting roughly 135px of chrome above the
   first photograph on a 375px screen, and the board is what the page is for. */
@media (max-width: 480px) {
  .filters { margin-top: var(--s3); }
  .filters + .filters { margin-top: var(--s3); }
  .page-head { margin-bottom: var(--s5); }
}

/* A tag that cannot combine with what is already selected. It stays in place,
   because the row is also the list of what exists: the point is that the
   combination is empty, not that the tag is gone. It loses its border rather
   than its contrast: greying the text far enough to read as disabled puts it
   under the 4.5:1 floor, and a dropped border says "not a target right now"
   without making the word hard to read. Border color only, so nothing in the
   row shifts as pills come and go. */
.pill[aria-disabled="true"] {
  border-color: transparent;
  cursor: default;
}

/* ---------------------------------------------------------------------------
   Touch, continued.

   Touch has no pointer to open the one-liner with, so it is left open on every
   tile: the same layout the page has with no script at all. This has to sit at
   the foot of the file, after the rule it is reversing, because both are
   single-class selectors of the same weight and order is the only thing that
   decides.
   --------------------------------------------------------------------------- */
@media (hover: none) {
  .board.is-columned .tile__reveal { grid-template-rows: 1fr; }
}
