{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "appearance",
  "title": "Appearance",
  "description": "Choose the look: scheme, key and type pair as three lists of picks, and the answer written back as one sentence, “Cotton, in ultramarine, set in Archivo and Bodoni.”, which the night toggle finishes. The new page opens as a circle from whatever you touched.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/mode-toggle.json",
    "https://thedirectors.agency/ui/r/picks.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/appearance.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ModeToggle } from \"@/components/ui/mode-toggle\"\nimport { Pick, PickDescription, Picks, PickTitle } from \"@/components/ui/picks\"\n\n/** The four switches on <html>. Each is optional: no scheme is cotton, no key is the scheme's own, no pair is parma. */\ntype AppearanceValue = { mode?: string; scheme?: string; key?: string; pair?: string }\n\n/** Where the choice is kept: one JSON object, the four switches together. */\nconst APPEARANCE_KEY = \"0nlytype-theme\"\n\nconst SCHEMES = [\"cotton\", \"blueprint\", \"statue\", \"silence\", \"riso\"]\n/** Each scheme's own accent, by name, for the sentence. */\nconst OWN: Record<string, string> = { cotton: \"ultramarine\", blueprint: \"orange\", statue: \"gold\", silence: \"cobalt\", riso: \"magenta\" }\nconst KEYS = [\"ultramarine\", \"viridian\", \"ember\", \"violet\"]\nconst PAIRS = [\n  { value: \"parma\", note: \"Archivo, Bodoni Moda\", words: \"Archivo and Bodoni\" },\n  { value: \"press\", note: \"Schibsted Grotesk, Newsreader\", words: \"Schibsted and Newsreader\" },\n  { value: \"paris\", note: \"Instrument Sans, EB Garamond\", words: \"Instrument and Garamond\" },\n  { value: \"salon\", note: \"Bricolage Grotesque, Cormorant\", words: \"Bricolage and Cormorant\" },\n]\n\n// A pair's faces arrive only when the page first asks for them, so a new pair would open with the old letters and\n// change again under the reader once its fonts landed. Pointing at the pairs fetches them; choosing one waits a moment\n// for its faces before the page turns.\nconst WAIT = 400\n/** The pair's two faces as font strings: the voice upright, the expression in its italic. */\nfunction faces(pair = \"parma\") {\n  const note = PAIRS.find((p) => p.value === pair)?.note\n  if (!note) return []\n  const [voice, expression] = note.split(\", \")\n  return [`400 1em \"${voice}\"`, `italic 400 1em \"${expression}\"`]\n}\n/** Starts fetching every pair's faces, so choosing one later need not wait. */\nfunction warmPairs() {\n  for (const p of PAIRS) for (const f of faces(p.value)) document.fonts.load(f).catch(() => {})\n}\n\n// The choice lives on <html>; everything reads it from there, so a head script, the page and every control agree.\n// A choice still waiting for its faces is shown at once, so the control answers the press while the page waits.\nconst ATTRS = [\"data-mode\", \"data-scheme\", \"data-key\", \"data-pair\"]\nlet wanted: AppearanceValue | null = null\nconst waiting = new Set<() => void>()\nfunction subscribe(changed: () => void) {\n  const watch = new MutationObserver(changed)\n  watch.observe(document.documentElement, { attributes: true, attributeFilter: ATTRS })\n  waiting.add(changed)\n  return () => {\n    watch.disconnect()\n    waiting.delete(changed)\n  }\n}\nfunction want(next: AppearanceValue | null) {\n  wanted = next\n  for (const changed of waiting) changed()\n}\nconst snapshot = () => wanted\n  ? [wanted.mode, wanted.scheme, wanted.key, wanted.pair].map((v) => v ?? \"\").join(\" \")\n  : ATTRS.map((a) => document.documentElement.getAttribute(a) ?? \"\").join(\" \")\nconst parse = (s: string): AppearanceValue => {\n  const [mode, scheme, key, pair] = s.split(\" \").map((v) => v || undefined)\n  return { mode, scheme, key, pair }\n}\n\n// A second change made while one waits builds on it (the hook reads the waiting choice), and only the last is drawn.\nlet turn = 0\n\n/**\n * Put a choice on <html> and keep it. The new page opens as a circle from the control that changed it: the one given,\n * else the focused one. Reduced motion, or a browser without view transitions, changes it at once. A new pair waits\n * up to WAIT for its faces, so the page opens already set in them.\n */\nfunction applyAppearance(next: AppearanceValue, from?: Element | null) {\n  const root = document.documentElement\n  const at = from ?? (document.activeElement === document.body ? null : document.activeElement)\n  if (at) {\n    const r = at.getBoundingClientRect()\n    root.style.setProperty(\"--ot-appearance-x\", `${r.left + r.width / 2}px`)\n    root.style.setProperty(\"--ot-appearance-y\", `${r.top + r.height / 2}px`)\n  } else {\n    root.style.removeProperty(\"--ot-appearance-x\")\n    root.style.removeProperty(\"--ot-appearance-y\")\n  }\n  const apply = () => {\n    for (const k of [\"mode\", \"scheme\", \"key\", \"pair\"] as const) {\n      const v = next[k]\n      if (v && !(k === \"scheme\" && v === \"cotton\") && !(k === \"pair\" && v === \"parma\")) root.dataset[k] = v\n      else delete root.dataset[k]\n    }\n    if (wanted === next) want(null)\n  }\n  const still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  // A control can ask for its own scene change (the mode toggle's data-scene): it becomes the transition's type.\n  const scene = at?.getAttribute(\"data-scene\")\n  const mine = ++turn\n  const go = () => {\n    if (mine !== turn) return\n    if (still || !(\"startViewTransition\" in document)) apply()\n    else if (scene) {\n      try { document.startViewTransition({ update: apply, types: [scene] }) }\n      catch { document.startViewTransition(apply) } // ponytail: browsers without types get the house circle\n    } else document.startViewTransition(apply)\n  }\n  // Faces already here (or a pair the page has no faces for) turn the page at once.\n  const missing = faces(next.pair).filter((f) => !document.fonts.check(f))\n  if (!missing.length) go()\n  else {\n    want(next)\n    const loaded = Promise.all(missing.map((f) => document.fonts.load(f))).catch(() => {})\n    void Promise.race([loaded, new Promise((done) => setTimeout(done, WAIT))]).then(go)\n  }\n  try { localStorage.setItem(APPEARANCE_KEY, JSON.stringify(next)) } catch {} // private mode: the choice lasts the visit\n}\n\n/** The appearance on <html>, and a setter that takes a patch and the control it came from. */\nfunction useAppearance() {\n  const now = parse(React.useSyncExternalStore(subscribe, snapshot, () => \"\"))\n  const set = (patch: AppearanceValue, from?: Element | null) => applyAppearance({ ...now, ...patch }, from)\n  return [now, set] as const\n}\n\n/** The choice as one sentence: \"Cotton, in ultramarine, set in Archivo and Bodoni.\" Yours in the italic. */\nfunction sentence({ scheme = \"cotton\", key, pair = \"parma\" }: AppearanceValue) {\n  const words = PAIRS.find((p) => p.value === pair)?.words ?? pair\n  return { scheme: scheme[0].toUpperCase() + scheme.slice(1), key: key ?? OWN[scheme] ?? \"its own colour\", pair: words }\n}\n\ntype AppearanceProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** The appearance, when you hold it. Leave it out and the control reads and writes <html> and keeps the choice. */\n  value?: AppearanceValue\n  /** Called with the whole appearance after every change, and the element that made it. */\n  onValueChange?: (value: AppearanceValue, from: Element | null) => void\n}\n\n/**\n * Choose the look: scheme, key and pair as three lists of picks, and the answer written back above them as one\n * sentence, \"Cotton, in ultramarine, set in Archivo and Bodoni.\", which the night toggle finishes: \"Read by light.\"\n * Yours in the italic. On its own it writes the four switches on <html>, keeps them under 0nlytype-theme, and the new page\n * opens as a circle from whatever you touched.\n */\nfunction Appearance({ value, onValueChange, className, ...props }: AppearanceProps) {\n  const [page, setPage] = useAppearance()\n  const now = value ?? page\n  const change = (patch: AppearanceValue, from: Element | null = null) => {\n    if (value === undefined) setPage(patch, from)\n    onValueChange?.({ ...now, ...patch }, from)\n  }\n  const said = sentence(now)\n  const scheme = now.scheme ?? \"cotton\"\n  const id = React.useId()\n  // One accent in view: only the list you're in (or were last in) marks its choice with the accent dot.\n  const [at, setAt] = React.useState<\"scheme\" | \"key\" | \"pair\">(\"scheme\")\n  const here = (list: typeof at) => ({ \"data-here\": at === list ? \"\" : undefined, onFocus: () => setAt(list) })\n\n  return (\n    <div data-slot=\"appearance\" className={cn(\"ot-appearance\", className)} {...props}>\n      <p className=\"ot-appearance-answer\">\n        <output htmlFor={`${id}-scheme ${id}-key ${id}-pair`}>\n          <span className=\"ot-yours\">{said.scheme}</span>, in <span className=\"ot-yours\">{said.key}</span>, set in <span className=\"ot-yours\">{said.pair}</span>.\n        </output>{\" \"}\n        <ModeToggle variant=\"sentence\" mode={now.mode === \"nocturne\" ? \"nocturne\" : \"day\"} onModeChange={(mode, e) => change({ mode }, e.currentTarget)} />\n      </p>\n      <div className=\"ot-appearance-picks\">\n        <Picks id={`${id}-scheme`} {...here(\"scheme\")} legend=\"Scheme\" value={scheme} onValueChange={(v) => change({ scheme: v })}>\n          {SCHEMES.map((s) => <Pick key={s} value={s}>{s}</Pick>)}\n        </Picks>\n        <Picks id={`${id}-key`} {...here(\"key\")} legend=\"Key\" value={now.key ?? \"\"} onValueChange={(v) => change({ key: v || undefined })}>\n          <Pick value=\"\">\n            <PickTitle>its own</PickTitle>\n            <PickDescription>{OWN[scheme] ?? \"the scheme's\"}</PickDescription>\n          </Pick>\n          {KEYS.map((k) => <Pick key={k} value={k}>{k}</Pick>)}\n        </Picks>\n        <Picks id={`${id}-pair`} {...here(\"pair\")} onFocus={() => { setAt(\"pair\"); warmPairs() }} onPointerEnter={warmPairs} legend=\"Pair\" value={now.pair ?? \"parma\"} onValueChange={(v) => change({ pair: v })}>\n          {PAIRS.map((p) => (\n            <Pick key={p.value} value={p.value}>\n              <PickTitle>{p.value}</PickTitle>\n              <PickDescription>{p.note}</PickDescription>\n            </Pick>\n          ))}\n        </Picks>\n      </div>\n    </div>\n  )\n}\n\nexport { Appearance, useAppearance, applyAppearance, warmPairs, APPEARANCE_KEY, SCHEMES, KEYS, PAIRS, type AppearanceProps, type AppearanceValue }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/appearance.css",
      "content": "@layer components {\n  /* ── ot-appearance ── choose the look. Three lists of picks, and above them the answer written back as one sentence,\n     \"Cotton, in ultramarine, set in Archivo and Bodoni.\", the night toggle finishing it: \"Read by light.\" Ours in roman,\n     the words you chose in the italic, the way \"Healthy habits → for creatives\" runs the voice into the italic on one\n     line of thought. */\n  .ot-appearance { container-type: inline-size; display: grid; gap: var(--ot-space-6); min-width: 0; }\n  .ot-appearance-answer {\n    margin: 0; max-width: 24ch; text-wrap: balance;\n    font-size: var(--ot-mp); line-height: var(--ot-mp-lh); letter-spacing: var(--ot-mp-tr); color: var(--ot-graphite);\n  }\n  .ot-appearance-answer .ot-yours { color: var(--ot-ink); }\n  /* The toggle reads as the sentence's second clause: on the line's baseline, in the same voice. */\n  .ot-appearance-answer .ot-mode { vertical-align: baseline; padding-block: 0; margin-block: 0; }\n  .ot-appearance-picks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ot-space-6) var(--ot-space-5); align-items: start; }\n  .ot-appearance-picks > :last-child { grid-column: 1 / -1; }\n  .ot-appearance-picks .ot-picks { gap: var(--ot-space-3); }\n  .ot-appearance-picks .ot-picks > label { font-size: var(--ot-p); line-height: var(--ot-p-lh); letter-spacing: var(--ot-p-tr); }\n  /* One accent in view: the list you're in (or were last in, data-here) keeps the accent dot; the others set theirs in ink,\n     their choice still read by the italic title beside it. */\n  .ot-appearance-picks > .ot-picks:not([data-here]) > .ot-picks-dot,\n  .ot-appearance-picks > .ot-picks:not([data-here]):not([data-placed]) > label:has(input:checked)::before { background: var(--ot-ink); }\n  .ot-appearance-picks .ot-pick-description { font-size: var(--ot-pp); line-height: var(--ot-pp-lh); letter-spacing: var(--ot-pp-tr); }\n  /* With room, the three questions stand side by side. */\n  @container (min-width: 36rem) {\n    .ot-appearance-answer { max-width: 30ch; font-size: var(--ot-mf); line-height: var(--ot-mf-lh); letter-spacing: var(--ot-mf-tr); }\n    .ot-appearance-picks { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n    .ot-appearance-picks > :last-child { grid-column: auto; }\n  }\n\n  /* A change opens the new page as a circle from the control that made it (applyAppearance sets the centre). It\n     arrives (exhale): the circle is wide at once and slows as it fills, so the answer is seen as the press lands. */\n  ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; }\n  /* Under the circle the colours are already new, so the body's own colour fade is off. Left on, every letter on the page\n     inherits a colour still on its way, and the page was restyled whole on every frame of the circle. */\n  :root:active-view-transition body { transition: none; }\n  ::view-transition-new(root) { animation: ot-appearance-open var(--ot-andante) var(--ot-exhale); }\n  @keyframes ot-appearance-open {\n    from { clip-path: circle(0 at var(--ot-appearance-x, 50%) var(--ot-appearance-y, 0)); }\n    to { clip-path: circle(150vmax at var(--ot-appearance-x, 50%) var(--ot-appearance-y, 0)); }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/appearance.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/appearance.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "hook",
    "contract": "ot-appearance"
  },
  "type": "registry:ui"
}
