Construindo o Greenhouse: as primeiras telas
Construindo o último passo de um assistente que não existia
Sentei pra construir o passo final do assistente de onboarding do Greenhouse: capturar um primeiro lote de ideias pra que o loop diário tenha algo pra mastigar. Abri a issue, li a spec, e fui procurar o assistente pra encaixar o passo.
Não havia assistente nenhum. O frontend inteiro era um único arquivo Svelte de smoke test provando que o round-trip de IPC com o Rust funcionava. Sem router, sem passos, sem comando de captura. A issue descrevia “o passo final”, mas seus três irmãos mais velhos (o passeio pelas regras, a configuração da pasta, a própria shell do assistente) ainda estavam todos no backlog. Eu estava prestes a construir o telhado de uma casa sem paredes.
Então parei e mudei o foco pra shell. Essa reordenação é o ponto inteiro desta entrada: a coisa mais útil que fiz nessa issue foi não construir o que o ticket pedia.
Uma shell é sobre as costuras, não as telas. O trabalho da shell é tornar as próximas issues chatas. Se eu acertar as costuras, cada passo futuro vira “abra este arquivo, preencha o corpo” em vez de “repense o fluxo inteiro”. Então gastei o orçamento de design no contrato, não nos pixels.
Cada passo é um registro de dado mais um componente Svelte:
export const ONBOARDING_STEPS = [
{ id: "welcome", title: "Welcome to Greenhouse", component: Welcome },
{ id: "rules", title: "The rules, and why", component: RulesStep },
{ id: "folder", title: "Choose your vault", component: FolderStep },
{ id: "seed", title: "Plant your first seeds", component: SeedStep },
{ id: "done", title: "You're set", component: Done },
];
Os três passos do meio são stubs por enquanto, mas são componentes reais conectados na navegação. Cada um renderiza uma nota tracejada de “chegando numa issue futura”. O fluxo funciona de ponta a ponta hoje; as próximas issues só trocam um arquivo. Folder vem antes de seed de propósito, porque você não consegue capturar ideias num vault que ainda não existe.
Duas coisas do Svelte 5 me pegaram. Renderizar um componente a partir desse registro não é o que eu buscaria de primeira: <svelte:component> está descontinuado no Svelte 5. A alternativa é guardar o componente numa variável capitalizada e usá-la como tag:
let StepComponent = $derived(step.component);
// ...
{#key current}
<StepComponent {setReady} />
{/key}
O {#key} remonta o passo a cada mudança, então seu estado e efeitos rodam de novo, limpos.
O segundo me custou mais coceira de cabeça. Tipei cada entrada do registro como Component<StepProps> pra que o container consiga entregar a cada passo um callback setReady que libera o botão Next. Mas um passo que ignora props (a introdução, o encerramento) infere um tipo de prop {}, e o svelte-check recusou: passar setReady pra um componente que declara nenhuma prop é um erro. A correção é um idioma minúsculo que parece um erro de digitação:
let {}: StepProps = $props(); // accept the contract, bind nothing
Desestruturação vazia, anotação de tipo presente. O componente agora aceita as props que pretende ignorar, e não sobra variável não usada pra travar nada.
Mais uma: eu controlo “pode avançar” resetando a prontidão de forma síncrona no handler do Next antes de incrementar o índice do passo, não num efeito atrelado ao índice. Se você faz isso num efeito, o setReady(false) do momento de montagem de um passo que exige prontidão entra numa corrida com seu reset e às vezes perde. Resetar no handler torna a ordem determinística.
Funcionou. Também parecia um app de celular arrastado pra um desktop. Deixei o build verde, lancei, e entreguei pra alguém dar uma olhada. O veredito: “tecnicamente funciona, meio vazio, parece um app de celular portado.” O screenshot deixou óbvio. Uma coluna estreita de 34rem de texto flutuando morta no centro de uma janela de desktop larga, oceanos de branco dos dois lados. Toda checagem automatizada que eu tinha passou, e nenhuma delas conseguia ver isso.
Essa é a armadilha da verificação de frontend: svelte-check, o build de produção e um log de processo limpo dizem que o código está correto, não que ele parece um app de desktop. O render precisa de olhos.
A correção foram primitivas de layout em vez de uma coluna centralizada: um fundo de viewport inteiro com um gradiente verde suave preenchendo a janela, e um único card delimitado centralizado nele, vertical e horizontalmente. O card do assistente ganhou uma altura mínima estável pra que avançar de passo não faça a coisa toda pular. O mesmo tratamento no dashboard e nas telas de carregamento e erro, pra que o app se leia como uma peça só.
E acessibilidade, enquanto a estrutura ainda estava fresca, mais barato fazer agora do que retrofitar depois em quatro issues de passo. O título do passo é o h1 da página dentro de um landmark <main>, não um h2 órfão sem título acima. O foco se move pro título do passo a cada mudança pra que usuários de teclado e leitor de tela pousem no conteúdo novo. O contador de progresso é um progressbar de verdade com os valores aria. E peguei uma falha de contraste no texto sutil de dica, abaixo da linha de 4.5:1, então escureci esse token até passar no AA.
Quando uma ideia não é uma caixa de texto
Eu estava terminando a última tela do assistente de onboarding: a parte que pede pra você capturar um primeiro lote de ideias antes de o app te soltar. Estava funcionando. Um campo de texto, um botão “Add”, um pequeno contador “3 de 5 capturadas” que liberava o botão Next assim que você batia a cota. Testes verdes, tipos limpos. Escrevi o backend, o IPC, o passo em Svelte, e um comentário organizado na issue dizendo que estava pronto, faltando só um clique de conferência.
Aí olhei aquilo com meu outro chapéu, o que é dono do produto, e a resposta foi: isso está errado. Arranque fora.
Duas coisas que uma caixa de texto quebra silenciosamente. Greenhouse é um gerenciador de processo criativo. Uma ideia nele não é uma linha de texto. É um esboço: uma melodia cantarolada, uma imagem rascunhada, uma nota rabiscada. O ponto inteiro é capturar a centelha em qualquer forma que ela apareça. Um campo de linha única redefine “ideia” pra baixo, virando “item de lista de tarefas”, sem ninguém decidir isso. O modelo de dados vaza pro conceito.
O segundo problema é pior. Capturar ideias é o loop diário central do app, não uma tarefa de configuração. Colocar uma versão simplificada e falsa disso dentro do assistente ensina a coisa errada logo na primeira execução. Você aprenderia que capturar é uma caixa que você preenche pra passar de um portão, que é o oposto do que o app é pra fazer.
Então a correção não foi “deixar o campo mais bonito”. Foi “esse passo não deveria existir aqui”.
O que o giro realmente custou: menos do que eu esperava, porque eu tinha dividido o trabalho ao longo de uma costura sem planejar isso de verdade. O backend era uma pequena função pura, capture_idea, que insere uma ideia e inicia seu timer de maturação. Essa parte é real e reaproveitável não importa como fique a UI de captura. A parte específica do assistente, um comando de progresso e um gate de cota e o passo em Svelte, era uma casca fina por cima.
Então o giro foi: manter a função motor, apagar a casca. O passo final do assistente virou uma simples entrega que basicamente diz “você está pronto, agora vá capturar suas primeiras ideias como esboços no app”. A captura de verdade, a que lida com áudio e imagens e texto, se mudou pra sua própria issue, onde pertence. Deixei uma nota lá apontando pra função motor pra que a próxima sessão comece a partir da primitiva em vez de um arquivo em branco.
Mais uma coisa caiu disso. O assistente tinha uma “cota de sementes” que liberava o loop diário, um gate rígido. Com a captura saindo do assistente, esse gate não tinha mais onde se sustentar. Virou o que deveria ter sido desde o início: um empurrãozinho gentil no dashboard pra construir um lote inicial, não um cadeado. A spec dizia “libera”, então atualizei a spec também. Um documento de design que discorda do app é só um bug report do futuro.
Construir a versão errada foi o jeito mais rápido de ver que estava errada. Encarar a caixa de texto funcionando foi o que tornou óbvio o problema de “ideias são esboços” de um jeito que a spec escrita nunca conseguiu. O custo de descobrir isso foi alguns arquivos, e prefiro pagar isso a lançar um assistente que desinforma todo mundo no primeiro dia. (Tem uma vitória mais discreta aqui também: o passo de configuração de pasta em si, um seletor nativo do sistema operacional conectado pelo plugin de dialog do Tauri, entrou limpo e é o primeiro lugar onde o app de fato toca o seu sistema de arquivos.)
A tela de regras, e um layout que eu não parava de errar
O trabalho era pequeno no papel: construir o passo de onboarding que percorre as regras do Greenhouse, cada restrição pareada com a razão exata pela qual ela existe. Capture um pouco todo dia. Trabalhe, depois esfrie. Ideias germinam antes de serem julgadas. Vault, nunca apague. Trabalho negligenciado sobe primeiro. Cinco cards, cinco razões. Coloquei tudo num único passo em vez de cinco subtelas, porque um paginador aninhado dentro do próprio Back e Next do assistente é dois botões de “next” brigando entre si.
Aí levei duas correções seguidas, e as duas valem a pena escrever sobre porque nenhuma era realmente sobre essa tela.
O texto: eu tinha escrito os cards de regra cheios de travessão e uma linha sobre a incubação impedir que o entusiasmo “se disfarçasse de qualidade”. As duas coisas estão na minha própria lista do que não escrever. As regras existiam, mas viviam sob um título “Blog Writing”, então eu tinha silenciosamente decidido que se aplicavam só a posts de blog, não a texto de produto ou chat. Essa é uma leitura conveniente e errada. A correção foi mover a regra pra um nível acima, cobrindo tudo que eu produzo, e depois tirar os travessões e o floreio artificial do assistente. Uma regra de estilo que só se aplica onde é mais óbvio não é uma regra de estilo.
O layout, o maior dos dois: o assistente era um card estreito flutuando no meio de uma janela de desktop larga, oceanos de espaço vazio dos dois lados. Parecia uma tela de celular esticada. A questão é que isso já tinha aparecido antes. Só nunca foi anotado em lugar nenhum que a próxima sessão de trabalho fosse ver, então continuava reaparecendo como surpresa.
Refiz o assistente num layout de duas colunas ocupando a janela inteira: uma faixa verde escura à esquerda com a marca e uma lista de passos ao vivo, o conteúdo e a navegação preenchendo o resto. Agora parece um app de desktop, não um site espremido numa coluna. Mas a mudança que realmente importou não foi o CSS. Foi registrar a preferência como uma nota durável pra que pare de ser reaprendida do zero. Um feedback que só vive numa conversa tem meia-vida de uma sessão.
Verificando um frontend Tauri sem nunca abrir o app
Depois construí o dashboard principal: a tela inicial do fluxo diário que mostra o que está maduro pra uma decisão hoje, a lista de trabalho ativa, o que está esfriando, e uma sequência de captura. A parte interessante não foi a tela. Foi como conferi meu trabalho.
A maior parte do risco do dashboard vivia num único lugar: layout. Já tinham me avisado, mais de uma vez, que o app não podia renderizar como uma coluna estreita e centralizada flutuando numa janela larga. É um app de desktop, então deveria usar a janela inteira. Isso é uma propriedade visual. Você não consegue afirmar isso num teste unitário, tem que olhar.
Então eu queria um screenshot. O movimento óbvio é lançar o app e capturar a janela. Com Electron existe um caminho limpo pra isso. Tauri não é Electron. Ele renderiza na webview do sistema operacional, WKWebView no macOS, e controlar isso programaticamente é pouco confiável. Lançar a janela nativa e capturá-la a partir de um contexto automatizado é complicado na melhor das hipóteses.
Fiquei olhando pra isso por um minuto até a coisa óbvia se encaixar: o frontend é só um app web. Ele fala com o Rust via invoke, e nada mais nele se importa se o Tauri está rodando ou não. Se eu conseguir falsificar o invoke, consigo renderizar o componente real como uma página web comum e tirar um screenshot disso.
Eu já tinha canalizado toda chamada IPC por um único módulo, ipc.ts. Existe exatamente uma função que o dashboard chama: getDailyState(). E o Tauri distribui mockIPC em @tauri-apps/api/mocks bem pra essa situação. Por baixo dos panos, o invoke lê window.__TAURI_INTERNALS__.invoke no momento da chamada, e mockIPC troca isso.
Então escrevi uma armação descartável, guardada em tmp/, que importa o Dashboard.svelte de verdade, mocka get_daily_state pra devolver uma fixture montada à mão, e monta o componente. Um query param alterna entre um vault vazio e recém-criado e um dia semeado com projetos que parecem reais. Depois Chrome headless contra o servidor de dev do Vite, viewport de desktop, screenshot:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --window-size=1280,860 --force-device-scale-factor=1 \
--virtual-time-budget=2500 --screenshot=out.png \
"http://localhost:1420/tmp/verify/harness.html?s=seeded"
Só isso. O componente real, o CSS real, a lógica de layout real rodando contra dados que eu controlo, renderizado num navegador que eu consigo capturar. As versões de vault vazio e semeado saíram exatamente como projetadas, três zonas de largura total pela janela, nenhuma coluna estreita à vista.
Duas coisas me pegaram. Primeiro, nomeei uma variável local derived dentro de um script Svelte 5. Svelte 5 tem uma rune $derived, e o compilador engasgou com “usado antes de sua declaração”. Renomear resolveu na hora, mas a mensagem de erro aponta pra coisa errada. A mesma armadilha espera por state, props e effect.
Segundo, a fonte do screenshot parecia Times New Roman. Pânico leve, depois lembrei: o Chrome headless resolve system-ui de um jeito diferente do WKWebView. No app de verdade é San Francisco. É um artefato de renderização do método de verificação, não um bug. Julgue o layout por esses screenshots, não a tipografia.
Isso verifica a camada web: layout, CSS, como o componente se comporta com dados específicos. Não exercita o comando Rust real, a leitura do SQLite, ou se minha fixture bate com o formato serializado de verdade. Mantive o cargo test pro backend, e me certifiquei que meu dado falso batia com a saída do serde, incluindo um enum que serializa pra snake_case, pra não estar verificando um layout que o app real nunca conseguiria produzir. É uma ferramenta estreita pra uma pergunta estreita: essa tela parece certa com esse dado. Nisso, bateu de longe o lançar o app inteiro, e vai ser a mesma receita de três linhas pra cada tela que eu construir daqui pra frente.
O diálogo de captura que jurava não fazer nada
A premissa inteira do Greenhouse é capturar um pouco, todo dia. Então o fluxo de captura tinha que ser sem atrito: um nome, talvez uma nota, pronto. A reviravolta no modelo é que uma ideia não é uma linha de texto, é um projeto com arquivos. Você esboça no Logic ou no Procreate, exporta um arquivo. Então em vez de pedir pra você anexar um arquivo, o Greenhouse cria uma pasta datada pra ideia e só diz onde salvar seu trabalho. Captura, depois um painel: “Salve seu arquivo de projeto e sua exportação nessa pasta”, com o caminho e um botão de copiar.
Construí, tirei um screenshot do diálogo isolado com um backend mockado, e ficou perfeito. Formulário de um lado, confirmação com o caminho do outro. Mandei pra mim mesmo experimentar no app de verdade.
Digitei um nome, cliquei em Capturar, e… nada. O diálogo ficou ali mostrando o formulário vazio de novo. Sem confirmação, sem caminho, nenhum sinal de que algo tinha acontecido. Só que a ideia tinha sido capturada, a pasta existia em disco. A UI só negava tudo, categoricamente.
Por que o screenshot mentiu: o componente do diálogo estava certo. O bug vivia no espaço entre o diálogo e a tela por trás dele, exatamente o espaço que um screenshot isolado não consegue ver.
O dashboard renderiza seu conteúdo dentro de um ramo controlado por um estado de carregamento: enquanto carrega, mostra um spinner; depois de carregado, mostra o dashboard (e qualquer diálogo aberto dentro dele). Numa captura bem-sucedida, o diálogo pedia pro dashboard atualizar seus números, a sequência, o aviso de “seus canteiros estão vazios”. E essa atualização reusava a mesma função que o carregamento inicial da página usava, que devolve o estado pra “carregando” enquanto rebusca os dados.
Essa troca é a faca. Por alguns milissegundos o dashboard trocava pra sua view de carregamento, o que desmontava tudo por baixo, incluindo meu diálogo aberto. Quando o dado voltava e o dashboard renderizava de novo, a flag “o diálogo de captura está aberto?” continuava true, então um diálogo novinho em folha montava do zero, seu estado interno de “aqui está o caminho salvo” completamente limpo. O formulário vazio, renascido. Parecia que nada tinha acontecido porque, até onde o diálogo recém-criado sabia, nada tinha.
A correção é uma única ideia: uma atualização em segundo plano precisa ser silenciosa. Não reuse o caminho de carregamento pra isso. Atualize o dado no lugar, nunca toque no estado que controla a montagem, e o diálogo sobrevive à atualização com sua confirmação intacta. Duas funções pequenas em vez de uma: o carregamento inicial é dono do spinner; a atualização pós-ação só troca o dado.
A lição é a que eu não paro de reaprender: testar um componente isolado prova o componente, não o sistema. Meu screenshot mockado do diálogo estava genuinamente correto e genuinamente inútil pra esse bug. O que pegou foi conduzir a interação real, montar o dashboard de verdade, clicar em Capturar, digitar, enviar, e confirmar que a confirmação continua visível. A correção de um modal inclui como a tela por trás dele se comporta enquanto ele está aberto. Adicionei isso como uma nota permanente pra mim mesmo.
(Tem uma segunda armadilha, mais discreta, aqui também: a migration de schema que adiciona uma coluna. CREATE TABLE IF NOT EXISTS alegremente pula uma tabela que já existe, então uma coluna nova que você anexa a ela nunca chega em nenhum banco existente, só nos novos, como todo banco de teste. Essa eu peguei antes de lançar, testando contra um banco com schema antigo montado à mão em vez de um limpo. A mesma forma de lição: o caminho feliz e o caminho real não são o mesmo caminho.)
Duas formas de um botão mentir
Essa semana conectei a decisão diária: uma ideia amadurece, e você pode promovê-la a um projeto de verdade (“Trabalhar nisso”) ou guardá-la no vault. Também adicionei um pequeno botão de pasta pra você abrir os arquivos de uma ideia e realmente olhar antes de decidir. Coisa simples. Levou duas rodadas de “não faz nada” pra funcionar.
Rodada um: o binário obsoleto. Mandei pra mim mesmo, atrasei a data de algumas ideias pra que contassem como maduras, cliquei em “Trabalhar nisso”. A ideia deslizou pra coluna Esfriando, errado. Promover um projeto não deveria colocá-lo no gelo; decidir trabalhar em algo não é o mesmo que ter trabalhado nele. O resfriamento é um descanso depois do esforço, não um pedágio pra começar. Então a correção foi fácil: não registre um toque na promoção. Fiz a mudança, meus testes ficaram verdes, disse que estava pronto.
A resposta: “você chegou a fazer alguma coisa?” Ainda esfriando.
Fui ler o banco de dados de verdade. Lá estava, um toque de “Promovido a ativo” em cada item, exatamente a linha que meu código novo não escreve mais. O código estava correto; só não estava rodando. O servidor de dev tinha feito hot-reload do frontend (os botões novos estavam ali, clicáveis) mas nunca recompilou o Rust por baixo. Eu estava clicando em botões novos conectados a um cérebro antigo. Um reinício completo do servidor de dev, e Work passou a mandar as coisas pra lista de trabalho como deveria.
Rodada dois: a permissão sem escopo. Depois o botão de pasta. Clique, nada. Sem pasta, sem erro, sem nada. Dessa vez o código definitivamente estava rodando.
O botão chama o plugin de opener pra abrir um caminho, e eu tinha diligentemente adicionado a permissão allow-open-path. O que eu não tinha lido com atenção era a própria descrição dessa permissão: “Habilita o comando open_path sem nenhum escopo pré-configurado.” O plugin checa todo caminho que você entrega contra uma allowlist. A permissão liga o comando mas deixa essa allowlist vazia, e uma allowlist vazia significa que tudo é negado. Então a chamada era rejeitada antes de fazer qualquer coisa, silenciosamente, numa camada que meu build e meus testes nunca tocam.
A correção limpa não foi ampliar o escopo, foi parar de passar por aquela porta. O opener também tem uma API em Rust, e a checagem de escopo só vive no handler de comando em JavaScript. Abrir a pasta a partir do meu próprio comando Rust e a questão inteira da ACL evapora: nenhuma permissão necessária, e de bônus abre a pasta em si em vez de só destacá-la dentro da pasta pai.
Os dois bugs são o mesmo bug, na real. Minhas marcações verdes, testes unitários, um build limpo, um screenshot de um componente mockado, todas vivem de um lado de uma linha. O binário obsoleto, o escopo de ACL vazio, esses vivem do outro lado, o lado que você só alcança lançando o app de verdade na máquina de verdade. Tudo que consigo automatizar prova que o código está formatado certo. Só rodar prova que ele está conectado certo. Eu não paro de reaprender que as marcações e a verdade não são a mesma coisa, e o buraco entre elas é exatamente onde vivem os bugs mais constrangedores.
A função que já estava pronta
O ritmo inteiro do Greenhouse depende de uma ação: tocar num item e ele começa a esfriar. Não visualizar, não abrir, uma declaração explícita de “trabalhei nisso”, opcionalmente com uma nota pro seu eu futuro sobre onde você parou. Fui construir a tela pra essa ação esperando uma issue de funcionalidade normal: função motor, comando IPC, diálogo, conectar tudo. O que encontrei em vez disso foi que três quartos disso já existia.
record_touch estava sentado no motor desde uma das primeiras issues, totalmente implementada, totalmente testada, inserindo uma linha de touch e atualizando o timestamp de último toque do item dentro de uma transação. Só nunca tinha sido conectada a nada. Nenhum comando Tauri a envolvia. Nenhuma entrada em ipc.ts apontava pra ela. Nenhum botão na UI conseguia alcançá-la. Uma função completa, correta e minuciosamente testada, com zero chamadores fora do próprio módulo de teste.
A versão preguiçosa dessa issue é: escreve um comando Tauri, escreve um diálogo Svelte, pronto. Mas três funções irmãs no mesmo arquivo, promote_idea, vault_item, move_project_to_stage, compartilham todas um padrão que record_touch estava silenciosamente sem. Cada uma delas, depois que sua escrita no banco confirma, regenera o project.md, o espelho em markdown que vive na própria pasta do projeto. E o project.md não só mostra status, ele renderiza o histórico completo de toques, uma linha por toque, nota de handoff e tudo.
Então a pergunta ficou óbvia assim que fui procurar: se record_touch é a única função cujo trabalho inteiro é criar um toque com uma nota de handoff, e project.md existe especificamente pra mostrar o histórico de toques, por que record_touch era a única função da família que nunca avisava o espelho de que algo tinha acontecido? Registre um toque com uma nota cuidadosa pro seu eu futuro, e o arquivo que esse eu futuro realmente abriria não mostraria nada de mudado, até você por acaso promover ou guardar outra coisa no vault e o espelho ser regenerado como efeito colateral de um trabalho não relacionado.
Isso não é um bug que ninguém teria batido testando a nova UI isoladamente. Só aparece se você já souber o trabalho do espelho e for procurar se o código novo está à altura dele. Adicionei a mesma regeneração de melhor esforço que as outras três funções já tinham: escreve o espelho, registra e engole o erro se falhar, nunca deixa uma escrita de markdown bloquear a mudança de estado de verdade, e escrevi um teste que promove uma ideia, toca nela com uma nota, e lê o project.md de volta do disco pra confirmar que a nota está realmente lá.
O diálogo tem o luxo de ser simples. O diálogo de captura (algumas seções atrás) precisa manter uma tela de confirmação aberta depois, Copiar, Mostrar no Finder, Capturar outra, porque existe um caminho de pasta que o usuário precisa. Um toque não tem nada disso. É uma nota, ou nenhuma nota, e acabou. Então o novo TouchDialog pula toda a dança de dois estados que o fluxo de captura precisava: envia, fecha, atualiza o dashboard silenciosamente por baixo. Uma máquina de estados em vez de duas economizou a complexidade de um componente inteiro, e só ficou óbvio quando perguntei o que o diálogo realmente precisava continuar mostrando depois do sucesso. Nada. Então não mostra.
O texto se apoia na metáfora de planta que o produto já assume em outros lugares, “não regue demais, deixe descansar”, em vez de imprimir a duração real do resfriamento. O app deliberadamente mantém valores de timer fora da UI na v1; a metáfora carrega o significado sem expor um número que ninguém deveria ajustar.
Leitura relacionada
Rescue-or-Keep: construindo a zona de vault para o Greenhouse
Agora arquivar no vault realmente move seus arquivos, e um "Keep Vaulted" que não era um no-op de verdade - reagendar teria resetado o timestamp que a revisão diária usa para ordenar.
Construindo a tela de detalhes de projeto do Greenhouse
Avançar um estágio conta como um toque, o que acontece no último estágio, e uma tangente de cooldown-vs-rotação que acabou sendo uma pergunta só, não duas.
O scan que se ofereceu para importar a si mesmo
Um scanner de importação que encontrou a própria estrutura interna do app, e depois voltou a oferecer uma pasta que tinha acabado de importar, duas versões da mesma conversa que faltava entre camadas.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba mais