1 Dispositivos, rastreamento e graus de liberdade — Projeto do Professor

Este é o projeto de referência resolvido pelo professor: a implementação que serve de modelo do que cada grupo deve produzir no Projeto Integrador. Ele existe para ser estudado, discutido e criticado — não para ser copiado. O que está aqui é uma solução defensável, com as decisões explicadas uma a uma; a solução de cada grupo será outra, e terá de ser defendida do mesmo jeito.

1.1 Visão geral

O primeiro artefato executável do percurso, e ele continua não desenhando nada.

No módulo anterior escrevemos duas declarações. Dissemos qual é o domínio da Bancada e o que cada regime pretende fazer com o mundo de quem observa. Uma delas dizia que o regime aumentado espera composição de fundo mesclada por transparência.

Neste módulo o aparelho responde. E ele pode discordar.

Essa é a diferença que organiza tudo o que vem a seguir. Até aqui, o projeto afirmava; daqui em diante, ele pergunta e escuta. As duas tarefas do módulo constroem exatamente esse canal: uma sonda que interroga o aparelho e um relatório que se lê no próprio aparelho interrogado.

O código nasce em uma pasta nova de dispositivos, ao lado das que já existiam. A consulta grossa do módulo anterior — “este aparelho entra neste regime?” — continua onde estava e é reaproveitada inteira. O que acrescentamos é a pergunta fina, que só a sessão responde.

Vale dizer de saída o que este módulo não faz, porque a tentação é grande. Ele não abre a bancada, não carrega peça, não desenha polígono. O único triângulo que este código chega perto de tocar é a superfície de composição que a API exige para entregar quadros — e nem nela desenhamos.

1.2 Graus de liberdade, e por que a API não os informa

A pergunta que todo mundo faz ao aparelho, e que a plataforma se recusa a responder.

Quantos graus de liberdade este visor rastreia? A pergunta é a mais natural do módulo, e a API não tem propriedade alguma que a responda. Não é esquecimento da especificação. O navegador não conversa com o sensor; ele conversa com o sistema do aparelho, e o que esse sistema entrega são espaços de referência.

Um espaço de referência é a origem contra a qual as poses são medidas. Pedir um deles é pedir uma promessa: entrego pose medida assim. Quando o aparelho concede, ele está dizendo que consegue sustentar aquela promessa.

Daí a inferência que o módulo de graus de liberdade faz, e ela é conservadora de propósito.

src/bancada/devices/graus.ts
// ---------------------------------------------------------------------------
// Graus de liberdade e classe do aparelho, inferidos do que foi concedido.
//
// A API XR não expõe um número de graus de liberdade. Não há propriedade a ler,
// e isso não é omissão: o navegador não sabe o que o sensor faz, sabe o que o
// runtime do aparelho aceitou entregar. O que existe para inferir é o conjunto de
// espaços de referência concedidos, e a inferência tem limites que este arquivo
// registra em vez de esconder.
//
// O porquê de a inferência ser conservadora: um relatório que afirma "seis graus
// de liberdade" com base em evidência fraca é pior que um relatório que diz "não
// dá para saber daqui". O primeiro será citado; o segundo, verificado.
// ---------------------------------------------------------------------------

/**
 * O que se pode afirmar sobre o rastreamento de posição a partir do que a sessão
 * concedeu — três graus (só orientação) contra seis (orientação e posição).
 */
export type GrausDeLiberdade = 'tres' | 'seis' | 'indeterminado';

/**
 * Classe do aparelho deduzida da capacidade declarada, e não do nome que o
 * navegador diz ter.
 *
 * A escolha é deliberada e é o conteúdo do módulo: a cadeia de identificação do
 * navegador é editável, imitada por outros aparelhos e envelhece a cada versão.
 * O que a sessão concede é o que o aparelho faz agora, na mão de quem está
 * usando.
 */
export type ClasseDeAparelho =
  | 'sem-api'
  | 'somente-janela'
  | 'visor-sem-posicao'
  | 'visor-com-posicao'
  | 'aparelho-de-mao-com-camera';

export interface LeituraDeEspacos {
  /** Espaços de referência que a sessão de fato entregou quando pedidos. */
  readonly concedidos: readonly string[];
  /** O modo de sessão em que a leitura foi feita. */
  readonly modo: 'immersive-vr' | 'immersive-ar';
  /** Havia alguma fonte de entrada com pose de punho declarada. */
  readonly comPoseDePunho: boolean;
}

/**
 * Regra da inferência, escrita por extenso porque é ela que o estudante precisa
 * poder contestar:
 *
 * - `local-floor` ou `bounded-floor` concedidos exigem que o aparelho saiba onde
 *   está o chão em relação a quem observa. Um visor que só gira não tem como
 *   sustentar isso, então a concessão é evidência forte de posição rastreada.
 * - `viewer` sozinho é o mínimo que qualquer sessão entrega: a origem acompanha
 *   quem observa, e translação alguma é observável a partir dela. Evidência forte
 *   da ausência.
 * - `local` no meio do caminho é ambíguo de verdade. A especificação o descreve
 *   como origem próxima de quem observa no início da sessão, e um aparelho de três
 *   graus pode concedê-lo mantendo a posição sempre na origem. Daí
 *   `indeterminado`, e não uma aposta.
 */
export function grausDeLiberdade(leitura: LeituraDeEspacos): GrausDeLiberdade {
  const temChao: boolean =
    leitura.concedidos.includes('local-floor') ||
    leitura.concedidos.includes('bounded-floor') ||
    leitura.concedidos.includes('unbounded');
  if (temChao) {
    return 'seis';
  }
  if (leitura.concedidos.length === 1 && leitura.concedidos[0] === 'viewer') {
    return 'tres';
  }
  return 'indeterminado';
}

/**
 * Classifica o aparelho pela combinação de modo de sessão, posição rastreada e
 * tipo de mira das fontes de entrada.
 *
 * `modosSuportados` vem da consulta feita sem sessão alguma — a mesma do módulo
 * anterior —, e por isso esta função responde mesmo quando nenhuma sessão chegou
 * a abrir.
 */
export function classificarAparelho(
  modosSuportados: readonly string[],
  graus: GrausDeLiberdade,
  temApiXr: boolean,
): ClasseDeAparelho {
  if (!temApiXr) {
    return 'sem-api';
  }
  const suportaVr: boolean = modosSuportados.includes('immersive-vr');
  const suportaAr: boolean = modosSuportados.includes('immersive-ar');

  if (!suportaVr && !suportaAr) {
    return 'somente-janela';
  }
  // Aparelho que faz realidade aumentada e não faz sessão imersiva completa é o
  // celular: a câmera vê o mundo, mas ninguém veste a tela no rosto.
  if (suportaAr && !suportaVr) {
    return 'aparelho-de-mao-com-camera';
  }
  return graus === 'tres' ? 'visor-sem-posicao' : 'visor-com-posicao';
}

/** Frase curta e legível para cada classe, usada no relatório. */
export function descreverClasse(classe: ClasseDeAparelho): string {
  switch (classe) {
    case 'sem-api':
      return 'Navegador sem a API XR, ou página fora de contexto seguro.';
    case 'somente-janela':
      return 'Aparelho que só sustenta o regime de janela — é o caso do desktop do laboratório.';
    case 'visor-sem-posicao':
      return 'Visor que acompanha a rotação da cabeça e não acompanha o deslocamento.';
    case 'visor-com-posicao':
      return 'Visor que acompanha rotação e deslocamento, com o chão do ambiente como referência.';
    case 'aparelho-de-mao-com-camera':
      return 'Aparelho de mão que compõe o virtual sobre a imagem da própria câmera.';
  }
}

A regra cabe em três frases. Espaço com chão concedido exige saber onde está o chão em relação a quem observa, e um visor que apenas gira não sustenta isso. Espaço de observador sozinho é o mínimo que qualquer sessão entrega, e translação alguma é observável a partir dele. O caso do meio é genuinamente ambíguo, e devolvemos indeterminado em vez de apostar.

Por que não arriscar o palpite no caso do meio? Porque relatório que afirma seis graus com evidência fraca será citado como se fosse medida. O que diz “não dá para saber daqui” será verificado por alguém. O primeiro produz confiança falsa; o segundo produz trabalho.

Repare também na classificação do aparelho. Ela não olha para a cadeia de identificação do navegador em momento nenhum, e essa foi uma decisão custosa: a cadeia é a informação mais fácil de obter e a mais confortável de ler. Ela também é editável pelo usuário, imitada por outros aparelhos e envelhece a cada versão. O que a sessão concede é o que o aparelho faz agora, nas mãos de quem está usando.

Aqui está o caso concreto que o módulo precisa deixar assentado. Um visor que acompanha a rotação da cabeça e ignora o deslocamento produz desconforto quando quem o usa dá um passo à frente. O corpo avança, os canais do ouvido interno registram o avanço, e a imagem não se aproxima de nada. O conflito entre o que o corpo mede e o que os olhos veem é o que produz o mal-estar, e ele não é falha de renderização.

1.3 Tarefa 1: Construir a sonda de capacidades

Enunciado da tarefa

A primeira peça executável do ambiente não desenha coisa alguma. Ela pergunta ao aparelho o que ele oferece — que tipos de sessão suporta, que recursos opcionais concede, que fontes de entrada declara, quantos graus de liberdade rastreia — e guarda a resposta numa estrutura que o resto do ambiente possa consultar.

O que fica pronto é essa consulta feita de verdade contra o aparelho, com a distinção preservada entre recurso ausente e recurso negado. Assumir capacidade que o aparelho nunca declarou é a origem da maior parte das falhas silenciosas dos capítulos seguintes.

1.3.1 A distinção que o enunciado cobra, e que a API não entrega pronta

O enunciado pede que recurso ausente e recurso negado sejam tratados de forma diferente. A API não ajuda: os dois passam pelo mesmo canal de pedido opcional e os dois simplesmente não aparecem depois. A distinção precisa ser construída aqui, à mão.

Construímos assim. A sessão informa, quando quer, a lista do que concedeu. Nome presente na lista é recurso concedido. Nome ausente de uma lista que existe é recurso recusado — o aparelho respondeu não. Lista inexistente é a terceira coisa, e é a que mais rende.

src/bancada/devices/recursos.ts
// ---------------------------------------------------------------------------
// Catálogo dos recursos opcionais que a sonda consulta, e a classificação do
// que o aparelho respondeu sobre cada um.
//
// Este arquivo existe para separar duas coisas que o vocabulário corrente
// confunde: o recurso que o aparelho NÃO TEM e o recurso que ele TEM e NÃO
// CONCEDEU. A API XR trata os dois de um jeito só na hora de pedir — passam
// ambos por `optionalFeatures` e simplesmente não aparecem depois —, e é por
// isso que a distinção precisa ser feita aqui, à mão, em vez de esperada da
// plataforma.
//
// O terceiro estado é o que mais dá trabalho e o que mais evita erro:
// `indeterminado`. A sessão só reporta o que concedeu através de
// `enabledFeatures`, e essa propriedade é opcional na especificação — um
// navegador pode entrar em sessão sem dizer o que ligou. Sem o terceiro estado,
// esse navegador apareceria no relatório como aparelho que negou tudo.
// ---------------------------------------------------------------------------

/**
 * O que se sabe sobre um recurso depois de a sessão abrir.
 *
 * - `concedido`: o nome está em `enabledFeatures`, e o recurso pode ser usado.
 * - `negado`: a sessão reportou a lista e o nome não está nela — o aparelho ou o
 *   navegador recusou, e a razão não é exposta.
 * - `indeterminado`: a sessão não reportou lista alguma. Não é negativa; é
 *   ausência de resposta, e tratá-la como negativa produz relatório confiante e
 *   errado.
 */
export type EstadoDeRecurso = 'concedido' | 'negado' | 'indeterminado';

/** Um recurso opcional da API XR, com o motivo de ele estar no catálogo. */
export interface RecursoOpcional {
  /** O nome exato aceito por `optionalFeatures` — não traduzir. */
  readonly nome: string;
  /** O que ele habilita no percurso, em uma frase. */
  readonly paraQueServe: string;
}

/**
 * Os recursos que a Bancada consulta.
 *
 * A lista é curta de propósito: pedir tudo o que a especificação prevê faria a
 * sonda demorar mais e algumas plataformas recusarem a sessão inteira por causa
 * de um item exótico. Cada entrada aqui é um recurso de que algum módulo adiante
 * realmente depende.
 *
 * `depth-sensing` ficou de fora, e a razão é técnica: ele exige um dicionário de
 * configuração próprio no pedido de sessão (formato de dado e ordem de uso), e um
 * pedido malformado derruba a sessão inteira em vez de apenas negar o recurso.
 * Consultá-lo custaria, aqui, o risco de perder tudo o mais.
 */
export const RECURSOS_CONSULTADOS: readonly RecursoOpcional[] = [
  {
    nome: 'local-floor',
    paraQueServe:
      'origem no chão do espaço físico — é o que faz a bancada nascer na altura certa',
  },
  {
    nome: 'bounded-floor',
    paraQueServe:
      'origem no chão mais os limites da área livre que o aparelho conhece',
  },
  {
    nome: 'unbounded',
    paraQueServe: 'espaço sem fronteira declarada, para percursos longos',
  },
  {
    nome: 'hit-test',
    paraQueServe:
      'lançar um raio contra as superfícies reais que o aparelho encontrou',
  },
  {
    nome: 'anchors',
    paraQueServe:
      'prender um objeto virtual a um ponto do mapa e deixar o aparelho corrigi-lo',
  },
  {
    nome: 'plane-detection',
    paraQueServe: 'receber os planos que o aparelho reconheceu no ambiente',
  },
  {
    nome: 'hand-tracking',
    paraQueServe:
      'pose das mãos sem controle — fora do núcleo do percurso, e consultado só para registro',
  },
];

/**
 * Classifica um recurso contra a lista que a sessão reportou.
 *
 * `concedidos` vem de `XRSession.enabledFeatures`, que é opcional na
 * especificação: `undefined` significa "esta sessão não diz", e é o que produz
 * `indeterminado`.
 */
