/* GENERATED -- do not edit. Run `npm run sync` in shared-ui/.
   source: /Users/Hanson_Leung/Desktop/websites/v9/css/style_v9.css
   sha256: 5d6eb91311e53534a6af06dbcb370a79b0d55f3a2edf326d8ea1e38c45b0fdd1
   synced: 2026-08-29
   dropped: work.html - articles, work.html - fold expandable, index.html
 */

:root {
  --baseline: 8;

  --space-2: calc((calc(var(--baseline) * 0.25) / 1024) * 100vw);
  --space-4: calc((calc(var(--baseline) * 0.5) / 1024) * 100vw);
  --space-8: calc((calc(var(--baseline) * 1) / 1024) * 100vw);
  --space-16: calc((calc(var(--baseline) * 2) / 1024) * 100vw);
  --space-32: calc((calc(var(--baseline) * 4) / 1024) * 100vw);
  --space-64: calc((calc(var(--baseline) * 8) / 1024) * 100vw);
  --space-128: calc((calc(var(--baseline) * 16) / 1024) * 100vw);

  --space-static-2: calc(var(--baseline) * 0.25px);
  --space-static-4: calc(var(--baseline) * 0.5px);
  --space-static-8: calc(var(--baseline) * 1px);
  --space-static-12: calc(var(--baseline) * 1.5px);
  --space-static-16: calc(var(--baseline) * 2px);
  --space-static-24: calc(var(--baseline) * 3px);
  --space-static-32: calc(var(--baseline) * 4px);
  --space-static-48: calc(var(--baseline) * 6px);
  --space-static-64: calc(var(--baseline) * 8px);
  --space-static-96: calc(var(--baseline) * 12px);
  --space-static-128: calc(var(--baseline) * 16px);

  --viewport-spacing: calc(var(--space-static-16) + var(--space-static-4));
  --indent: var(--space-static-24);

  --grid-columns-xxs: 1;
  --grid-columns-xs: 2;
  --grid-columns-s: 3;
  --grid-columns-m: 4;
  --grid-columns-l: 6;
  --grid-columns-xl: 8;
  --grid-columns-fill: 12;

  --gap-xs: var(--space-static-8);
  --gap-s: var(--space-static-16);
  --gap-m: var(--space-static-24);
  --gap-l: var(--space-static-32);
  --gap-xl: var(--space-static-64);
  --gap-xxl: var(--space-static-128);

  --font-size-xs: calc(var(--baseline) * 1.5px);
  --font-size-s: calc(var(--baseline) * 1.75px);
  --font-size-m: calc(var(--baseline) * 2.25px);
  --font-size-l: calc(var(--baseline) * 2.75px);

  --line-height: calc(var(--baseline) * 0.2rem);
  --line-height-m: calc(var(--baseline) * 0.3rem);
  --line-height-l: calc(var(--baseline) * 0.4rem);

  --color-reverse: #ffffff;
  --color-primary: #000000;
  --color-primarydarker: #303030;
  --color-primarydark: #6c6c6c;
  --color-primarymedium: #bababa;
  --color-primarylight: #d3d3d3;
  --color-primarylighter: #e6e6e6;
  --color-primarylightest: #f8f8f8;
  --color-destructive: #ff594c;

  --border-radius: calc(var(--baseline) * 0.5px);

  --border-primary: 1px solid var(--color-primary);
  --border-primarydark: 1px solid var(--color-primarydark);
  --border-primarymedium: 1px solid var(--color-primarymedium);
  --border-primarylight: 1px solid var(--color-primarylight);
  --border-primarylighter: 1px solid var(--color-primarylighter);
  --border-primarylightest: 1px solid var(--color-primarylightest);

  --border-blockquote: var(--space-static-4) solid var(--color-primarylighter);
  --border-destructive: 1px solid var(--color-destructive);

  --transition:
    all 0s, text-decoration-color 0.15s var(--transition-timing-function),
    transform 0.3s var(--transition-timing-function),
    opacity 0.5s var(--transition-timing-function);

  --transition-timing-function: cubic-bezier(0.2, 0, 0.1, 1);
}

