{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "book-3d",
  "title": "Book 3D",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "O item de catálogo como objeto: capa dura em WebGL com lombada curva, espessura vinda da contagem de páginas e praça. Cor derivada dos tokens da marca e do matiz da própria arte, sem asset baixado.",
  "dependencies": [
    "three@^0.170.0",
    "@react-three/fiber@^8.18.0",
    "@react-three/drei@^9.122.0"
  ],
  "devDependencies": [
    "@types/three@^0.170.0"
  ],
  "registryDependencies": [
    "utils",
    "@visant/cover-art"
  ],
  "files": [
    {
      "path": "registry/visant/lib/book-geometry.ts",
      "content": "/**\n * A geometria e o gesto de um livro de capa dura, fora do componente.\n *\n * Mora separado porque é a parte TESTÁVEL: um livro que muda de espessura entre\n * renders quebra a prateleira, e \"a inclinação parece errada\" não é coisa que se\n * audita olhando JSX de cena 3D.\n *\n * Nada de `Math.random`, nada de `Date`: dimensão sai da contagem de páginas,\n * inclinação sai do ponteiro. Zero cor — a cor vive em `book-material`.\n *\n * Unidade de mundo: ALTURA DO LIVRO = 1. Toda medida daqui é relativa a ela,\n * então trocar a escala da cena é mexer em um número só, na câmera.\n */\n\n/* ── Geometria ──────────────────────────────────────────────────────────── */\n\n/** Proporção de livro comercial (≈ 6×9 pol). Não é escolha estética: é o que o\n *  olho reconhece como livro em vez de caderno ou catálogo. */\nexport const RATIO = 0.68;\n\n/**\n * Espessura por página, em altura-relativo. Um livro de 229 mm com 300 páginas\n * de offset dá ~16 mm de miolo; 0.00026 × 300 × 229 mm ≈ 18 mm, contando capa.\n */\nconst PAPER_PER_PAGE = 0.00026;\n\n/** Piso e teto. Sem eles, um e-book de 40 páginas vira panfleto e um curso de\n *  900 vira tijolo — e os dois leem como defeito, não como informação. */\nconst MIN_THICKNESS = 0.05;\nconst MAX_THICKNESS = 0.17;\n\n/** Sem contagem de páginas, este é o livro \"de estante\" médio. */\nconst DEFAULT_PAGES = 240;\n\n/** Cartão da capa dura. É ele que cria o degrau na borda — ver `SQUARE`. */\nexport const BOARD = 0.007;\n\n/**\n * A \"praça\": o quanto a capa avança além do miolo nas três bordas livres.\n *\n * É o detalhe que separa livro de caixa texturizada. Numa capa dura real o\n * cartão sobra ~3 mm além do bloco de páginas, e é essa sombra fina em volta do\n * corte que o olho lê como \"isso abre\". Sem ela, qualquer render vira\n * paralelepípedo com uma foto colada.\n */\nexport const SQUARE = 0.014;\n\nexport type BookDimensions = {\n  /** Eixo X. Lombada em `-width/2`, corte em `+width/2`. */\n  width: number;\n  /** Eixo Y. Sempre 1 — é a unidade. */\n  height: number;\n  /** Eixo Z. Capa em `+thickness/2`. */\n  thickness: number;\n  /** Espessura só do bloco de páginas, já descontados os dois cartões. */\n  block: number;\n};\n\nexport function thicknessFor(pages?: number | null): number {\n  const p = pages && pages > 0 ? pages : DEFAULT_PAGES;\n  return Math.min(MAX_THICKNESS, Math.max(MIN_THICKNESS, p * PAPER_PER_PAGE));\n}\n\nexport function bookDimensions(pages?: number | null): BookDimensions {\n  const thickness = thicknessFor(pages);\n  return { width: RATIO, height: 1, thickness, block: thickness - BOARD * 2 };\n}\n\n/* ── Inclinação ─────────────────────────────────────────────────────────── */\n\n/**\n * Repouso: três-quartos, lombada à vista.\n *\n * Livro de frente é a capa achatada — é a imagem 2D que já existe, e pagar\n * WebGL pra chegar nela seria peso sem receita. O ângulo existe pra mostrar a\n * ÚNICA coisa que a versão 2D não mostra: que o objeto tem lado.\n *\n * `y` positivo gira a lombada (que fica em −X) na direção do observador.\n */\nexport const REST_Y = 0.38;\n\n/**\n * Inclinação de repouso no eixo X — quem revela a CABEÇA do miolo.\n *\n * ┌─ O SINAL, QUE JÁ FOI ERRADO DUAS VEZES ───────────────────────────────────┐\n * │ Uma face só é visível se o olho estiver do lado de fora do plano dela:    │\n * │ `n · (olho − p) > 0`. Pra tampa de cima, com a câmera abaixo do topo do   │\n * │ livro (y=0.5), isso dá NEGATIVO — não se vê a cabeça de jeito nenhum,     │\n * │ incline-se o livro pra onde for.                                          │\n * │                                                                            │\n * │ E `x` negativo, que parece \"baixar o topo\", faz o contrário do que o nome │\n * │ sugere: joga a normal do topo pra −z, ou seja, pra LONGE de quem olha.    │\n * │                                                                            │\n * │ Quem resolve é a CÂMERA subir acima de y=0.5. Com ela lá em cima, um `x`  │\n * │ levemente POSITIVO inclina o topo na direção do olho e abre a cabeça — é  │\n * │ o gesto de tombar uma caixa pra ver a tampa.                              │\n * └────────────────────────────────────────────────────────────────────────────┘\n *\n * 0.06 é pouco de propósito: acima disso o livro lê como tombando pra frente.\n */\nexport const REST_X = 0.06;\n\n/** Curso do hover, em radianos. Teto de propósito: passar disto mostra a\n *  contracapa, que é a face mais fraca do modelo (não tem arte, só tom). */\nconst SWING_Y = 0.34;\nconst SWING_X = 0.18;\n\nexport type Tilt = { rx: number; ry: number };\n\nexport const REST: Tilt = { rx: REST_X, ry: REST_Y };\n\n/**\n * Ponteiro normalizado (−1..1, origem no centro da peça) → rotação do grupo.\n *\n * `px` INVERTE: cursor à direita puxa a lombada pra fora, como se o dedo\n * girasse o livro pelo corte. O contrário — cursor à direita, lombada some — lê\n * como o objeto FUGINDO do cursor, e foi o primeiro descarte da bancada.\n *\n * `py` não inverte, e é o mesmo gesto: cursor na base empurra o pé e o topo vem\n * na direção do olho, mostrando mais cabeça. O objeto obedece ao dedo nos dois\n * eixos.\n */\nexport function tilt(px: number, py: number): Tilt {\n  const cx = Math.max(-1, Math.min(1, px));\n  const cy = Math.max(-1, Math.min(1, py));\n  return { ry: REST_Y - cx * SWING_Y, rx: REST_X + cy * SWING_X };\n}\n\n/**\n * Amortecimento independente de taxa de quadros.\n *\n * `atual + (alvo − atual) * k` — o lerp que todo mundo escreve — depende do\n * intervalo entre quadros: a 144 Hz o objeto chega quase instantâneo e a 30 Hz\n * arrasta. Duas máquinas, duas animações diferentes. A exponencial mata isso:\n * `k` vira \"quanto da distância por SEGUNDO\", e o resultado é o mesmo em\n * qualquer monitor.\n *\n * O teto de `dt` existe porque aba em segundo plano devolve um delta gigante no\n * primeiro quadro de volta. Sem ele o livro TELEPORTA, e teleporte lê como\n * falha de render.\n */\nexport function damp(current: number, target: number, k: number, dt: number): number {\n  return current + (target - current) * (1 - Math.exp(-k * Math.min(dt, 0.1)));\n}\n\n/** Rigidez do retorno. Rápido o bastante pra parecer resposta, lento o bastante\n *  pra ter peso — livro não é botão. */\nexport const STIFFNESS = 7;\n\n/**\n * Câmera de foto de produto.\n *\n * `fov` longo (24) porque grande-angular deforma a capa e denuncia o render —\n * 24 mm equivalente é onde foto de produto vive. A ALTURA não é gosto: o topo\n * do livro está em y=0.5 e uma face só aparece pra quem está fora do plano\n * dela, então abaixo de 0.5 a cabeça do miolo não aparece em ângulo nenhum e a\n * praça — metade da razão de a malha ter quatro peças — nunca chega à tela.\n */\nexport const CAMERA = { position: [0, 0.55, 2.9] as const, fov: 24 };\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/visant/lib/book-material.ts",
      "content": "/**\n * A matéria do livro, derivada dos tokens da marca.\n *\n * ┌─ WEBGL NÃO LÊ CSS ────────────────────────────────────────────────────────┐\n * │ A tese da library é \"componente nunca tem cor própria: ele consome        │\n * │ `var(--brand)`\". Um material do three.js não pode fazer isso — não há     │\n * │ cascata dentro de uma cena WebGL, e `var(--card)` não significa nada pra  │\n * │ um `MeshStandardMaterial`.                                                │\n * │                                                                            │\n * │ A saída não é abrir exceção e escrever hex: é usar o PRÓPRIO NAVEGADOR    │\n * │ como resolvedor. Escreve-se a expressão de token num elemento             │\n * │ descartável, lê-se o `getComputedStyle` de volta, e o que chega é o rgb   │\n * │ exato que o CSS pintaria — inclusive através de `color-mix(in oklch, …)`, │\n * │ que ninguém quer reimplementar. Nenhuma cor literal existe neste arquivo, │\n * │ e não por disciplina: por construção.                                     │\n * └────────────────────────────────────────────────────────────────────────────┘\n *\n * ┌─ MATIZ VEM DA ARTE, VALOR VEM DA MARCA ───────────────────────────────────┐\n * │ Pegar a cor dominante de uma capa e pintar a lombada com ela é o caminho  │\n * │ óbvio e está errado: pinturas têm dominantes claras, e uma lombada bege   │\n * │ de luminância 0.65 numa interface escura não lê como livro, lê como       │\n * │ vazamento de CSS.                                                         │\n * │                                                                            │\n * │ Então só o MATIZ atravessa. A saturação entra amortecida e a luminância   │\n * │ é derivada do `--background` de quem instalou: marca escura → livro       │\n * │ escuro, marca clara → livro claro. O objeto pertence à sua arte e         │\n * │ continua pertencendo à tela.                                              │\n * └────────────────────────────────────────────────────────────────────────────┘\n */\n\nimport { coverLook, mix } from \"@/lib/cover-look\";\n\nexport type Rgb = { r: number; g: number; b: number };\nexport type Hsl = { h: number; s: number; l: number };\n\n/* ── O resolvedor ───────────────────────────────────────────────────────── */\n\n/**\n * Qualquer expressão de cor do CSS → rgb sRGB, em DUAS etapas.\n *\n * ┌─ POR QUE DUAS, E NÃO UMA ─────────────────────────────────────────────────┐\n * │ A primeira versão lia `getComputedStyle(el).color` e extraía os números    │\n * │ com regex, assumindo `rgb(r, g, b)`. Isso está errado desde que os         │\n * │ navegadores passaram a PRESERVAR o espaço de cor: um token declarado como  │\n * │ `oklch(0.99 0.002 17)` volta como `oklch(0.99 0.002 17)`, e a regex lia    │\n * │ 0.99 / 0.002 / 17 como se fossem canais 0–255. O resultado era preto quase │\n * │ puro onde deveria haver branco.                                            │\n * │                                                                            │\n * │ E falhava CALADO: a palheta inteira saía plausível — escura, monocromática │\n * │ — só que da cor errada. Nada quebrava. Foi preciso medir o token na página │\n * │ pra ver. É a pior classe de bug de cor que existe, porque o olho aceita.   │\n * │                                                                            │\n * │ `canvas.fillStyle` também preserva `oklch`, então normalizar por ele não   │\n * │ resolve. O único ponto do pipeline que OBRIGA sRGB é o rasterizador: pinta │\n * │ 1×1 e lê o pixel. Funciona pra `oklch`, `lab`, `color(display-p3 …)` e     │\n * │ pro que a CSS inventar depois, porque o que se lê é o que a tela mostra.   │\n * └────────────────────────────────────────────────────────────────────────────┘\n *\n * Etapa 1 resolve `var()` e `color-mix()` (que o canvas não entende) num\n * elemento fora do fluxo; etapa 2 rasteriza. As duas forçam layout/pintura, e é\n * por isso que isto roda uma vez por livro, na montagem, nunca por quadro.\n *\n * Devolve `null` fora do navegador e quando o token não existe: quem chama\n * decide o que fazer, e a decisão nunca é \"usa este hex aqui\".\n */\nexport function resolveRgb(css: string): Rgb | null {\n  if (typeof document === \"undefined\") return null;\n\n  const probe = document.createElement(\"span\");\n  probe.style.cssText = \"position:absolute;visibility:hidden;pointer-events:none\";\n  probe.style.color = css;\n  document.body.appendChild(probe);\n  const concrete = getComputedStyle(probe).color;\n  probe.remove();\n  if (!concrete) return null;\n\n  try {\n    const c = document.createElement(\"canvas\");\n    c.width = 1;\n    c.height = 1;\n    const ctx = c.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) return null;\n    /* Sem sentinela de \"cor inválida\" de propósito: `getComputedStyle` já\n       devolve sempre uma cor válida. Token que NÃO EXISTE cai na cor herdada da\n       página, e essa degradação é sã — o objeto herda a tinta de quem o\n       hospeda, em vez de sumir. */\n    ctx.fillStyle = concrete;\n    ctx.clearRect(0, 0, 1, 1);\n    ctx.fillRect(0, 0, 1, 1);\n    const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;\n    if (a === 0) return null;\n    return { r, g, b };\n  } catch {\n    return null;\n  }\n}\n\n/** Token do documento, ou `''` se ele não existir. */\nexport function token(name: string): string {\n  if (typeof document === \"undefined\") return \"\";\n  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n}\n\n/* ── Conversão ──────────────────────────────────────────────────────────── */\n\nexport function rgbToHsl({ r, g, b }: Rgb): Hsl {\n  const rr = r / 255;\n  const gg = g / 255;\n  const bb = b / 255;\n  const max = Math.max(rr, gg, bb);\n  const min = Math.min(rr, gg, bb);\n  const l = (max + min) / 2;\n  if (max === min) return { h: 0, s: 0, l };\n  const d = max - min;\n  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n  const h =\n    max === rr\n      ? ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6\n      : max === gg\n        ? ((bb - rr) / d + 2) / 6\n        : ((rr - gg) / d + 4) / 6;\n  return { h, s, l };\n}\n\nfunction channel(p: number, q: number, t: number): number {\n  let tt = t;\n  if (tt < 0) tt += 1;\n  if (tt > 1) tt -= 1;\n  if (tt < 1 / 6) return p + (q - p) * 6 * tt;\n  if (tt < 1 / 2) return q;\n  if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;\n  return p;\n}\n\n/** Sempre `rgb(…)`, nunca hex — o portão de cor do registry lê o CÓDIGO. */\nexport function hslToCss({ h, s, l }: Hsl): string {\n  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n  const p = 2 * l - q;\n  const to = (t: number) => Math.round((s === 0 ? l : channel(p, q, t)) * 255);\n  return `rgb(${to(h + 1 / 3)}, ${to(h)}, ${to(h - 1 / 3)})`;\n}\n\nexport const rgbToCss = ({ r, g, b }: Rgb) => `rgb(${r}, ${g}, ${b})`;\n\n/* ── A régua, derivada do --background ──────────────────────────────────── */\n\n/**\n * Degraus de luminância a partir da superfície da marca, e o SINAL que decide\n * pra que lado eles crescem.\n *\n * Numa marca escura o livro tem que ser mais claro que a página pra ter\n * silhueta; numa marca clara, mais escuro. Um único `sign` resolve os dois\n * casos e é a razão de a peça não precisar de uma variante \"dark\".\n *\n * O papel dá um salto grande de propósito: o bloco de páginas é o único\n * material do objeto que não é a capa, e se ele ficar perto do tom do cartão o\n * livro vira um bloco só.\n */\nconst STEP = { spine: 0.15, dark: 0.035, board: 0.09, back: 0.055, paper: 0.72 };\n\n/** Nem branco estourado nem preto chapado: os dois matam o degrau da praça. */\nconst clampL = (l: number) => Math.min(0.94, Math.max(0.05, l));\n\n/** Teto de saturação. Livro tingido, não livro pintado. */\nconst SAT_CEILING = 0.34;\n\nexport type BookPalette = {\n  /** Topo da lombada e da contracapa. */\n  cover: string;\n  /** Pé da lombada, e o par escuro de todo gradiente. */\n  coverDark: string;\n  /** Cartão da capa — o degrau que aparece na praça e na quina. */\n  board: string;\n  /** Fundo da contracapa. */\n  back: string;\n  /** Corte do miolo. */\n  paper: string;\n  /** Fundo do estúdio virtual. Não é o fundo da PÁGINA (o canvas é\n   *  transparente): é o que o ambiente reflete na capa. */\n  studio: string;\n};\n\n/**\n * A palheta inteira, a partir da semente do item e — se houver — do matiz da\n * arte da capa.\n *\n * Sem arte, o matiz vem da MESMA rampa que o `cover-art` usa pra desenhar a\n * capa 2D do mesmo item. Não é economia: é o que faz o livro e o card do\n * catálogo serem reconhecíveis como a mesma coisa.\n */\nexport function bookPalette(seed: string, artColor?: string | null): BookPalette {\n  const look = coverLook(seed);\n\n  const surface = resolveRgb(\"var(--background)\");\n  const lBg = surface ? rgbToHsl(surface).l : 0.05;\n  const sign = lBg < 0.5 ? 1 : -1;\n  const at = (step: number) => clampL(lBg + sign * step);\n\n  /* O matiz: da arte quando ela existe, senão da rampa tonal da marca. */\n  const source = resolveRgb(artColor || mix(look.light));\n  const hsl = source ? rgbToHsl(source) : { h: 0, s: 0, l: 0.5 };\n  const h = hsl.h;\n  const s = Math.min(hsl.s, SAT_CEILING);\n\n  return {\n    cover: hslToCss({ h, s, l: at(STEP.spine) }),\n    coverDark: hslToCss({ h, s: s * 0.85, l: at(STEP.dark) }),\n    board: hslToCss({ h, s: s * 0.7, l: at(STEP.board) }),\n    back: hslToCss({ h, s: s * 0.8, l: at(STEP.back) }),\n    /* Papel quase neutro: um creme saturado puxaria cor pra dentro de um\n       produto que pode ter decidido não ter cor. */\n    paper: hslToCss({ h, s: s * 0.12, l: at(STEP.paper) }),\n    studio: surface ? rgbToCss(surface) : hslToCss({ h: 0, s: 0, l: 0.06 }),\n  };\n}\n\n/* ── Aspereza ───────────────────────────────────────────────────────────── */\n\n/** Alto = pano e papel-cartão, não plástico. O erro clássico de render de livro\n *  é deixar isto baixo e o objeto virar brinquedo lustroso. */\nexport const ROUGH_COVER = 0.82;\nexport const ROUGH_PAPER = 0.95;\n\n/* ── O matiz da arte ────────────────────────────────────────────────────── */\n\n/** Grade de amostragem. Pequena de propósito: a média de uma pintura não muda\n *  entre 32×48 e 800×1200, e a leitura fica em microssegundos. */\nconst GRID_W = 32;\nconst GRID_H = 48;\n\n/**\n * A cor de caráter de uma imagem, medida da própria textura já carregada.\n *\n * Existe pra que um item cadastrado amanhã já saia com a lombada na cor da arte,\n * sem ninguém rodar nada. Uma palheta curada por gente continua ganhando quando\n * existe (é o parâmetro `artColor`) — ela sabe o que é ASSUNTO e o que é fundo,\n * e isto aqui não sabe. Isto é o piso, não o teto.\n *\n * A média é CIRCULAR: matiz é ângulo, e a média aritmética de 350° e 10° dá\n * 180° — ciano, uma cor que não existe em nenhum dos dois pixels. Somar vetores\n * unitários e tirar o `atan2` no fim é o único jeito que respeita a roda.\n *\n * O peso é `saturação × proximidade do meio-tom`: pixel cinza não tem matiz pra\n * opinar, e branco estourado ou preto de sombra também não.\n */\nexport function characterColor(img: CanvasImageSource): string | null {\n  try {\n    const c = document.createElement(\"canvas\");\n    c.width = GRID_W;\n    c.height = GRID_H;\n    const ctx = c.getContext(\"2d\", { willReadFrequently: true });\n    if (!ctx) return null;\n    ctx.drawImage(img, 0, 0, GRID_W, GRID_H);\n    const { data } = ctx.getImageData(0, 0, GRID_W, GRID_H);\n\n    let x = 0;\n    let y = 0;\n    let sumS = 0;\n    let weight = 0;\n\n    for (let i = 0; i < data.length; i += 4) {\n      if (data[i + 3] < 128) continue;\n      const { h, s, l } = rgbToHsl({ r: data[i], g: data[i + 1], b: data[i + 2] });\n      const w = s * Math.max(0, 1 - Math.abs(l - 0.5) * 1.6);\n      if (w <= 0) continue;\n      const a = h * Math.PI * 2;\n      x += Math.cos(a) * w;\n      y += Math.sin(a) * w;\n      sumS += s * w;\n      weight += w;\n    }\n\n    /* Arte acromática (uma foto em preto e branco, um diagrama): não há matiz\n       pra colher, e inventar um seria pior que herdar o tom da marca. */\n    if (weight < 1e-6) return null;\n\n    const h = (Math.atan2(y, x) / (Math.PI * 2) + 1) % 1;\n    return hslToCss({ h, s: Math.min(1, sumS / weight), l: 0.5 });\n  } catch {\n    /* `getImageData` numa textura de outra origem sem CORS joga SecurityError.\n       Livro sem cor de lombada é muito melhor que livro que não renderiza. */\n    return null;\n  }\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/visant/lib/book-faces.ts",
      "content": "/**\n * As faces que o item não fornece, pintadas em `<canvas>`.\n *\n * O item entrega UMA imagem: a capa de frente. Lombada, contracapa e corte do\n * miolo saem daqui, derivados da mesma semente e da mesma palheta. Isso não é\n * economia de esforço: é o que faz o mesmo item ser o mesmo objeto em qualquer\n * render, e o que permite cadastrar produto sem virar trabalho de arte.\n *\n * Zero byte de rede. A alternativa — três imagens por item num bucket —\n * significaria que trocar a paleta da marca deixaria o acervo inteiro com a cor\n * velha.\n */\n\nimport * as THREE from \"three\";\n\nimport { coverLook, monogram, type CoverLook } from \"@/lib/cover-look\";\nimport { resolveRgb, token, type BookPalette } from \"@/lib/book-material\";\nimport type { BookDimensions } from \"@/lib/book-geometry\";\n\n/** Resolução das faces de texto. 1024 na altura é o que segura o título da\n *  lombada legível num herói sem serrilhar; acima disso é VRAM à toa. */\nconst TEX_H = 1024;\n\ntype Ctx = CanvasRenderingContext2D;\n\nfunction surface(w: number, h: number): [HTMLCanvasElement, Ctx] {\n  const c = document.createElement(\"canvas\");\n  c.width = w;\n  c.height = h;\n  return [c, c.getContext(\"2d\")!];\n}\n\n/**\n * A tinta do texto, com alfa, saída do token — nunca escrita à mão.\n *\n * Canvas 2D não enxerga `var()` nem `color-mix()`: não há elemento, não há\n * cascata. Sem esta ponte, toda face pintada aqui teria a cor em canais crus, e\n * trocar o tema deixaria a lombada com a cor velha, em silêncio.\n */\nfunction ink(pct: number): string {\n  const fg = resolveRgb(\"var(--foreground)\");\n  const base = fg ? `${fg.r}, ${fg.g}, ${fg.b}` : \"128, 128, 128\";\n  return `rgba(${base}, ${pct / 100})`;\n}\n\n/**\n * O nome da família que o pipeline de fonte gerou, lido do token.\n *\n * Hardcodar `'Inter'` aqui funcionaria em dev e falharia em produção: um\n * `next/font` publica a fonte com nome hasheado e o nome humano simplesmente\n * não existe no documento. Canvas não avisa — cai no fallback em silêncio e a\n * lombada sai com outra letra.\n */\nfunction family(name: \"--font-sans\" | \"--font-display\"): string {\n  return token(name) || \"sans-serif\";\n}\n\nfunction texture(canvas: HTMLCanvasElement): THREE.CanvasTexture {\n  const t = new THREE.CanvasTexture(canvas);\n  t.colorSpace = THREE.SRGBColorSpace;\n  t.anisotropy = 8;\n  return t;\n}\n\n/** Encolhe o corpo até caber em UMA linha. É o caso da lombada, que não tem\n *  segunda linha pra oferecer: título cortado com reticências ali seria pior\n *  que título pequeno, porque a lombada É o nome do objeto. */\nfunction fitOneLine(ctx: Ctx, text: string, limit: number, max: number, fam: string): number {\n  let px = max;\n  while (px > 10) {\n    ctx.font = `700 ${px}px ${fam}`;\n    if (ctx.measureText(text).width <= limit) break;\n    px -= 2;\n  }\n  return px;\n}\n\nfunction wrap(ctx: Ctx, text: string, width: number): string[] {\n  const lines: string[] = [];\n  let line = \"\";\n  for (const word of text.split(/\\s+/)) {\n    const attempt = line ? `${line} ${word}` : word;\n    if (ctx.measureText(attempt).width > width && line) {\n      lines.push(line);\n      line = word;\n    } else line = attempt;\n  }\n  if (line) lines.push(line);\n  return lines;\n}\n\n/**\n * O maior corpo em que o título cabe em `maxLines` — a régua de CAPA, que é\n * outra coisa da régua de lombada.\n *\n * Usar `fitOneLine` aqui foi um defeito real: ela encolhe até caber numa linha,\n * e um título de cinco palavras só cabe numa linha em corpo minúsculo. A capa\n * saía ilegível ao lado das que têm arte, e a degradação — que existe pra o\n * item sem arte continuar tendo cara — virava a prova de que ele não tem.\n *\n * A ordem certa é a do tipógrafo: escolhe o corpo, QUEBRA, e só encolhe se\n * passar do número de linhas que a página aguenta. Uma palavra sozinha mais\n * larga que a coluna também derruba o corpo, e por isso a maior linha entra na\n * condição.\n */\nfunction fitBlock(\n  ctx: Ctx,\n  text: string,\n  width: number,\n  max: number,\n  maxLines: number,\n  fam: string,\n): { px: number; lines: string[] } {\n  let px = max;\n  let lines = [\"\"];\n  while (px > 12) {\n    ctx.font = `800 ${px}px ${fam}`;\n    lines = wrap(ctx, text, width);\n    const widest = Math.max(...lines.map((l) => ctx.measureText(l).width));\n    if (lines.length <= maxLines && widest <= width) break;\n    px -= 2;\n  }\n  return { px, lines };\n}\n\n/* ── Lombada ────────────────────────────────────────────────────────────── */\n\n/**\n * A face que justifica o 3D existir.\n *\n * Vai numa meia-cilíndrica, então a UV corre em volta da curva (u = largura da\n * lombada) e ao longo da altura (v). O texto roda 90° pra correr no v: livro\n * ocidental lê de cima pra baixo, com a cabeça inclinada pra direita.\n *\n * A LARGURA da textura é o ARCO, não a largura da capa. Foi `width / height`\n * (0.68) e o certo é `π·thickness/2 / height` — num livro grosso, 0.27. Assim a\n * textura saía duas vezes e meia mais larga do que a superfície onde pousa, e a\n * curva espremia tudo: o título ficava alto e fino, como letra esticada na\n * vertical. É o erro que mais denuncia mapeamento em superfície curva, porque o\n * olho conhece a proporção da letra e vê a distorção sem saber nomeá-la.\n *\n * As duas sombras nas pontas do u são a CHARNEIRA — o vinco onde a capa dobra.\n * Sem elas a curva lê como tubo colado no livro; com elas, como lombada.\n */\nexport function paintSpine(title: string, p: BookPalette, d: BookDimensions) {\n  const arc = (Math.PI * d.thickness) / 2;\n  /* Piso de 40px é só resolução: numa lombada fina o texto já é minúsculo, e\n     forçar a largura acima da razão real traria a distorção de volta. */\n  const W = Math.max(40, Math.round((arc / d.height) * TEX_H));\n  const [canvas, ctx] = surface(W, TEX_H);\n\n  const g = ctx.createLinearGradient(0, 0, 0, TEX_H);\n  g.addColorStop(0, p.cover);\n  g.addColorStop(1, p.coverDark);\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, TEX_H);\n\n  const hinge = ctx.createLinearGradient(0, 0, W, 0);\n  hinge.addColorStop(0, \"rgba(0,0,0,0.55)\");\n  hinge.addColorStop(0.13, \"rgba(0,0,0,0)\");\n  hinge.addColorStop(0.87, \"rgba(0,0,0,0)\");\n  hinge.addColorStop(1, \"rgba(0,0,0,0.55)\");\n  ctx.fillStyle = hinge;\n  ctx.fillRect(0, 0, W, TEX_H);\n\n  const margin = W * 0.22;\n  ctx.fillStyle = ink(22);\n  ctx.fillRect(margin, TEX_H * 0.055, W - margin * 2, 2);\n  ctx.fillRect(margin, TEX_H * 0.945, W - margin * 2, 2);\n\n  ctx.save();\n  ctx.translate(W / 2, TEX_H * 0.11);\n  ctx.rotate(Math.PI / 2);\n  const fam = family(\"--font-sans\");\n  const px = fitOneLine(ctx, title, TEX_H * 0.7, W * 0.46, fam);\n  ctx.font = `700 ${px}px ${fam}`;\n  ctx.fillStyle = ink(92);\n  ctx.textAlign = \"left\";\n  ctx.textBaseline = \"middle\";\n  ctx.fillText(title, 0, 0);\n  ctx.restore();\n\n  /* Monograma no pé, onde numa estante real fica o selo da editora. */\n  ctx.save();\n  ctx.translate(W / 2, TEX_H * 0.9);\n  ctx.rotate(Math.PI / 2);\n  ctx.font = `400 ${Math.round(W * 0.3)}px ${family(\"--font-display\")}`;\n  ctx.fillStyle = ink(55);\n  ctx.textAlign = \"center\";\n  ctx.textBaseline = \"middle\";\n  ctx.fillText(monogram(title), 0, 0);\n  ctx.restore();\n\n  return texture(canvas);\n}\n\n/* ── Contracapa ─────────────────────────────────────────────────────────── */\n\n/** Nunca fica de frente — o curso do hover para antes. Existe pra que a borda\n *  que se vê de três-quartos não seja um retângulo chapado. */\nexport function paintBack(p: BookPalette, ratio: number, mark?: string) {\n  const W = Math.round(TEX_H * ratio);\n  const [canvas, ctx] = surface(W, TEX_H);\n\n  const g = ctx.createLinearGradient(0, 0, W, TEX_H);\n  g.addColorStop(0, p.back);\n  g.addColorStop(1, p.coverDark);\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, TEX_H);\n\n  ctx.fillStyle = ink(16);\n  ctx.fillRect(W * 0.12, TEX_H * 0.86, W * 0.16, 2);\n\n  if (mark) {\n    ctx.font = `400 ${Math.round(W * 0.05)}px ${family(\"--font-display\")}`;\n    ctx.fillStyle = ink(40);\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"alphabetic\";\n    ctx.fillText(mark, W * 0.12, TEX_H * 0.91);\n  }\n\n  return texture(canvas);\n}\n\n/* ── Corte do miolo ─────────────────────────────────────────────────────── */\n\n/**\n * Uma textura por PALHETA, memoizada — é a mesma folha de papel em todo livro\n * de uma mesma marca, e refazê-la por item seria VRAM repetida.\n *\n * As listras não são regulares: passo constante bate com a grade de pixels e\n * produz moiré quando o livro gira, que é o artefato que mais denuncia render\n * barato. A variação vem de um seno, não de `Math.random` — determinismo aqui é\n * a diferença entre \"renderizar duas vezes dá o mesmo resultado\" e não dar.\n */\nconst blockCache = new Map<string, THREE.CanvasTexture>();\n\nexport function paintBlock(p: BookPalette): THREE.CanvasTexture {\n  const cached = blockCache.get(p.paper);\n  if (cached) return cached;\n\n  const N = 1024;\n  const [canvas, ctx] = surface(N, 64);\n\n  ctx.fillStyle = p.paper;\n  ctx.fillRect(0, 0, N, 64);\n\n  for (let i = 0; i < 260; i++) {\n    const x = (i / 260) * N + Math.sin(i * 2.399) * 1.6;\n    const a = 0.06 + (Math.sin(i * 1.117) + 1) * 0.05;\n    ctx.fillStyle = `rgba(0,0,0,${a.toFixed(3)})`;\n    ctx.fillRect(x, 0, 1.2, 64);\n  }\n\n  /* Escurecimento nas duas pontas: o corte encosta nos cartões e ali entra\n     menos luz. É o que impede o bloco de páginas de virar barra clara chapada. */\n  const g = ctx.createLinearGradient(0, 0, N, 0);\n  g.addColorStop(0, \"rgba(0,0,0,0.42)\");\n  g.addColorStop(0.1, \"rgba(0,0,0,0)\");\n  g.addColorStop(0.9, \"rgba(0,0,0,0)\");\n  g.addColorStop(1, \"rgba(0,0,0,0.42)\");\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, N, 64);\n\n  const t = texture(canvas);\n  blockCache.set(p.paper, t);\n  return t;\n}\n\n/* ── Capa de frente, quando não há arte ─────────────────────────────────── */\n\n/**\n * Degradação honesta: sem arte o livro não vira caixa cinza nem imagem gerada —\n * vira a mesma capa desenhada que o `cover-art` mostra na prateleira, agora com\n * espessura. Mesmo hash, então o item é reconhecível nas duas superfícies.\n */\nexport function paintCover(title: string, look: CoverLook, p: BookPalette, ratio: number) {\n  const W = Math.round(TEX_H * ratio);\n  const [canvas, ctx] = surface(W, TEX_H);\n\n  const rad = (look.angle * Math.PI) / 180;\n  const g = ctx.createLinearGradient(0, 0, Math.cos(rad) * W, Math.sin(rad) * TEX_H);\n  g.addColorStop(0, p.cover);\n  g.addColorStop(1, p.coverDark);\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, W, TEX_H);\n\n  const m = W * 0.11;\n  const base = look.titleOnTop ? TEX_H * 0.16 : TEX_H * 0.82;\n\n  ctx.fillStyle = ink(28);\n  ctx.fillRect(m, base - TEX_H * 0.055, W * 0.14, 2);\n\n  const fam = family(\"--font-sans\");\n  /* Teto de 4 linhas: acima disso o título vira parágrafo e a capa vira\n     folheto. O corpo de partida é generoso — capa não é lombada. */\n  const { px, lines } = fitBlock(ctx, title, W - m * 2, W * 0.17, 4, fam);\n  ctx.font = `800 ${px}px ${fam}`;\n  ctx.fillStyle = ink(95);\n  ctx.textAlign = \"left\";\n  ctx.textBaseline = \"alphabetic\";\n\n  /* Composição de BASE cresce pra cima e a de topo pra baixo. Sem isso, um\n     título de quatro linhas ancorado no pé sangrava pra fora do cartão. */\n  const leading = px * 1.08;\n  const top = look.titleOnTop ? base : base - (lines.length - 1) * leading;\n  lines.forEach((l, i) => ctx.fillText(l, m, top + i * leading));\n\n  return texture(canvas);\n}\n\n/** Tudo que o livro precisa, num lugar só. */\nexport function bookFaces(\n  seed: string,\n  title: string,\n  d: BookDimensions,\n  palette: BookPalette,\n  mark?: string,\n) {\n  const look = coverLook(seed);\n  const ratio = d.width / d.height;\n  return {\n    spine: paintSpine(title, palette, d),\n    back: paintBack(palette, ratio, mark),\n    cover: paintCover(title, look, palette, ratio),\n    block: paintBlock(palette),\n  };\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/visant/ui/book-stage.tsx",
      "content": "\"use client\";\n\n/**\n * O palco: canvas, câmera, luz e a malha. Carregado sob demanda pelo\n * `book-3d` — nunca importe este arquivo direto, ou os ~180 KB de WebGL entram\n * no bundle de quem só queria mostrar um card.\n *\n * ┌─ POR QUE A MALHA NÃO É UMA CAIXA COM TEXTURA ─────────────────────────────┐\n * │ Caixa com a arte colada é o render de livro que todo mundo faz e que      │\n * │ ninguém acredita. Faltam duas coisas, e são as duas que o olho procura:   │\n * │                                                                            │\n * │   PRAÇA     a capa sobra além do bloco de páginas nas três bordas livres. │\n * │             É a sombra fina em volta do corte que diz \"isso abre\".        │\n * │   CHARNEIRA a lombada é uma meia-cilíndrica de verdade, com o vinco onde  │\n * │             a capa dobra. Lombada pintada num plano lê como adesivo.      │\n * │                                                                            │\n * │ Por isso são quatro peças (dois cartões + lombada + miolo) e não uma:     │\n * │ nenhuma textura produz um degrau que responde à luz quando o objeto gira. │\n * └────────────────────────────────────────────────────────────────────────────┘\n *\n * Nenhum asset baixado: sem GLB, sem HDR de CDN. Modelo de banco público seria\n * pior em quatro frentes — UV desconhecida (refazer o unwrap é o trabalho que o\n * modelo deveria evitar), proporção travada (espessura aqui é parâmetro),\n * 300 KB–2 MB de rede, e passivo de atribuição numa superfície comercial.\n */\n\nimport * as React from \"react\";\nimport * as THREE from \"three\";\nimport { Canvas, useFrame, useLoader, useThree } from \"@react-three/fiber\";\nimport { Environment, Lightformer } from \"@react-three/drei\";\n\nimport {\n  BOARD,\n  CAMERA,\n  REST,\n  SQUARE,\n  STIFFNESS,\n  bookDimensions,\n  damp,\n  tilt,\n} from \"@/lib/book-geometry\";\nimport {\n  ROUGH_COVER,\n  ROUGH_PAPER,\n  bookPalette,\n  characterColor,\n  resolveRgb,\n  rgbToCss,\n  type BookPalette,\n} from \"@/lib/book-material\";\nimport { bookFaces } from \"@/lib/book-faces\";\n\n/** O que o ponteiro está pedindo. Ref e não estado: mexer nisso 60× por segundo\n *  por `setState` re-renderiza a árvore React inteira a cada quadro. */\nexport type PointerTarget = { px: number; py: number; active: boolean };\n\nexport interface BookStageProps {\n  /** Chave ESTÁVEL: slug > id > título. Título editado mudaria o objeto. */\n  seed: string;\n  title: string;\n  /** A capa. Sem ela, o livro sai com a capa desenhada — nunca caixa cinza. */\n  art?: string | null;\n  /** Manda na espessura. Sem número, usa o livro de estante médio. */\n  pages?: number | null;\n  /**\n   * Cor curada da arte, se houver. Ganha da amostragem automática — uma pessoa\n   * (ou um extrator com noção de papel) sabe que o ocre da pedra é o assunto e\n   * o céu é o fundo; uma média não sabe.\n   */\n  artColor?: string | null;\n  /** Marca d'água na contracapa. Uma linha curta, tipo o selo da editora. */\n  mark?: string;\n  /** Chamado no primeiro quadro em que o livro existe POR INTEIRO. */\n  onReady?: () => void;\n}\n\ntype Shared = Pick<BookStageProps, \"seed\" | \"title\" | \"pages\" | \"artColor\" | \"mark\" | \"onReady\"> & {\n  /**\n   * Tipo ESTRUTURAL, e não `React.RefObject` / `MutableRefObject`.\n   *\n   * Os dois nomes mudaram de significado entre os types do React 18 e do 19:\n   * no 18, `useRef` com valor inicial devolve `MutableRefObject`, que não é\n   * atribuível a `RefObject`; no 19, `MutableRefObject` está depreciado. Um item\n   * de registry é instalado em projetos das duas versões, então amarrar-se a\n   * qualquer um dos dois nomes garante erro de tipo na metade dos consumidores.\n   * `{ current: T }` é o que os dois têm em comum e sempre terão.\n   */\n  target: { current: PointerTarget };\n  reduced: boolean;\n};\n\n/* ── O objeto ───────────────────────────────────────────────────────────── */\n\nfunction Body({\n  seed,\n  title,\n  pages,\n  artColor,\n  mark,\n  onReady,\n  target,\n  reduced,\n  cover,\n  sampled,\n  epoch,\n}: Shared & { cover: THREE.Texture | null; sampled: string | null; epoch: number }) {\n  const group = React.useRef<THREE.Group>(null);\n  const announced = React.useRef(false);\n  const { invalidate } = useThree();\n\n  const d = React.useMemo(() => bookDimensions(pages), [pages]);\n  /* `epoch` na lista de dependências é o que reassa tudo quando o tema troca.\n     Ver `useThemeEpoch`. */\n  const palette: BookPalette = React.useMemo(\n    () => bookPalette(seed, artColor ?? sampled),\n    [seed, artColor, sampled, epoch],\n  );\n  const faces = React.useMemo(\n    () => bookFaces(seed, title, d, palette, mark),\n    [seed, title, d, palette, mark],\n  );\n\n  /**\n   * O miolo leva SEIS materiais porque o corte e a cabeça mostram o mesmo\n   * empilhamento em direções diferentes: no corte as páginas correm na altura,\n   * na cabeça correm na largura. Uma textura só nas duas faces deixa a cabeça\n   * com listra atravessada — o tipo de erro que ninguém nomeia e todo mundo vê.\n   */\n  const blockMaterials = React.useMemo(() => {\n    const edge = new THREE.MeshStandardMaterial({\n      map: faces.block,\n      roughness: ROUGH_PAPER,\n      metalness: 0,\n    });\n    const turned = faces.block.clone();\n    turned.needsUpdate = true;\n    turned.center.set(0.5, 0.5);\n    turned.rotation = Math.PI / 2;\n    const head = new THREE.MeshStandardMaterial({\n      map: turned,\n      roughness: ROUGH_PAPER,\n      metalness: 0,\n    });\n    /**\n     * Faces que ficam atrás dos cartões e dentro da lombada. Escuras, e não cor\n     * de papel — é a diferença entre um vazamento que lê como sombra e um que\n     * lê como DEFEITO. Qualquer fresta de um pixel nesta montagem (e sempre há\n     * uma, em algum ângulo) some se o que está atrás for escuro, e vira risco\n     * claro se for papel.\n     */\n    const hidden = new THREE.MeshStandardMaterial({\n      color: palette.coverDark,\n      roughness: ROUGH_PAPER,\n    });\n    return [edge, hidden, head, head, hidden, hidden]; // +x −x +y −y +z −z\n  }, [faces.block, palette.coverDark]);\n\n  /** Lombada: [lateral, tampa de cima, tampa de baixo]. As tampas são o vazio\n   *  entre a curva e o miolo — escuras, como o crescente que um livro de capa\n   *  dura mostra na cabeça. */\n  const spineMaterials = React.useMemo(() => {\n    const side = new THREE.MeshStandardMaterial({\n      map: faces.spine,\n      roughness: ROUGH_COVER,\n      metalness: 0,\n    });\n    const hollow = new THREE.MeshStandardMaterial({\n      color: palette.coverDark,\n      roughness: 1,\n    });\n    return [side, hollow, hollow];\n  }, [faces.spine, palette.coverDark]);\n\n  /**\n   * Devolver a memória de vídeo ao desmontar.\n   *\n   * `three` não tem coleta de lixo: textura e material vivem na GPU até alguém\n   * chamar `dispose()`. Numa página só isso nunca aparece, e é por isso que é\n   * perigoso — o vazamento mora no CAMINHO que ainda não existe: prateleira que\n   * pagina, filtro que remonta a lista, rota que entra e sai. Cada livro carrega\n   * três canvases de 1024 de altura; algumas dezenas de remontagens e o\n   * navegador começa a derrubar contexto WebGL, o que na tela lê como \"os livros\n   * sumiram\" sem nenhum erro.\n   *\n   * Duas coisas NÃO se descartam, e é o detalhe que separa limpar de quebrar:\n   * o corte do miolo é memoizado por palheta e serve todos os livros da marca,\n   * e a textura da ARTE vem do cache de loader do fiber, que a reusa entre\n   * montagens. Quem descarta essas é quem as guarda.\n   */\n  React.useEffect(() => {\n    const own = [faces.spine, faces.back, faces.cover];\n    const materials = [...blockMaterials, ...spineMaterials];\n    return () => {\n      own.forEach((t) => t.dispose());\n      materials.forEach((m) => {\n        if (m.map && m.map !== faces.block) m.map.dispose();\n        m.dispose();\n      });\n    };\n  }, [faces, blockMaterials, spineMaterials]);\n\n  /* `frameloop=\"demand\"` não desenha só porque um material mudou: sem este\n     empurrão, a palheta nova só apareceria no próximo movimento do ponteiro. */\n  React.useEffect(() => invalidate(), [palette, invalidate]);\n\n  useFrame((_, dt) => {\n    const g = group.current;\n    if (!g) return;\n\n    /* Primeiro quadro DESTE livro — não do canvas. Ver `onReady` em `book-3d`. */\n    if (!announced.current) {\n      announced.current = true;\n      onReady?.();\n    }\n\n    const t = target.current;\n    const goal = reduced || !t?.active ? REST : tilt(t.px, t.py);\n    g.rotation.y = damp(g.rotation.y, goal.ry, STIFFNESS, dt);\n    g.rotation.x = damp(g.rotation.x, goal.rx, STIFFNESS, dt);\n\n    /**\n     * `frameloop=\"demand\"`: o quadro só acontece quando alguém pede. Enquanto o\n     * livro ainda está indo pro destino, ele pede o próximo; parado, a GPU dorme\n     * em 0 fps. Sem isto, uma página de catálogo queima bateria a 60 fps pra\n     * desenhar um objeto que não se move.\n     */\n    const settled =\n      Math.abs(g.rotation.y - goal.ry) < 1e-4 && Math.abs(g.rotation.x - goal.rx) < 1e-4;\n    if (!settled) invalidate();\n  });\n\n  const half = d.thickness / 2;\n  const lift = 0.0015; // folga da arte sobre o cartão; evita z-fighting\n\n  return (\n    <group ref={group} rotation={[REST.rx, REST.ry, 0]}>\n      {/* CARTÃO DA FRENTE.\n          Caixa reta, e não uma caixa com canto arredondado. O bisel parecia\n          refinamento e era defeito: ele curva TODAS as arestas, inclusive a que\n          encosta na lombada, e ali abria uma fresta por onde o miolo claro\n          vazava — uma linha vertical entre a lombada e a capa, do topo ao pé.\n          Aresta de cartão de livro é reta mesmo; o único arredondamento honesto\n          neste objeto é a lombada, e essa é geometria de verdade. */}\n      <mesh position={[0, 0, half - BOARD / 2]}>\n        <boxGeometry args={[d.width, d.height, BOARD]} />\n        <meshStandardMaterial color={palette.board} roughness={ROUGH_COVER} />\n      </mesh>\n\n      {/* A ARTE — a única coisa que o item precisa fornecer. */}\n      <mesh position={[0, 0, half + lift]}>\n        <planeGeometry args={[d.width, d.height]} />\n        <meshStandardMaterial\n          map={cover ?? faces.cover}\n          roughness={ROUGH_COVER}\n          metalness={0}\n        />\n      </mesh>\n\n      {/* CARTÃO DE TRÁS + contracapa */}\n      <mesh position={[0, 0, -half + BOARD / 2]}>\n        <boxGeometry args={[d.width, d.height, BOARD]} />\n        <meshStandardMaterial color={palette.board} roughness={ROUGH_COVER} />\n      </mesh>\n      <mesh position={[0, 0, -half - lift]} rotation={[0, Math.PI, 0]}>\n        <planeGeometry args={[d.width, d.height]} />\n        <meshStandardMaterial map={faces.back} roughness={ROUGH_COVER} metalness={0} />\n      </mesh>\n\n      {/* LOMBADA — meia-cilíndrica de raio E/2. Esse raio não é escolha: é o\n          único que faz a curva encostar exatamente nas quinas dos dois cartões\n          (−L/2, ±E/2). Qualquer outro abre fresta ou cria degrau. */}\n      <mesh position={[-d.width / 2, 0, 0]} material={spineMaterials}>\n        <cylinderGeometry args={[half, half, d.height, 48, 1, false, Math.PI, Math.PI]} />\n      </mesh>\n\n      {/* MIOLO — recuado pela praça nas três bordas livres, e entrando meia\n          espessura pra DENTRO da cavidade da lombada. Um livro rebatido faz\n          isso de verdade (o bloco acompanha a curva), e aqui resolve de quebra\n          o vão que sobrava entre a face reta do miolo e a casca curva: em\n          ângulo raso dava pra enxergar por dentro do livro. */}\n      <mesh position={[-(half / 2 + SQUARE) / 2, 0, 0]} material={blockMaterials}>\n        <boxGeometry args={[d.width - SQUARE + half / 2, d.height - SQUARE * 2, d.block]} />\n      </mesh>\n    </group>\n  );\n}\n\n/* ── As duas naturezas de capa ──────────────────────────────────────────── */\n\n/**\n * Livro COM arte. Suspende no `useLoader` até o byte chegar — por isso o\n * `<Suspense>` abaixo tem o livro SEM arte como reserva, e não um vazio.\n *\n * É aqui que a cor do objeto nasce quando ninguém curou uma: a imagem já está\n * decodificada na memória, então medir o matiz custa um `drawImage` de 32×48 e\n * nenhuma requisição.\n */\nfunction WithArt({ art, ...shared }: Shared & { art: string; epoch: number }) {\n  const tex = useLoader(THREE.TextureLoader, art);\n  const sampled = React.useMemo(() => {\n    tex.colorSpace = THREE.SRGBColorSpace;\n    tex.anisotropy = 8;\n    return characterColor(tex.image as CanvasImageSource);\n  }, [tex]);\n  return <Body {...shared} cover={tex} sampled={sampled} />;\n}\n\nfunction WithoutArt(shared: Shared & { epoch: number }) {\n  return <Body {...shared} cover={null} sampled={null} />;\n}\n\n/* ── O estúdio ──────────────────────────────────────────────────────────── */\n\n/**\n * Luz sem baixar nada.\n *\n * `<Environment preset=\"studio\" />` é o caminho óbvio e está errado aqui: o\n * preset busca um HDR num CDN de terceiro. Numa página de produto, quando o CDN\n * pisca o objeto fica preto. Com `<Lightformer>` o ambiente é RENDERIZADO na\n * hora, num cubo de 256, e o custo é uma vez na montagem.\n *\n * ┌─ O AMBIENTE NÃO SUBSTITUI A LUZ DIRETA ───────────────────────────────────┐\n * │ A primeira versão tinha só os lightformers, e na bancada o livro saiu     │\n * │ quase invisível: ambiente escuro sobre matéria escura não produz FORMA    │\n * │ nenhuma.                                                                  │\n * │                                                                            │\n * │ Ambiente dá o brilho da superfície — o pano da capa, o reflexo fraco na   │\n * │ curva da lombada. Quem desenha o volume é luz direcional, porque só ela   │\n * │ produz gradiente na face e sombra na quina. As duas coisas, não uma.      │\n * │                                                                            │\n * │ A chave vem de cima e da ESQUERDA: é o lado da lombada, e é o contraste   │\n * │ entre a curva iluminada e a capa em meia-luz que diz \"isso tem lado\".     │\n * │ Chave frontal achataria o objeto de volta ao retângulo.                   │\n * └────────────────────────────────────────────────────────────────────────────┘\n *\n * NÃO HÁ SOMBRA DE CONTATO, e isso foi medido, não esquecido. O\n * `<ContactShadows>` esteve aqui: sobre uma superfície escura, uma sombra preta\n * a 75% de opacidade move o pixel em menos de 15/255 — invisível. Custava um\n * alvo de render de 512 por quadro pra não aparecer em foto nenhuma. Numa marca\n * clara ela voltaria a valer, e por isso o comentário fica.\n */\nfunction Studio({ backdrop }: { backdrop: string }) {\n  return (\n    <>\n      {/* Piso de luz. Sem ele a contracapa e a sombra da praça viram buraco\n          preto, e buraco preto lê como falha de textura. */}\n      <ambientLight intensity={0.55} />\n      <directionalLight position={[-2.6, 3.2, 3.4]} intensity={2.6} />\n      {/* CONTRA — de trás e da direita, rasante. Acende a quina do corte e\n          separa o livro do fundo, que costuma ser quase da mesma cor. */}\n      <directionalLight position={[3.4, 1.2, -2.4]} intensity={1.5} />\n      <directionalLight position={[1.2, -2.2, 1.6]} intensity={0.35} />\n\n      <Environment resolution={256}>\n        <color attach=\"background\" args={[backdrop]} />\n        <Lightformer form=\"rect\" intensity={6} position={[0, 4, 2]} scale={[8, 5, 1]} target={[0, 0, 0]} />\n        <Lightformer form=\"rect\" intensity={4} position={[4, 1, -3]} scale={[5, 5, 1]} target={[0, 0, 0]} />\n        <Lightformer form=\"rect\" intensity={2} position={[-4, 0, 2]} scale={[5, 5, 1]} target={[0, 0, 0]} />\n      </Environment>\n    </>\n  );\n}\n\n/* ── Tema ───────────────────────────────────────────────────────────────── */\n\n/**\n * Um contador que muda quando o TEMA muda.\n *\n * A palheta e as três faces de canvas são assadas uma vez, na montagem, porque\n * `getComputedStyle` força layout e não pode rodar por quadro. O preço disso é\n * que um livro montado no claro continua claro depois de a pessoa clicar no\n * botão de tema — e num app que tem esse botão, isso aparece no primeiro\n * clique: a página inteira vira escura e o objeto fica de outra marca.\n *\n * Observar o `documentElement` cobre os dois jeitos de trocar tema no mundo\n * real (classe `dark` e atributo `data-theme`) e o `matchMedia` cobre quem\n * segue o sistema. Não há laço: cada troca de tema paga uma reassagem, e\n * trocar de tema não é coisa que se faz sessenta vezes por segundo.\n */\nfunction useThemeEpoch(): number {\n  const [epoch, setEpoch] = React.useState(0);\n  React.useEffect(() => {\n    const bump = () => setEpoch((n) => n + 1);\n    const obs = new MutationObserver(bump);\n    obs.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    });\n    const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    mq.addEventListener(\"change\", bump);\n    return () => {\n      obs.disconnect();\n      mq.removeEventListener(\"change\", bump);\n    };\n  }, []);\n  return epoch;\n}\n\n/* ── Compilar antes de desenhar ─────────────────────────────────────────── */\n\n/**\n * Compila os shaders em paralelo, e só então libera o primeiro quadro.\n *\n * Medido com GPU real: montar a estante custava 6 tarefas longas (até 315 ms).\n * Quase todo esse tempo era o three.js esperando a validação do programa\n * (`getProgramParameter(LINK_STATUS)`), que BLOQUEIA a thread até o driver\n * terminar de compilar. Com quatro livros são quatro contextos, e programa não\n * se compartilha entre contextos.\n *\n * `compileAsync` (three r158+) compila pela `KHR_parallel_shader_compile` e\n * espera o `COMPLETION_STATUS` sem travar. O canvas nasce com `frameloop`\n * \"never\", e o livro 2D de reserva (`Book3D`) segue na tela até o primeiro\n * quadro inteiro, como já era. Só a espera deixa de ser uma tarefa longa.\n *\n * A espera é nossa e não o `compileAsync`: o timer dele não para quando a peça\n * desmonta e lê `currentProgram` de material já descartado. Isso jogava\n * `isReady of undefined` fora de qualquer `catch`, e a promessa nunca resolvia:\n * o livro ficava preso na reserva. Aqui material sem programa conta como pronto,\n * o laço para ao desmontar e, passado o teto, libera e o three compila no quadro.\n */\nconst TETO_DA_COMPILACAO_MS = 8000;\n\nfunction CompilarAntes({ onCompilado }: { onCompilado: () => void }) {\n  const { gl, scene, camera } = useThree();\n  React.useEffect(() => {\n    let vivo = true;\n    let relogio: ReturnType<typeof setTimeout> | undefined;\n    const inicio = performance.now();\n    let pendentes: Set<THREE.Material>;\n    try {\n      pendentes = gl.compile(scene, camera) as unknown as Set<THREE.Material>;\n    } catch {\n      onCompilado();\n      return;\n    }\n    const conferir = () => {\n      if (!vivo) return;\n      for (const material of pendentes) {\n        const { currentProgram: programa } = gl.properties.get(material) as {\n          currentProgram?: { isReady(): boolean };\n        };\n        if (!programa || programa.isReady()) pendentes.delete(material);\n      }\n      if (pendentes.size === 0 || performance.now() - inicio > TETO_DA_COMPILACAO_MS) onCompilado();\n      else relogio = setTimeout(conferir, 10);\n    };\n    conferir();\n    return () => {\n      vivo = false;\n      clearTimeout(relogio);\n    };\n  }, [gl, scene, camera, onCompilado]);\n  return null;\n}\n\n/**\n * O primeiro quadro depois da compilação. Com `frameloop` \"never\" o r3f ignora\n * `invalidate()`, então quem invalidou durante a espera (a palheta, a arte) não\n * deixou pedido nenhum. O `requestAnimationFrame` é porque o efeito do filho\n * roda antes de o `Canvas` aplicar o `frameloop` novo.\n */\nfunction Acordar() {\n  const invalidate = useThree((s) => s.invalidate);\n  React.useEffect(() => {\n    const r = requestAnimationFrame(() => invalidate());\n    return () => cancelAnimationFrame(r);\n  }, [invalidate]);\n  return null;\n}\n\n/* ── Densidade de pixels ─────────────────────────────────────────────────── */\n\n/**\n * Pixels de desenho por pixel de layout, contando a escala VISUAL do palco.\n *\n * Mostrado reduzido por `transform: scale` (uma vitrine, um card de preview), o\n * livro pintava 3x os pixels que apareciam. Sem GPU forte, seguindo o cursor:\n * 51 fps e p95 de 50 ms. Desenhando no tamanho que aparece, a nitidez na tela é\n * a mesma; fora de escala o resultado é o `[1, 2]` de antes. Recalcula no\n * `resize` da janela, porque a escala do ancestral muda sem mudar o layout.\n */\nfunction useDensidade(): [(el: HTMLDivElement | null) => void, number] {\n  const [el, setEl] = React.useState<HTMLDivElement | null>(null);\n  const [dpr, setDpr] = React.useState(1);\n  React.useEffect(() => {\n    if (!el) return;\n    const medir = () => {\n      const teto = Math.min(window.devicePixelRatio || 1, 2);\n      const layout = el.offsetWidth;\n      const visivel = el.getBoundingClientRect().width;\n      const escala = layout > 0 && visivel > 0 ? Math.min(1, visivel / layout) : 1;\n      setDpr(Math.max(0.5, Math.max(1, teto) * escala));\n    };\n    medir();\n    window.addEventListener(\"resize\", medir);\n    return () => window.removeEventListener(\"resize\", medir);\n  }, [el]);\n  return [setEl, dpr];\n}\n\n/* ── Movimento reduzido ─────────────────────────────────────────────────── */\n\n/** Seis linhas em vez de uma dependência: o item já pesa three + fiber + drei,\n *  e puxar uma lib de animação inteira por um `matchMedia` seria caro. */\nfunction usePrefersReducedMotion(): boolean {\n  const [reduced, setReduced] = React.useState(false);\n  React.useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => setReduced(mq.matches);\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n  return reduced;\n}\n\n/* ── O palco ────────────────────────────────────────────────────────────── */\n\nexport default function BookStage({ art, onReady, ...rest }: BookStageProps) {\n  const target = React.useRef<PointerTarget>({ px: 0, py: 0, active: false });\n  const nudge = React.useRef<(() => void) | null>(null);\n  const reduced = usePrefersReducedMotion();\n  const epoch = useThemeEpoch();\n\n  /**\n   * O ponteiro é lido no DIV, não na malha.\n   *\n   * Raycast no objeto só dispara com o cursor EM CIMA do livro, e o livro ocupa\n   * menos de um terço da área. Na bancada isso lia como peça quebrada: o cursor\n   * entrava na região, nada acontecia, e só perto do centro o objeto acordava.\n   * A caixa inteira é a superfície de hover.\n   */\n  const move = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    target.current = {\n      px: ((e.clientX - r.left) / r.width) * 2 - 1,\n      py: ((e.clientY - r.top) / r.height) * 2 - 1,\n      active: true,\n    };\n    nudge.current?.();\n  }, []);\n\n  const release = React.useCallback(() => {\n    target.current = { ...target.current, active: false };\n    nudge.current?.();\n  }, []);\n\n  const shared = { ...rest, target, reduced, onReady, epoch };\n  const [compilado, setCompilado] = React.useState(false);\n  const [refDensidade, dpr] = useDensidade();\n  const liberar = React.useCallback(() => setCompilado(true), []);\n\n  /**\n   * O que o ambiente reflete na capa é a SUPERFÍCIE DA MARCA, não um cinza\n   * escolhido aqui. Numa marca clara o reflexo tem que ser claro, senão o livro\n   * parece recortado de outra foto. Resolvido uma vez: `getComputedStyle` força\n   * layout e não pode entrar num laço de quadro.\n   */\n  const backdrop = React.useMemo(() => {\n    const bg = resolveRgb(\"var(--background)\");\n    return bg ? rgbToCss(bg) : \"rgb(16, 16, 20)\";\n  }, [epoch]);\n\n  return (\n    <div\n      ref={refDensidade}\n      className=\"h-full w-full\"\n      onPointerMove={move}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      /* Toque não tem hover: o dedo arrasta e o livro segue, e ao soltar volta\n         ao repouso. Sai de graça do Pointer Events — não há ramo de touch aqui. */\n      onPointerUp={release}\n    >\n      <Canvas\n        frameloop={compilado ? \"demand\" : \"never\"}\n        dpr={dpr}\n        gl={{ antialias: true, alpha: true }}\n        camera={{ position: [...CAMERA.position], fov: CAMERA.fov }}\n        // `offsetSize`: dentro de um layout escalado (`transform: scale`) o\n        // canvas mede a caixa de layout. Medindo o retângulo desenhado ele\n        // nascia menor, encolhia de novo pela escala e o livro ia pro canto.\n        resize={{ offsetSize: true }}\n        onCreated={({ invalidate }) => {\n          nudge.current = invalidate;\n        }}\n        style={{ touchAction: \"pan-y\" }}\n      >\n        {compilado ? <Acordar /> : <CompilarAntes onCompilado={liberar} />}\n        <Studio backdrop={backdrop} />\n        {/* A reserva do Suspense é o LIVRO INTEIRO com a capa desenhada, não um\n            vazio nem um esqueleto. Enquanto a arte desce já há um objeto com\n            lombada e espessura na tela, e a troca é só a face da frente. */}\n        <React.Suspense fallback={<WithoutArt {...shared} />}>\n          {art ? <WithArt art={art} {...shared} /> : <WithoutArt {...shared} />}\n        </React.Suspense>\n      </Canvas>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/visant/ui/book-3d.tsx",
      "content": "\"use client\";\n\n/**\n * Book 3D — o item de catálogo como OBJETO.\n *\n * Capa dura com lombada curva, espessura vinda da contagem de páginas, praça\n * (a sobra do cartão além do bloco de páginas) e inclinação que segue o\n * ponteiro. A cor do objeto sai dos tokens da marca e do matiz da própria arte.\n *\n * ── Por que separado do palco ──────────────────────────────────────────────\n *\n * `three` + `fiber` + `drei` somam ~180 KB gz. Um `import` direto colaria isso\n * no bundle de QUALQUER rota que importasse este arquivo, inclusive as que só\n * mostram um card. Aqui o WebGL só desce quando o navegador chega na peça.\n *\n * `React.lazy` e não `next/dynamic` de propósito: o item roda em Vite, Remix e\n * Next igual, e `next/dynamic` só existe num deles.\n *\n * ── A LINHA DO react-three-fiber, e por que ela está fixada ────────────────\n *\n * As dependências declaram `@react-three/fiber@^8` + `@react-three/drei@^9`.\n * Não é conservadorismo: o fiber v9 exige React >= 19 e a instalação FALHA num\n * projeto React 18, com um erro de peer que não diz o que fazer. A linha 8\n * instala nos dois.\n *\n * Este código roda igual nas duas linhas — foi verificado nas duas, e não\n * deduzido: fiber 8 + drei 9 sobre React 18 nesta vitrine, e fiber 9 + drei 10\n * sobre React 19 no app de onde a peça foi colhida.\n *\n * Num projeto React 19, subir vale a pena (o v9 é a linha mantida):\n *   npm i three@latest @react-three/fiber@^9 @react-three/drei@^10\n *\n * Atenção ao contrário: instalar o fiber v9 num projeto React 18 não quebra só\n * em runtime. A augmentação de JSX dele mira `React.JSX`, que não existe nos\n * types do 18, e o namespace vazio que ela cria APAGA a tipagem de todo\n * elemento intrínseco do projeto — `<div>` passa a aceitar `never`. O sintoma\n * aparece em arquivos que não têm nada a ver com 3D.\n *\n * ── ONDE ISTO NÃO VAI ──────────────────────────────────────────────────────\n *\n * Num trilho de 20–40 cards. Quarenta contextos WebGL derrubam telefone, e o\n * navegador começa a descartar os mais antigos — o que na tela lê como \"os\n * livros sumiram\", sem erro nenhum. Pra prateleira longa, renderize uma vez e\n * sirva imagem; pra uma dobra de destaque com poucos itens, é aqui.\n */\n\nimport * as React from \"react\";\n\nimport { CoverArt } from \"@/components/ui/cover-art\";\nimport { cn } from \"@/lib/utils\";\nimport type { BookStageProps } from \"@/components/ui/book-stage\";\n\nconst BookStage = React.lazy(() => import(\"@/components/ui/book-stage\"));\n\nexport interface Book3DProps extends BookStageProps {\n  className?: string;\n}\n\n/**\n * O reserva é a capa 2D determinística do `cover-art`, na proporção do livro.\n *\n * Não é spinner e não é caixa cinza de propósito: enquanto o WebGL desce, e em\n * qualquer máquina onde ele não desça, a peça mostra a MESMA arte derivada da\n * mesma semente que o card do catálogo mostra. Quem olha nunca vê buraco, e\n * nunca vê duas identidades pro mesmo item.\n */\nfunction Standby({ seed, title }: { seed: string; title: string }) {\n  return (\n    <div className=\"flex h-full w-full items-center justify-center\">\n      <div\n        className=\"relative aspect-[0.68] h-[76%] overflow-hidden\"\n        /* Raio e transição vão em `style`, e não em classe arbitrária do\n           Tailwind: `rounded-[var(--r-surface,4px)]` não gera regra nenhuma —\n           a vírgula do fallback quebra o parser de valor arbitrário, e o portão\n           `check:css-tokens` pegou as duas classes sem estilo. Falha silenciosa\n           clássica: o className existe, o CSS não. */\n        style={{ borderRadius: \"var(--r-surface, 4px)\" }}\n      >\n        <CoverArt title={title} seed={seed} family=\"book\" titleless />\n      </div>\n    </div>\n  );\n}\n\nexport function Book3D({ className, ...stage }: Book3DProps) {\n  const [ready, setReady] = React.useState(false);\n  const announce = React.useCallback(() => setReady(true), []);\n  const { seed, title, onReady } = stage;\n\n  const handleReady = React.useCallback(() => {\n    announce();\n    onReady?.();\n  }, [announce, onReady]);\n\n  return (\n    <div\n      className={cn(\"relative aspect-[4/5] w-full\", className)}\n      role=\"img\"\n      aria-label={title}\n    >\n      {/**\n       * O reserva fica ATRÁS e some no primeiro quadro do livro INTEIRO — não\n       * do canvas.\n       *\n       * Duas armadilhas foram pagas aqui. A primeira: o canvas é transparente,\n       * então enquanto as duas artes existem elas somam, e o objeto sai turvo\n       * com a capa 2D aparecendo em volta da silhueta 3D. A segunda: avisar no\n       * `onCreated` do canvas é cedo demais — ele nasce pronto muito antes de a\n       * arte descer, então o reserva saía, o livro aparecia com a capa\n       * DESENHADA e só então a pintura entrava. Duas trocas visíveis pra montar\n       * uma peça só.\n       *\n       * `visibility` junto de `opacity` porque um canvas transparente sobre um\n       * elemento invisível ainda paga composição — e some do leitor de tela,\n       * que já ouve o `aria-label` do pai.\n       */}\n      <div\n        className=\"absolute inset-0\"\n        aria-hidden\n        style={{\n          opacity: ready ? 0 : 1,\n          visibility: ready ? \"hidden\" : \"visible\",\n          transition: \"opacity var(--dur-slow, 300ms) var(--ease-out, ease-out)\",\n        }}\n      >\n        <Standby seed={seed} title={title} />\n      </div>\n\n      <div className=\"absolute inset-0\">\n        <React.Suspense fallback={null}>\n          <BookStage {...stage} onReady={handleReady} />\n        </React.Suspense>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Mostrar livro, revista ou catálogo como objeto 3D com capa, lombada e espessura reais",
      "evite": "Muitos itens numa grade leve: spine-card ou book-shelf; item sem arte de capa, cover-art"
    }
  },
  "categories": [
    "conteudo",
    "livro",
    "3d",
    "webgl",
    "catalogo",
    "capa",
    "objeto"
  ],
  "type": "registry:ui"
}