export function estadoDoRecurso(
  nome: string,
  concedidos: readonly string[] | undefined,
): EstadoDeRecurso {
  if (concedidos === undefined) {
    return 'indeterminado';
  }
  return concedidos.includes(nome) ? 'concedido' : 'negado';
}

Sem esse terceiro estado, um navegador que entra em sessão sem declarar o que ligou apareceria no relatório como aparelho que negou tudo. Seria um aparelho competente descrito como incapaz, e ninguém desconfiaria — porque a tabela estaria preenchida, bonita, com sete linhas dizendo não.

O catálogo é curto por dois motivos, e o segundo não é óbvio. O primeiro é tempo: pedir tudo o que a especificação prevê alonga a sondagem sem retorno. O segundo é risco. Um pedido malformado não nega o recurso pedido — derruba a sessão inteira, e a sonda perde tudo o mais. É por isso que o recurso de leitura de profundidade ficou fora: ele exige um dicionário de configuração próprio, e configurá-lo errado custaria a sondagem completa por causa de um item de que ainda não precisamos.

1.3.2 A sonda, e as duas restrições que moldam o arquivo inteiro

src/bancada/devices/sonda.ts
// ---------------------------------------------------------------------------
// A sonda de capacidades — a Tarefa 1 deste módulo.
//
// Ela pergunta ao aparelho o que ele oferece e guarda a resposta numa estrutura
// que o resto do ambiente possa consultar. Não desenha, não carrega malha e não
// monta cena: o que ela produz é conhecimento sobre o aparelho, e é isso que os
// módulos seguintes consomem para decidir o que sequer tentar.
//
// Duas restrições da plataforma moldam o arquivo inteiro, e nenhuma delas é
// contornável:
//
// 1. Metade das respostas só existe DENTRO de uma sessão. Recursos concedidos,
//    modo de composição do fundo, espaços de referência entregues e fontes de
//    entrada são propriedades da sessão, não do navegador. Sondar de fora
//    devolve uma lista de suposições.
// 2. Abrir sessão imersiva exige gesto de quem usa. O navegador recusa o pedido
//    que não venha de um clique, e a recusa se parece com defeito do código.
//    Daí a sonda ser função chamada por botão, e não coisa que roda ao carregar.
// ---------------------------------------------------------------------------

import { REGIMES, type Regime, type RegimeId } from '../modes/regimes';
import { levantarRelatorio, type LinhaDoRelatorio } from '../modes/verificacao';
import {
  RECURSOS_CONSULTADOS,
  estadoDoRecurso,
  type EstadoDeRecurso,
} from './recursos';
import {
  ContadorDeEstabilidade,
  diagnosticar,
  type Estabilidade,
} from './estabilidade';
import {
  classificarAparelho,
  grausDeLiberdade,
  type ClasseDeAparelho,
  type GrausDeLiberdade,
} from './graus';

/** Modos em que uma sessão de sondagem pode ser aberta. */
export type ModoSondavel = 'immersive-vr' | 'immersive-ar';

/** Espaços de referência que a sonda tenta obter, do mais exigente ao mínimo. */
const ESPACOS_TENTADOS: readonly XRReferenceSpaceType[] = [
  'bounded-floor',
  'local-floor',
  'unbounded',
  'local',
  'viewer',
];

/** Quantos quadros a sonda observa antes de encerrar a sessão. */
const QUADROS_OBSERVADOS: number = 90;

export interface RecursoSondado {
  readonly nome: string;
  readonly paraQueServe: string;
  readonly estado: EstadoDeRecurso;
}

export interface FonteDeEntradaSondada {
  /** Lado declarado: `left`, `right` ou `none`. */
  readonly lado: string;
  /** Como a mira é produzida: raio de controle, olhar ou toque na tela. */
  readonly mira: string;
  /** Há pose de punho — objeto rastreado no espaço, não apenas uma direção. */
  readonly temPoseDePunho: boolean;
  /** Há pose de mão articulada. */
  readonly temMao: boolean;
  /** Perfis declarados pelo aparelho, do mais específico ao mais genérico. */
  readonly perfis: readonly string[];
}

/** O que a sonda descobre sem abrir sessão alguma. */
export interface SondaSemSessao {
  readonly temApiXr: boolean;
  readonly contextoSeguro: boolean;
  readonly regimes: readonly LinhaDoRelatorio[];
  readonly modosSuportados: readonly string[];
}

/** O que só a sessão responde. */
export interface SondaEmSessao {
  readonly modo: ModoSondavel;
  readonly recursos: readonly RecursoSondado[];
  readonly espacosConcedidos: readonly string[];
  readonly composicaoObservada: XREnvironmentBlendMode;
  readonly fontesDeEntrada: readonly FonteDeEntradaSondada[];
  readonly graus: GrausDeLiberdade;
  readonly estabilidade: Estabilidade;
  readonly diagnostico: string;
}

export interface ResultadoDaSonda {
  readonly semSessao: SondaSemSessao;
  readonly emSessao: SondaEmSessao | undefined;
  /** Por que não houve sessão, quando não houve. */
  readonly motivoSemSessao: string | undefined;
  readonly classe: ClasseDeAparelho;
}

// A API XR não é exposta fora de contexto seguro. O sintoma é idêntico ao de um
// aparelho sem suporte, e a causa é a URL — o erro de laboratório mais frequente
// do percurso, e o que mais custa tempo de aula por parecer defeito de código.
function contextoSeguro(): boolean {
  return window.isSecureContext;
}

export async function sondarSemSessao(): Promise<SondaSemSessao> {
  const regimes: LinhaDoRelatorio[] = await levantarRelatorio();
  const suportados: string[] = regimes
    .filter((linha) => linha.suporte === 'sim')
    .map((linha) => linha.regime.id);

  return {
    temApiXr: navigator.xr !== undefined,
    contextoSeguro: contextoSeguro(),
    regimes,
    modosSuportados: suportados,
  };
}

/**
 * Tenta obter cada espaço de referência e devolve os que vieram.
 *
 * O pedido rejeita quando o espaço não é concedido, e é essa rejeição que
 * informa — o `catch` vazio aqui não esconde erro algum: ele é a leitura.
 */
async function espacosConcedidos(sessao: XRSession): Promise<string[]> {
  const obtidos: string[] = [];
  for (const tipo of ESPACOS_TENTADOS) {
    try {
      await sessao.requestReferenceSpace(tipo);
      obtidos.push(tipo);
    } catch {
      // Espaço não concedido. É resposta, não falha.
    }
  }
  return obtidos;
}

function lerFontesDeEntrada(sessao: XRSession): FonteDeEntradaSondada[] {
  const fontes: FonteDeEntradaSondada[] = [];
  for (const fonte of sessao.inputSources) {
    fontes.push({
      lado: fonte.handedness,
      mira: fonte.targetRayMode,
      temPoseDePunho: fonte.gripSpace !== undefined,
      temMao: fonte.hand !== undefined,
      perfis: [...fonte.profiles],
    });
  }
  return fontes;
}

/**
 * A camada de composição mínima.
 *
 * A especificação só entrega quadros a uma sessão que tenha superfície de
 * composição declarada. Não desenhamos nada nela: ela é a condição para o laço de
 * quadros existir. A distinção entre a superfície e a cena é exatamente o que
 * este módulo ainda não tem, e declará-la aqui evita que alguém leia este trecho,
 * dois módulos adiante, como início de um renderizador paralelo.
 */
function camadaMinima(sessao: XRSession): void {
  const tela: HTMLCanvasElement = document.createElement('canvas');
  const gl: WebGL2RenderingContext | null = tela.getContext('webgl2', {
    xrCompatible: true,
  });
  if (gl === null) {
    throw new Error('Este navegador não entregou contexto WebGL 2 compatível com XR.');
  }
  sessao.updateRenderState({ baseLayer: new XRWebGLLayer(sessao, gl) });
}

/** Observa alguns quadros, contando os que vieram sem pose de quem observa. */
function observarQuadros(
  sessao: XRSession,
  referencia: XRReferenceSpace,
): Promise<Estabilidade> {
  return new Promise<Estabilidade>((resolver) => {
    const contador: ContadorDeEstabilidade = new ContadorDeEstabilidade();
    let restantes: number = QUADROS_OBSERVADOS;

    const passo: XRFrameRequestCallback = (_tempo: number, quadro: XRFrame): void => {
      const pose: XRViewerPose | undefined = quadro.getViewerPose(referencia);
      contador.registrar(pose !== undefined, sessao.visibilityState === 'visible');
      restantes -= 1;
      if (restantes > 0) {
        sessao.requestAnimationFrame(passo);
        return;
      }
      resolver(contador.resultado());
    };

    sessao.requestAnimationFrame(passo);
  });
}

export async function sondarEmSessao(modo: ModoSondavel): Promise<SondaEmSessao> {
  const xr: XRSystem | undefined = navigator.xr;
  if (xr === undefined) {
    throw new Error('Não há API XR neste navegador.');
  }

  // Todo recurso vai como opcional. Passar qualquer um deles como obrigatório
  // faria o aparelho recusar a sessão inteira por causa de um item — e a sonda
  // perderia justamente a informação que veio buscar.
  const sessao: XRSession = await xr.requestSession(modo, {
    optionalFeatures: RECURSOS_CONSULTADOS.map((recurso) => recurso.nome),
  });

  try {
    camadaMinima(sessao);
    const concedidos: readonly string[] | undefined = sessao.enabledFeatures;
    const espacos: string[] = await espacosConcedidos(sessao);
    const referencia: XRReferenceSpace = await sessao.requestReferenceSpace(
      espacos.includes('local-floor') ? 'local-floor' : 'viewer',
    );
    const estabilidade: Estabilidade = await observarQuadros(sessao, referencia);
    const fontes: FonteDeEntradaSondada[] = lerFontesDeEntrada(sessao);

    return {
      modo,
      recursos: RECURSOS_CONSULTADOS.map((recurso) => ({
        nome: recurso.nome,
        paraQueServe: recurso.paraQueServe,
        estado: estadoDoRecurso(recurso.nome, concedidos),
      })),
      espacosConcedidos: espacos,
      composicaoObservada: sessao.environmentBlendMode,
      fontesDeEntrada: fontes,
      graus: grausDeLiberdade({
        concedidos: espacos,
        modo,
        comPoseDePunho: fontes.some((fonte) => fonte.temPoseDePunho),
      }),
      estabilidade,
      diagnostico: diagnosticar(estabilidade),
    };
  } finally {
    // A sessão precisa terminar mesmo quando a sondagem falha no meio. Sessão
    // imersiva viva com página parada prende o visor numa tela vazia, e quem está
    // com o aparelho no rosto só sai pelo menu do sistema.
    await sessao.end();
  }
}

/** Escolhe o modo mais informativo entre os que o aparelho declara suportar. */
export function modoPreferido(
  modosSuportados: readonly string[],
): ModoSondavel | undefined {
  const ordem: readonly ModoSondavel[] = ['immersive-ar', 'immersive-vr'];
  return ordem.find((modo) => modosSuportados.includes(modo));
}

export async function sondar(): Promise<ResultadoDaSonda> {
  const semSessao: SondaSemSessao = await sondarSemSessao();
  const modo: ModoSondavel | undefined = modoPreferido(semSessao.modosSuportados);

  if (modo === undefined) {
    return {
      semSessao,
      emSessao: undefined,
      motivoSemSessao: semSessao.temApiXr
        ? 'Este aparelho não declara sessão imersiva alguma, e metade da sonda não tem onde acontecer. É informação sobre o aparelho, não defeito do código.'
        : 'Sem API XR neste navegador. Se a página não está em contexto seguro, a causa é a URL, e não o aparelho.',
      classe: classificarAparelho(
        semSessao.modosSuportados,
        'indeterminado',
        semSessao.temApiXr,
      ),
    };
  }

  const emSessao: SondaEmSessao = await sondarEmSessao(modo);
  return {
    semSessao,
    emSessao,
    motivoSemSessao: undefined,
    classe: classificarAparelho(
      semSessao.modosSuportados,
      emSessao.graus,
      semSessao.temApiXr,
    ),
  };
}

/**
 * Confronta a composição declarada no módulo anterior com a que a sessão
 * informou. É o primeiro ponto do percurso em que uma declaração nossa pode ser
 * desmentida pelo aparelho, e o desmentido é o resultado mais valioso dos dois.
 */
export function conferirComposicao(emSessao: SondaEmSessao): string {
  const id: RegimeId = emSessao.modo;
  const regime: Regime | undefined = REGIMES.find((candidato) => candidato.id === id);
  if (regime === undefined) {
    return 'O regime sondado não consta da declaração de regimes.';
  }
  if (regime.composicaoEsperada === emSessao.composicaoObservada) {
    return `A composição declarada (${regime.composicaoEsperada}) foi confirmada pela sessão.`;
  }
  return (
    `Declaramos composição ${regime.composicaoEsperada} e a sessão informou ` +
    `${emSessao.composicaoObservada}. A declaração estava errada, e quem tem razão é o aparelho.`
  );
}

A primeira restrição é que metade das respostas só existe dentro de uma sessão. Recursos concedidos, composição do fundo, espaços de referência entregues e fontes de entrada são propriedades da sessão, não do navegador. Sondar de fora devolve uma lista de suposições bem formatadas.

A segunda é que abrir sessão imersiva exige gesto de quem usa. O navegador recusa o pedido que não venha de um toque, e a recusa chega como erro de segurança. É por isso que a sonda é função chamada por botão, e não coisa que roda ao carregar a página.

Três decisões dentro do arquivo merecem justificativa.

Todo recurso vai pedido como opcional, sem exceção. Marcar um único deles como obrigatório faria o aparelho recusar a sessão inteira por causa daquele item — e a sonda perderia exatamente a informação que veio buscar. O que queremos saber é o que ele concede, e para isso ele precisa primeiro deixar entrar.

O encerramento da sessão está no bloco que roda mesmo quando algo falha no meio. A razão é física, não estética: sessão imersiva viva com página parada prende o visor numa tela vazia, e quem está com o aparelho no rosto só sai pelo menu do sistema. Descobrimos isso da maneira desconfortável, e o custo foi um voluntário tateando o botão lateral.

