1 O contínuo entre o sintético e o real — 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

Duas tarefas que não desenham nada, e por que elas mesmo assim viram código.

As duas tarefas deste módulo pedem declarações. A primeira pede a delimitação de um domínio; a segunda, o registro do que cada regime faz com o mundo de quem observa. Nenhuma das duas pede um polígono na tela.

A tentação, aqui, é resolver as duas num documento de texto e voltar ao código no módulo seguinte. Resistimos a ela por um motivo prático. Declaração guardada em documento não se confronta com nada. Quando o ambiente existir e se comportar de um jeito, ninguém vai reabrir a ata da reunião para conferir se era aquilo que se pretendia.

Então resolvemos as duas tarefas em código, na forma de dados tipados que o compilador confere e que a página carrega. A Bancada começa neste módulo, e começa sem renderizar: o que ela entrega é a descrição do domínio, a declaração dos três regimes e uma página que pergunta ao aparelho em que está aberta se ele entra em cada um deles. A mesma URL responde diferente no desktop, no celular e no visor, e essa diferença é a primeira coisa observável do percurso.

O código deste módulo vive em src/bancada/, dentro do mesmo projeto que já servia de andaime de ambiente. O andaime continua onde estava, na raiz do servidor, e segue demonstrando a montagem da máquina. O artefato condutor nasce em uma segunda página, e as duas convivem sem se tocar.

1.2 O contínuo como eixo, e não como três caixas

Onde a taxonomia de 1994 deixa de ser desenho de livro e vira um valor que a sessão informa.

Milgram e Kishino pararam de perguntar se um visor de ver-através “era” realidade virtual. Passaram a perguntar quanto de cada lado o sistema exibia. É essa troca de pergunta que torna a área um eixo, e ela tem consequência direta no desenho do código.

A consequência é que os três regimes da Bancada não podem ser três programas. Se cada um for um programa, a comparação entre eles vira comparação entre implementações, e o estudante conclui — com razão — que a diferença está no código, não no conceito. O que queremos exibir é o oposto: o mesmo domínio, a mesma lógica, e apenas a relação com o mundo mudando.

Por isso a declaração de regime é uma lista de registros com os mesmos campos. Cada regime declara o que faz com o ambiente de quem observa, qual espaço de referência pretende, o que rastreia e contra o que a cena é registrada. Um regime que precisasse de um campo próprio, que os outros dois não têm, seria o sinal de que estamos tratando o contínuo como caixas outra vez.

Há um detalhe da API que vale mais que qualquer diagrama nesta altura. Uma sessão informa o modo de composição do fundo, e os valores possíveis são fundo opaco, aditivo e mesclado por transparência. O contínuo, ali, é um campo de dado. Fundo opaco esconde o mundo; os outros dois o deixam passar, com qualidades diferentes de passagem.

Repare no que fizemos com esse campo no código: ele está declarado como esperado, não como lido. O valor real só existe com uma sessão ativa, e sessão é assunto de módulos adiante. Declarar agora o que se espera, e ler depois o que veio, é o que transforma esta tarefa numa aposta verificável em vez de uma opinião.

1.3 Tarefa 1: Delimitar o domínio da cena

Enunciado da tarefa

O ambiente que acompanha a leitura precisa de um domínio antes de precisar de uma linha de código. Uma oficina de montagem, um laboratório de instrumentos, uma maquete que se percorre: serve qualquer cena em que objetos sejam selecionados, movidos, orientados e postos em algum lugar certo.

O que fica pronto é a descrição do domínio escolhido, com a tarefa que o ambiente suporta enunciada em uma frase, os objetos que a compõem e o estado que caracteriza a tarefa concluída. Domínio sem tarefa produz uma cena bonita e vazia, e é ela que trava os capítulos de manipulação adiante.

1.3.1 A escolha, e o que ela precisava garantir

Escolhemos uma oficina de montagem. Sobre uma bancada de trabalho estão as peças de um mecanismo e um suporte com encaixes; quem usa o ambiente monta o mecanismo peça por peça.

