mirror of
https://github.com/zoo-apps/zoolabs.io.git
synced 2026-10-30 14:56:27 +00:00
The clip is the page rather than a picture on it, and the conversation was sitting in the middle of it — a column of type over the animal it exists to show. On a wide screen the column is now on the right and Blue swims in the two thirds nobody is typing over. It moves once, on the first question: before anyone speaks it sits wider and further in, because the greeting is the only content on the page and a narrow ribbon at the edge is not an opening; on the first question it settles to its talking width at the edge. Two concrete lengths, so it animates rather than jumping. A phone has no two thirds to spare, so below 60rem nothing changes. Blue also answers for the foundation now, not only for the ocean — on the front door that is most of what people ask. The counts come from the surface Blue is standing on: zoo.ngo counts its own papers, proposals and open models from the sources, and hands them over. Blue states those numbers and guesses none. They arrive as three integers in a plausible range or they do not arrive at all, because anything a URL can carry reaches a prompt.
487 lines
17 KiB
CSS
487 lines
17 KiB
CSS
/* The design tokens are NOT authored here. `@hanzo/design` is the one place the
|
||
colour ladder, type scale, spacing ramp, radii, elevation and motion are
|
||
defined, and every surface in the fleet reads that same file. Carrying a local
|
||
copy of the semantic block is how two sites drift a shade apart with nobody
|
||
editing anything.
|
||
|
||
Zen comes from `@hanzo/font`, which is where the family is authored. It is
|
||
the FIRST rule in the file on purpose: CSS drops an @import that does not
|
||
precede every other rule, so a face declared later in a flattened sheet never
|
||
loads. */
|
||
@import '@hanzo/font/css';
|
||
@import '@hanzo/design/tokens/colors.css';
|
||
@import '@hanzo/design/tokens/typography.css';
|
||
@import '@hanzo/design/tokens/spacing.css';
|
||
@import '@hanzo/design/tokens/radius.css';
|
||
@import '@hanzo/design/tokens/elevation.css';
|
||
@import '@hanzo/design/tokens/motion.css';
|
||
@import '@hanzo/design/tokens/z.css';
|
||
|
||
/* base.css is the only group with ELEMENT selectors (`a`, `h1`, `body`, …), and
|
||
they belong in a layer so that every app rule here outranks them whatever its
|
||
specificity. The file already opens `@layer base { … }` itself, so it arrives
|
||
layered and this import must NOT restate it: webpack renders
|
||
`@import … layer(base)` as `@media layer(base){…}`, which is not a media query
|
||
any browser can match, so the whole file was dead in the built site. Measured:
|
||
`body` inherited the UA's Times New Roman at 16px, `a` kept the UA underline
|
||
on all 95 research links, and CSS.getMatchedStylesForNode returned nothing
|
||
from base for <body>. */
|
||
@import '@hanzo/design/tokens/base.css';
|
||
|
||
/* border-box must be UNLAYERED: gui's primitives state box-sizing on their own
|
||
elements unlayered, so a layered reset loses to them and every gui View falls
|
||
back to content-box — a width:100% box then adds padding outside the measure
|
||
and overflows its parent. */
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* The one place a typeface is named on this site, and it is named by REFERENCE.
|
||
@hanzo/design reads `--font-sans` and multiplies it into every ramp and into
|
||
gui's `--f-family`; @hanzo/font publishes the family under its own name. Bind
|
||
one to the other and nothing in any component has to spell a face. Undeclared,
|
||
`var(--font-sans, …)` fell through to the generic tail — the faces loaded and
|
||
nothing asked for them.
|
||
|
||
Left as a default rather than forced: `--font-sans` is one of the knobs
|
||
@hanzo/appearance writes INLINE on <html>, so a reader who asks for the system
|
||
face, a serif or mono outranks this, and asking for the default again removes
|
||
the property and lands back here. */
|
||
:root {
|
||
--font-sans: var(--font-zen-sans), ui-sans-serif, system-ui, sans-serif;
|
||
--font-display: var(--font-sans);
|
||
--font-mono: var(--font-zen-mono), ui-monospace, SFMono-Regular, monospace;
|
||
}
|
||
|
||
:root {
|
||
/* The five hues of the Zoo mark, and the ink and paper they sit on. They live
|
||
here rather than in TypeScript because a colour a stylesheet cannot see is a
|
||
colour @hanzo/appearance cannot reach: `lib/brand.ts` now names them by
|
||
reference for the gui props that need a value. One home, two readers.
|
||
|
||
The mark's cyan also fills the design system's accent roles, so a reader who
|
||
picks an accent moves the send key rather than nothing. The other six are
|
||
the foundation's identity and are passed where they are meant — the active
|
||
tab, a card's spine — never as "the accent". */
|
||
--ink: #000000;
|
||
--paper: #f5e8c8;
|
||
/* The deep. The third ground, and the one the door is painted in — the lab is
|
||
ink on paper, the door is light on this. It was a literal inside `.deep`,
|
||
which made it a colour only that one class could reach; floating chrome
|
||
needs it too, and a second copy of a ground is how two dark rooms end up a
|
||
shade apart. */
|
||
--night: #06202c;
|
||
--magenta: #ea018e;
|
||
--green: #00a652;
|
||
--blue: #2e3192;
|
||
--cyan: #01acf1;
|
||
--yellow: #fcf006;
|
||
--red: #ed1c24;
|
||
--primary: var(--cyan);
|
||
--accent: var(--primary);
|
||
|
||
/* The ramp knobs are NOT here. `--type-scale` and `--type-ratio` are the
|
||
READER's dial, and this site's own register is the install layer that dial
|
||
turns from — `lib/look.ts`, applied before first paint. There is one
|
||
`--type-scale` and the panel writes it absolutely, so a stylesheet that
|
||
sets it has spent the reader's setting on the brand's size: the panel then
|
||
shows "M / Default" selected while the page is at 8/7, and pressing the step
|
||
that is already lit drops the anchor rung from 16px to 14px.
|
||
|
||
`--font-sans` below stays, and the difference is not a preference. The face
|
||
has a real absent state — the panel's "Default" writes nothing and the
|
||
stylesheet answers — so a default there means THIS SITE's face. The type
|
||
steps are absolute numbers with no such state. */
|
||
|
||
/* Display tracking, under the name zoo.ngo already gives it. --tracking-tight
|
||
(−0.025em) is right down the middle of the ramp and too loose at the top of
|
||
it: at 64px it leaves 1.6px of air between letterforms that should be
|
||
touching. −0.035em is −2.24px there. */
|
||
--tracking-display: -0.035em;
|
||
}
|
||
|
||
/* ─── type roles ───
|
||
Stated on the ELEMENT, so a page says what a thing IS and this file says what
|
||
it looks like. It is also the only place that can say it: gui carries a
|
||
letterSpacing of its own on every display rung, as an atomic class in a sheet
|
||
it injects at render, so a heading drawn by gui answers to the fleet's tracking
|
||
and never to this site's.
|
||
|
||
Colour is inherited rather than named, because this site has two rooms: the
|
||
heading is ink on the paper page and white over the video. */
|
||
h1,
|
||
h2 {
|
||
color: inherit;
|
||
font-weight: var(--weight-bold);
|
||
text-wrap: balance;
|
||
}
|
||
|
||
h1 {
|
||
font-size: var(--text-4xl);
|
||
line-height: var(--leading-4xl);
|
||
letter-spacing: var(--tracking-display);
|
||
}
|
||
|
||
/* A page head has room from `md` that a phone does not, and on the door the
|
||
greeting shares the fold with the composer. */
|
||
@media (min-width: 48rem) {
|
||
h1 {
|
||
font-size: var(--text-5xl);
|
||
line-height: var(--leading-5xl);
|
||
}
|
||
}
|
||
|
||
/* No leading of its own: the ramp pairs --leading-xl with this rung for a
|
||
paragraph, and on a two-line card title it opens a gap you can drive through.
|
||
Inherited from the page it lands at 1.3, which is what a title wants. */
|
||
h2 {
|
||
font-size: var(--text-xl);
|
||
}
|
||
|
||
/* A link inside a heading IS the heading; it is a link only because you can
|
||
press it. Said here because base.css colours every `a`, and a rule beats
|
||
inheritance however specific the rule is not. */
|
||
h1 a,
|
||
h2 a {
|
||
color: inherit;
|
||
}
|
||
|
||
/* The lockup, as one shape: the org at full weight, what it does at light.
|
||
Display type, so it takes the display tracking with the headings. */
|
||
.wordmark {
|
||
font-size: var(--text-2xl);
|
||
font-weight: var(--weight-bold);
|
||
letter-spacing: var(--tracking-display);
|
||
user-select: none;
|
||
}
|
||
|
||
.wordmark span {
|
||
font-weight: 300;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
color: var(--ink);
|
||
background-color: var(--paper);
|
||
/* Iridescent wash: the Zoo mark's five hues dialled down to a pastel tint, so
|
||
the page shares the logo's family without competing with the buttons. */
|
||
background-image:
|
||
radial-gradient(at 14% 16%, #fff3b8 0%, transparent 38%),
|
||
radial-gradient(at 86% 12%, #ffc6e6 0%, transparent 40%),
|
||
radial-gradient(at 92% 62%, #b8dff8 0%, transparent 42%),
|
||
radial-gradient(at 28% 82%, #b8e8c8 0%, transparent 44%),
|
||
radial-gradient(at 62% 50%, #c4c8ec 0%, transparent 44%),
|
||
radial-gradient(at 8% 92%, #ffc6e6 0%, transparent 40%),
|
||
linear-gradient(135deg, #fff3d8 0%, #ffd9ec 28%, #d8dcef 55%, #cfeede 80%, #d6ecf8 100%);
|
||
background-attachment: fixed;
|
||
background-size: cover;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
animation-duration: 0.01ms !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
}
|
||
|
||
/* ─── the night ───
|
||
The dark room, as a set of ROLES rather than a place. @hanzo/design names
|
||
every job a colour can hold and scopes the light half of them to `.light`,
|
||
which this site wears on <html>; anything drawn on the deep is drawn on the
|
||
wrong half of that. It showed: @hanzo/appearance's panel sets its labels in
|
||
--foreground, resolved #0a0a0a here, so the shared control that explains the
|
||
design system was the one thing on the door that could not be read on it.
|
||
|
||
Stated once and worn by whatever is in the room — the door below, and the
|
||
floating chrome in components/Look, which is over the door as often as not.
|
||
Custom properties inherit, so a class on the element beats the `.light` on
|
||
<html> for its whole subtree without out-specifying anything. Every value is
|
||
a rung of the design system's own opacity ladder, so the surfaces inside lift
|
||
by the steps the fleet lifts by instead of by numbers chosen here.
|
||
|
||
The GROUND is not here, and that is the one thing this class does not decide.
|
||
gui paints a component's own background as an atomic class it injects at
|
||
render, which a rule in this file cannot outrank — so each wearer lays its
|
||
own: the door below as a full-bleed fixed layer, the panel as a 320px card.
|
||
`--night` is the value both of them ask for, so there is still one dark.
|
||
|
||
--ink is deliberately absent for the same reason from the other direction:
|
||
@hanzo/gui publishes one at `:root.t_light` and a class cannot outrank that
|
||
on <html>. It stays the hard edge this site draws every panel with, which is
|
||
what it is for. */
|
||
.night,
|
||
.deep {
|
||
color: #ffffff;
|
||
|
||
--foreground: #ffffff;
|
||
--muted-foreground: var(--white-78);
|
||
--secondary: var(--white-08);
|
||
--surface-card: var(--white-14);
|
||
/* A filled accent carries the night as its ink — which is what the door's own
|
||
send key already does by hand. */
|
||
--primary-foreground: var(--night);
|
||
}
|
||
|
||
/* The deep — the logged-out door at /.
|
||
A different room from the lab: the clip is the page rather than a picture on
|
||
it, so everything here sits over video and the type is light on dark. The
|
||
lab, and every other Zoo surface, stays on the paper ground above. It IS the
|
||
night, so it is listed with it above rather than repeating the ground. */
|
||
.deep {
|
||
position: fixed;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Two of these are stacked, and they trade places rather than one of them
|
||
changing what it plays — see components/Film.tsx. The dissolve is here
|
||
because it is how the page looks; which of the pair is in front is state,
|
||
and stays in the component. */
|
||
.deep-film {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
transition: opacity 700ms ease-in-out;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.deep-film {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
/* Darkened toward the bottom, where the question box and the type sit. */
|
||
.deep-veil {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
linear-gradient(to bottom, rgba(3, 20, 30, 0.35) 0%, rgba(3, 20, 30, 0.15) 35%, rgba(3, 20, 30, 0.8) 100%);
|
||
}
|
||
|
||
.deep-stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
color: #fff;
|
||
/* The clip behind this is different every few seconds and some frames are
|
||
nearly white, so the type carries its own contrast rather than trusting
|
||
the veil to provide it. */
|
||
text-shadow: 0 1px 14px rgba(0, 20, 30, 0.75), 0 0 3px rgba(0, 20, 30, 0.5);
|
||
}
|
||
|
||
/* Sizes here are RUNGS, never numbers. Every one is a `--text-*` step, so the
|
||
reader's own type setting reaches the door as surely as it reaches a heading;
|
||
a literal rem would be the one place the knob could not move. The door reads
|
||
a rung apart from the page because it is a conversation and not a dense
|
||
screen: the field at `xl`, an answer at `lg`, a chip at `sm`, the line under
|
||
the field at `xs`. */
|
||
.deep-chip {
|
||
padding: 0.5rem 0.9rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||
border-radius: var(--radius-full);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
backdrop-filter: blur(8px);
|
||
color: #fff;
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
|
||
.deep-chip:hover {
|
||
background: rgba(255, 255, 255, 0.16);
|
||
border-color: rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
/* The measure is the design system's, not a number: `--container-prose` is the
|
||
width prose reads at across the estate, and it is what @hanzo/appearance moves
|
||
when a reader asks for a narrower or wider page. */
|
||
/* THE WHALE KEEPS THE LEFT. On a wide screen the conversation is a column on
|
||
the right and the clip is not covered: Blue swims in the two thirds nobody is
|
||
typing over, which is the whole reason the clip is the page. A phone has no
|
||
two thirds to spare, so below 60rem the column is the width of the screen and
|
||
this rule does not apply.
|
||
|
||
The column also MOVES once. Before anyone speaks it sits wider and further in,
|
||
because the greeting is the page's only content and a narrow ribbon at the
|
||
edge is not an opening. On the first question it settles to its talking width
|
||
at the edge — one transition on two concrete lengths, so it animates rather
|
||
than jumping, and the whale is clear from the first frame either way. */
|
||
@media (min-width: 60rem) {
|
||
.deep-stage {
|
||
--talk: min(38vw, 30rem);
|
||
--sit: min(52vw, 40rem);
|
||
--edge: clamp(1.5rem, 4vw, 4rem);
|
||
}
|
||
|
||
.deep-middle,
|
||
.deep-ask-row {
|
||
width: var(--sit);
|
||
margin-left: auto;
|
||
margin-right: calc((100vw - var(--sit)) / 2);
|
||
transition: width 520ms cubic-bezier(0.22, 1, 0.36, 1),
|
||
margin-right 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
.talking .deep-middle,
|
||
.talking .deep-ask-row {
|
||
width: var(--talk);
|
||
margin-right: var(--edge);
|
||
}
|
||
|
||
/* The thread and the field are the column, so they stop asking for a measure
|
||
of their own — the column is the measure. */
|
||
.deep-thread,
|
||
.deep-ask {
|
||
max-width: none;
|
||
}
|
||
}
|
||
|
||
/* Between the bar and the ask, and it SHRINKS. A flex child sizes to its
|
||
content by default, so on a short window — a phone in landscape, or this page
|
||
in the height someone else's hero gives it — the greeting and the openers
|
||
spilled out of both ends and printed over the bar and over the composer.
|
||
Scrolling inside is what a room does when it is smaller than what is in it. */
|
||
.deep-middle {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* A SHORT ROOM. The door is normally a whole viewport; it is also a phone in
|
||
landscape, and the height someone else's hero gives it. Under about 34rem the
|
||
greeting has to give way to the thing a reader came to do — so the type comes
|
||
down, the spacing closes, and the third opener stands aside rather than
|
||
sitting behind the ask. Two questions are still an invitation. */
|
||
@media (max-height: 34rem) {
|
||
.deep-middle h1 {
|
||
font-size: var(--text-2xl);
|
||
line-height: 1.15;
|
||
}
|
||
|
||
.deep-middle {
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.deep-chip:nth-of-type(n + 3) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.deep-thread {
|
||
width: 100%;
|
||
max-width: var(--container-prose);
|
||
max-height: 58vh;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
padding-right: 0.25rem;
|
||
}
|
||
|
||
/* A turn takes ITS OWN SIDE. Blue's had neither an align-self nor a width, so
|
||
in a column flexbox it stretched the full thread while the reader's hugged
|
||
the right — one voice as a wall of text, the other as a bubble, which does
|
||
not read as two people talking. Both are pinned now, and both stop short of
|
||
the far edge so the shape of the exchange is visible at a glance. */
|
||
.deep-said {
|
||
align-self: flex-start;
|
||
max-width: 85%;
|
||
padding: 0.75rem 1rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||
border-radius: 14px;
|
||
background: rgba(4, 22, 32, 0.62);
|
||
backdrop-filter: blur(10px);
|
||
font-size: var(--text-lg);
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.deep-mine {
|
||
align-self: flex-end;
|
||
background: rgba(1, 172, 241, 0.28);
|
||
border-color: rgba(1, 172, 241, 0.5);
|
||
}
|
||
|
||
.deep-wrong {
|
||
border-color: rgba(237, 28, 36, 0.55);
|
||
background: rgba(60, 10, 14, 0.6);
|
||
}
|
||
|
||
.deep-ask {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
max-width: var(--container-prose);
|
||
padding: 0.45rem 0.45rem 0.45rem 1rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||
border-radius: var(--radius-full);
|
||
background: rgba(4, 22, 32, 0.7);
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
|
||
.deep-ask input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 0;
|
||
outline: none;
|
||
background: none;
|
||
color: #fff;
|
||
font: inherit;
|
||
/* After the shorthand, which resets size along with everything else. The field
|
||
is the page's one action and it is the rung that clears iOS Safari's
|
||
zoom-on-focus (anything under 16px buys it). */
|
||
font-size: var(--text-xl);
|
||
padding: 0.55rem 0;
|
||
}
|
||
|
||
.deep-ask input::placeholder {
|
||
color: rgba(255, 255, 255, 0.45);
|
||
}
|
||
|
||
.deep-ask button {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
width: 38px;
|
||
height: 38px;
|
||
border: 0;
|
||
border-radius: var(--radius-full);
|
||
background: var(--primary);
|
||
color: #04202c;
|
||
font-size: var(--text-xl);
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.deep-ask button:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
/* The speaker sits quieter than the send button: it is a setting, not the
|
||
action. It brightens when it is on, so the state reads at a glance and not
|
||
only from the icon. */
|
||
.deep-ask .deep-voice {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: rgba(255, 255, 255, 0.65);
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
|
||
.deep-ask .deep-voice:hover {
|
||
background: rgba(255, 255, 255, 0.18);
|
||
color: #fff;
|
||
}
|
||
|
||
.deep-ask .deep-voice[aria-pressed='true'] {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #04202c;
|
||
}
|