*,
::after,
::before {
  box-sizing: border-box;
}

@font-face {
  font-family: "Hanson Sans";
  src: url("https://hansonleung.co/fonts/HelveticaNowText-Regular.otf") format("opentype");
  font-weight: 400;
}

/* body */
html,
body {
  background-color: var(--color-reverse);
}

body {
  opacity: 0;
  transition: var(--transition);
}

body.fade-in {
  opacity: 1;
  transition: var(--transition);
}

/* text */
html,
body,
h1,
h2,
h3,
p,
a,
ul,
li {
  font-family:
    "Hanson Sans",
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    Oxygen,
    Ubuntu,
    Cantarell,
    "Open Sans",
    "Helvetica Neue",
    sans-serif;
  font-size: var(--font-size-s);
  font-weight: normal;
  line-height: var(--line-height);
  padding: 0;
  margin: 0;
  color: var(--color-primary);
  letter-spacing: 0.02em;
}

h1 {
  font-size: var(--font-size-l);
  line-height: var(--line-height-m);
}

h2 {
  font-size: var(--font-size-m);
  line-height: var(--line-height-m);
}

p {
  max-width: fit-content;
}

p.eyebrow,
p.caption {
  font-size: var(--font-size-xs);
  letter-spacing: 0.05em;
  color: inherit;
}

.destructive {
  color: var(--color-destructive);
}

.truncate {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.indent {
  padding: 0 var(--indent);
  width: 100%;
}

.center {
  text-align: center;
  justify-content: center;
  width: 100%;
}

@media screen and (max-width: 768px) {
  .indent,
  .media__text {
    padding: 0;
  }
}

.blockquote {
  position: relative;
  padding-left: var(--indent);
  margin: var(--space-static-4) 0;
  width: 100%;
}

.blockquote::before {
  content: "";
  height: 100%;
  border-left: var(--border-blockquote);
  position: absolute;
  left: 0;
}

/* buttons, inputs and links */
input,
select,
textarea {
  border: var(--border-primarylight);
  border-radius: var(--border-radius);
  font: inherit;
  outline: inherit;
  width: 100%;
  padding-inline: var(--space-static-12);
  padding-block: var(--space-static-8);
  background: var(--color-reverse);
}

input:hover,
textarea:hover {
  background: var(--color-primarylightest);
}

input:focus,
textarea:focus {
  border: var(--border-primarymedium);
  background: var(--color-reverse);
}

select {
  border: var(--border-primarylight);
  background: var(--color-primarylighter);
  appearance: none;
  background-repeat: no-repeat;
  background-position: right var(--gap-s) center;
  /* Must clear the chevron, not just approach it: the arrow is 10px wide and
     sits --gap-s (16px) from the right edge, so it occupies up to 26px in.
     --gap-m (24px) left the longest option text running underneath it. */
  padding-right: calc(var(--gap-s) + var(--space-static-24));
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none' stroke='%23000' stroke-width='1.25'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
  -webkit-text-fill-color: var(--color-primary);
  cursor: pointer;
}

select:hover {
  border: var(--border-primarymedium);
}

button,
input[type="submit"],
input[type="reset"] {
  background: var(--color-primary);
  color: var(--color-reverse);
  border: var(--border-primary);
  padding: var(--space-static-8) var(--space-static-16);
  border-radius: var(--border-radius);
  font: inherit;
  cursor: pointer;
  outline: inherit;
  width: fit-content;
}

button:hover,
input[type="submit"]:hover,
input[type="reset"]:hover {
  background: var(--color-primarydarker);
}

button:disabled,
input[type="submit"]:disabled,
input[type="reset"]:disabled {
  background: var(--color-primarylighter);
  border: var(--border-primarylighter);
  color: var(--color-primarydark);
  cursor: not-allowed;
}

button.secondary,
input[type="submit"].secondary,
input[type="reset"].secondary {
  color: var(--color-primary);
  border: var(--border-primary);
  background: var(--color-reverse);
}

button.secondary:hover,
input[type="submit"].secondary:hover,
input[type="reset"].secondary:hover {
  color: var(--color-primary);
  border: var(--border-primary);
  background: var(--color-primarylightest);
}

button.minimal {
  border: none;
  padding: 0;
  color: var(--color-primary);
  text-decoration: underline;
  background: none !important;
}

button.minimal:hover {
  color: var(--color-primarydark);
}

a,
a:visited,
a:active,
a:focus,
ul,
li {
  text-decoration: none;
  list-style: none;
  transition: var(--transition);
  overflow: hidden;
}

p a,
a p {
  color: inherit;
  text-decoration: underline;
}

p a:hover,
a:hover p {
  color: var(--color-primarydark);
}

/* link with icon */
.link {
  position: relative;
  display: inline-flex;
}

.link:hover {
  color: var(--color-primarydark);
}

.link.link--external::after,
.link.link--next::after,
.link.link--prev::before {
  position: absolute;
  font-size: var(--font-size-xs);
}

.link.link--external,
.link.link--next {
  padding-right: var(--indent);
}

.link.link--external::after,
.link.link--next::after {
  right: 0;
}

.link.link--external::after {
  content: "↗";
}
.link.link--next::after {
  content: "→";
}

.link.link--prev {
  padding-left: var(--indent);
}

.link.link--prev::before {
  content: "←";
  left: 0;
}

/* lists */
ul.list--dash li {
  padding-left: var(--indent);
  position: relative;
}

ul.list--dash li::before {
  content: "–";
  position: absolute;
  left: 0;
}

/* badge */
.badge {
  display: inline-block;
  padding: var(--space-static-2) var(--space-static-8);
  border-radius: var(--border-radius);
  background: var(--color-primarylightest);
  color: var(--color-primary);
  font-size: var(--font-size-xs);
  text-decoration: none;
}

/* parent grid */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns-fill), 1fr);
  column-gap: var(--gap-s);
  row-gap: var(--gap-xxl);
  padding: 0 var(--viewport-spacing);
  margin: var(--viewport-spacing) auto;
  width: 100%;
  max-width: 1800px;
}