A superfície de composição mínima existe porque a especificação só entrega quadros a uma sessão que tenha uma declarada. Não desenhamos nada nela. Ela é a condição para o laço de quadros existir, e o comentário no arquivo diz isso em voz alta — sem essa nota, alguém leria o trecho, dois módulos adiante, como o começo de um segundo renderizador.

1.3.3 O confronto que fecha o módulo anterior

A última função do arquivo é pequena e é o ponto alto do módulo. Ela pega a composição de fundo que declaramos como esperada, pega a que a sessão informou, e compara.

Quando batem, o ganho é uma confirmação. Quando não batem, o ganho é a descoberta de que a declaração estava errada, feita pela única entidade com autoridade para dizê-lo. O texto que a função devolve nesse caso registra quem tem razão, e não é o projeto.

1.3.4 Onde é fácil errar, e como conferir

O erro mais caro desta tarefa não está no código. Está na URL. Sem conexão cifrada, a API não é exposta, e todo aparelho responde exatamente como responderia um aparelho sem suporte. O relatório fica coerente, completo e falso.

Por isso a sonda registra o estado do contexto seguro antes de qualquer outra coisa, e o relatório diz, em uma linha, se o que vem abaixo é informação sobre o aparelho ou sobre o endereço. É a linha que evita o diagnóstico errado mais comum do laboratório.

O segundo erro é assumir capacidade não declarada. Um grupo escreve o módulo de ancoragem supondo detecção de plano porque o aparelho da bancada de teste a concede, e a turma inteira descobre em outro aparelho que não. A sonda existe para que essa suposição vire consulta.

A conferência é abrir a mesma página em três aparelhos e comparar as tabelas. No desktop, a sessão imersiva não abre e a metade de baixo do relatório explica por quê. No celular, a composição vem mesclada e a lista de recursos traz detecção de plano. No visor, aparecem duas fontes de entrada com pose de punho.

1.4 Tarefa 2: Tornar o relatório visível

Enunciado da tarefa

Um relatório que só existe no console de depuração serve a quem escreveu o código e a mais ninguém. O resultado da sonda precisa aparecer em algum lugar que qualquer pessoa consiga ler no próprio aparelho.

A tarefa está cumprida quando o mesmo endereço, aberto em aparelhos de classes diferentes, produz relatórios diferentes e legíveis. É o instante em que a palavra dispositivo deixa de ser tópico narrado e passa a ser algo que se lê na tela.

1.4.1 O canal que não existe dentro de um visor

Quem está com o aparelho no rosto não abre painel de desenvolvedor. Não lê aviso de rede, não vê exceção, não sabe que houve exceção. Todo canal de erro que o desenvolvimento web trata como natural desaparece no instante em que a tela sobe para os olhos.

O diário resolve isso, e a solução é deliberadamente simples.

src/bancada/relatorio/diario.ts
// ---------------------------------------------------------------------------
// O diário da sondagem — o que aconteceu, escrito onde se possa ler.
//
// Este arquivo é a metade menos vistosa da Tarefa 2, e a que mais decide se o
// módulo funciona em sala. O console de depuração não existe dentro de um visor:
// quem está com o aparelho no rosto não abre painel de desenvolvedor, não lê
// aviso de rede e não vê exceção. Tudo o que a sondagem tiver a dizer precisa
// aparecer na própria página, em corpo de texto que se leia a um braço de
// distância.
//
// O espelho no console continua existindo, e não por hábito: quando o aparelho
// está ligado ao computador por depuração remota, o mesmo texto em dois lugares
// é o que permite comparar o que a página mostrou com o que o navegador
// registrou.
// ---------------------------------------------------------------------------

export type Severidade = 'nota' | 'alerta' | 'falha';

export interface Entrada {
  readonly severidade: Severidade;
  readonly texto: string;
}

/**
 * Acumula as entradas e as escreve num elemento da página.
 *
 * O acúmulo é o que permite ao diário existir antes de o elemento existir: a
 * sondagem pode falhar durante o carregamento, e uma mensagem perdida por não ter
 * onde ser escrita é a pior das mensagens.
 */
export class Diario {
  private readonly entradas: Entrada[] = [];
  private destino: HTMLElement | undefined = undefined;

  public fixarDestino(destino: HTMLElement): void {
    this.destino = destino;
    this.redesenhar();
  }

  public nota(texto: string): void {
    this.registrar({ severidade: 'nota', texto });
  }

  public alerta(texto: string): void {
    this.registrar({ severidade: 'alerta', texto });
  }

  public falha(texto: string): void {
    this.registrar({ severidade: 'falha', texto });
  }

  private registrar(entrada: Entrada): void {
    this.entradas.push(entrada);
    // O espelho no console serve à depuração remota, quando o aparelho está
    // ligado ao computador. Nunca é o canal principal.
    console.info(`[bancada:${entrada.severidade}] ${entrada.texto}`);
    this.redesenhar();
  }

  private redesenhar(): void {
    const destino: HTMLElement | undefined = this.destino;
    if (destino === undefined) {
      return;
    }
    destino.replaceChildren();
    for (const entrada of this.entradas) {
      const linha: HTMLParagraphElement = document.createElement('p');
      linha.className = `diario diario-${entrada.severidade}`;
      linha.textContent = entrada.texto;
      destino.appendChild(linha);
    }
  }
}

/**
 * Traduz o que veio de um `catch` em texto legível.
 *
 * A recusa de sessão chega como erro, e o erro cru — nome da classe e uma frase
 * em inglês — é exatamente o que faz um grupo concluir que o código quebrou
 * quando o que houve foi o aparelho dizendo não.
 */
export function explicarFalha(erro: unknown): string {
  if (erro instanceof DOMException && erro.name === 'NotSupportedError') {
    return 'O aparelho recusou a sessão neste modo. Ele não a sustenta, e o pedido foi respondido.';
  }
  if (erro instanceof DOMException && erro.name === 'SecurityError') {
    return 'O navegador recusou o pedido por falta de gesto de quem usa ou por contexto inseguro. O botão precisa ser tocado, e a página precisa estar em conexão cifrada.';
  }
  if (erro instanceof DOMException && erro.name === 'InvalidStateError') {
    return 'Já existe uma sessão aberta neste navegador. Encerre a anterior antes de sondar de novo.';
  }
  if (erro instanceof Error) {
    return `A sondagem parou: ${erro.message}`;
  }
  return 'A sondagem parou por um motivo que o navegador não descreveu.';
}

Ele acumula as entradas antes de ter onde escrevê-las, e essa ordem importa. A sondagem pode falhar durante o carregamento, e mensagem perdida por falta de destino é a pior categoria de mensagem: existiu, foi formatada, e ninguém a leu.

O espelho no console continua ali, e não por hábito. Quando o aparelho está ligado ao computador por depuração remota, ter o mesmo texto nos dois lugares permite comparar o que a página mostrou com o que o navegador registrou. Divergência entre os dois é sintoma próprio, e vale ter como notá-la.

A função que traduz a falha merece atenção. A recusa de sessão chega como erro cru — um nome de classe e uma frase curta em inglês —, e é exatamente isso que faz um grupo concluir que o código quebrou quando o que houve foi o aparelho dizendo não. Traduzimos os três casos que aparecem de verdade: aparelho que não sustenta o modo, pedido sem gesto ou fora de contexto seguro, e sessão já aberta.

1.4.2 A apresentação, e o que ela não pode ser ainda

O painel definitivo da Bancada é diegético, preso à própria bancada, lido de dentro do mundo. Ele continua impossível pelo mesmo motivo do módulo anterior: não há mundo. O que fizemos foi estender o relatório em texto comum, preservando inteiro o que já existia.

src/bancada/relatorio/relatorio.ts
// ---------------------------------------------------------------------------
// Apresentação do confronto — versão provisória, fora da cena.
//
// O painel definitivo da Bancada é diegético: um cartaz preso à própria bancada,
// dentro do mundo, lido de dentro do ambiente. Ele não pode existir ainda, pelo
// motivo mais simples possível — não há mundo. Enquanto o grafo de cena não
// chega, o relatório sai em HTML comum, e essa é uma decisão temporária que vale
// a pena declarar em vez de esconder: quando a bancada existir, este arquivo é o
// que muda de lugar, e nada mais.
// ---------------------------------------------------------------------------

import type { Dominio } from '../dominio/dominio';
import type { EstadoDeRecurso } from '../devices/recursos';
import { descreverClasse, type GrausDeLiberdade } from '../devices/graus';
import type { ResultadoDaSonda, SondaEmSessao } from '../devices/sonda';
import type { LinhaDoRelatorio, Suporte } from '../modes/verificacao';

function rotuloDoSuporte(suporte: Suporte): string {
  switch (suporte) {
    case 'sim':
      return 'suportado';
    case 'nao':
      return 'não suportado';
    case 'desconhecido':
      return 'sem resposta';
  }
}

function celula(texto: string, cabecalho: boolean = false): HTMLTableCellElement {
  const elemento: HTMLTableCellElement = document.createElement(cabecalho ? 'th' : 'td');
  elemento.textContent = texto;
  return elemento;
}

function tabelaDeRegimes(linhas: readonly LinhaDoRelatorio[]): HTMLTableElement {
  const tabela: HTMLTableElement = document.createElement('table');

  const cabecalho: HTMLTableRowElement = tabela.insertRow();
  for (const titulo of [
    'Regime',
    'O que faz com o mundo',
    'Espaço de referência',
    'Rastreia',
    'Registro contra',
    'Neste aparelho',
  ]) {
    cabecalho.appendChild(celula(titulo, true));
  }

  for (const linha of linhas) {
    const fileira: HTMLTableRowElement = tabela.insertRow();
    fileira.appendChild(celula(linha.regime.nome));
    fileira.appendChild(celula(linha.regime.tratamentoDoMundo));
    fileira.appendChild(celula(linha.regime.espacoDeReferencia));
    fileira.appendChild(celula(linha.regime.rastreia));
    fileira.appendChild(celula(linha.regime.registroContra));
    fileira.appendChild(celula(`${rotuloDoSuporte(linha.suporte)}${linha.observacao}`));
  }

  return tabela;
}

function blocoDoDominio(dominio: Dominio, problemas: readonly string[]): HTMLElement {
  const bloco: HTMLElement = document.createElement('section');

  const titulo: HTMLHeadingElement = document.createElement('h2');
  titulo.textContent = `Domínio: ${dominio.nome}`;
  bloco.appendChild(titulo);

  const descricao: HTMLParagraphElement = document.createElement('p');
  descricao.textContent = dominio.descricao;
  bloco.appendChild(descricao);

  const tarefa: HTMLParagraphElement = document.createElement('p');
  tarefa.textContent = `Tarefa: ${dominio.tarefa.enunciado} Concluída quando: ${dominio.tarefa.estadoFinal}`;
  bloco.appendChild(tarefa);

  const inventario: HTMLParagraphElement = document.createElement('p');
  inventario.textContent =
    `${dominio.pecas.length} peças e ${dominio.sockets.length} encaixes declarados. ` +
    (problemas.length === 0
      ? 'Nenhuma inconsistência entre peças e encaixes.'
      : `Inconsistências: ${problemas.join(' ')}`);
  bloco.appendChild(inventario);

  return bloco;
}

export function montarRelatorio(
  raiz: HTMLElement,
  dominio: Dominio,
  problemas: readonly string[],
  linhas: readonly LinhaDoRelatorio[],
): void {
  raiz.replaceChildren();
  raiz.appendChild(blocoDoDominio(dominio, problemas));

  const tituloRegimes: HTMLHeadingElement = document.createElement('h2');
  tituloRegimes.textContent = 'Regimes: o que foi declarado e o que este aparelho responde';
  raiz.appendChild(tituloRegimes);
  raiz.appendChild(tabelaDeRegimes(linhas));
}

// ---------------------------------------------------------------------------
// Acréscimo deste módulo: a apresentação da sonda de capacidades.
//
// O relatório de regimes acima continua onde estava — ele responde "este
// aparelho entra?", que é a pergunta grossa. O que vem daqui para baixo responde
// "e uma vez dentro, o que ele concede?", que é a pergunta do módulo de
// dispositivos e que só a sessão responde.
// ---------------------------------------------------------------------------

function rotuloDoEstado(estado: EstadoDeRecurso): string {
  switch (estado) {
    case 'concedido':
      return 'concedido';
    case 'negado':
      return 'não concedido';
    case 'indeterminado':
      return 'sem resposta';
  }
}

function rotuloDosGraus(graus: GrausDeLiberdade): string {
  switch (graus) {
    case 'tres':
      return 'três graus de liberdade — o aparelho acompanha para onde a cabeça aponta e não acompanha para onde ela vai';
    case 'seis':
      return 'seis graus de liberdade — o aparelho acompanha orientação e deslocamento';
    case 'indeterminado':
      return 'indeterminado — os espaços concedidos não bastam para afirmar nem uma coisa nem outra';
  }
}

function tabelaDeRecursos(sonda: SondaEmSessao): HTMLTableElement {
  const tabela: HTMLTableElement = document.createElement('table');
  const cabecalho: HTMLTableRowElement = tabela.insertRow();
  for (const titulo of ['Recurso', 'Para que serve', 'Neste aparelho']) {
    cabecalho.appendChild(celula(titulo, true));
  }
  for (const recurso of sonda.recursos) {
    const fileira: HTMLTableRowElement = tabela.insertRow();
    fileira.appendChild(celula(recurso.nome));
    fileira.appendChild(celula(recurso.paraQueServe));
    fileira.appendChild(celula(rotuloDoEstado(recurso.estado)));
  }
  return tabela;
}