O critério de escolha não foi estético. Precisávamos de um domínio em que objetos sejam selecionados, apanhados, orientados e postos num lugar certo — porque são essas quatro ações que os módulos de interação e manipulação vão exercitar. Uma maquete de cidade que se sobrevoa é bonita e não exercita nenhuma delas.

O segundo critério foi a existência de um estado final verificável. Montar tem fim; passear não tem. Um domínio sem fim obriga a inventar, mais adiante, um critério artificial de sucesso, e todo critério artificial acaba virando uma pontuação em que ninguém acredita.

O terceiro critério foi a escala. A bancada cabe no alcance dos braços de quem está de pé diante dela. Isso importa porque o regime imersivo é o único em que alcance de braço existe, e um domínio que exigisse locomoção constante desperdiçaria exatamente a diferença que queremos exibir.

1.3.2 A delimitação escrita como dado

O módulo de domínio carrega a descrição, a tarefa em uma frase, o estado que a caracteriza concluída, as cinco peças e os cinco encaixes. Os identificadores de peça e de encaixe são tipos de união literal, o que faz o compilador recusar um nome inventado antes de qualquer execução.

src/bancada/dominio/dominio.ts
// ---------------------------------------------------------------------------
// Delimitação do domínio da Bancada.
//
// Este arquivo é a Tarefa 1 do Projeto Integrador escrita como dado, e não como
// texto solto num documento à parte. O motivo é operacional: a delimitação
// precisa ser confrontável com o que o ambiente faz mais adiante, e texto em
// documento não se confronta com nada. Aqui ela é tipada, e o compilador passa
// a cobrar o que antes dependia de alguém reler a ata da reunião.
//
// Nada nesta etapa desenha, carrega malha ou abre sessão. A cena chega quando
// houver grafo de cena; o encaixe, quando houver interação. O que existe agora é
// a descrição do que aquele ambiente terá de suportar.
// ---------------------------------------------------------------------------

/** Identificador de uma peça manipulável da oficina. */
export type PecaId =
  | 'corpo'
  | 'eixo'
  | 'engrenagem-grande'
  | 'engrenagem-pequena'
  | 'tampa';

/** Identificador de um encaixe do suporte de montagem. */
export type SocketId =
  | 'base-do-suporte'
  | 'furo-do-eixo'
  | 'dente-maior'
  | 'dente-menor'
  | 'topo';

/**
 * Uma peça do mecanismo. `sockets` lista os encaixes que a aceitam — uma peça
 * que não serve em lugar nenhum é um objeto decorativo, e a delimitação existe
 * justamente para não deixar objeto decorativo entrar como se fosse conteúdo.
 */
export interface Peca {
  readonly id: PecaId;
  readonly nome: string;
  readonly sockets: readonly SocketId[];
}

/**
 * A tarefa que o ambiente suporta, enunciada em uma frase, mais o estado que a
 * caracteriza concluída. Os dois campos andam juntos de propósito: tarefa sem
 * estado final é intenção, e é o que produz a cena bonita e vazia.
 */
export interface TarefaDoAmbiente {
  readonly enunciado: string;
  readonly estadoFinal: string;
}

export interface Dominio {
  readonly nome: string;
  readonly descricao: string;
  readonly tarefa: TarefaDoAmbiente;
  readonly pecas: readonly Peca[];
  readonly sockets: readonly SocketId[];
}

export const BANCADA: Dominio = {
  nome: 'Bancada',
  descricao:
    'Uma oficina de montagem: sobre uma bancada de trabalho estão as peças de um ' +
    'mecanismo e um suporte com encaixes, e quem usa o ambiente monta o mecanismo ' +
    'peça por peça.',
  tarefa: {
    enunciado:
      'Montar o mecanismo encaixando cada peça no suporte, na ordem em que uma ' +
      'depende da outra.',
    estadoFinal:
      'As cinco peças estão encaixadas nos sockets compatíveis, na ordem válida, ' +
      'e o mecanismo montado gira.',
  },
  pecas: [
    { id: 'corpo', nome: 'Corpo', sockets: ['base-do-suporte'] },
    { id: 'eixo', nome: 'Eixo', sockets: ['furo-do-eixo'] },
    { id: 'engrenagem-grande', nome: 'Engrenagem grande', sockets: ['dente-maior'] },
    { id: 'engrenagem-pequena', nome: 'Engrenagem pequena', sockets: ['dente-menor'] },
    { id: 'tampa', nome: 'Tampa', sockets: ['topo'] },
  ],
  sockets: ['base-do-suporte', 'furo-do-eixo', 'dente-maior', 'dente-menor', 'topo'],
};