@media screen and (max-width: 1024px) {
  :root {
    --grid-columns-fill: var(--grid-columns-xl);
  }
}

@media screen and (max-width: 768px) {
  .grid {
    row-gap: var(--gap-xl);
  }
  :root {
    --grid-columns-fill: var(--grid-columns-l);
    --grid-columns-xl: var(--grid-columns-l);
    --grid-columns-m: var(--grid-columns-s);
  }
}

@media screen and (max-width: 512px) {
  :root {
    --grid-columns-fill: var(--grid-columns-m);
    --grid-columns-l: var(--grid-columns-m);
  }
}

/* subgrid */
.subgrid {
  display: grid;
  grid-template-columns: subgrid;
}

.subgrid.subgrid--m {
  row-gap: var(--gap-m);
}

.subgrid.subgrid--l {
  row-gap: var(--gap-xl);
}

.subgrid.subgrid--xl {
  row-gap: var(--gap-xxl);
}

@media screen and (max-width: 768px) {
  .subgrid.subgrid--xl {
    row-gap: var(--gap-xl);
  }
}

/* static grids */
.row,
.column,
.split {
  display: grid;
  gap: var(--gap-m);
  align-items: baseline;
}

.row {
  grid-auto-flow: column;
  width: fit-content;
}

.row.row--none {
  column-gap: 0;
}

.row.row--xs {
  column-gap: var(--gap-xs);
}

.row.row--s {
  column-gap: var(--gap-s);
}

.row.row--l {
  column-gap: var(--gap-l);
}

.row.row--xl {
  column-gap: var(--gap-xl);
}

.column {
  grid-auto-flow: row;
  grid-template-columns: minmax(0, 1fr);
}

.column.column--none {
  row-gap: 0;
}

.column.column--xs {
  row-gap: var(--gap-xs);
}

.column.column--s {
  row-gap: var(--gap-s);
}

.column.column--l {
  row-gap: var(--gap-l);
}

.column.column--xl {
  row-gap: var(--gap-xl);
}