function tabelaDeFontes(sonda: SondaEmSessao): HTMLElement {
  if (sonda.fontesDeEntrada.length === 0) {
    const vazio: HTMLParagraphElement = document.createElement('p');
    vazio.textContent =
      'Nenhuma fonte de entrada foi declarada durante a sondagem. Num visor, isso costuma significar controle desligado ou fora de alcance; num aparelho de mão, é o esperado até a primeira toque na tela.';
    return vazio;
  }
  const tabela: HTMLTableElement = document.createElement('table');
  const cabecalho: HTMLTableRowElement = tabela.insertRow();
  for (const titulo of ['Lado', 'Mira', 'Pose de punho', 'Mão articulada', 'Perfis']) {
    cabecalho.appendChild(celula(titulo, true));
  }
  for (const fonte of sonda.fontesDeEntrada) {
    const fileira: HTMLTableRowElement = tabela.insertRow();
    fileira.appendChild(celula(fonte.lado));
    fileira.appendChild(celula(fonte.mira));
    fileira.appendChild(celula(fonte.temPoseDePunho ? 'sim' : 'não'));
    fileira.appendChild(celula(fonte.temMao ? 'sim' : 'não'));
    fileira.appendChild(celula(fonte.perfis.join(', ')));
  }
  return tabela;
}

function paragrafo(texto: string): HTMLParagraphElement {
  const elemento: HTMLParagraphElement = document.createElement('p');
  elemento.textContent = texto;
  return elemento;
}

function subtitulo(texto: string): HTMLHeadingElement {
  const elemento: HTMLHeadingElement = document.createElement('h3');
  elemento.textContent = texto;
  return elemento;
}

/**
 * Escreve o resultado da sonda no elemento indicado.
 *
 * `confronto` é a frase que compara a composição declarada no módulo anterior com
 * a que a sessão informou, e ela vem pronta de fora porque quem a produz é o
 * módulo de dispositivos, não a apresentação.
 */
export function montarSonda(
  raiz: HTMLElement,
  resultado: ResultadoDaSonda,
  confronto: string | undefined,
): void {
  raiz.replaceChildren();

  const titulo: HTMLHeadingElement = document.createElement('h2');
  titulo.textContent = 'Sonda de capacidades';
  raiz.appendChild(titulo);

  raiz.appendChild(paragrafo(descreverClasse(resultado.classe)));
  raiz.appendChild(
    paragrafo(
      resultado.semSessao.contextoSeguro
        ? 'A página está em contexto seguro, então a ausência de um recurso é resposta do aparelho.'
        : 'A página NÃO está em contexto seguro. Nada abaixo é informação sobre o aparelho: é a URL impedindo a pergunta.',
    ),
  );

  const sonda: SondaEmSessao | undefined = resultado.emSessao;
  if (sonda === undefined) {
    raiz.appendChild(
      paragrafo(resultado.motivoSemSessao ?? 'Não houve sessão, e o motivo não foi registrado.'),
    );
    return;
  }

  raiz.appendChild(subtitulo(`Recursos opcionais pedidos em ${sonda.modo}`));
  raiz.appendChild(tabelaDeRecursos(sonda));

  raiz.appendChild(subtitulo('Espaços de referência e graus de liberdade'));
  raiz.appendChild(
    paragrafo(
      sonda.espacosConcedidos.length === 0
        ? 'Nenhum espaço de referência foi concedido.'
        : `Concedidos: ${sonda.espacosConcedidos.join(', ')}.`,
    ),
  );
  raiz.appendChild(paragrafo(rotuloDosGraus(sonda.graus)));

  raiz.appendChild(subtitulo('Fontes de entrada declaradas'));
  raiz.appendChild(tabelaDeFontes(sonda));

  raiz.appendChild(subtitulo('Composição do fundo'));
  raiz.appendChild(paragrafo(`A sessão informou composição ${sonda.composicaoObservada}.`));
  if (confronto !== undefined) {
    raiz.appendChild(paragrafo(confronto));
  }

  raiz.appendChild(subtitulo('Estabilidade do rastreamento na janela observada'));
  raiz.appendChild(
    paragrafo(
      `${sonda.estabilidade.quadros} quadros observados, ` +
        `${sonda.estabilidade.quadrosSemPose} sem pose, ` +
        `${sonda.estabilidade.quadrosOcultos} com a sessão fora de primeiro plano.`,
    ),
  );
  raiz.appendChild(paragrafo(sonda.diagnostico));
}

/**
 * A estrutura da cena e a demonstração da ordem das operações, na página comum.
 *
 * Isto não substitui o painel dentro da cena, e não concorre com ele: o painel
 * mostra o que muda a cada quadro e precisa ser lido de dentro do ambiente; esta
 * seção mostra o que é fixo e se lê melhor com o texto parado diante dos olhos.
 */
export function montarEstruturaDaCena(
  raiz: HTMLElement,
  arvore: readonly string[],
  frasesDaOrdem: readonly string[],
): void {
  raiz.replaceChildren();
  raiz.appendChild(subtitulo('Como a cena está montada'));

  const bloco: HTMLPreElement = document.createElement('pre');
  bloco.textContent = arvore.join('\n');
  raiz.appendChild(bloco);

  raiz.appendChild(subtitulo('A ordem das operações não é livre'));
  for (const frase of frasesDaOrdem) {
    raiz.appendChild(paragrafo(frase));
  }
}

/**
 * O inventário do conteúdo do módulo de modelagem: de onde veio a forma de cada
 * peça, quanto ela custa em triângulos, que superfícies a cena usa e — o que
 * nenhuma outra seção mostra — qual é o volume de contato de cada peça e por que
 * a folga é aquela.
 *
 * O volume vive aqui em texto porque ele decide comportamento sem aparecer na
 * tela. Ler a caixa em centímetros ao lado da razão escrita é o que permite
 * discutir a escolha antes de o encaixe existir para reclamar dela.
 */
export function montarConteudo(
  raiz: HTMLElement,
  inventario: readonly string[],
  materiais: readonly string[],
  volumes: readonly string[],
  comparacao: string,
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('De onde veio a forma de cada peça'));
  for (const linha of inventario) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('As superfícies, e o que cada uma cobra'));
  for (const linha of materiais) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('O que se vê e o que colide'));
  for (const linha of volumes) {
    raiz.appendChild(paragrafo(linha));
  }
  raiz.appendChild(paragrafo(comparacao));
}

/**
 * A folha de ativos e de custo: o laudo da importação, o que a instanciação
 * comprou, os dois níveis da prateleira e a medição com a máquina ao lado.
 *
 * As quatro coisas ficam na mesma folha de propósito. Separá-las é o que produz
 * o relatório de desempenho que ninguém consegue repetir: o número numa página,
 * a máquina noutra, e a decisão tomada sem as duas à vista.
 */
export function montarAtivosECusto(
  raiz: HTMLElement,
  ativo: readonly string[],
  repeticao: readonly string[],
  niveis: readonly string[],
  medicao: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O ativo que veio de fora, e o que foi ajustado nele'));
  for (const linha of ativo) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('Repetição tratada como repetição'));
  for (const linha of repeticao) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('Detalhe cobrado por distância'));
  for (const linha of niveis) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('A medição, e a máquina em que ela foi obtida'));
  const bloco: HTMLPreElement = document.createElement('pre');
  bloco.textContent = medicao.join('\n');
  raiz.appendChild(bloco);
}

/**
 * O regime em janela: o estado da órbita e, logo abaixo, a lista dos limites com
 * a aferição de cada um.
 *
 * A lista de limites fica na mesma página do ambiente, e não num documento
 * separado, porque é assim que ela chega a quem abre o endereço sem instrução
 * verbal — que é exatamente a conferência cruzada entre grupos que este módulo
 * usa. Limite guardado no repositório não alcança quem está usando o ambiente.
 */
export function montarRegimeEmJanela(
  raiz: HTMLElement,
  orbita: readonly string[],
  regime: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('A câmera em órbita, e como percorrer a cena'));
  raiz.appendChild(
    paragrafo(
      'Arraste com o cursor ou com um dedo para girar em volta da bancada. Use a roda, ' +
        'ou dois dedos, para aproximar e afastar.',
    ),
  );
  for (const linha of orbita) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('O que este regime não oferece'));
  const bloco: HTMLPreElement = document.createElement('pre');
  bloco.textContent = regime.join('\n');
  raiz.appendChild(bloco);
}

/**
 * A camada de interação, em três blocos.
 *
 * O primeiro é a abstração: quais fontes alimentam o apontamento e o que cada
 * campo carrega. O segundo é a fronteira das dependências, que sai na página pela
 * mesma razão que os limites do regime saem — decisão de projeto guardada no
 * repositório não alcança quem está usando o ambiente, nem quem o está avaliando.
 * O terceiro é o estado vivo da mira, e ele é o que a tutoria lê enquanto alguém
 * move o cursor: o nó que o raio acertou aparece ao lado da peça a que ele
 * pertence, e a distância entre os dois é o conteúdo do módulo.
 */
export function montarInteracao(
  raiz: HTMLElement,
  apontamento: readonly string[],
  fronteira: readonly string[],
  interacao: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('A abstração de apontar, e quem a alimenta'));
  raiz.appendChild(
    paragrafo(
      'Passe o cursor sobre as peças para ver o realce da mira, e clique para escolher. ' +
        'Arrastar gira a câmera e não seleciona nada: o gesto se decide pelo tanto que o ' +
        'cursor andou entre o botão descer e subir.',
    ),
  );
  for (const linha of apontamento) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('O que vem pronto, o que se escreve aqui'));
  const limite: HTMLPreElement = document.createElement('pre');
  limite.textContent = fronteira.join('\n');
  raiz.appendChild(limite);

  raiz.appendChild(subtitulo('Mira e escolha, agora'));
  const estado: HTMLPreElement = document.createElement('pre');
  estado.textContent = interacao.join('\n');
  raiz.appendChild(estado);
}

/**
 * A folha da manipulação: o que está na mão, a folga adotada e o estado da
 * tarefa.
 *
 * A folga aparece junto do que se experimentou antes de fixá-la, e não sozinha.
 * Número sem o ensaio ao lado é indistinguível de chute para quem lê, inclusive
 * para quem o escolheu, seis meses depois.
 */
export function montarManipulacaoEMontagem(
  raiz: HTMLElement,
  manipulacao: readonly string[],
  tolerancia: readonly string[],
  montagem: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('Pegar, orientar e soltar'));
  raiz.appendChild(
    paragrafo(
      'Clique na peça para apanhá-la e clique de novo para soltar: no cursor a pega ' +
        'alterna, porque o botão está em disputa com a órbita e só a soltura diz se o ' +
        'gesto era clique ou arrasto. Com a peça na mão, Q e E a giram em torno do eixo ' +
        'vertical, R e F em torno do lateral. Onde há controle rastreado nada disso é ' +
        'preciso: a orientação vem da mão.',
    ),
  );
  for (const linha of manipulacao) {
    raiz.appendChild(paragrafo(linha));
  }

  raiz.appendChild(subtitulo('A folga do encaixe, e o que se tentou antes dela'));
  const folga: HTMLPreElement = document.createElement('pre');
  folga.textContent = tolerancia.join('\n');
  raiz.appendChild(folga);

  raiz.appendChild(subtitulo('A tarefa, e de que cada peça depende'));
  const tarefa: HTMLPreElement = document.createElement('pre');
  tarefa.textContent = montagem.join('\n');
  raiz.appendChild(tarefa);
}

/**
 * A folha da sessão: o que foi negociado com a plataforma e onde passa a linha
 * entre ela e o projeto.
 *
 * As duas metades ficam juntas de propósito. A primeira é o resultado da
 * negociação neste aparelho, que muda de aparelho para aparelho; a segunda é a
 * divisão de responsabilidade, que não muda. Lidas lado a lado, a segunda deixa
 * de ser declaração de intenção e passa a ter, em cada linha, a verificação
 * correspondente logo acima.
 */
export function montarSessao(
  raiz: HTMLElement,
  sessao: readonly string[],
  plataforma: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O ciclo de sessão, neste aparelho'));
  raiz.appendChild(
    paragrafo(
      'Entrar em sessão exige toque em um botão: o navegador recusa o pedido que não ' +
        'venha de um gesto de quem usa, e a recusa se parece com defeito do código. ' +
        'Fora de contexto seguro a interface sequer é exposta, e aí o sintoma é ' +
        'idêntico ao de um aparelho sem suporte — a causa, nesse caso, é o endereço.',
    ),
  );
  const negociado: HTMLPreElement = document.createElement('pre');
  negociado.textContent = sessao.join('\n');
  raiz.appendChild(negociado);

  raiz.appendChild(subtitulo('O que é da plataforma, o que se negocia e o que é deste projeto'));
  const linha: HTMLPreElement = document.createElement('pre');
  linha.textContent = plataforma.join('\n');
  raiz.appendChild(linha);
}

/**
 * A seção do ambiente imersivo: escala corporal, alcance do braço e conforto.
 *
 * As três saem juntas porque são a mesma pergunta feita de três ângulos — o
 * ambiente cabe no corpo de quem entrou? —, e separá-las em três blocos faria
 * perder justamente a leitura cruzada: o posto que resolve a folga da entrada é o
 * que estraga o alcance, e o teto de quadro que basta para a média não basta para
 * o engasgo.
 */
export function montarImersao(
  raiz: HTMLElement,
  escala: readonly string[],
  alcance: readonly string[],
  conforto: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('Escala corporal e os dois percursos'));
  raiz.appendChild(
    paragrafo(
      'A conferência abaixo mede a cena e a confronta com o que ela declara. Fora de sessão ' +
        'ela vale como conferência de construção; dentro dela, some o assentamento contra a ' +
        'origem que o aparelho concedeu — e é aí que a diferença entre pedir o piso real e ' +
        'aceitar a altura da cabeça deixa de ser detalhe.',
    ),
  );
  const primeiro: HTMLPreElement = document.createElement('pre');
  primeiro.textContent = escala.join('\n');
  raiz.appendChild(primeiro);

  raiz.appendChild(subtitulo('O alcance do braço, medido peça por peça'));
  raiz.appendChild(
    paragrafo(
      'O que estiver fora do braço é problema de desenho, e o conserto é trazer a peça para ' +
        'perto. Instruir quem usa a dar um passo à frente transfere o problema para o outro ' +
        'lado e falha na sessão que não tem espaço livre para andar.',
    ),
  );
  const segundo: HTMLPreElement = document.createElement('pre');
  segundo.textContent = alcance.join('\n');
  raiz.appendChild(segundo);

  raiz.appendChild(subtitulo('Conforto: o que se conta e o que se provoca'));
  raiz.appendChild(
    paragrafo(
      'As provocações duram poucos segundos e se corrigem sozinhas. Aplique-as apenas em quem ' +
        'foi avisado do que vai sentir, e nunca em quem está sozinho com o visor no rosto.',
    ),
  );
  const terceiro: HTMLPreElement = document.createElement('pre');
  terceiro.textContent = conforto.join('\n');
  raiz.appendChild(terceiro);
}