/**
 * Confere que todo socket citado por alguma peça existe no suporte e que todo
 * socket do suporte recebe alguma peça. Devolve a lista de inconsistências, que
 * é vazia quando o domínio fecha.
 *
 * O porquê de isto ser código, e não conferência a olho: a delimitação vai ser
 * editada muitas vezes ao longo do percurso, e um socket órfão sobrevive a
 * qualquer releitura distraída — some só quando alguém tenta encaixar, muitos
 * módulos adiante, quando trocar de domínio já custa caro.
 */
export function inconsistenciasDoDominio(dominio: Dominio): string[] {
  const declarados: ReadonlySet<SocketId> = new Set(dominio.sockets);
  const usados: Set<SocketId> = new Set();
  const problemas: string[] = [];

  for (const peca of dominio.pecas) {
    if (peca.sockets.length === 0) {
      problemas.push(`A peça "${peca.nome}" não encaixa em socket nenhum.`);
    }
    for (const socket of peca.sockets) {
      if (!declarados.has(socket)) {
        problemas.push(
          `A peça "${peca.nome}" cita o socket "${socket}", que o suporte não declara.`,
        );
      }
      usados.add(socket);
    }
  }

  for (const socket of dominio.sockets) {
    if (!usados.has(socket)) {
      problemas.push(`O socket "${socket}" não recebe peça alguma.`);
    }
  }

  return problemas;
}

Duas decisões merecem justificativa.

A primeira é a lista de encaixes compatíveis dentro de cada peça, em vez de uma tabela separada de compatibilidades. Ela nos deixa perguntar, olhando para uma peça só, onde ela serve. Uma peça que não serve em lugar nenhum aparece imediatamente — e uma peça assim é decoração entrando no projeto disfarçada de conteúdo.

A segunda é a função que devolve as inconsistências do domínio. Ela existe porque a delimitação vai ser editada muitas vezes ao longo do percurso, e um encaixe órfão sobrevive a qualquer releitura distraída. Ele só apareceria no módulo de manipulação, quando trocar de domínio já custa caro. A conferência custa uma dúzia de linhas agora e evita um retrabalho grande depois.

Repare no que essa função não faz: ela não valida a ordem da montagem. A ordem é conteúdo do módulo de encaixe, e antecipá-la aqui produziria uma regra que ainda não temos como testar contra comportamento algum.

1.3.3 Onde é fácil errar, e como conferir

O erro mais comum nesta tarefa é o excesso. Nada custa nada quando ainda não há código, e o domínio cresce sem resistência: vinte peças, uma narrativa, três salas. O custo aparece três módulos adiante, quando cada peça precisa de malha, de volume de colisão e de tolerância de encaixe.

A conferência é curta e cabe em três perguntas. A tarefa está enunciada em uma frase? O estado final é observável por alguém que olhe a cena, sem consultar uma variável interna? Toda peça declarada encaixa em algum lugar, e todo encaixe recebe alguma peça?

A última das três é a única que a máquina responde sozinha, e é por isso que ela virou função. As duas primeiras dependem de leitura, e é nelas que a tutoria costuma encontrar o problema real.

1.4 Tarefa 2: Declarar o que cada regime faz com o mundo

Enunciado da tarefa

Os três regimes do ambiente diferem pelo que fazem com o ambiente de quem observa: um o substitui inteiro, outro o mantém e deposita sobre ele, o terceiro o mostra por uma janela sem tocá-lo. Essa distinção precisa estar escrita antes de existir código, porque ela decide o que cada regime terá de provar depois.