@media screen and (max-width: 768px) {
  .row,
  .column {
    gap: var(--gap-s);
  }

  .row.row--xl {
    column-gap: var(--gap-m);
  }

  .column.column--xl {
    row-gap: var(--gap-m);
  }
}

.split {
  grid-template-columns: auto auto;
  justify-content: space-between;
  width: 100%;
  white-space: nowrap; /* for header and footer */
}

/* card sizing */
.card {
  grid-auto-rows: min-content;
  align-content: flex-start;
  width: 100%;
}

.card.card--fill {
  --span: var(--grid-columns-fill);
}
.card.card--xl {
  --span: var(--grid-columns-xl);
}
.card.card--l {
  --span: var(--grid-columns-l);
}
.card.card--m {
  --span: var(--grid-columns-m);
}
.card.card--s {
  --span: var(--grid-columns-s);
}
.card.card--xs {
  --span: var(--grid-columns-xs);
}
.card.card--xxs {
  --span: var(--grid-columns-xxs);
}

.card.card--fill,
.card.card--xl,
.card.card--l,
.card.card--m,
.card.card--s,
.card.card--xs,
.card.card--xxs {
  grid-column: span var(--span);
}

.card.card--centered {
  grid-column: calc((var(--grid-columns-fill) - var(--span)) / 2 + 1) / span
    var(--span);
}

/* media */
.media__container {
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  border: var(--border-primarylightest);
  position: relative;
  border-radius: var(--border-radius);
  aspect-ratio: 3/4;
}

.media__container.aspect--unset {
  aspect-ratio: unset;
}

.media__container.aspect--16by9 {
  aspect-ratio: 16/9;
}

.media__content {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: var(--transition);
  transform: scale(1.01);
  will-change: transform;
}

/* ── header ──────────────────────────────────────────────────────────────────
   Layout only. Type, colour and spacing come from the primitives, and the
   markup already carries `split` and `row row--s`.

   The host element must be a `.card`, because the primitives give cards their
   `grid-column: span var(--span)`. A bare <header> gets `grid-column: auto` and
   occupies a single track, which reads as a header mysteriously narrower than
   the content beneath it. Both apps use `card card--fill` directly under
   `body.grid`, so the header spans the full page rather than the content column.

   Deliberately NOT sticky. Spend already has its own sticky #bar (the month
   switcher and Add); a second pinned bar would eat a phone viewport. */

.ui-header {
  padding-bottom: var(--space-static-8);
}

/* Muted so the app name carries the emphasis. aria-hidden in the markup, so it
   is never announced as content. primarydark rather than primarylight: at
   #d3d3d3 on white it was essentially invisible. */
.ui-header__sep {
  color: var(--color-primarydark);
}

/* The primitives style `select` completely -- border, radius, chevron, hover --
   so the switcher needs only to stop stretching. `select` there is width: 100%,
   which inside a `row` grid track would blow the header out. */
.ui-header__apps {
  width: fit-content;
  padding-block: var(--space-static-4);
}

/* body.grid's row-gap is --gap-xxl (128px). That is right between page sections
   but far too much between a header and the content it labels, so pull it back
   to --gap-l. The override mirrors .grid's own breakpoint, where the row-gap
   drops to --gap-xl -- without it the negative margin would over-pull on
   narrow screens and the header would collide with the content. */
#ui-header {
  margin-bottom: calc(var(--gap-l) - var(--gap-xxl));
}

@media screen and (max-width: 768px) {
  #ui-header {
    margin-bottom: calc(var(--gap-l) - var(--gap-xl));
  }
}