/**
 * A seção da locomoção: o salto, a borda da sala e a comparação entre as duas
 * formas de se deslocar.
 *
 * Sai depois da seção do ambiente imersivo, e não junto dela, porque responde a
 * outra pergunta. Aquela pergunta se o ambiente cabe no corpo de quem entrou;
 * esta pergunta como esse corpo atravessa um ambiente maior que a sala em que
 * ele está de pé.
 */
export function montarLocomocao(
  raiz: HTMLElement,
  locomocao: readonly string[],
  area: readonly string[],
  comparacao: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O salto, o giro e a máscara'));
  raiz.appendChild(
    paragrafo(
      'Empurrar o comando para a frente abre a mira; soltar confirma o destino. No desktop o ' +
        'comando são as setas, e o salto leva o alvo da órbita — serve para conferir o gesto sem ' +
        'visor, o que importa quando há três aparelhos para a turma inteira.',
    ),
  );
  const primeiro: HTMLPreElement = document.createElement('pre');
  primeiro.textContent = locomocao.join('\n');
  raiz.appendChild(primeiro);

  raiz.appendChild(subtitulo('A borda da sala real'));
  raiz.appendChild(
    paragrafo(
      'A área física é a única coisa deste percurso que o ambiente não tem como deduzir: ela é ' +
        'propriedade do cômodo, e quem a declarou foi quem instalou o aparelho. Onde ela não é ' +
        'concedida, o ambiente diz que não sabe, em vez de desenhar um retângulo plausível.',
    ),
  );
  const segundo: HTMLPreElement = document.createElement('pre');
  segundo.textContent = area.join('\n');
  raiz.appendChild(segundo);

  raiz.appendChild(subtitulo('Contínuo e discreto, comparados por critério'));
  raiz.appendChild(
    paragrafo(
      'Os critérios estão escritos antes de qualquer ensaio, e metade deles só uma pessoa ' +
        'responde. O relato que vale é o de quem experimentou sem ter construído: quem construiu ' +
        'já se habituou ao próprio movimento e deixou de sentir o que ele produz.',
    ),
  );
  const terceiro: HTMLPreElement = document.createElement('pre');
  terceiro.textContent = comparacao.join('\n');
  raiz.appendChild(terceiro);
}

/**
 * A folha da ancoragem: composição sobre o mundo, consulta de superfície, pouso
 * e comportamento sob perda.
 *
 * Ela é a última folha da página, e a única cujo conteúdo inteiro só tem resposta
 * dentro de uma sessão aumentada. Lida no desktop, ela diz que não há composição,
 * que não há consulta e que não há pouso — o que é a verdade sobre o desktop, e
 * não um relatório vazio.
 */
export function montarMarcador(raiz: HTMLElement, marcador: readonly string[]): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O papel sobre a mesa, e o que ele custa em precisão'));
  raiz.appendChild(
    paragrafo(
      'Imprima o marcador pelo botão acima, confira o lado do quadrado preto com uma régua e ' +
        'ponha o papel sobre a mesa. Ligue a câmera e aponte: a bancada nasce sobre o papel, na ' +
        'escala que o número declarado no código determina.',
    ),
  );
  raiz.appendChild(
    paragrafo(
      'A comparação que interessa é com o registro do trecho anterior, e ela se faz no mesmo ' +
        'ambiente. Aqui a bancada treme, e o tremor está medido abaixo em milímetros; ela some ' +
        'quando o papel sai de quadro, porque não há mapa do cômodo a que recorrer; e a distância ' +
        'estimada depende de um campo de visão que o navegador não informa. Nada disso é defeito ' +
        'a corrigir: é o preço de reconhecer um gabarito numa imagem plana.',
    ),
  );
  const folha: HTMLPreElement = document.createElement('pre');
  folha.textContent = marcador.join('\n');
  raiz.appendChild(folha);
}

export function montarDegradacao(raiz: HTMLElement, degradacao: readonly string[]): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('Um endereço, muitos aparelhos, ninguém diante de uma tela em branco'));
  raiz.appendChild(
    paragrafo(
      'O ambiente consulta o aparelho ao carregar e escolhe qual regime abrir, por uma ordem de ' +
        'preferência decidida no projeto. A ordem está escrita abaixo com a razão de cada posição, ' +
        'porque nenhuma delas é obviamente correta quando os aparelhos diferem em várias ' +
        'dimensões ao mesmo tempo.',
    ),
  );
  raiz.appendChild(
    paragrafo(
      'Ao aparelho que não alcança um regime, o que se deve é uma mensagem que diga o que falta e ' +
        'o que ele ainda consegue fazer. Botão desabilitado ensina que o ambiente não funciona ' +
        'ali, e essa é a leitura errada: o regime em janela monta a bancada inteira.',
    ),
  );
  const folha: HTMLPreElement = document.createElement('pre');
  folha.textContent = degradacao.join('\n');
  raiz.appendChild(folha);
}

export function montarAncoragem(raiz: HTMLElement, ancoragem: readonly string[]): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('A cena sobre o mundo, e o que a mantém no lugar'));
  raiz.appendChild(
    paragrafo(
      'Entre em realidade aumentada, mire uma superfície até o anel aparecer e acione para ' +
        'pousar a bancada nela. Depois ande em volta: o que se verifica não é o instante do ' +
        'pouso, é a bancada continuar onde foi posta enquanto quem observa se move.',
    ),
  );
  raiz.appendChild(
    paragrafo(
      'A conferência que separa registro de papel de parede leva segundos e não pede ' +
        'instrumento nenhum. Se a bancada acompanhar a tela em vez de ficar sobre a mesa, a ' +
        'câmera está sendo usada como fundo, e o assunto deste trecho passou ao largo.',
    ),
  );
  const folha: HTMLPreElement = document.createElement('pre');
  folha.textContent = ancoragem.join('\n');
  raiz.appendChild(folha);
}

// ---------------------------------------------------------------------------
// As três folhas do módulo final.
//
// Elas não acrescentam camada nenhuma ao ambiente: mostram o que ele já sabe
// sobre si mesmo. A primeira responde se o artefato contém tudo o que foi
// construído; a segunda, se o painel está sendo lido de onde se está; a
// terceira, o que ninguém reconstitui depois olhando o código.
// ---------------------------------------------------------------------------

export function montarComposicao(
  raiz: HTMLElement,
  auditoria: readonly string[],
  percurso: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('Está tudo aqui, e tudo alcançável a partir deste endereço?'));
  raiz.appendChild(
    paragrafo(
      'Cada módulo do percurso deixou uma camada. Nenhum deles podia responder se ela continuava ' +
        'ligada dois módulos depois: quem escreve a camada olha para ela, e o que se perde é ' +
        'justamente a ligação. A lista abaixo é declarada à mão e conferida por máquina a cada ' +
        'carregamento, contra o que esta composição de fato ligou.',
    ),
  );
  raiz.appendChild(
    paragrafo(
      'O que a conferência não faz é dizer que as camadas funcionam. Ela prova ligação, não ' +
        'comportamento — e é por isso que cada linha traz o que se deve VER acontecer. Essa ' +
        'coluna é o roteiro da conferência a olho, e ela continua sendo de gente.',
    ),
  );
  const folhaDaAuditoria: HTMLPreElement = document.createElement('pre');
  folhaDaAuditoria.textContent = auditoria.join('\n');
  raiz.appendChild(folhaDaAuditoria);

  raiz.appendChild(subtitulo('Entre escolher o regime e conseguir abri-lo'));
  raiz.appendChild(
    paragrafo(
      'A consulta ao aparelho diz qual regime ele alcança. A abertura pode falhar depois disso — ' +
        'permissão negada no diálogo, sessão tomada por outra aba, aparelho que declara suporte e ' +
        'recusa o pedido. Quando isso acontece, a degradação continua descendo a ordem declarada ' +
        'até o regime em janela, que não precisa ser aberto porque já está de pé desde que a ' +
        'página subiu.',
    ),
  );
  const folhaDoPercurso: HTMLPreElement = document.createElement('pre');
  folhaDoPercurso.textContent = percurso.join('\n');
  raiz.appendChild(folhaDoPercurso);
}

export function montarPainelDiegetico(raiz: HTMLElement, legibilidade: readonly string[]): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O cartaz que sobrevive à entrada na sessão'));
  raiz.appendChild(
    paragrafo(
      'Um painel preso à janela do navegador desaparece no instante em que a sessão imersiva ' +
        'começa — que é exatamente quando ele faria mais falta. O deste ambiente é objeto da cena, ' +
        'preso à bancada, e por isso continua ali dentro do visor e sobre a mesa real, ancorado ao ' +
        'mesmo objeto que a ancoragem move.',
    ),
  );
  raiz.appendChild(
    paragrafo(
      'Ser objeto tem custo, e o custo é a leitura. O cartaz visto de lado é uma linha, e o texto ' +
        'a três metros não se lê. A resposta não foi aumentar o painel, que mentiria sobre a ' +
        'escala do mundo: ele gira em torno do próprio eixo vertical para encarar quem lê, e MEDE ' +
        'a altura aparente da letra, dizendo quando ela caiu abaixo do limiar em vez de fingir que ' +
        'está sendo lida.',
    ),
  );
  const folha: HTMLPreElement = document.createElement('pre');
  folha.textContent = legibilidade.join('\n');
  raiz.appendChild(folha);
}

export function montarRegistroDoProjeto(
  raiz: HTMLElement,
  registro: readonly string[],
  uso: readonly string[],
): void {
  raiz.replaceChildren();

  raiz.appendChild(subtitulo('O que ninguém reconstitui depois olhando o código'));
  raiz.appendChild(
    paragrafo(
      'O código diz o que o ambiente faz. Não diz por que a folga do encaixe é de quatro ' +
        'centímetros, o que se experimentou antes de fixá-la, em que máquina o número de ' +
        'milissegundos foi obtido, nem em que aparelhos este endereço foi de fato aberto. As ' +
        'grandezas sem medida aparecem como não medidas: preencher uma delas com valor plausível ' +
        'tornaria todas as outras não confiáveis.',
    ),
  );
  const folhaDoRegistro: HTMLPreElement = document.createElement('pre');
  folhaDoRegistro.textContent = registro.join('\n');
  raiz.appendChild(folhaDoRegistro);

  raiz.appendChild(subtitulo('Avaliar o ambiente pela tarefa que ele suporta'));
  raiz.appendChild(
    paragrafo(
      'A pergunta "o ambiente ficou bom?" não tem resposta. A que tem é outra: alguém consegue ' +
        'montar o mecanismo, e onde essa pessoa trava? A análise abaixo decompõe a tarefa em ' +
        'demandas — julgar profundidade, orientar a peça, lembrar a ordem, alcançar o que está ' +
        'longe — e mostra o que cada regime oferece a cada uma delas. Duas demandas desaparecem ' +
        'nos regimes sem corpo, e desaparecer não é ficar mais fácil: a tarefa passa a ser outra.',
    ),
  );
  const folhaDoUso: HTMLPreElement = document.createElement('pre');
  folhaDoUso.textContent = uso.join('\n');
  raiz.appendChild(folhaDoUso);
}

A composição final liga as peças e divide a página em dois tempos.

src/bancada/main.ts
// ---------------------------------------------------------------------------
// Composição do estado demonstrável do percurso.
//
// A Bancada passa a desenhar. Sobre o que já existia — a delimitação do domínio,
// a declaração dos regimes e a sonda de capacidades — entra a oficina como árvore
// de nós, com o laço andando contra o relógio e o custo do quadro exibido em um
// painel preso à própria bancada.
//
// A página se divide em três tempos. A cena sobe ao carregar. O que se responde
// sem sessão aparece logo abaixo. O que só a sessão responde espera um toque no
// botão, porque o navegador recusa o pedido de sessão imersiva que não venha de
// gesto de quem usa.
// ---------------------------------------------------------------------------

import { BANCADA, inconsistenciasDoDominio } from './dominio/dominio';
import { levantarRelatorio } from './modes/verificacao';
import { conferirComposicao, sondar, type ResultadoDaSonda } from './devices/sonda';
import {
  montarAtivosECusto,
  montarConteudo,
  montarEstruturaDaCena,
  montarInteracao,
  montarManipulacaoEMontagem,
  montarRegimeEmJanela,
  montarRelatorio,
  montarSessao,
  montarImersao,
  montarLocomocao,
  montarAncoragem,
  montarMarcador,
  montarDegradacao,
  montarSonda,
  montarComposicao,
  montarPainelDiegetico,
  montarRegistroDoProjeto,
} from './relatorio/relatorio';
import { Diario, explicarFalha } from './relatorio/diario';
import { frasesSobreAOrdem, iniciarOficina, type Oficina } from './app/oficina';
import type { ModoDeSessao } from './modes/sessao';
import { CASOS } from './modes/conforto';
import type { Ensaio } from './locomotion/comparacao';
import type { ComparacaoDeFolga } from './content/pecas';
import { desenharParaImpressao, inconsistenciasDoPadrao } from './anchoring/padrao';
import type { Escolha } from './app/degradacao';

function exigirCanvas(id: string): HTMLCanvasElement {
  const elemento: HTMLElement = exigirElemento(id);
  if (!(elemento instanceof HTMLCanvasElement)) {
    throw new Error(`O elemento #${id} existe, mas não é uma superfície de desenho.`);
  }
  return elemento;
}