O que fica pronto é o registro, por regime, do espaço de referência pretendido, do que será rastreado e contra o que a cena será registrada. Ao longo do percurso essa declaração vira comportamento observável, e comparar o que se escreveu aqui com o que o ambiente faz adiante é o exercício mais barato de honestidade técnica que o projeto oferece.

1.4.1 Os três registros

src/bancada/modes/regimes.ts
// ---------------------------------------------------------------------------
// Declaração dos três regimes da Bancada.
//
// Este arquivo é a Tarefa 2 do Projeto Integrador. Ele não abre sessão, não
// renderiza e não detecta nada: declara, regime a regime, o espaço de referência
// pretendido, o que será rastreado e contra o que a cena será registrada.
//
// Escrever isso antes do código tem uma consequência barata e boa: mais adiante,
// quando os regimes existirem de fato, a declaração pode ser confrontada com o
// comportamento observado. Declaração que ninguém guarda não se confronta com
// coisa alguma, e o projeto perde o único registro de qual era a intenção.
// ---------------------------------------------------------------------------

/**
 * Os três regimes do ambiente. Os dois primeiros nomes coincidem com os modos de
 * sessão da API XR do navegador de propósito — é o que permite perguntar ao
 * aparelho, sem tradução no meio, se ele suporta o que declaramos.
 */
export type RegimeId = 'inline' | 'immersive-vr' | 'immersive-ar';

/**
 * O que o regime faz com o ambiente de quem observa — a distinção que separa os
 * três antes de qualquer detalhe técnico.
 */
export type TratamentoDoMundo =
  | 'substitui'   // o ambiente sintético toma o lugar do ambiente real
  | 'preserva'    // o ambiente real permanece visível e recebe o sintético sobre si
  | 'exibe';      // o ambiente sintético é mostrado por uma janela, sem tocar o real

/**
 * Modo de composição do fundo, tal como a API XR o nomeia. É o ponto em que o
 * contínuo entre o real e o sintético deixa de ser desenho de livro e vira um
 * valor que a sessão informa: fundo opaco esconde o mundo, os outros dois o
 * deixam passar. Só é legível com uma sessão ativa, e por isso aqui ele é o
 * valor ESPERADO — a leitura do valor real chega quando houver sessão.
 */
export type ModoDeComposicao = 'opaque' | 'additive' | 'alpha-blend';

export interface Regime {
  readonly id: RegimeId;
  readonly nome: string;
  readonly tratamentoDoMundo: TratamentoDoMundo;
  /** Espaço de referência pretendido, no vocabulário da API XR. */
  readonly espacoDeReferencia: 'viewer' | 'local' | 'local-floor' | 'unbounded';
  /** O que o sistema rastreia neste regime, em uma frase. */
  readonly rastreia: string;
  /** Contra o que a cena é registrada — a origem do mundo virtual. */
  readonly registroContra: string;
  readonly composicaoEsperada: ModoDeComposicao;
  /** Por que este regime existe no projeto, e não como enfeite comparativo. */
  readonly papel: string;
}