/* ── rows ────────────────────────────────────────────────────────────────────
   A list of records: a name with optional supporting detail on the left, an
   optional value on the right, and an optional trailing action.

   Built from Spend's ledger rows, which is the treatment we settled on. It
   replaces the bare `table` rules that used to live in style_v9.css -- those
   styled a real <table>, which forced every column width to be decided up front
   and gave a phone no way to reflow. Rows are a grid instead, so the name
   column absorbs the slack and truncates, and the value column stays legible.

   Markup contract (see table.js, which builds it):

     <div class="ui-rows">
       <p class="ui-rows__group">optional heading</p>
       <button class="ui-row"> or <div class="ui-row">
         <span class="ui-row__main">
           <span class="ui-row__name">required</span>
           <span class="ui-row__detail">optional</span>
           <span class="ui-row__chips"><span class="ui-chip">…</span></span>
         </span>
         <span class="ui-row__value">optional
           <span class="ui-row__sub">optional</span>
         </span>
         <span class="ui-row__action">optional control</span>
       </button>
     </div>

   Use <button class="ui-row"> when the whole row opens something, and
   <div class="ui-row"> when it contains its own links or buttons -- a button
   inside a button is invalid and browsers will not nest the hit areas. */

/* The list owns the column tracks and every row borrows them via subgrid, so
   values line up down the page instead of each row sizing its own columns
   independently. Without this a short value and a long one sit at different
   x positions and never read as a column.

   Apps set --ui-row-columns to suit their data: column widths belong to the
   list, not to the component. The default gives the name column all the slack. */
.ui-rows {
  display: grid;
  grid-template-columns: var(--ui-row-columns, minmax(0, 1fr) auto auto);
  row-gap: 0;
  column-gap: var(--gap-m);
}

/* A heading over a run of rows, so a shared value (a date, a status) is written
   once instead of repeated down the column. */
.ui-rows__group {
  grid-column: 1 / -1;
  color: var(--color-primarydark);
  padding: var(--space-static-16) 0 var(--space-static-4);
  border-bottom: var(--border-primarylightest);
  /* The primitives' caption styling, restated rather than relying on the app to
     add class="caption": the heading should look the same wherever it is used,
     and `p.caption` there is element-qualified so it would not apply to a group
     rendered as anything but a <p>. */
  font-size: var(--font-size-xs);
  letter-spacing: 0.05em;
}

.ui-rows__group:first-child {
  padding-top: 0;
}

.ui-row {
  /* Spans every track and then re-uses them, which is what keeps one row's
     columns aligned with the next one's. */
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  align-items: baseline;
  padding: var(--space-static-12) 0;
  border-bottom: var(--border-primarylightest);
  width: 100%;
  text-align: left;
  /* Reset the element-level `button` styling from the primitives, so a row that
     happens to be a <button> is not drawn as one. */
  background: none;
  border-left: none;
  border-right: none;
  border-top: none;
  border-radius: 0;
  color: inherit;
  font: inherit;
}

/* Only rows that actually do something on click get the affordance. */
.ui-row:is(button, a) {
  cursor: pointer;
}

.ui-row:is(button, a):hover {
  background: var(--color-primarylightest);
}

/* min-width: 0 is what lets the ellipsis below work: without it a grid item
   refuses to shrink below its content and the long text overflows the row. */
.ui-row__main {
  min-width: 0;
  display: grid;
  gap: var(--space-static-2);
}

.ui-row__name,
.ui-row__detail {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

.ui-row__detail {
  color: var(--color-primarydark);
  font-size: var(--font-size-xs);
}

.ui-row__chips {
  display: flex;
  gap: var(--space-static-4);
  flex-wrap: wrap;
}

/* Tabular figures so decimal points line up down the column, which is the one
   thing a real table gave for free and a grid does not. */
.ui-row__value {
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  align-self: start;
  display: grid;
  justify-items: end;
  gap: var(--space-static-2);
}

.ui-row__sub {
  color: var(--color-primarydark);
  font-size: var(--font-size-xs);
}

/* No align-self: the row is `align-items: baseline`, so a control here lines up
   with the name and the value rather than floating in the middle of a row whose
   height is set by however many lines the detail and chips took. */
.ui-row__action {
  white-space: nowrap;
}

/* ── chips ───────────────────────────────────────────────────────────────────
   A small inline label on a row. Only the base is shared: status meanings
   belong to the app, so modifiers live in each app's own stylesheet. */

.ui-chip {
  display: inline-block;
  padding: var(--space-static-2) var(--space-static-8);
  border-radius: var(--border-radius);
  background: var(--color-primarylightest);
  font-size: var(--font-size-xs);
  white-space: nowrap;
}