function exigirElemento(id: string): HTMLElement {
  const elemento: HTMLElement | null = document.getElementById(id);
  if (elemento === null) {
    throw new Error(`A página não tem o elemento #${id}.`);
  }
  return elemento;
}

const raizRelatorio: HTMLElement = exigirElemento('relatorio');
const raizSonda: HTMLElement = exigirElemento('sonda');
const raizDiario: HTMLElement = exigirElemento('diario');
const botao: HTMLElement = exigirElemento('sondar');
const raizEstrutura: HTMLElement = exigirElemento('estrutura');
const botaoPrender: HTMLElement = exigirElemento('prender');
const raizConteudo: HTMLElement = exigirElemento('conteudo');
const botaoVolumes: HTMLElement = exigirElemento('volumes');
const raizCusto: HTMLElement = exigirElemento('custo');
const raizJanela: HTMLElement = exigirElemento('janela');
const raizInteracao: HTMLElement = exigirElemento('interacao');
const raizManipulacao: HTMLElement = exigirElemento('manipulacao');
const botaoFolgas: HTMLElement = exigirElemento('folgas');
const botaoEnquadrar: HTMLElement = exigirElemento('enquadrar');
const botaoAproximar: HTMLElement = exigirElemento('aproximar');
const botaoMedir: HTMLElement = exigirElemento('medir');
const botaoEntrarVr: HTMLElement = exigirElemento('entrar-vr');
const botaoEntrarAr: HTMLElement = exigirElemento('entrar-ar');
const botaoSair: HTMLElement = exigirElemento('sair-sessao');
const raizSessao: HTMLElement = exigirElemento('sessao');

const diario: Diario = new Diario();
diario.fixarDestino(raizDiario);

const problemas: string[] = inconsistenciasDoDominio(BANCADA);
if (problemas.length > 0) {
  diario.alerta(`O domínio tem inconsistências: ${problemas.join(' ')}`);
}

// A consulta ao suporte é assíncrona porque a API XR responde por promessa: o
// navegador pode precisar consultar o runtime do aparelho antes de saber.
void levantarRelatorio().then((linhas) => {
  montarRelatorio(raizRelatorio, BANCADA, problemas, linhas);
  diario.nota('Consulta sem sessão concluída. A sonda completa espera um toque no botão.');
});

if (!window.isSecureContext) {
  diario.alerta(
    'Esta página não está em contexto seguro. A API XR não é exposta aqui, e o botão vai responder como se o aparelho não tivesse suporte — o que seria mentira sobre o aparelho.',
  );
}

async function executarSonda(): Promise<void> {
  diario.nota('Sondando. Se um visor pedir permissão, aceite: sem ela a sessão não abre.');
  try {
    const resultado: ResultadoDaSonda = await sondar();
    const confronto: string | undefined =
      resultado.emSessao === undefined ? undefined : conferirComposicao(resultado.emSessao);
    montarSonda(raizSonda, resultado, confronto);
    diario.nota('Sondagem concluída e sessão encerrada.');
  } catch (erro: unknown) {
    // A falha é resultado, e precisa ser lida no próprio aparelho — quem está de
    // visor não abre console de depuração.
    diario.falha(explicarFalha(erro));
  }
}

botao.addEventListener('click', () => {
  void executarSonda();
});

// A oficina sobe assim que a página carrega: o regime não imersivo não pede
// gesto de ninguém, e é ele o caso base do projeto inteiro.
const oficina: Oficina = iniciarOficina(exigirCanvas('cena'));
montarEstruturaDaCena(raizEstrutura, oficina.estrutura(), frasesSobreAOrdem());

/**
 * A frase da comparação de folgas. O ponto de teste fica a doze centímetros do
 * centro da engrenagem, que é fora dela por uma margem confortável: a folga adotada não o alcança, e a
 * folga generosa alcança. É a captura do que passou perto, medida.
 */
function frasesDaFolga(): string {
  const c: ComparacaoDeFolga = oficina.folgas(0.12, 0.06);
  const adotada: string = c.capturaComAFolgaAdotada ? 'captura' : 'não captura';
  const generosa: string = c.capturaComAFolgaGenerosa ? 'captura' : 'não captura';
  return (
    `Um ponto a ${(c.distanciaDeTeste * 100).toFixed(0)} cm do centro da engrenagem grande: ` +
    `com a folga adotada de ${(c.folgaAdotada * 100).toFixed(1)} cm, o volume ${adotada}; ` +
    `com uma folga de ${(c.folgaGenerosa * 100).toFixed(1)} cm, ${generosa}. ` +
    `O segundo caso é a peça grudando em quem só passou perto.`
  );
}

montarConteudo(raizConteudo, oficina.inventario(), oficina.materiais(), oficina.volumes(), frasesDaFolga());

botaoVolumes.addEventListener('click', () => {
  const exibindo: boolean = oficina.alternarVolumes();
  botaoVolumes.textContent = exibindo ? 'Esconder os volumes de contato' : 'Mostrar os volumes de contato';
  diario.nota(
    exibindo
      ? 'Os volumes de contato estão à vista. O que decide o encaixe é a caixa, e não a silhueta.'
      : 'Volumes escondidos. A cena volta a mostrar só o que o olho veria.',
  );
});

// O ativo externo chega pela rede, e a cena já está de pé quando ele chega. Essa
// ordem não é conveniência de código: é como o ambiente se comporta de verdade,
// e escondê-la atrás de uma tela de carregamento ensinaria o contrário.
let laudoDoAtivo: string[] = ['O ativo externo ainda não foi trazido.'];

function atualizarCusto(): void {
  montarAtivosECusto(
    raizCusto,
    laudoDoAtivo,
    oficina.repeticao(),
    oficina.niveisDeDetalhe(),
    oficina.medicao(),
  );
}

atualizarCusto();

function atualizarJanela(): void {
  montarRegimeEmJanela(raizJanela, oficina.orbita(), oficina.regimeEmJanela());
}

atualizarJanela();

function atualizarInteracao(): void {
  montarInteracao(raizInteracao, oficina.apontamento(), oficina.fronteira(), oficina.interacao());
}

atualizarInteracao();

function atualizarManipulacao(): void {
  montarManipulacaoEMontagem(
    raizManipulacao,
    oficina.manipulacao(),
    oficina.tolerancia(),
    oficina.montagem(),
  );
}

atualizarManipulacao();

// Toda resposta a quem soltou uma peça vai para o diário, e não só as recusas.
// O encaixe bem-sucedido também traz um número — a quantos centímetros do alvo a
// peça estava —, e é esse número que torna a folga discutível em vez de mágica.
oficina.aoResponder((parecer: string) => {
  diario.nota(parecer);
  atualizarManipulacao();
});

botaoFolgas.addEventListener('click', () => {
  const exibindo: boolean = oficina.alternarFolgas();
  botaoFolgas.textContent = exibindo
    ? 'Esconder a folga dos encaixes'
    : 'Mostrar a folga dos encaixes';
  diario.nota(
    exibindo
      ? 'Cada cubo verde tem exatamente o tamanho da folga linear do encaixe. Soltar a peça com o centro dela dentro do cubo manda encaixar.'
      : 'As folgas voltaram a ficar invisíveis. Elas continuam valendo: o que sumiu foi o desenho, não o número.',
  );
});

// A mira muda muitas vezes por segundo, e a página não é redesenhada a cada
// mudança: quem observa o estado vivo lê o bloco depois de mexer o cursor e
// tocar em qualquer botão. O diário registra só a escolha, que é o evento raro.
oficina.aoMudarSelecao((mudanca) => {
  if (mudanca.selecionada === undefined) {
    return;
  }
  diario.nota(
    `Peça escolhida: ${mudanca.selecionada}. O raio acertou "${mudanca.noAtingido ?? 'nada'}" e ` +
      `subiu ${mudanca.degrausDeSubida} nível(is) da árvore até chegar nela.`,
  );
  atualizarInteracao();
});

// O enquadramento é a prova da exigência da tarefa: todos os objetos do domínio
// alcançáveis, e não só os que por acaso nasceram diante da câmera.
botaoEnquadrar.addEventListener('click', () => {
  oficina.enquadrarTudo();
  atualizarJanela();
  atualizarCusto();
  atualizarInteracao();
  diario.nota(
    'A cena inteira foi enquadrada. A distância ao alvo é a que faz a esfera envolvente caber no campo de visão.',
  );
});

void oficina
  .trazerAtivoExterno()
  .then((linhas: string[]) => {
    laudoDoAtivo = linhas;
    atualizarCusto();
    diario.nota(
      'A morsa chegou e foi ajustada à convenção da cena. O laudo com o que foi corrigido está na folha de ativos.',
    );
  })
  .catch((erro: unknown) => {
    // Ativo que não chega é falha de conteúdo, e precisa aparecer como falha. A
    // cena continua de pé sem ele, e é justamente por continuar que o silêncio
    // seria perigoso: ninguém notaria a morsa faltando.
    diario.falha(explicarFalha(erro));
  });

function atualizarSessao(): void {
  montarSessao(raizSessao, oficina.sessao(), oficina.plataforma());
}

atualizarSessao();

// A sessão pode terminar sem que este código peça: quem usa sai pelo menu do
// sistema, tira o aparelho da cabeça, deixa a bateria acabar. Por isso o estado
// da página acompanha o aviso do ciclo, e não o retorno do botão.
oficina.aoMudarSessao(
  (aberta) => {
    atualizarSessao();
    atualizarInteracao();
    diario.nota(
      `Sessão ${aberta.modo} aberta, com espaço de referência ${aberta.espacoObtido} e ` +
        `composição ${aberta.composicao}. O controle rastreado assumiu o apontamento, e ` +
        'nenhuma linha de seleção, agarre ou encaixe mudou para isso acontecer.',
    );
  },
  () => {
    atualizarSessao();
    atualizarInteracao();
    atualizarJanela();
    diario.nota(
      'Sessão encerrada. O laço voltou à cadência da janela, o cursor voltou a ser a ' +
        'fonte de apontamento e a câmera foi devolvida ao ponto em que a órbita a deixou.',
    );
  },
);

async function entrarEm(modo: ModoDeSessao): Promise<void> {
  try {
    await oficina.entrarEmSessao(modo);
  } catch (erro: unknown) {
    // Sessão que não abre é resultado, e precisa ser lido no próprio aparelho —
    // quem está de visor não abre console de depuração.
    diario.falha(explicarFalha(erro));
    atualizarSessao();
  }
}

botaoEntrarVr.addEventListener('click', () => {
  void entrarEm('immersive-vr');
});

botaoEntrarAr.addEventListener('click', () => {
  void entrarEm('immersive-ar');
});

botaoSair.addEventListener('click', () => {
  void oficina.sairDaSessao();
});

botaoMedir.addEventListener('click', () => {
  atualizarCusto();
  atualizarInteracao();
  atualizarManipulacao();
  atualizarSessao();
  diario.nota(
    'Medição refeita. O número vale para esta máquina e para este instante — anote os dois ao lado dele.',
  );
});

botaoAproximar.addEventListener('click', () => {
  const perto: boolean = oficina.alternarAproximacao();
  botaoAproximar.textContent = perto
    ? 'Voltar para a bancada'
    : 'Aproximar da prateleira do fundo';
  diario.nota(
    perto
      ? 'A câmera foi para junto da prateleira: o nível detalhado entrou, e a contagem de triângulos subiu.'
      : 'A câmera voltou para a bancada: a prateleira ficou longe, e o nível simplificado assumiu.',
  );
  atualizarCusto();
  atualizarJanela();
});

botaoPrender.addEventListener('click', () => {
  const antes: string = oficina.posicaoDaEngrenagem();
  const desvio: number = oficina.presa() ? oficina.soltar() : oficina.prender();
  const destino: string = oficina.presa() ? 'ao eixo' : 'ao tampo';
  diario.nota(
    `A engrenagem passou a pertencer ${destino}. Estava em ${antes}, ficou em ` +
      `${oficina.posicaoDaEngrenagem()}, e o desvio medido foi de ${desvio.toExponential(1)} m.`,
  );
  botaoPrender.textContent = oficina.presa()
    ? 'Soltar a engrenagem do eixo'
    : 'Prender a engrenagem ao eixo';
  montarEstruturaDaCena(raizEstrutura, oficina.estrutura(), frasesSobreAOrdem());
});

// ---------------------------------------------------------------------------
// Ambiente imersivo: escala corporal, alcance do braço e conforto.
//
// Os controles ficam no fim da página de propósito. Eles só dizem alguma coisa
// depois de uma sessão ter sido aberta, e pô-los ao lado dos botões da cena
// convidaria a lê-los no desktop — onde a conferência de escala mede a
// construção da cena, e escala corporal, alcance e conforto simplesmente não
// existem.
// ---------------------------------------------------------------------------

const raizImersao: HTMLElement = exigirElemento('imersao');
const botaoAferirCorpo: HTMLElement = exigirElemento('aferir-corpo');
const botaoAproximarPecas: HTMLElement = exigirElemento('aproximar-pecas');
const botaoCorrigir: HTMLElement = exigirElemento('corrigir-desconforto');

function atualizarImersao(): void {
  montarImersao(raizImersao, oficina.escalaCorporal(), oficina.alcance(), oficina.conforto());
}

atualizarImersao();

botaoAferirCorpo.addEventListener('click', () => {
  atualizarImersao();
  diario.nota(
    'Aferição refeita. Fora de sessão isto confere a construção da cena; dentro dela, também o ' +
      'assentamento contra a origem que o aparelho concedeu.',
  );
});

botaoAproximarPecas.addEventListener('click', () => {
  const relato: string[] = oficina.aproximarPecas();
  atualizarImersao();
  diario.nota(
    'As peças fora do braço foram trazidas para a frente do tampo. ' +
      `${relato.length} linhas de laudo abaixo, com o avanço de cada uma.`,
  );
});

for (const caso of CASOS) {
  const botao: HTMLElement = exigirElemento(`provocar-${caso.id}`);
  botao.addEventListener('click', () => {
    diario.alerta(
      `Provocação em curso: ${oficina.provocar(caso.id)} Ela se corrige sozinha em poucos ` +
        'segundos. Avise antes quem estiver com o visor no rosto.',
    );
    atualizarImersao();
  });
}