export const REGIMES: readonly Regime[] = [
  {
    id: 'inline',
    nome: 'Realidade virtual não imersiva',
    tratamentoDoMundo: 'exibe',
    espacoDeReferencia: 'viewer',
    rastreia: 'nada do corpo; a câmera obedece ao mouse',
    registroContra: 'a origem arbitrária da própria cena, fixada por quem a modelou',
    composicaoEsperada: 'opaque',
    papel:
      'É o caso base e o destino de quem não tem headset: a bancada inteira precisa ' +
      'ser montável aqui.',
  },
  {
    id: 'immersive-vr',
    nome: 'Realidade virtual imersiva',
    tratamentoDoMundo: 'substitui',
    espacoDeReferencia: 'local-floor',
    rastreia: 'a pose da cabeça e a das duas mãos, com seis graus de liberdade',
    registroContra:
      'o chão do espaço físico onde a pessoa está, o que faz a bancada nascer na ' +
      'altura certa em vez de flutuar',
    composicaoEsperada: 'opaque',
    papel:
      'É onde escala corporal e alcance de braço passam a existir — e nenhum dos ' +
      'dois tem equivalente na janela do desktop.',
  },
  {
    id: 'immersive-ar',
    nome: 'Realidade aumentada',
    tratamentoDoMundo: 'preserva',
    espacoDeReferencia: 'local-floor',
    rastreia:
      'a pose da cabeça, a das mãos e as superfícies que o aparelho encontra no ' +
      'ambiente',
    registroContra:
      'uma superfície real escolhida no ambiente, à qual a bancada permanece presa ' +
      'enquanto a pessoa caminha em volta',
    composicaoEsperada: 'alpha-blend',
    papel:
      'É o único regime em que errar o registro é visível a olho nu: a bancada ' +
      'desliza sobre a mesa, e ninguém precisa de instrumento para notar.',
  },
];

export function regimePorId(id: RegimeId): Regime {
  const encontrado: Regime | undefined = REGIMES.find((regime) => regime.id === id);
  if (encontrado === undefined) {
    // Inalcançável enquanto REGIMES cobrir RegimeId, e é o compilador que garante
    // isso ao construir a lista. O lançamento existe para o caso de alguém
    // acrescentar um id ao tipo e esquecer a entrada correspondente.
    throw new Error(`Regime não declarado: ${id}`);
  }
  return encontrado;
}

Os identificadores dos regimes coincidem com os modos de sessão da API do navegador, e isso é deliberado. Podíamos ter chamado os três de nomes próprios, que leem melhor. Perderíamos a possibilidade de perguntar ao aparelho, sem tradução no meio, se ele suporta aquilo que declaramos. Um nome bonito que exige uma tabela de conversão é um nome caro.

O campo mais importante dos três registros é o último a ser lido e o primeiro a ser esquecido: contra o que a cena é registrada. No regime de janela, a origem do mundo virtual é arbitrária — quem modelou a cena a fixou onde quis, e ninguém tem como discordar. No regime imersivo, ela passa a ser o chão do espaço físico onde a pessoa está, e é por isso que a bancada nasce na altura certa em vez de flutuar. Na realidade aumentada, é uma superfície real escolhida no ambiente.

Essa escada é o conteúdo do módulo. À medida que se sobe por ela, o mundo virtual perde liberdade de escolher a própria origem e passa a depender de uma medida do mundo físico. Registro, aqui, é exatamente isso: o quanto a origem do virtual está presa a algo do real.

1.4.2 Imersão, presença e registro, definidos pelo que o sistema faz

Três palavras costumam ser usadas como sinônimos, e as três descrevem coisas diferentes.

Imersão é propriedade do sistema, e é mensurável: campo de visão, latência, graus de liberdade rastreados, taxa de quadros. Dois aparelhos podem ser comparados por ela sem que ninguém os vista.

Presença é a resposta da pessoa a esse sistema, e ela não decorre da fidelidade da imagem. O palco é a figura que atravessa o percurso inteiro: presença é o palco acertando escala e tempo; fidelidade é a pintura do cenário, e cenário pintado não segura ninguém no lugar. É a distinção que mais resiste à intuição, porque o estudante chega convencido de que o que convence o corpo é a qualidade da imagem.

Registro é a relação geométrica entre o que é desenhado e o mundo físico. Ele só existe onde há mundo físico visível, o que o torna assunto do regime aumentado e de mais nenhum.

Nenhuma das três admite implementação neste módulo, e é bom dizer isso em voz alta em vez de fabricar um exemplo. O que fizemos foi menos ambicioso e mais útil: os campos de espaço de referência, de rastreamento e de registro, na declaração de regime, são a forma operacional das três — escritos de modo que o comportamento futuro possa contradizê-los.

1.4.3 Confrontar a declaração com o aparelho

A declaração sozinha envelhece bem demais: continua parecendo certa muito depois de ter deixado de ser. O módulo de verificação faz a pergunta correspondente ao navegador em que a página está aberta.

src/bancada/modes/verificacao.ts
// ---------------------------------------------------------------------------
// Confronto entre o que declaramos e o que o aparelho oferece.
//
// A Tarefa 2 termina numa declaração de intenção, e intenção envelhece bem
// demais: continua parecendo certa muito depois de ter deixado de ser. Este
// arquivo faz a pergunta correspondente ao navegador em que a página está aberta
// e devolve as duas colunas lado a lado.
//
// O que ele NÃO faz, e a distinção importa: não abre sessão, não mede graus de
// liberdade, não enumera fontes de entrada nem lista capacidades opcionais. Isso
// é a sonda de capacidades, que pertence ao módulo de dispositivos. Aqui a
// pergunta é a mais grossa possível — "este aparelho entra neste regime?" —, que
// é exatamente a que o conceito deste módulo suporta.
// ---------------------------------------------------------------------------

import { REGIMES, type Regime, type RegimeId } from './regimes';

/**
 * `desconhecido` não é sinônimo de `nao`. O navegador sem a API XR não está
 * dizendo que o aparelho não serve — está dizendo que não sabe responder, e
 * tratar as duas coisas como a mesma é o erro que faz um relatório honesto
 * virar um relatório confiante e errado.
 */
export type Suporte = 'sim' | 'nao' | 'desconhecido';

export interface LinhaDoRelatorio {
  readonly regime: Regime;
  readonly suporte: Suporte;
  readonly observacao: string;
}

/** A API XR do navegador, quando existe. */
function sistemaXr(): XRSystem | undefined {
  return navigator.xr;
}

async function suporteDe(id: RegimeId): Promise<Suporte> {
  const xr: XRSystem | undefined = sistemaXr();
  if (xr === undefined) {
    return 'desconhecido';
  }
  try {
    const suportado: boolean = await xr.isSessionSupported(id);
    return suportado ? 'sim' : 'nao';
  } catch {
    // Alguns navegadores rejeitam a promessa em vez de devolver `false` — para
    // um modo de sessão que não reconhecem, ou fora de contexto seguro. Nos dois
    // casos o que se sabe é que não houve resposta utilizável.
    return 'desconhecido';
  }
}

function observacaoDe(regime: Regime, suporte: Suporte): string {
  if (suporte === 'sim') {
    return `Declarado com registro contra ${regime.registroContra}. Falta confrontar em sessão.`;
  }
  if (suporte === 'nao') {
    return 'Este aparelho não entra neste regime. É informação sobre o aparelho, não defeito do código.';
  }
  return 'Sem API XR neste navegador, ou página fora de contexto seguro (HTTPS).';
}

export async function levantarRelatorio(): Promise<LinhaDoRelatorio[]> {
  const linhas: LinhaDoRelatorio[] = [];
  for (const regime of REGIMES) {
    const suporte: Suporte = await suporteDe(regime.id);
    linhas.push({ regime, suporte, observacao: observacaoDe(regime, suporte) });
  }
  return linhas;
}

A pergunta é a mais grossa possível: este aparelho entra neste regime? Não medimos graus de liberdade, não enumeramos fontes de entrada e não listamos capacidades opcionais. Tudo isso é a sonda de capacidades, e a sonda pertence ao módulo de dispositivos. Antecipá-la aqui roubaria o conteúdo do módulo seguinte e deixaria este com um artefato grande demais para o conceito que ele sustenta.

A decisão de desenho que mais rende nesta tarefa é o terceiro valor de resposta. Um navegador sem a API não está dizendo que o aparelho não serve; está dizendo que não sabe responder. Tratar as duas coisas como a mesma produz um relatório confiante e errado, e o desktop do laboratório apareceria como aparelho que “não suporta realidade aumentada” quando o que houve foi ausência de resposta.

O mesmo vale para a página aberta fora de contexto seguro. Sem conexão cifrada, a API não está disponível, e o relatório precisa dizer isso em vez de acusar o aparelho.