botaoCorrigir.addEventListener('click', () => {
  oficina.corrigirDesconforto();
  atualizarImersao();
  diario.nota('Provocação interrompida. O ambiente voltou ao caso bom.');
});

// ---------------------------------------------------------------------------
// Locomoção: o salto, a borda da sala e a comparação entre as duas formas.
//
// Fecha a página porque depende de tudo o que veio antes — da abstração de
// apontar, que dá a mira; do ciclo de sessão, que dá o espaço de referência a
// deslocar; e do contador de engasgos, que dá metade da evidência da comparação.
// ---------------------------------------------------------------------------

const raizLocomocao: HTMLElement = exigirElemento('locomocao');
const botaoAlternarDeslize: HTMLElement = exigirElemento('alternar-deslize');
const botaoAferirLocomocao: HTMLElement = exigirElemento('aferir-locomocao');
const botaoIniciarEnsaio: HTMLElement = exigirElemento('iniciar-ensaio');
const botaoRegistrarEnsaio: HTMLElement = exigirElemento('registrar-ensaio');

function atualizarLocomocao(): void {
  montarLocomocao(
    raizLocomocao,
    oficina.locomocao(),
    oficina.areaFisica(),
    oficina.comparacao(),
  );
}

atualizarLocomocao();

botaoAlternarDeslize.addEventListener('click', () => {
  const deslizando: boolean = oficina.alternarDeslize();
  botaoAlternarDeslize.textContent = deslizando
    ? 'Voltar ao salto'
    : 'Trocar o salto pelo deslize contínuo';
  diario.alerta(
    deslizando
      ? 'Deslize contínuo ligado. Ele existe para ser experimentado e medido, não para ficar ' +
          'ligado: avise quem for entrar, e mantenha o ensaio curto.'
      : 'De volta ao salto. O comando para a frente volta a abrir a mira, e o eixo horizontal ' +
          'volta a girar em passos.',
  );
  atualizarLocomocao();
});

botaoAferirLocomocao.addEventListener('click', () => {
  atualizarLocomocao();
  atualizarImersao();
  diario.nota(
    'Aferição refeita. Fora de sessão, o salto move o alvo da órbita e a borda física não existe ' +
      'para ser consultada; dentro dela, os dois passam a valer.',
  );
});

botaoIniciarEnsaio.addEventListener('click', () => {
  oficina.iniciarEnsaio();
  diario.nota(
    'Ensaio começado. A partir de agora contam-se o tempo, os metros percorridos e os engasgos, ' +
      'e no fim resta perguntar a quem experimentou o que só ele responde.',
  );
});

botaoRegistrarEnsaio.addEventListener('click', () => {
  // O registro se faz no desktop, com o visor já fora do rosto: uma caixa de
  // diálogo do navegador dentro da sessão imersiva não aparece onde quem está lá
  // dentro consegue ler.
  const observador: string | null = window.prompt(
    'Quem experimentou? Nunca quem construiu a locomoção — o hábito apaga o efeito.',
  );
  if (observador === null || observador.trim() === '') {
    diario.alerta('Ensaio não registrado: sem observador, o relato não tem origem.');
    return;
  }
  const relato: string | null = window.prompt('Como foi, nas palavras de quem experimentou?');
  if (relato === null || relato.trim() === '') {
    diario.alerta('Ensaio não registrado: o relato é a metade que a instrumentação não mede.');
    return;
  }
  const ensaio: Ensaio = oficina.registrarEnsaio(observador.trim(), relato.trim());
  atualizarLocomocao();
  diario.nota(
    `Ensaio de ${ensaio.forma} registrado: ${ensaio.duracaoS.toFixed(0)} s, ` +
      `${ensaio.metrosPercorridos.toFixed(1)} m e ${ensaio.engasgosNoPeriodo} engasgos no período.`,
  );
});

// ---------------------------------------------------------------------------
// Ancoragem: a cena sobre o mundo físico, o pouso e a perda de referência.
//
// Última seção da página porque é a única cujo conteúdo inteiro depende de uma
// sessão aumentada aberta. Os dois botões abaixo só têm efeito lá dentro, e é
// deliberado que eles digam isso pela leitura da folha, em vez de aparecerem
// desabilitados: botão morto ensina que o ambiente não funciona neste aparelho,
// e o que se quer ensinar é qual capacidade falta.
// ---------------------------------------------------------------------------

const raizAncoragem: HTMLElement = exigirElemento('ancoragem');
const botaoRepousar: HTMLElement = exigirElemento('repousar');
const botaoAferirAncoragem: HTMLElement = exigirElemento('aferir-ancoragem');

function atualizarAncoragem(): void {
  montarAncoragem(raizAncoragem, oficina.ancoragem());
}

atualizarAncoragem();

botaoRepousar.addEventListener('click', () => {
  oficina.repousar();
  atualizarAncoragem();
  diario.nota(
    'A bancada voltou ao lugar de origem e espera outra escolha. Repousá-la sobre a mesma mesa, ' +
      'de dois pontos diferentes, é o ensaio que mostra a precisão real da detecção nesta sala.',
  );
});

// ---------------------------------------------------------------------------
// Registro por marcador e degradação graciosa: o parque heterogêneo resolvido.
//
// Fecha a página porque é o trecho que só faz sentido depois de todos os outros:
// ele escolhe entre os regimes que os trechos anteriores construíram, e o
// registro por papel impresso só se entende por contraste com o registro por
// superfície do trecho de cima.
// ---------------------------------------------------------------------------

const raizMarcador: HTMLElement = exigirElemento('marcador');
const raizDegradacao: HTMLElement = exigirElemento('degradacao');
const botaoImprimirMarcador: HTMLElement = exigirElemento('imprimir-marcador');
const botaoEntrarMarcador: HTMLElement = exigirElemento('entrar-marcador');
const botaoAferirMarcador: HTMLElement = exigirElemento('aferir-marcador');

const problemasDoPadrao: string[] = inconsistenciasDoPadrao();
if (problemasDoPadrao.length > 0) {
  diario.alerta(`O padrão do marcador tem problemas: ${problemasDoPadrao.join(' ')}`);
}

function atualizarMarcador(): void {
  montarMarcador(raizMarcador, oficina.marcador());
}

function atualizarDegradacao(): void {
  montarDegradacao(raizDegradacao, oficina.degradacao());
}

atualizarMarcador();
atualizarDegradacao();

// A decisão de regime é tomada ao carregar, e não ao clicar. O que ela NÃO faz
// é abrir sessão nem ligar câmera por conta própria: as duas coisas exigem gesto
// de quem usa, e a segunda acenderia a luz do aparelho sem ninguém pedir.
void oficina.decidirRegime().then((escolha: Escolha) => {
  atualizarDegradacao();
  diario.nota(
    `Consulta ao aparelho concluída. O melhor regime disponível aqui é ${escolha.nome}` +
      (escolha.preteridos.length === 0
        ? ', e nada foi degradado.'
        : `, depois de ${escolha.preteridos.length} regime(s) fora de alcance — a folha abaixo diz o que falta em cada um e o que este aparelho ainda faz.`),
  );
});

botaoImprimirMarcador.addEventListener('click', () => {
  // A imagem é aberta numa aba nova em vez de baixada. Baixar exigiria um nome
  // de arquivo e um caminho, e o que se quer aqui é levar o desenho até a
  // impressora com o menor número de passos possível.
  const desenho: HTMLCanvasElement = desenharParaImpressao(1024);
  const aba: Window | null = window.open('');
  if (aba === null) {
    diario.alerta(
      'O navegador bloqueou a abertura da aba com o marcador. Libere as janelas para este ' +
        'endereço, ou imprima o desenho a partir de outra máquina.',
    );
    return;
  }
  const imagem: HTMLImageElement = aba.document.createElement('img');
  imagem.src = desenho.toDataURL('image/png');
  imagem.style.width = '15cm';
  aba.document.body.appendChild(imagem);
  diario.nota(
    'Marcador aberto em outra aba, dimensionado para 15 cm de lado. Imprima SEM o ajuste ' +
      'automático à página e confira o quadrado preto com uma régua: o número está no código, e ' +
      'divergência entre os dois vira erro de escala do mundo virtual.',
  );
});

botaoEntrarMarcador.addEventListener('click', () => {
  if (oficina.registrandoPorMarcador()) {
    oficina.sairDoMarcador();
    botaoEntrarMarcador.textContent = 'Registrar por marcador impresso';
    atualizarMarcador();
    atualizarJanela();
    diario.nota('Câmera desligada e regime em janela de volta. O fundo da cena voltou a ser nosso.');
    return;
  }
  void oficina.entrarPorMarcador().then(() => {
    const ligou: boolean = oficina.registrandoPorMarcador();
    botaoEntrarMarcador.textContent = ligou
      ? 'Desligar a câmera e voltar à janela'
      : 'Registrar por marcador impresso';
    atualizarMarcador();
    diario.nota(
      ligou
        ? 'Câmera ligada e detecção em curso. Aponte para o papel: a bancada nasce sobre ele, e ' +
            'o tremor entre quadros está medido na folha abaixo.'
        : 'A câmera não foi obtida, e a folha abaixo diz por quê. O regime em janela continua ' +
            'inteiro, e ele monta a bancada do primeiro parafuso ao último.',
    );
  });
});

botaoAferirMarcador.addEventListener('click', () => {
  atualizarMarcador();
  atualizarDegradacao();
  diario.nota(
    oficina.registrandoPorMarcador()
      ? 'Aferição refeita com a câmera ligada. Apoie o aparelho numa superfície firme e leia o ' +
          'tremor: com tudo parado, ele deveria ser zero, e não é.'
      : 'Aferição refeita fora do regime por marcador. A folha informa a medida do papel a ' +
          'conferir com régua e o que a estimativa de câmera assume.',
  );
});

botaoAferirAncoragem.addEventListener('click', () => {
  atualizarAncoragem();
  diario.nota(
    oficina.emRealidadeAumentada()
      ? 'Aferição refeita dentro da sessão aumentada. A correção acumulada é o número que ' +
          'cresce enquanto se anda em volta da bancada.'
      : 'Aferição refeita fora de sessão aumentada: a folha informa o que este aparelho responde ' +
          'sem ela, que é quase nada — e essa é a resposta certa, não uma folha vazia.',
  );
});

// ---------------------------------------------------------------------------
// O fecho do percurso: compor, medir a própria leitura e registrar.
//
// Este trecho é o único da página que não estreia camada. Ele pergunta ao
// ambiente três coisas que nenhum módulo anterior tinha como perguntar: se tudo
// o que foi construído continua ligado, se o painel está sendo lido de onde a
// pessoa está, e o que se sabe sobre as decisões e os aparelhos.
//
// Os botões ficam no fim por ordem de uso, e não por importância. O de abrir o
// melhor regime é o que se aperta primeiro numa demonstração — e mesmo ele fica
// aqui, porque a orientação é começar pelo regime em janela, que sempre abre e
// já mostra a cena inteira, e só depois trocar de aparelho sem trocar de
// endereço.
// ---------------------------------------------------------------------------

const raizComposicao: HTMLElement = exigirElemento('composicao');
const raizPainelDiegetico: HTMLElement = exigirElemento('painel-diegetico');
const raizRegistro: HTMLElement = exigirElemento('registro');
const botaoAbrirMelhor: HTMLElement = exigirElemento('abrir-melhor');
const botaoAuditarComposicao: HTMLElement = exigirElemento('auditar-composicao');
const botaoChamarPainel: HTMLElement = exigirElemento('chamar-painel');
const botaoAferirPainel: HTMLElement = exigirElemento('aferir-painel');
const botaoRegistrarAparelho: HTMLElement = exigirElemento('registrar-aparelho');
const botaoRegistrarObservacao: HTMLElement = exigirElemento('registrar-observacao');
const botaoExportarRegistro: HTMLElement = exigirElemento('exportar-registro');

function atualizarComposicao(): void {
  montarComposicao(raizComposicao, oficina.composicao(), oficina.percurso());
}

function atualizarPainelDiegetico(): void {
  montarPainelDiegetico(raizPainelDiegetico, oficina.legibilidadeDoPainel());
}

function atualizarRegistro(): void {
  montarRegistroDoProjeto(raizRegistro, oficina.registro(), oficina.uso());
}

atualizarComposicao();
atualizarPainelDiegetico();
atualizarRegistro();

botaoAuditarComposicao.addEventListener('click', () => {
  atualizarComposicao();
  diario.nota(
    'Auditoria refeita. Ela confere ligação, e não comportamento: o que cada camada deve fazer ' +
      'à vista está escrito ao lado dela, e essa conferência é de gente.',
  );
});

// A abertura pelo melhor regime é o gesto que faltava para a decisão do módulo
// anterior virar ação. Ela mora num botão pelo mesmo motivo de sempre: o
// navegador recusa sessão e câmera pedidas sem toque de quem usa.
botaoAbrirMelhor.addEventListener('click', () => {
  void oficina.abrirMelhorRegime().then(() => {
    atualizarComposicao();
    atualizarSessao();
    atualizarMarcador();
    atualizarDegradacao();
    diario.nota(
      `Abertura concluída. O regime em uso é ${oficina.regimeEmUso()}, e a folha da composição ` +
        'traz cada tentativa com o que o aparelho respondeu.',
    );
  });
});

botaoChamarPainel.addEventListener('click', () => {
  const perto: boolean = oficina.alternarChamadaDoPainel();
  botaoChamarPainel.textContent = perto
    ? 'Devolver o painel ao lugar'
    : 'Chamar o painel para perto';
  atualizarPainelDiegetico();
  diario.nota(
    perto
      ? 'O painel veio para a borda da bancada voltada a quem lê. Ele continua pendurado no ' +
          'suporte do tampo: soltá-lo da bancada para segui-lo pela sala o faria deixar de ser ' +
          'objeto do mundo justamente quando é mais útil.'
      : 'O painel voltou ao lugar de origem sobre o tampo.',
  );
});