1.4.4 Onde é fácil errar, e como conferir

O erro previsível desta tarefa é declarar o que se gostaria de ter em vez do que se pretende construir. Um grupo declara rastreamento de mãos no regime imersivo porque a palavra soa bem, e passa os módulos seguintes carregando uma promessa que ninguém pediu.

A conferência é comparar as três linhas entre si. Se dois regimes declaram o mesmo espaço de referência, o mesmo rastreamento e o mesmo registro, então eles não são dois regimes — são a mesma cena com um rótulo trocado, e o projeto ainda não tem os dois modos de percorrer o ambiente que a disciplina exige.

A conferência executável é abrir a página em três aparelhos e ler a última coluna. É o exercício mais barato do percurso, e o único deste módulo que produz evidência em vez de argumento.

1.5 O marco histórico, e por que ele não vira código

O visor que precisava ficar pendurado no teto, e o que ele ensina sobre demonstração vistosa.

O visor que Sutherland montou em 1968 era pesado a ponto de precisar ficar suspenso do teto do laboratório por um braço mecânico articulado, que também media a posição da cabeça de quem o vestia. O apelido que o aparelho ganhou depois, “espada de Dâmocles”, descreve com precisão a imagem de quem entrava embaixo dele. O que ele exibia eram arestas de arame, e ainda assim é o ponto em que a área começa.

Este tópico não admite implementação, e forçar um exemplo executável para ele seria fabricar conteúdo. O que ele faz no projeto é outra coisa: é o critério contra o encantamento com a demonstração bonita, e é por isso que ele aparece no primeiro módulo em vez de num apêndice histórico.

A ligação com o que construímos aqui é direta. A distância entre a ambição declarada e o que o equipamento entrega é exatamente o que a última coluna do relatório mede, aparelho por aparelho. Sutherland declarou muito e entregou arestas de arame; o relatório da Bancada declara três regimes e informa, sem eufemismo, quais deles aquele aparelho não sustenta.

1.6 A página que junta as duas tarefas

O painel provisório, e por que vale declarar que ele é provisório.

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 agora, pelo motivo mais simples possível — não há mundo. Enquanto o grafo de cena não chega, o relatório sai em texto comum na página.

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 é curta, e é o que se abre em aula.

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.',
  );
});

Declarar o painel como provisório, em comentário no próprio arquivo, tem uma função que vai além da boa educação. Sem essa nota, o arquivo seria lido, três módulos adiante, como uma decisão de projeto a ser preservada — e alguém gastaria uma tarde tentando conciliar dois painéis que nunca deveriam coexistir.

O artefato condutor entrou numa segunda página do mesmo projeto, e o andaime de ambiente continua na raiz do servidor. Foi preciso declarar as duas entradas para o empacotamento de produção; em desenvolvimento, o servidor serve as duas sem configuração alguma. A separação é o que permite continuar exibindo a montagem da máquina aos alunos sem que ela se misture ao projeto que cresce.

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 tarefa do domínio cabe em uma frase leitura do campo correspondente
O estado final é observável na cena leitura, sem consultar variável interna
Nenhuma peça ou encaixe fica órfão a função de inconsistências devolve lista vazia
Os três regimes diferem em pelo menos um campo cada comparação das três linhas entre si
O código analisa limpo em modo estrito verificação de tipos do projeto, sem emissão
A página responde diferente em aparelhos diferentes abrir a mesma URL no desktop, no celular e no visor

A última linha é a que costuma falhar por um motivo que não é de código. Sem contexto seguro, a API não existe e os três aparelhos respondem igual. Quem descobre isso na véspera da aula de imersão perde a aula inteira, e é por isso que o servidor de desenvolvimento já sobe com certificado desde o andaime.

O que fica pronto ao fim deste módulo, e que o seguinte pressupõe, é modesto de propósito: um domínio delimitado, três regimes declarados e uma resposta honesta sobre o que este aparelho faz. Nada disso desenha. Tudo isso será contradito, campo por campo, pelo comportamento que vem adiante — e é para poder ser contradito que foi escrito.