botaoAferirPainel.addEventListener('click', () => {
  atualizarPainelDiegetico();
  diario.nota(
    'Leitura aferida a partir de onde a câmera está agora. Dentro do visor, afaste-se dois ' +
      'passos e refaça: é o ponto em que a altura aparente da letra cruza o limiar adotado.',
  );
});

botaoRegistrarAparelho.addEventListener('click', () => {
  // O registro se faz por caixa de diálogo do navegador, e portanto fora da
  // sessão imersiva, com o visor já fora do rosto. Dentro dela o diálogo não
  // aparece onde quem está lá consegue ler — a mesma limitação do ensaio de
  // conforto, e a mesma resposta.
  const aparelho: string | null = window.prompt(
    'Qual aparelho? Modelo e navegador, não "meu celular" — o parque é heterogêneo, e o que se ' +
      'quer saber é onde funcionou.',
  );
  if (aparelho === null || aparelho.trim() === '') {
    diario.alerta('Aparelho não registrado: sem o modelo, a linha não diz onde o ambiente abriu.');
    return;
  }
  const funcionou: string | null = window.prompt('O que funcionou neste aparelho?');
  const naoFuncionou: string | null = window.prompt(
    'E o que não funcionou? "Nada" também é resposta.',
  );
  const quem: string | null = window.prompt('Quem abriu?');
  if (funcionou === null || naoFuncionou === null || quem === null || quem.trim() === '') {
    diario.alerta('Aparelho não registrado: o registro incompleto é o que envelhece pior.');
    return;
  }
  oficina.registrarAparelho({
    aparelho: aparelho.trim(),
    regimeAberto: oficina.regimeEmUso(),
    oQueFuncionou: funcionou.trim(),
    oQueNaoFuncionou: naoFuncionou.trim(),
    quemAbriu: quem.trim(),
  });
  atualizarRegistro();
  diario.nota(
    'Aparelho registrado. Ele fica no armazenamento local DESTE navegador: o que foi anotado no ' +
      'celular ficou no celular, e o destino da lista é o repositório do projeto.',
  );
});

botaoRegistrarObservacao.addEventListener('click', () => {
  const quem: string | null = window.prompt(
    'Quem montou? Nunca quem construiu o ambiente — o hábito apaga o efeito.',
  );
  if (quem === null || quem.trim() === '') {
    diario.alerta('Observação não registrada: sem quem experimentou, o relato não tem origem.');
    return;
  }
  const travou: string | null = window.prompt(
    'Onde essa pessoa travou? Deixe vazio se ela concluiu a montagem.',
  );
  if (travou === null) {
    return;
  }
  const relato: string | null = window.prompt('Nas palavras dela, e não no seu resumo:');
  if (relato === null || relato.trim() === '') {
    diario.alerta('Observação não registrada: o relato é a metade que a instrumentação não mede.');
    return;
  }
  oficina.registrarObservacao({
    quem: quem.trim(),
    regime: oficina.regimeEmUso(),
    ondeTravou: travou.trim(),
    relato: relato.trim(),
    concluiu: travou.trim() === '',
  });
  atualizarRegistro();
  diario.nota(
    'Observação registrada nesta sessão. Ela some ao recarregar a página, de propósito: ' +
      'transcreva-a enquanto quem montou ainda está por perto e pode ser perguntado.',
  );
});

botaoExportarRegistro.addEventListener('click', () => {
  // O texto vai para uma aba nova em vez de baixar como arquivo, pela mesma
  // razão do desenho do marcador: baixar exigiria nome e caminho, e o que se
  // quer é levar o texto até o repositório com o menor número de passos.
  const aba: Window | null = window.open('');
  if (aba === null) {
    diario.alerta(
      'O navegador bloqueou a aba com o registro. Libere as janelas para este endereço, ou copie ' +
        'o texto da folha acima.',
    );
    return;
  }
  const bloco: HTMLPreElement = aba.document.createElement('pre');
  bloco.textContent = oficina.exportarRegistro();
  aba.document.body.appendChild(bloco);
  diario.nota(
    'Registro aberto em outra aba, já formatado. Cole-o no repositório do projeto: o ' +
      'armazenamento local não viaja, e registro que morre no aparelho em que foi feito é a ' +
      'lista escrita de memória na véspera da entrega.',
  );
});

O primeiro tempo roda ao carregar e traz o que se responde sem sessão. O segundo espera o toque, porque não tem escolha. A divisão parece detalhe de interface e é consequência direta da regra de gesto do navegador.

Duas decisões de apresentação são de engenharia, e não de gosto. O botão é grande porque, no visor, a mira é um raio de controle a um metro de distância, e alvo pequeno vira exercício de pontaria. O corpo de texto é generoso pelo mesmo motivo: leitura a um braço de distância, com a resolução angular que o aparelho tem, não é leitura de tela de computador.

1.4.3 Onde é fácil errar, e como conferir

O erro previsível é o que a tutoria vai encontrar em pelo menos um grupo: o relatório escrito no console e em mais lugar nenhum. Ele funciona perfeitamente na máquina de quem programou. Ele some no aparelho, que é justamente onde precisava ser lido.

O segundo erro é mais sutil e passa por competência. O grupo captura a exceção, evita que a página quebre, e não mostra nada. A página fica intacta e silenciosa, e a conclusão de quem está de visor é que o botão não funciona.

A conferência tem duas perguntas. Alguém que nunca viu o código consegue ler o relatório no aparelho e dizer o que ele concede? E quando a sondagem falha, a página diz que falhou e por quê, sem que ninguém precise de um computador ao lado?

1.5 De onde vem a pose: por dentro e por fora

Um tópico do módulo que não vira código, e é honesto declarar isso.

Há dois arranjos para descobrir onde um aparelho está. No primeiro, os sensores estão no próprio aparelho e olham para fora, reconhecendo o ambiente. No segundo, há equipamento fixo na sala que olha para o aparelho e calcula sua posição.

Cada arranjo cobra um preço diferente. O sensor embarcado não exige instalação, funciona em qualquer sala e depende do que a sala oferece de textura e de luz. A infraestrutura externa exige montagem, calibração e um espaço dedicado, e em troca entrega precisão que não depende da aparência da sala.

O ancestral do segundo arranjo é o mesmo aparelho que abre a história da área. Em 1968, o visor de Sutherland precisava ficar pendurado no teto do laboratório por um braço mecânico articulado — e esse braço não estava ali só para segurar peso. Ele media a posição da cabeça de quem o vestia. O rastreamento por infraestrutura externa nasceu literalmente preso ao teto.

Este tópico não vira código, e forçá-lo seria fabricar conteúdo. A API não informa qual arranjo o aparelho usa, e não informa de propósito: para quem escreve o ambiente, a pose chega igual dos dois. O que o arranjo decide é o que acontece quando ele falha, e é aí que ele volta a importar.

1.6 O cartógrafo no escuro, e o quadro em que ele se perde

A pergunta que o módulo inteiro persegue: como o aparelho sabe onde está?

O aparelho desenha a planta da sala enquanto atravessa a sala, e corrige o desenho toda vez que reconhece um canto por onde já passou. É isso que localização e mapeamento simultâneos querem dizer, e as duas metades da frase acontecem ao mesmo tempo — daí o nome.

O problema tem forma de círculo. Para saber onde está, o aparelho precisa do mapa. Para desenhar o mapa, precisa saber de onde está olhando. Ele resolve o círculo estimando os dois juntos e corrigindo os dois quando reconhece algo já visto.

Nada disso é chamada de função nossa. O mapa é construído pelo sistema do aparelho, e o que a sessão nos entrega são consequências dele: os planos que ele reconheceu, o raio que se lança contra superfícies reais, a âncora que ele mantém corrigida. Um aparelho que concede esses três está declarando que mantém um mapa; um que não os concede está declarando que não.

E quando o cartógrafo se perde? Aí a pose simplesmente não vem no quadro. A API prevê isso: o quadro entrega a pose ou não entrega nada. É o único sintoma de degradação de rastreamento que se lê de dentro do código, sem sensor adicional.

src/bancada/devices/estabilidade.ts
// ---------------------------------------------------------------------------
// A falha como medida, e não como anedota.
//
// A pose de quem observa pode simplesmente não vir. A API prevê isso: o quadro
// entrega a pose ou entrega nada, e nada significa que o aparelho perdeu, naquele
// instante, a conta de onde está. É o único sintoma de degradação de rastreamento
// que se lê de dentro do código, sem instrumento e sem sensor extra.
//
// Este contador existe porque o módulo cobra prever a falha, e previsão que não
// se confronta com contagem alguma é opinião. Ele é deliberadamente burro: conta
// quadros com pose e quadros sem, e mais nada. A interpretação fica na função de
// diagnóstico, separada de propósito — misturar contagem e julgamento é o que
// produz medidor que sempre concorda com quem o escreveu.
// ---------------------------------------------------------------------------

export interface Estabilidade {
  readonly quadros: number;
  readonly quadrosSemPose: number;
  /** Maior sequência ininterrupta de quadros sem pose. */
  readonly maiorLacuna: number;
  /** Quadros descartados por a sessão não estar em primeiro plano. */
  readonly quadrosOcultos: number;
}

/**
 * Acumula a contagem quadro a quadro.
 *
 * O parâmetro `visivel` é o que separa perda de rastreamento de sessão que saiu
 * de foco. Quando quem usa abre o menu do sistema do visor, a sessão continua
 * viva, os quadros continuam chegando e a pose deixa de vir — e contar isso como
 * falha de sensor acusaria o aparelho de um defeito que é comportamento normal
 * do sistema operacional.
 */
export class ContadorDeEstabilidade {
  private quadros: number = 0;
  private quadrosSemPose: number = 0;
  private quadrosOcultos: number = 0;
  private maiorLacuna: number = 0;
  private lacunaCorrente: number = 0;

  public registrar(temPose: boolean, visivel: boolean): void {
    this.quadros += 1;

    if (!visivel) {
      this.quadrosOcultos += 1;
      this.lacunaCorrente = 0;
      return;
    }

    if (temPose) {
      this.lacunaCorrente = 0;
      return;
    }

    this.quadrosSemPose += 1;
    this.lacunaCorrente += 1;
    if (this.lacunaCorrente > this.maiorLacuna) {
      this.maiorLacuna = this.lacunaCorrente;
    }
  }

  public resultado(): Estabilidade {
    return {
      quadros: this.quadros,
      quadrosSemPose: this.quadrosSemPose,
      maiorLacuna: this.maiorLacuna,
      quadrosOcultos: this.quadrosOcultos,
    };
  }
}

/**
 * Traduz a contagem numa frase, com as condições que a bibliografia da disciplina
 * aponta como causas de degradação de rastreamento óptico: superfície sem textura
 * (não há canto a reconhecer), iluminação pobre (não há contraste para extrair
 * canto algum) e movimento brusco (a imagem borra e o canto some entre quadros).
 *
 * A frase não afirma qual das três aconteceu. Não dá para saber daqui, e escolher
 * uma seria inventar o diagnóstico junto com a medida.
 */
export function diagnosticar(estabilidade: Estabilidade): string {
  if (estabilidade.quadros === 0) {
    return 'Nenhum quadro foi entregue — a sessão não chegou a produzir imagem.';
  }
  if (estabilidade.quadrosSemPose === 0) {
    return 'A pose veio em todos os quadros observados. A janela de observação é curta, e ausência de falha aqui não é promessa de estabilidade em uso prolongado.';
  }
  const proporcao: number = Math.round(
    (estabilidade.quadrosSemPose / estabilidade.quadros) * 100,
  );
  return (
    `A pose faltou em ${proporcao}% dos quadros, com lacuna máxima de ` +
    `${estabilidade.maiorLacuna} quadros seguidos. As causas prováveis são superfície ` +
    'sem textura, iluminação pobre ou movimento brusco — e daqui não se distingue qual delas.'
  );
}

O contador é deliberadamente burro. Conta quadros com pose e quadros sem, e o julgamento fica numa função separada. Misturar contagem e interpretação produz medidor que sempre concorda com quem o escreveu.

Um cuidado nesse contador vale por si só. Quando quem usa abre o menu do sistema do visor, a sessão continua viva, os quadros continuam chegando e a pose deixa de vir. Contar isso como perda de rastreamento acusaria o aparelho de um defeito que é comportamento normal. Por isso o contador recebe também se a sessão está em primeiro plano.

As três condições que degradam o rastreamento óptico são conhecidas e se explicam pelo mecanismo. Superfície sem textura não oferece canto algum a reconhecer. Iluminação pobre não oferece contraste para extrair canto algum. Movimento brusco borra a imagem, e o canto some entre um quadro e o seguinte.

O diagnóstico que a função devolve nomeia as três e não escolhe uma. Daqui não se distingue qual delas aconteceu, e escolher seria inventar o laudo junto com a medida.

1.7 Verificação do estado deste módulo

O que precisa estar verdadeiro antes de o módulo seguinte começar.

Verificação Como se confere
A página está em contexto seguro a primeira linha do relatório da sonda diz
Recurso negado e recurso sem resposta aparecem diferentes ler a última coluna da tabela de recursos
A sessão encerra mesmo quando a sondagem falha forçar erro e conferir que o visor volta ao navegador
O relatório é legível no próprio aparelho ler no visor, sem computador ao lado
A falha aparece na página, e não só no console desligar a rede e tocar o botão
O código analisa limpo em modo estrito verificação de tipos do projeto, sem emissão
Aparelhos diferentes produzem relatórios diferentes abrir a mesma URL no desktop, no celular e no visor

A terceira linha é a que mais falha em sala, e falha silenciosamente. O erro acontece, a página captura, e o visor fica preso numa tela vazia até alguém achar o botão lateral. Vale forçar o caso ruim de propósito antes de a turma chegar.

O que fica pronto ao fim deste módulo é uma consulta honesta. O ambiente ainda não desenha nada, e passou a saber contra o que vai desenhar. A partir do módulo seguinte existe cena, e cada capacidade sondada aqui vira decisão de projeto lá.