rustploy-gui 0.1.25

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
# rustploy-gui por dentro

Este documento explica o código do `rustploy-gui`: o **Rust** (`src/`, `build.rs`,
`tests/`) e os **templates de markup** (`views/**/*.gvb`). Ele existe porque o código
foi deliberadamente enxugado. No Rust, cada item tem **uma frase** de documentação (a que
o `make index` usa como descrição) e nenhum comentário `//`. Nos `.gvb`, cada arquivo
guarda só a primeira frase do cabeçalho e comentários discretos de uma linha (divisores,
rótulos). Tudo o que é *porquê*, *contexto* e *decisão descartada* mora aqui. Os nomes
entre crases (`arquivo.rs::função`, `views/x.gvb`) apontam para onde cada assunto vive.

Os assuntos, na ordem em que fazem sentido para quem chega agora:

1. [O que é este crate e o que ele não é]#1-o-que-é-este-crate-e-o-que-ele-não-é
2. [Onde estão os assets]#2-onde-estão-os-assets
3. [A ponte do zip do Infra as Code]#3-a-ponte-do-zip-do-infra-as-code
4. [A API de agente (saiu do app)]#4-a-api-de-agente-saiu-do-app
5. [Os testes e o que cada um trava]#5-os-testes-e-o-que-cada-um-trava
6. [Os templates `.gvb` por dentro]#6-os-templates-gvb-por-dentro

Documentos relacionados: `docs/plano-chave-de-acesso-e-mcp.md` (login por chave
e a API de agente no daemon) e `docs/plano-reforma-gui-glacier-0.102.md` (a reforma da UI).

---

## 1. O que é este crate e o que ele não é

O Rustploy GUI é um cliente desktop cuja interface é descrita em **templates de
markup** (`views/**/*.gvb`) e renderizada pelo motor `glacier-ui` publicado. Toda
a lógica de rede vive em **Luau** (`views/scripts/app.luau` e os módulos que ele
carrega), falando HTTP/JSON e SSE com o daemon. O Rust é só a casca da janela:
`src/main.rs` e `src/app/mod.rs`.

### O manifesto `app(...)`

A configuração de janela, de aplicativo e as telas moram no **markup** de
`views/app.gvb`, cuja raiz é o `app(...)` (glacier-ui 0.117+):

- `id`, `single_instance` e `remember_geometry` — instância única, geometria
  lembrada e o diretório de dados do `storage` do Luau;
- a janela principal: `size`, `min_size`, `decorations`, `icon`;
- a `tray`, com o menu da bandeja;
- as `screen`s: a principal e as cinco janelas auxiliares, abertas por
  `open_window{ component = "…", size = "…" }`.

O runner `GlacierDaemon` lê isso antes de abrir qualquer janela. A partir do
glacier-ui 0.119 o mesmo `app(...)` também declara as fontes (`font(src, family)`
e `font = …`), o `antialiasing`, o `toast_period` e o `application_id` do Linux.

Sobra para o Rust só o que o markup não expressa:

1. a **extensão Luau do `.zip`** (ver [seção 3]#3-a-ponte-do-zip-do-infra-as-code);
2. a **fonte de assets embutida** em release (ver [seção 2]#2-onde-estão-os-assets).

O `.main_template("views/app.gvb")` carrega o manifesto. O caminho é relativo ao
workspace, onde `assets::locate_and_chdir` deixa o diretório corrente. Um
`.main(|motor| …)` só serviria para registrar um `impl Component` escrito em
Rust, e este app não tem nenhum.

### O ciclo de vida em `app::run`

`app::run` sobe o daemon multi-janela e roda o loop do `iced` até a última
janela fechar. Fechar a janela apenas recolhe o app para a bandeja; `run` só
volta quando o app encerra de verdade. A ordem das peças importa:

1. **Extensão Luau.** `lua_extension(manifest_zip::install)` registra
   `manifest_zip_read`/`manifest_zip_write`, usadas pelo Infra as Code (Settings).
   O motor tem `zip_dir`, mas não o inverso, e a camada Lua não abre um `.zip`.
2. **Release.** Injeta-se a fonte de assets embutida: o motor passa a ler
   templates, estilos, scripts e binários de dentro do executável, e nada do
   disco. Em dev o daemon fica com o `DiskAssets` padrão (disco com hot-reload).

### `main.rs`

- `#![cfg_attr(…, windows_subsystem = "windows")]` vale só em release no Windows:
  abrir a GUI não deve mostrar (e manter aberta) uma janela de console atrás
  dela. É ignorado nos outros alvos e em debug, onde o console é útil para logs.
- `mod manifest_zip` é a ponte Lua ↔ Rust do zip do Infra as Code (ler/gravar o
  `.zip` de manifesto), registrada como extensão da camada Luau no builder do
  daemon.
- `assets::locate_and_chdir()` só existe em **debug**: lá os assets são lidos do
  disco, com hot-reload, por caminho relativo ao diretório corrente, então é
  preciso entrar na pasta-base antes de qualquer carga. Em release eles estão
  embutidos no binário (`embedded` + `app::run`); nem o localizador nem o `chdir`
  existem, e o executável roda de qualquer diretório, sozinho.
- A casca customizada (fontes, janela *borderless*, ícone, persistência de
  geometria) é multi-janela sobre `iced::daemon` e vive em `app::run`.

---

### Instalação por `cargo install` e o menu de aplicativos (`build.rs` + `desktop.rs`)

O cargo não tem hook de pós-instalação, mas o `build.rs` roda na máquina do
usuário. Num `cargo install rustploy-gui` (Linux), o `build.rs` grava
`rustploy-gui.desktop` e os ícones hicolor em `$XDG_DATA_HOME` (padrão
`~/.local/share`), com `Exec=` apontando para `$CARGO_INSTALL_ROOT/bin`,
`$CARGO_HOME/bin` ou `~/.cargo/bin`.

Como o `build.rs` sabe que é um install: o cargo compila instalações do
registro/git num diretório temporário `cargo-install*`, e é isso que ele procura
no `OUT_DIR`. Detalhe interno do cargo — se mudar, só deixa de instalar sozinho.
`cargo install --path .` e `cargo build` **não** instalam (compilam no `target/`
do projeto). Nunca falha o build: erro vira `cargo:warning`.
`RUSTPLOY_INSTALL_DESKTOP=0` desliga; `=1` força.

Reparo manual (binário movido, `--root` fora do padrão, build local): rode
`rustploy-gui --install-desktop` — grava os mesmos arquivos com o `Exec=` do
executável que rodou o comando (os arquivos vão embutidos no binário).

## 2. Onde estão os assets

Todo template, folha de estilo, ícone e logo de blueprint é referenciado por um
caminho **relativo ao diretório corrente do processo** — tanto do Rust
(`views/styles/app.gss`, `views/app.gvb`, `assets/blueprint-logos/<id>/<logo>`)
quanto de *dentro* dos próprios templates (`import … from="views/service.gvb"`,
`theme "views/styles/theme.json"`, `Svg "assets/icons/…"`).

Em vez de reescrever cada literal, a solução tem duas metades, uma por modo de
compilação.

### 2.1 Debug: achar a pasta-base e entrar nela (`assets.rs`)

`assets::locate_and_chdir` localiza **uma vez**, na partida, o diretório que
contém as árvores `views/` e `assets/` e faz `chdir` para ele. Depois disso todo
caminho relativo resolve, não importa como o app foi lançado: `cargo run` na raiz
do workspace, o `.zip` do Windows (assets ao lado do `.exe`) ou o pacote Debian
(assets em `/usr/share/rustploy`).

A ordem de resolução:

1. `$RUSTPLOY_UI_ASSETS` — sobrescrita explícita;
2. o diretório do próprio executável — layout portátil / `.zip` do Windows;
3. o diretório corrente, se ele já contém os assets — o `cargo run` na raiz do
   workspace em desenvolvimento, **sem** `chdir`. Vem **antes** de
   `SYSTEM_PREFIX` de propósito: um `.deb` instalado em `/usr/share/rustploy` não
   pode esconder a árvore de trabalho durante um `cargo run`;
4. `SYSTEM_PREFIX` (`/usr/share/rustploy`) — o layout do pacote Debian (ver a
   metadata `deb` no `Cargo.toml`).

O teste de validade é a existência de `views/app.gvb` (`MARKER`), o arquivo que
precisa existir em qualquer base válida. A função é *best-effort*: se nenhum
candidato servir, o diretório corrente fica como está e o app mostra um erro de
"stylesheet/template não encontrado", que é o sinal mais claro possível.

### 2.2 Release: tudo dentro do binário (`embedded.rs`)

Em **release** (`cfg(not(debug_assertions))`) toda a árvore de assets que o motor
lê em runtime é embutida no executável com `include_dir!`, e uma
`EmbeddedAssets` é injetada no `GlacierDaemon` (em `app::run`). O binário fica
**100% desacoplado dos arquivos**: pode ser copiado sozinho para qualquer lugar e
rodar, sem as árvores `views/` e `assets/` ao lado e sem o `chdir` de
`assets.rs`. Em debug o módulo nem é compilado.

Os caminhos que chegam são os mesmos que o motor resolve hoje (relativos ao
diretório corrente antes do modo standalone), roteados por prefixo:

| prefixo de runtime | árvore embutida |
|---|---|
| `views/…` | `VIEWS` — `.gvb`, `styles/*.gss`, `styles/theme.json`, `scripts/**/*.luau` (resolvidos por `require`/`<script src>`) |
| `assets/icons/…` | `ICONS` — ícones SVG referenciados por `<svg src="assets/icons/…">` |
| `assets/blueprint-logos/…` | `BLUEPRINTS` — logos dos templates, via o `{logo}` data-driven do catálogo do daemon (`<id>/<arquivo>`) |
| `assets/fonts/…` | `FONTS` — as fontes declaradas com `font(src = …)` no `app(...)` (JetBrains Mono) |

Detalhes que valem saber:

- **Só as imagens dos blueprints** entram. O `build.rs` as espelha em
  `<id>/<arquivo>` e filtra o `docker-compose.yml`/`template.toml` (ver
  [2.3]#23-o-buildrs).
- `route` normaliza os separadores (`\` vira `/`) e um eventual `./` inicial: as
  chaves do `include_dir` são sempre relativas e com `/`.
- Como o embutido não muda sob o processo, `EmbeddedAssets` devolve `None` em
  `modified` (desliga o hot-reload) e responde `false` à pergunta "há algo para
  vigiar?". Sem esse segundo ponto o daemon manteria o *ticker* de hot-reload
  rodando para sempre — redesenhando a tela inteira a cada tick — sem nunca ter
  trabalho de verdade a fazer.
- **Os testes deste módulo só existem em release** (o módulo inteiro é
  `cfg(not(debug_assertions))`): rode com `cargo test --release -p rustploy-gui`.
  Um confere que cada caminho realmente referenciado em runtime resolve na árvore
  embutida — as três convenções de prefixo, texto e binário, e que um caminho
  ausente dá `NotFound`/`exists = false`. O outro é a prova de ponta a ponta,
  *headless*, sem janela e sem `chdir`: o motor carrega o app inteiro só da
  árvore embutida (template, `<link>` de estilo/tema, `<script src>` Luau, a
  cadeia de `require` dos handlers) e renderiza. Se qualquer asset — inclusive um
  módulo `require`d — faltasse, `register_component`/`render` falharia.

### 2.3 O `build.rs`

O build script faz duas coisas.

**1. Prepara os logos dos blueprints** (só imagens) em `$OUT_DIR`; é de lá que o
`embedded.rs` os embute no binário de release.

- *Origem.* Os logos moram em `assets/blueprint-logos/` (saíram do pacote dos templates,
  que não os carrega mais); o nome de cada um vem do catálogo
  do daemon (`templates/logos.txt`). Só as extensões de imagem entram (`LOGO_EXTS`);
  o resto da pasta — `docker-compose.yml`, `template.toml`, `.md` — é do daemon e a
  GUI nunca o lê.
- *Por que reduzir.* Os logos raster originais têm ~512×512 e aparecem a ~30px
  (`template_row.gvb`). Sem redução, a GPU faria um *downscale* de ~17× por
  quadro (serrilhado) e o binário carregaria ~12 MB de logo. Por isso os raster
  são reduzidos com **Lanczos3** para no máximo `LOGO_MAX_DIM` = **96px** na
  maior dimensão, preservando a proporção, e re-codificados como PNG. 96px cobre
  telas HiDPI (até ~3×) e ainda corta os originais em ordens de grandeza. O
  processo só **reduz**: imagem que já é menor, e todo vetor, passa intacta — não
  se borra o que já é pequeno.
- *Vetor e formatos raros.* SVG é copiado intacto (nítido em qualquer tamanho). O
  mesmo vale para `.ico`/`.avif`, que o crate `image` não cobre: continuam sendo
  logos válidos e caem no ramo de cópia, sem passar pelo resize. `is_logo` decide
  o que entra no staging; `is_raster` é o subconjunto que o `image` decodifica (as
  features habilitadas no `Cargo.toml`).
- *Degradação.* `stage_raster` nunca derruba o build: se não consegue decodificar
  (ou a imagem já é pequena), copia os bytes originais. `downscale_png` devolve
  `None` nesses casos — re-codificar não valeria o custo e o risco de mexer no que
  já está bom.
- *Nomes.* O nome do arquivo no staging tem de ser **idêntico** ao original: o
  daemon devolve o caminho `<id>/<arquivo>` e o `EmbeddedAssets` o serve por essa
  chave. Re-codificar um raster para PNG mantendo, digamos, um nome `.jpg` é
  inofensivo — o `iced`/`image` decodifica por conteúdo (*magic bytes*), não pela
  extensão.
- *Staging.* A pasta é limpa a cada build (para não reter logos de blueprints
  removidos) e o build é refeito quando a árvore de blueprints muda
  (`cargo:rerun-if-changed`).

**2. Embute no `.exe` do Windows** o ícone da aplicação, o manifesto e os
metadados de versão. Build scripts rodam no *host*, então o **alvo** é detectado
pela variável `CARGO_CFG_TARGET_OS` que o Cargo define — `cfg!(windows)` refletiria
o host e ficaria falso durante o cross build Linux→Windows. O recurso é compilado
pela ferramenta RC que o `embed-resource` achar; no cross build é o `llvm-rc`
(o `assets/rustploy.rc` não tem `#include`, então nenhum header do Windows SDK é
necessário). As macros do `VERSIONINFO` são derivadas de `CARGO_PKG_VERSION`
(ex.: `"0.1.0"` → vírgulas `0,1,0,0` e string `"0.1.0"`) e passadas como defines
ao RC, de modo que a versão do `.exe` nunca sai do lugar em relação ao
`Cargo.toml`. O `llvm-rc` recebe os defines via macros do `embed-resource`, e a
string precisa de aspas escapadas para chegar como literal entre aspas no `.rc`.

---

## 3. A ponte do zip do Infra as Code

`manifest_zip.rs` existe porque o motor glacier-ui traz `zip_dir` (compacta um
diretório) mas **não** o inverso, e a camada Lua não tem como abrir um `.zip`. O
Infra as Code precisa dos dois sentidos:

- **exportar** grava um `.zip` com exatamente dois arquivos, `rustploy.yml` e
  `rustploy.vars.toml`, na raiz — sem diretório de staging: os dois são escritos
  direto no arquivo final (`write_manifest_zip`);
- **importar** lê um `.zip` e exige **exatamente** um `*.yml`/`*.yaml` e um
  `*.toml`, na raiz, e mais nada (`read_manifest_zip`). Diretórios e qualquer
  subpasta contam como "extra" e invalidam o arquivo. O retorno é `(yaml, toml)`
  ou uma mensagem de erro pronta para exibir.

As duas funções entram como globais de todo `<script>` via
`GlacierDaemon::lua_extension` (`app::run`), implementadas com o mesmo crate `zip`
que o glacier já usa — então o Cargo deduplica e não há uma segunda cópia no
binário. `install` registra:

- `manifest_zip_write(zip_path, yaml, toml) -> (ok: boolean, err: string?)`
- `manifest_zip_read(zip_path) -> { ok: boolean, yaml: string?, toml: string?, error: string? }`

---

## 4. A API de agente (saiu do app)

Até 2026-10-13 este crate subia um servidor HTTP local (`src/agent/`, porta
9800) que emprestava a sessão da janela a um agente de IA na mesma máquina. Ele
foi **removido**: o acesso de agente agora é servido pelo próprio daemon —
`POST /api/login` com a chave de acesso, o servidor MCP em `POST /mcp` e o
manual em `GET /api/agent/manual` (ver `rustploy-daemon/crates/daemon/src/api/agent/`
e `docs/plano-chave-de-acesso-e-mcp.md` no agregador). Um agente não depende
mais de a GUI estar aberta, nem de estar na mesma máquina.

O que sobrou aqui do assunto é a tela de login: um campo só, a chave de acesso,
decodificada em Luau (`views/scripts/access_key.luau`) para saber onde fazer o
`POST /api/login`.

---

## 5. Os testes e o que cada um trava

### 5.1 Testes da API de agente

Saíram junto com `src/agent/` (ver [seção 4](#4-a-api-de-agente-saiu-do-app)).
Os equivalentes vivem no daemon, ao lado de `api/agent/`.

### 5.2 Os testes de `format` em Luau (`tests/format_*.rs`)

Rodam módulos Luau de formatação no motor de verdade, com uma **fixture** — uma tela
mínima que carrega o módulo e exibe o resultado. A fixture mora fora da árvore de
scripts do app (para não virar script do app), então o `require("format/…")` dela
precisa de uma raiz extra, adicionada por variável de ambiente dentro de um bloco
`unsafe`.

- **`fmt_service_detail`** (`compose_host` e `internal_url`) existe desde o rename
  de serviço: o hostname interno de um serviço Compose é a **chave do YAML**, que
  não muda quando o serviço é renomeado, e o card "Internal URL" da aba Connection
  depende disso. O mesmo cálculo existe em JS (`webui/format.js::composeHost`), coberto
  pelo teste `renomear_servico_na_aba_general` do daemon; aqui é a metade Luau. Num
  Compose o nome do serviço (`meu_banco`) não aparece — vale a chave do YAML; numa
  Application é `rp_<nome>`, e sem `db_kind` a GUI põe `http://` (a webui, hoje, não
  põe esquema nenhum — divergência anterior ao rename, não mexida aqui).
- **`fmt_time`** existe desde que o arquivo deixou de fazer a aritmética de fuso à
  mão e passou a chamar o global `date` da glacier-ui 0.73. A conversão UTC → hora
  local é o tipo de coisa que quebra em silêncio — um timestamp errado por três
  horas ainda parece um timestamp —, então vale um teste próprio. Toda asserção é
  **independente do fuso** da máquina que roda. A que carrega o peso é a primeira: o
  mesmo instante escrito como `…Z` e como `…-03:00` tem de renderizar igual, seja
  qual for o fuso local. As demais: fração de segundo é aceita e descartada;
  a conversão realmente aconteceu (o horário exibido é o UTC deslocado pelo offset
  local, a menos que a máquina esteja em UTC); o prefixo de data e hora é o mesmo nas
  duas formas; ausente ou malformado vira `""`, que é o que os templates esperam; sem
  `finished_at` conta até agora — o valor varia, o formato não. O offset local é
  perguntado ao sistema com `date +%z` (a mesma fonte que o `localtime` do Luau
  consulta), para o teste não precisar de crate de data só para conferir uma
  subtração; `hora_deslocada` soma o offset descartando a virada de dia (só as
  horas importam).

### 5.3 A validação *headless* das telas (`tests/tela/render.test.luau` e `tests/app_manifest.rs`)

**Onde cada coisa mora.** O que antes era um único `templates_render.rs` se dividiu
conforme o que cada runner enxerga:

- **`tests/tela/render.test.luau`** (`glacier test`, sem compilar testes Rust): as
  telas sobem, mostram o texto e o botão certos, escondem o que deve esconder, e o
  estilo resolvido na largura da janela. Usa a árvore avaliada (`tela:render()`,
  `texts()`, `buttons()`, `find`, `set_viewport`, `style`; `assert_renders`,
  `assert_has_text`, `assert_has_button`, glacier-ui 0.133). Cobre as visões do
  shell, as abas, as janelas auxiliares, os jobs agendados, a sidebar, as ações do
  serviço por largura, o item "Projects" aceso e a trava de ação em andamento.
  Uma view quebrada, mesmo uma que nenhum outro teste carrega, falha em
  `test_o_app_todo_carrega`. O logout zerando a sessão é de
  `tests/handlers/connection.test.luau`.
- **`tests/app_manifest.rs`** (`cargo test --test app_manifest`): o que o Luau não
  alcança — título e tamanho das janelas e as fontes (`app(...)`), a casca de cada
  `.gvb`, a avaliação escopada, as regras do `<form>` de "Novo projeto", o `open` do
  `<Reveal>` da gaveta e a **construção dos widgets do iced** de cada visão e janela.
  O Luau avalia o markup mas não desenha: um widget que o iced recusa só aparece aqui
  ou no `make run`.

O resto desta seção descreve **o que** é conferido; os nomes de teste citados são os
do arquivo antigo, hoje distribuídos como acima.

Todo template parseia, toda tela/aba avalia e monta uma árvore de elementos `iced`
sem erro. Pega KDL malformado e propriedade `.gss` desconhecida — que derrubaria
uma folha de estilo inteira — sem precisar de display. `boot` sobe o motor do jeito
que o `main.rs` faria, mas da raiz do workspace para os caminhos de template
resolverem; o `app.gvb` já linka o `app.gss` (`<link rel="stylesheet">`, global
desde o glacier-ui 0.23), então `register_component` o pega sem um
`load_stylesheet` à parte.

**Janelas à parte.** "Novo projeto", "Novo job", "Logs" e o wizard "Novo serviço"
são, cada uma, um motor isolado aberto por `open_window`; não passam pelo `app.gvb`,
então cada teste as registra e renderiza por conta própria, **semeando** a conexão
(e projetos/serviços já buscados) como `open_window({ data = … })` faria. O init do
script real tenta o catálogo, mas o *fetch* suspende sem executor — por isso os
dados que os passos esperam são semeados à mão. Chaves como `new_job_time` ("HH:MM" do
`<timeedit>`) e a coleção do `<radiogroup>` de dia da semana são semeadas pelo
`init()` da janela real; o teste faz o papel dele.

**Onda 1 da reforma** (glacier-ui 0.102+): o campo NOME de "Novo projeto" usa a
validação declarada no `<form>`. Na árvore avaliada, o `form_control` `new_project_name` deve
carregar `rules` com `required` e ter o `on_validation_error` do `<form>` propagado
(`form_error_action`) — é o que garante que um envio vazio roteie para o
`new_project_show_validation_errors` em vez do `submit_project`.

**O teste que passa por todas as telas** (`all_screens_and_service_tabs_render`)
percorre: as views do shell; o Deploy Engine com a fila "NA FILA" (itens
arrastáveis, estado pausado, botão de retomar); o Ingress com a tabela de portas TCP
de host (separada das rotas de domínio); as **cinco sub-abas** do Docker (o laço de
views só renderiza a padrão, `containers`; cada sub-aba tem painel escopado por
`docker_tab`, antes nunca exercitado) e, no Registry, também o ramo "repo
selecionado" (lista de tags); Schedules; o projeto aberto (grade de serviços e a aba
de variáveis de ambiente de projeto, com um nome de variável absurdamente longo para
exercitar o truncamento de `key_display` sem quebrar o `key` completo usado por
delete/reorder/`.env`); a aba Variáveis no modo "usar secret" e a lista de secrets
vazia; Settings (Git com os dois métodos, Web Server — cuja URL pública é derivada
pelo daemon e exibida só-leitura —, Infra as Code com os ramos de variáveis
faltando e de relatório aplicado, e Manutenção com as três recorrências, cada uma
mostrando campos diferentes, o toggle geral e os seis sub-toggles); e as abas do
detalhe de serviço (editor de env, painel de build log, sub-aba Gitea, os estados de
webhook — com URL emitida, sem token por nunca ter sido deployado, e serviço Compose
sem webhook — e as sub-abas Git/Zip do provider e o editor de Compose).

**Contratos travados por testes próprios:**

- **A sidebar é uma `<drawer>`** (Onda 2 da reforma,
  `docs/plano-reforma-gui-glacier-0.102.md`), não mais um trilho de ícones
  colapsável por `@media`. O teste trava três coisas: (1) o `<drawer>` (um
  `<Reveal axis="x">` após o eval) reflete `{menu}` no seu `open` — `"true"` abre,
  vazio fecha; (2) o gatilho ☰ da topbar dispara `drawer::toggle:menu`, a ação que o
  builtin `Drawer` consome de qualquer lugar da tela; (3) abaixo de 900 px os
  rótulos dos `NavItem` **não** são forçados a `hidden` por um `@media` — era o bug
  antigo (o trilho sumia o rótulo; agora a gaveta inteira fecha pelo ☰ e, aberta,
  cabe em 264 px). O teste usa uma largura estreita, a que reproduziu o bug de layout
  original.
- **Ações do serviço por largura.** As ações Deploy/Reload/Rebuild/Stop têm duas
  fileiras que se alternam: acima de 1080 px vale a de texto; abaixo, a compacta
  (ícone + tooltip) — senão os quatro rótulos por extenso não cabem e o título de
  30 px transborda por baixo deles ("Deploy por cima do nome"). Ambas existem sempre
  no AST; o que muda é qual está `hidden`. O rótulo de um botão é `Button { text }`,
  não um nó `Text` filho; as quatro ações são as únicas a usar esses textos, então não
  há colisão com os ícones da sidebar (que são nós `<text>`, não botões).
- **Avaliação escopada** (glacier 0.38+): só a tela ativa é construída, não todo
  template registrado. Importa mais aqui que na média dos apps: o `app.gvb` importa a
  árvore inteira de views, e avaliar um template inlina recursivamente tudo o que ele
  usa — então a versão antiga reconstruía a UI completa uma vez **por template
  importado**, a cada tecla digitada e a cada linha de log vinda do SSE. O teste
  trava o ganho: registrar o `app.gvb` e ativá-lo deve deixar exatamente **uma**
  árvore avaliada (as views importadas ficam registradas, mas só a tela ativa é
  avaliada; as demais são inlinadas dentro dela).
- **Logout zera a RAM da sessão.** Nada do daemon anterior pode continuar no contexto
  (nomes de projeto, linhas de log, o próprio `api_token`). Foi um bug real: o
  `disconnect` antigo limpava só quatro chaves à mão. Hoje ele apaga o `ctx` inteiro
  e deixa o `init()` semear os defaults; o teste dispara a ação de verdade
  (`UiClick`, o mesmo caminho do botão Disconnect) com um estado de sessão
  conectada "do trivial ao sensível" e inspeciona o contexto — que deve voltar ao
  estado de boot, não ao da sessão.
- **O item "Projects" não apaga nas sub-telas.** Regressão: ele perdia o fundo azul
  ao entrar num projeto ou serviço, porque o `nav_item.gvb` comparava `{view}` contra
  um `target` de **uma** view só (`equals`) e `project_services`/`service` não são
  `"projects"`. A correção usa `one_of` (glacier-ui 0.57.8): `target="projects
  project_services service"` casa com qualquer das três. `nav-item--active` é a classe do
  fundo azul, mas o widget `<button>` lê a propriedade `color:` do GSS para o fundo
  (`background:` é ignorada em botões), então o campo que importa é o `color` de
  `node.kind`, não o `node.background` genérico (esse é para containers/rows). O
  `on_click` chega **namespaceado** pelo componente que o hospeda (`namespace_action`,
  em `eval.rs` do glacier-ui): mesmo com o valor vindo de uma prop
  (`action="nav_projects"` em `shell.gvb`), o botão vive dentro do template do
  `NavItem`, então o dispatch final é `NavItem::nav_projects`.
- **Janela, título e tamanho** (glacier-ui 0.117). A janela **principal** é do
  `app(...)` (tamanho, mínimo, moldura, ícone); o título é da `screen` (acompanha a
  navegação); e o tamanho de cada janela **filha** vai na chamada
  `open_window{ component = "…", size = "…" }` dos handlers, porque a `screen` é só
  conteúdo. O teste garante que nada disso sumiu no caminho — um atributo apagado por
  engano não quebra render nenhum, só faz a janela nascer com o padrão do iced. A
  janela de logs é a exceção proposital: o título é dinâmico ("Logs — nginx",
  "Build — abc123") e vem de quem a abre.
- **Os ajustes do daemon no `app(...)`** (glacier-ui 0.119): fontes, fonte padrão,
  `antialiasing`, período dos toasts e `application_id`. Um atributo apagado por
  engano não quebra render nenhum — o app só passa a abrir sem a fonte e com MSAA
  ligado, em silêncio.
- **Todo `.gvb` abre com a casca certa:** `app` no manifesto, `screen` nas telas
  abertas em outra janela e `component` no resto. O teste precisa existir porque o
  glacier-ui aceita a forma sem cabeçalho (declarações soltas na raiz) por
  compatibilidade: nada no build reclamaria de um arquivo que voltasse a ela, e o que
  se perde é silencioso — numa tela, título e tamanho caem no padrão; num componente,
  some a fronteira entre declaração e layout. É **textual** de propósito (um
  `component` não declara nada por desenho, então só o texto diz em que forma o
  arquivo está) e **varre o diretório** em vez de listar arquivos: o alvo é o `.gvb`
  que ainda não foi escrito. `comentarios_fora` remove `//` e `/* … */` para que "a
  primeira tag" seja a primeira de verdade (todo template abre com um comentário de
  cabeçalho). `views/` mistura manifesto, telas e views internas (`component`); só
  `views/components/` é homogêneo — nada ali é tela. Uma guarda impede o teste de
  passar vazio (um caminho errado tornaria tudo um *no-op*), com comparação frouxa
  de propósito: acrescentar um `.gvb` não deve obrigar a editar o teste.
- **As grades de cards passam o item inteiro** ao componente via `spread="{c}"`
  (glacier-ui 0.62). Isso troca um atributo por campo por um só — e move a checagem
  do contrato para o **dado**: um campo que o `format/dashboard.luau` não emitir vira
  `MissingProp` e derruba a tela inteira, não um `{placeholder}` vazio como antes. O
  caso perigoso é o **filler** (o card vazio que completa a fileira): ele nasce de
  um único `FILLER` compartilhado pelas duas grades, e em Lua um campo `= nil`
  simplesmente não existe — por isso ele carrega a união dos dois contratos como
  string vazia, e é isso que o teste tranca. Ele fixa `data_loading = "false"`
  porque o `connection.luau` semeia `"true"` no `init` e o `<scrollable>` da grade
  fica escondido atrás disso — sem a correção o `for-each` nunca roda e o teste
  passaria sem ter avaliado um card sequer. A fileira de teste é um card real mais
  um filler, exatamente a forma que `M.project_rows` produz com 1 projeto numa grade
  de 2 colunas.

---

## 6. Os templates `.gvb` por dentro

Os templates descrevem a interface; o comportamento mora em Luau (`views/scripts/`).
Esta seção reúne o que antes estava nos comentários dos `.gvb`, agrupado por assunto.
A estrutura de arquivos (um roteador fino por tela, um arquivo por view ou aba) e as
regras de divisão estão no `AGENTS.md`, em "Dividir um template grande em arquivos".

### 6.1 Regras que valem para todos os templates

**Um só script, um só contexto.** Toda a lógica de rede e de estado (login, navegação,
busca, stream SSE e ações) vive em Luau, no script que o `app.gvb` carrega
(`scripts/app.luau`, sucessor de `app/root.rs` + `app/net/*`). É o único script ativo.
Como o contexto do glacier é **global**, os templates importados (login, shell, home,
service) só têm markup, e os handlers deles resolvem para as funções desse script.
`require("net/api")` e `require("format")` resolvem em `scripts/`, relativo ao diretório do
script. As janelas auxiliares são a exceção: cada uma tem o próprio script e o próprio
motor.

**Janelas isoladas para o que é caro.** O glacier reavalia **todos** os templates
registrados a cada reavaliação. Na janela principal (shell + home + service + wizard, com
dezenas de `for-each`) isso é caro, e sob um stream de logs verborrágico satura a UI,
mesmo coalescido a 30 fps. Por isso os logs (runtime e build) abrem em janela própria:
lá o motor só tem um template minúsculo, cada reavaliação é trivial e o log rola liso
independente da taxa de chegada. O mesmo vale para a aba Logs do serviço, que é só um
*launcher* (ver [6.5](#65-o-detalhe-do-serviço)), e para o build log de um deployment
(`deployment_logs` → `open_window`): renderizar milhares de linhas por reavaliação travava a
principal.

**A condição vai na chamada, não na raiz do componente.** Num componente extraído, o `if`
do nó raiz não é aplicado: o conteúdo apareceria sempre. Por isso as abas se escrevem
`ServiceLogsTab(if = @tab, equals = logs)` e as abas de projeto
`ProjectEnvTab(if = @project_loading, not_equals = true)`, com o `if` no ponto de uso. O
mesmo raciocínio vale para o `LoadingRow` (ver [6.7](#67-os-componentes)).

**Vários filhos condicionais pedem `if @x { … }`, não `if=` como atributo.** O atributo
`if=` num elemento condiciona **só aquele elemento**. Um bloco com vários filhos
(o editor `.env`, o modo *secret*, os blocos de cada view) usa o bloco `if`/`<template>`.
No editor `.env`, por exemplo, o atributo só esconderia o `Text` e deixaria o
`TextArea` e os botões sempre visíveis.

**`scrollable` só renderiza o primeiro filho** (`NodeType::Scrollable` usa
`node.children.first()`, em `widget.rs` do glacier-ui). Por isso a lista e o editor `.env`
vivem dentro de **uma única** `column`, nunca como irmãos diretos do `scrollable`. Foi
também o que corrigiu os botões Salvar/Cancelar do editor, que antes eram irmãos fixos
depois de um `scrollable` com `height: fill` e podiam ficar cortados em janelas baixas.

**Listas vazias são `fallback`.** Cada lista tem um componente `Sem…` que o `fallback` do
`for-each` (e o `foreach_fallback` do `<template>`) desenha no lugar dela. Eles moram no
arquivo que os usa.

**Janelas sem `background: var(--bg)`.** As telas das janelas auxiliares (e o login) não
repetem o fundo no CSS: `var(--bg)` é a cor do tema, já pintada na janela inteira, e
repeti-la era uma camada redobrada em cada pixel (ver `PRIMITIVAS.md`, "O que custa num
quadro").

**Ícone + tooltip em vez de rótulo por extenso** onde a coluna é estreita. As linhas de
job (Schedules), de repositório (Registry) e de deployment (aba do serviço) trocaram
"Rodar agora", "Ver logs", "Pausar", "Build log ↗" e "Remover" por ícones com tooltip: os
rótulos quebravam em duas linhas e estouravam a coluna quando a janela estreitava, e uma
linha de 360 px pensada para rótulos por extenso empurrava o botão de remover para fora
da largura visível (sem scroll horizontal).

**Validação declarada no `<form>`** (glacier-ui 0.102+). As regras moram no campo
(`rules=`); o motor valida ao enviar e roteia `on_submit` (tudo certo) ou
`on_validation_error` (algo falhou). O `<button type="submit">` dispara sem `on_click`, e
o `.np_input:invalid` (em `app.gss`) acende sozinho. `form` não aninha: um formulário
próprio tem de ficar **fora** de outro.

**Entradas numéricas são widgets, não texto livre.** O `<spinbox>` e o `<timeedit>`
validam por construção; ver "Healthcheck" e "Manutenção" abaixo para o que deu errado
antes.

**Estilo e responsividade.** O estilo inline fica no `<style>` de cada arquivo; o que é
compartilhado e responsivo (as regras de `@media`) vive em `app.gss`, não nos
templates: cruzar arquivo com o inline deixaria a precedência ambígua. A cor de um estado
vem da camada GSS, nunca de hex vindos do Rust.

### 6.2 O manifesto (`views/app.gvb`)

A raiz do arquivo é o `app(...)` e as telas — a janela principal e as cinco auxiliares —
são filhas dele (glacier-ui 0.117+).

- **`id`** nomeia o diretório de dados (`~/.local/share/rustploy`, onde moram o `storage`
  do Luau — o login lembrado — e a geometria da janela) e é a chave da **instância
  única**. Com a bandeja declarada, fechar a janela recolhe o app em vez de encerrá-lo, e
  uma segunda execução foca a primeira.
- **`size`, `min_size`, `icon`, `decorations`** são da janela **principal**. O ícone as
  filhas herdam; o tamanho de cada uma vai no `open_window("tela", { size = … })` dos
  handlers.
- **Fonte e daemon.** A fonte padrão vale para todas as janelas (declarada no próprio
  `app(...)`); o `application_id` do Linux casa a janela com o `rustploy-gui.desktop`.
  O **MSAA x4** padrão do iced custa caro num fallback 100% por software (sem GPU
  compatível: o `wgpu` recusa adapters não-Vulkan-compliant e cai para a CPU), e telas de
  formulário e de lista não perdem legibilidade sem ele — por isso o `antialiasing` é
  declarado desligado.
- **Janela borderless.** Com `decorations = false`, uma moldura de 6 px de *handles* de
  resize envolve o conteúdo. Cada handle define o cursor de hover e inicia um resize
  interativo ao pressionar (`window:resize:<dir>`), tratado no runtime contra o id da
  janela em cache. O glacier não tinha overlay/z-stack quando a moldura foi escrita (hoje tem `<popup>`, `<popover>` e `<dialog>`, ver `job_actions` em 6.7), então a moldura é montada como
  linha de cima / faixa do meio (bordas esquerda e direita + corpo) / linha de baixo.
- **Titlebar custom.** `titlebar_drag` (`width: fill`) é a alça de arraste e empurra os
  controles para a borda direita; as ações `on_press`/`on_click` `window:*` são tratadas
  no runtime contra o id da janela.
- **As janelas auxiliares** (`log`, `new_job`, `new_service`, `new_project`,
  `new_registry_token`): cada uma é um motor isolado que abre **só** a tela pedida
  (`open_window("log", …)`), com o próprio script — o `init` delas não roda na principal.
  Os tamanhos estão nos handlers.

### 6.3 O shell (`views/shell.gvb` e `views/shell/`)

O shell é a casca do app conectado: sidebar, topbar e as views de projeto (Deployments,
Projects, serviços de um projeto, detalhe de serviço); delega as telas globais ao
`home.gvb`.

- **A sidebar é uma `<drawer>`** (glacier-ui 0.92+, Onda 2 da reforma,
  `docs/plano-reforma-gui-glacier-0.102.md`). Largura, padding e spacing vêm das props do
  `<drawer>` no markup; o CSS só dá a cor (o painel carrega `drawer-panel` + `sidebar`). A
  gaveta **empurra** o conteúdo (não cobre) e não desenha gatilho: o ☰ mora na topbar e
  `drawer::toggle:menu` funciona de qualquer lugar. O `init()` (`connection.luau`) semeia
  `menu = "true"` para começar aberta.
- **Responsivo** (glacier-ui 0.9.0+): em janela estreita a sidebar-gaveta **fecha** pelo ☰
  (o motor anima a largura 264→0), em vez do antigo trilho de ícones. Sobra só o aperto da
  própria topbar; abaixo de 560 px somem a busca e o status do daemon (baixa prioridade)
  para o ☰, o *Stop All* e o *Disconnect* não serem cortados.
- **`app:` no ☰.** O prefixo `app:` tira o prefixo de dono (`Shell::…`) que o motor poria
  numa ação escrita dentro de um `<component>`; sem ele a ação não chegaria ao builtin
  `drawer`. Sai `drawer::toggle:menu`, que o dispatch roteia para o `<drawer>` (o alias
  minúsculo é um componente registrado).
- **Detalhe de serviço e o wizard.** O wizard "Novo serviço" saiu do shell
  (`view = new_service`): agora abre numa janela à parte (`new_service_window.gvb`),
  disparada por `open_new_service_window`.
- **Projects.** Criar projeto abre uma janela à parte (`new_project_form.gvb`, motor
  Glacier próprio); ao concluir, ela envia um `broadcast("project_created")` que o
  `on_broadcast` (`handlers/projects.luau`) recebe para atualizar a grade. Ver
  `open_new_project_window`.
- **Projeto aberto (`project_services.gvb`).** Barra superior com *voltar* à esquerda e as
  ações do projeto empurradas para a extremidade direita (`hspacer` = `width: fill`); em
  modo de edição as ações somem (o form tem Salvar/Cancelar). A grade de serviços usa
  `virtualize` = a altura de **uma** fileira de cartões: só as fileiras visíveis são
  montadas e as de fora viram um vão do tamanho exato (glacier-ui 0.77, ver
  `PRIMITIVAS.md`). O valor é a altura do card; o `spacing: 10` da `.grid` o motor soma
  sozinho. Errar desalinha a barra de rolagem, não quebra.
- **Aba Variáveis (`project_env.gvb`).** No modo *secret* o campo valor passa a receber o
  **nome** de um secret do projeto (a referência, não o conteúdo), que o daemon decifra no
  deploy. Os comentários (`# …`) do `.env` são arrastáveis como as variáveis (ao soltar,
  reancoram na variável seguinte da nova ordem), mas sem delete — removem-se editando o
  `.env`. O editor `.env` é um bloco condicional com vários filhos.
- **Aba Secrets.** É *write-only*: o daemon só devolve os nomes (o valor é cifrado com a
  chave mestre do servidor), então não há coluna de valor nem edição — sobrescrever é salvar
  de novo com o mesmo nome. Ver `handlers/secrets.luau` e `docs/secrets.md`.

### 6.4 As telas globais (`views/home.gvb` e `views/home/`)

Cada tela é uma seção por valor de `view`: Monitoring, Ingress, Deploy Engine, Docker (e
Registry), Settings, Schedules e Suporte.

- **Monitoring.** Bloco com vários filhos condicionais: precisa de bloco `if`/`<template>`,
  não do atributo `if=`.
- **Deploy Engine.** KPIs + deploys em andamento + histórico das últimas 24 h. Tudo vem do
  `Command::DeployEngineStatus` (ver os `eng_*`); sem backend novo. A fila é **global** (um
  deploy por vez): o primeiro é o próximo a rodar. Arrasta-se pela alça (⋮) para reordenar,
  ↑ fura a fila, ✕ cancela, e pausar/retomar interrompe o worker de puxar o próximo (o que já
  está rodando segue). Tudo em `handlers/deploy_queue.luau`. São três abas (`deploy_engine_tab`: fila, executando, histórico; handler `deploy_engine_tab` em `handlers/nav.luau`),
  cada uma num arquivo de `views/home/deploy_engine/`. Separadas, cada lista tem a tela toda:
  quando eram três seções empilhadas, uma fila grande empurrava as outras para fora da janela.
- **Docker.** Sub-abas Containers / Images / Volumes / Networks / Registry. As três do meio
  listam **todo** o host Docker (não só os recursos geridos pelo Rustploy; ver
  `docker_inventory` no daemon), com indicação de uso e botão de limpar as que estão sem uso.
  - *Containers:* o container nasce de um `Service`, então a ligação com projeto/serviço é
    direta. A lista mostra todos do host (rodando + parados); remover só é oferecido nos
    **parados** (o Docker recusa `rm` de um rodando sem `force`).
  - *Images:* a lista já vem ordenada por tag (`docker_images_json`); o checkbox "Somente em
    uso" só esconde as linhas sem uso, sem *round-trip*.
  - *Volumes:* o Rustploy só usa *bind mounts*, então os volumes nomeados aqui são sempre
    externos a ele (criados à mão ou por `VOLUME` da imagem).
  - *Registry:* repositórios/tags do registry OCI embutido (Fase 1: só push/pull via docker
    CLI; sem auth ainda). Leitura + delete (metadados) + GC (libera do disco blobs/manifests
    órfãos). As tags de um repositório são buscadas **sob demanda**, ao clicar "Ver tags" —
    não vêm no snapshot periódico, diferente das outras sub-abas. `registry_repos` chega
    **filtrado** pela busca (`format.registry_repos(snap.registry_repos, term)` em
    `stream.luau`), enquanto o `registry_repos_count` é da lista crua; por isso a mensagem de
    "Faça `docker push`…" usa o count (trocar pelo `empty` de `registry_repos`, como nos
    outros pares lista/count, a faria aparecer quando a busca só não achou nada, não quando o
    registry está genuinamente vazio), e o `fallback` distingue "registry vazio" de "busca sem
    resultado".
- **CSS compartilhado das colunas de ação.** `align-x` só afeta como um container de
  linha/coluna posiciona seus **filhos**; não alinha o texto de um elemento dentro da própria
  caixa (isso é `text-align`). Como a classe `.col_act` é reaproveitada no texto do cabeçalho e
  no container dos botões, sem `text-align` o rótulo "AÇÃO" ficava colado à esquerda enquanto
  os botões (`align-x: end`) ficavam colados à direita. `.col_act_2` é a linha de repositório
  do Registry (2 ícones: ver tags/remover, com tooltip), `.col_act_4` a de job em Schedules (4
  controles: rodar/logs/pausar-ativar/remover) e `.col_act_5` a mesma com um controle a mais
  (editar).
- **Ingress.** Os estados vazios das listas usam o `fallback`. As portas TCP de host são
  exposição direta de porta, fora do proxy HTTP por domínio: um serviço pode ter as duas, só
  uma, ou nenhuma.
- **Settings → Git.** OAuth: o Luau não abre o navegador; guarda a URL em `git_provider_oauth_url` e o
  built-in `open:<chave>` do glacier abre no navegador padrão.
- **Settings → Infra as Code.** O manifesto é um `.zip` com exatamente `rustploy.yml`
  (projetos/serviços; variáveis de ambiente sempre como `${VAR}`) + `rustploy.vars.toml` (os
  valores reais, aninhados por escopo). Exportar/importar usam os diálogos nativos do SO, e o
  import rejeita um zip que tenha qualquer outra coisa. Ver `manifest_export`/`manifest_import` em
  `handlers/settings.luau` e a ponte do zip na [seção 3]#3-a-ponte-do-zip-do-infra-as-code.
- **Settings → Manutenção.** Limpeza automática de recursos Docker sem uso (ver
  `docs/plano-limpeza-automatica-docker.md`): as mesmas funções dos botões manuais da aba
  Docker, disparadas por agendamento em vez de clique.
  - O **dia da semana** eram sete `<TabButton>` escritos à mão mais o handler `docker_cleanup_weekday` só
    para gravar a escolha. O `<radiogroup>` (glacier-ui 0.66) grava a chave sozinho, no update
    dele em Rust, então o handler foi embora junto. As opções vêm de `weekdays`, semeada em
    `handlers/connection.luau` a partir de `format.WEEKDAYS_JSON`; a aparência agora é de radio
    buttons redondos, não da fileira de pílulas.
  - O **horário** era um par de campos de texto livre ("HORA (0-23)" e "MINUTO (0-59)"), e a
    faixa no próprio rótulo era a confissão de que nada impedia digitar 99: o handler fazia
    `tonumber(...) or 0` e agendava a limpeza para a meia-noite sem avisar ninguém. O
    `<timeedit>` (glacier-ui 0.68) é um campo só, editado por seções (clique na hora, setas ▴▾
    mexem nela), onde não dá para digitar e portanto não dá para digitar errado. A chave
    `docker_cleanup_time` é `"HH:MM"`; `format.hour_minute_join`/`format.hour_minute_split` fazem a ponte com o `{hour, minute}`
    que o daemon espera — o contrato HTTP não mudou.

### 6.5 O detalhe do serviço (`views/service.gvb` e `views/service/`)

O cabeçalho tem as ações (deploy, stop, reload), e as abas são General (fonte), Connection,
Domains, Environment, Deployments, Logs e Advanced (mais Databases, Migrar e Healthcheck
conforme o serviço).

- **Status numa sub-linha.** Status + timer ficam numa **sub-linha** abaixo do nome, nunca na
  mesma linha do título. Antes o badge ficava inline e, quando o nome quebrava em duas linhas,
  o "Running" era desenhado por cima da segunda: um elemento de texto do iced não corta ao
  ultrapassar a caixa `fill`, e o badge (posicionado logo após a caixa estreita) caía sobre o
  texto vazado. Descê-lo elimina qualquer disputa horizontal com o nome.
- **Timer de deploy.** O "1s, 2s, 3s…" só aparece enquanto um deploy iniciado por este painel
  (Deploy/Rebuild) está em andamento. É incrementado uma vez por segundo pela subscription de
  poll (`sec_tick`, em `net::poll_stream`) e some quando o deploy termina; o desfecho e o
  tempo total aparecem então em `service_action_message`, logo abaixo.
- **Ações responsivas.** As ações compactas (ícones) ficam **empilhadas** abaixo do título e
  só valem até 1240 px; ficam dentro da coluna do título de propósito: quando aperta, a
  fileira de rótulos à direita some (`@media`) e `header_titles` passa a ocupar a largura toda,
  então os ícones descem para cá — título e ações nunca dividem a mesma linha em janela
  estreita, e por isso nada sobrepõe o nome. A fileira de rótulos por extenso fica à **direita**
  do título, só acima de 1240 px; abaixo disso ela some (`@media` em `app.gss`) e entram os
  ícones. O `.svc_actions_full`/`.svc_actions_compact` e o swap por `@media` vivem em
  `app.gss`, junto das outras regras responsivas.
- **O nome é o elemento flexível da linha do título** (`.svc_title { width: fill }`): ele
  absorve a folga e quebra dentro da coluna quando aperta. Sem isso, o botão de voltar, o nome
  e o badge são todos `shrink` (largura natural) e, quando a soma passa da largura da coluna, o
  iced **não** encolhe — transborda para a direita e o nome/badge invadem os botões de ação (o
  "Deploy/▶ por cima do nome").
- **Abas com scroll horizontal.** São 8 abas e, em janela estreita, as últimas
  ("Healthcheck"/"Logs"/"Advanced") saíam da tela sem como alcançá-las. A viewport
  (`.tabs_scroll`) é `width: fill`; a fileira interna (`.tabs_main`) é `width: shrink` (largura
  natural), então quando as abas não cabem ela transborda a viewport e a barra de rolagem
  aparece.
- **Painel lateral.** Mostra o status. O log ao vivo saiu dali: era um `for-each` sempre
  renderizado que a reavaliação processava a cada tick; agora é janela.
- **CSS.** `.btn_export`/`.btn_export_on`/`.env_editor` vivem em `app.gss` (compartilhados com o
  shell). `.dep_actions` são 2 botões-ícone (ver build log / remover) com o rótulo no tooltip;
  o padding era `7 15`, dimensionado para "Build log ↗" e "Remover" por extenso, que estouravam
  a coluna em janela estreita.
- **General.** Enquanto o fetch do detalhe (spec + listas conta/repo/branch do Gitea) não
  completa, mostra-se um loading — os `Select` só aparecem depois, já populados, em vez de
  piscarem vazios. Renomear é um **form próprio**, fora do form `general` (form não aninha); o
  nome é só de exibição para Compose (stack e volumes ficam gravados), e em Application o alias
  de rede acompanha o nome no próximo deploy — o aviso abaixo do campo diz qual dos dois vale. A
  aba "Gitea/GitHub" vale para qualquer conta conectada em Settings → Git; o `target`/`action`
  seguem `gitea` por ser a chave de estado (`provider_tab`), não o nome do provedor. O `placeholder`
  do editor Compose é um `l"""…"""` (glacier-ui 0.119+): um texto em que a quebra de linha é
  conteúdo. `"…"` não interpreta `\n` e só aceitaria a quebra literal, com as linhas de
  continuação coladas na margem; `"""` dobra as linhas em espaços. O `l"""` (de *linhas*, não de
  *raw*) mantém as quebras, tira o recuo comum e continua interpolando `@nome`; o mesmo vale para
  o placeholder do compose em `new_job_window.gvb`.
- **Environment.** Valem as regras de `scrollable` e do editor `.env` de [6.1]#61-regras-que-valem-para-todos-os-templates.
  Os comentários (`# …`) do `.env` são arrastáveis como as variáveis (ao soltar, reancoram na
  seguinte), mas sem delete; a classe da linha muda para `kv_row_drag` no item agarrado
  (`{e.__dragging}` vem do glacier-ui durante o arrasto).
- **Deployments.** A URL do webhook e os botões ficam em **linhas separadas**: a URL é longa
  (~100 caracteres) e, com `width: fill`, empurraria os botões para fora do painel. Ela é exibida
  **truncada** (`service_webhook_url_short`), porque inteira estoura o card em janelas estreitas; o
  valor completo vai no tooltip e no clipboard. As ações da linha são ícone + tooltip (ver
  [6.1]#61-regras-que-valem-para-todos-os-templates), e o build log abre em janela isolada.
- **Domains.** Lista de rotas HTTP (domínio → porta de container, TLS por rota), mais o form de
  adição e a porta TCP crua do host.
- **Advanced.** O teto de 20 réplicas é arbitrário mas deliberado: um número acima disso num
  single-node é quase sempre engano de digitação, e o widget não deixa mais chegar lá. O piso de
  1 substitui o `if r < 1 then r = 1` do `advanced_save`, que fica como rede redundante. A **fila de
  pré-deploy** é editável com efeito imediato (como Domains, não passa pelo Save do form acima):
  cada item roda em **ordem** antes do deploy, e a primeira falha para a fila inteira.
- **Healthcheck.** O *expected status* era um `<input>` de texto livre com uma linha de erro
  abaixo, `<text if="{erro_f_hc_status}" …>`. Só que `erro_f_hc_status` **nunca foi escrita por
  ninguém** (nem Luau nem Rust): a validação que ela anunciava não existia, e o handler engolia
  lixo em silêncio (`tonumber(...) or 200`). Quem valida agora é o próprio widget, que satura em
  mínimo/máximo; o mesmo vale para os outros cinco campos numéricos da tela, e as linhas
  `erro_f_hc_*` saíram junto. A faixa dos quatro números do health check sai do que o daemon
  aceita: intervalo/timeout/start em segundos (1 h de teto é folgado para qualquer check
  razoável) e *retries* na faixa do Docker.
- **Logs.** A aba é só o *launcher*: os logs de runtime vivem numa janela isolada (motor leve),
  não inline, porque renderizar o stream aqui reavaliaria a árvore inteira da janela principal
  por linha e a travaria. A condição de visibilidade vai na **chamada** (ver 6.1).

### 6.6 As janelas auxiliares

Cada uma é um motor Glacier **isolado** e abre só a própria tela. O tema e o `app.gss` são
declarações globais do `app` (`app.gvb`), que cada janela carrega. Todas são *borderless*
(`decorations = false`, como a principal): a titlebar é custom e as ações `window:*`
(`drag`/`minimize`/`close`) são tratadas no runtime contra o id da janela; as de diálogo não têm
*maximize*.

**O que elas têm em comum.** O app principal as abre com `open_window` e **semeia** a conexão
(`api_url`/`api_token`) e o que já tem à mão via `open_window({ data = … })`. Nada é buscado ali
dentro: um `fetch` rodado no `init()` é descartado pelo motor (só ações despachadas chegam ao
executor), a mesma razão de `new_service_window.luau`. A `screen` é só conteúdo — o tamanho vai
no `open_window{ size = … }`.

- **Logs (`log_window.gvb`).** Janela de **logs ao vivo** (runtime ou build), aberta por
  `handlers/services.luau` (`open_logs_window`/`open_build_logs_window`). É genérica: o script
  decide o tipo pelos dados semeados — `api_url`/`api_token`, `log_window_title`, `log_window_stream_url`
  (endpoint SSE dedicado) e `log_window_seed` (histórico inicial); ver `scripts/log_window.luau`. O motivo
  da janela está em [6.1]#61-regras-que-valem-para-todos-os-templates. Não tem `title` no
  cabeçalho de propósito: o título é dinâmico ("Logs — nginx", "Build — abc123", "Job — …") e
  quem o sabe é quem a abre, no `open_window{ title = … }`; o tamanho (900×560) vai no
  `open_window{ size = … }`. O corpo é um `<textarea readonly>` (`text_editor` do iced): texto
  **selecionável** e copiável (arrastar + Ctrl+C), mas não editável (`readonly` ignora
  digitação, apagar e colar; seleção e scroll seguem funcionando). Cada linha nova é
  **appendada** no fim via `append_textarea` (o motor insere sem recriar o buffer, preservando o
  scroll). O botão ↓ rola até o fundo (`textarea_end:`) sem precisar arrastar a roda. O
  `textarea` do glacier-ui desenha a própria barra de rolagem (arraste o *thumb*, clique na
  trilha) e rola também por roda, teclado e pelos botões ↑/↓.
- **Novo job (`new_job_window.gvb`).** Aberta por `handlers/jobs.luau` (`open_new_job_window`), que
  semeia a conexão + os catálogos de projetos/serviços já buscados pela janela principal. Passos:
  escolher projeto → escolher serviço gatilho (rede + env vars de base) → formulário (nome,
  compose, `main_service`, recorrência). Ao concluir emite `broadcast("job_created")` e fecha.
  - *Passo 2:* o serviço gatilho é opcional — só traz env vars de base; a rede Docker já vem do
    projeto escolhido no passo 1.
  - *Passo 3:* o formulário também é o **único** passo em modo edição (`open_edit_job_window`,
    sem passos 1/2: projeto e serviço gatilho não são editáveis via `JobUpdate`).
  - *Aba Git:* o mesmo picker conta→repo→branch da aba Gitea do serviço; o repo escolhido
    precisa já ter um `docker-compose.yml` no caminho informado — o job clona e roda esse
    arquivo a cada execução, em vez do colado acima.
  - *Env vars:* as próprias do job têm a **maior precedência** na resolução (por cima de projeto +
    serviço gatilho); formato `.env` colado, igual ao editor de projeto/serviço, parseado em
    `new_job_create`.
  - *Recorrência diária:* os pares HORA/MINUTO de texto livre viraram um `<timeedit>` só (chave
    `new_job_time`, `"HH:MM"`) — a mesma troca e o mesmo motivo da limpeza automática (ver 6.4).
- **Novo projeto (`new_project_form.gvb`).** Aberta por `handlers/projects.luau`
  (`open_new_project_window`). Ao concluir, o script (`scripts/new_project_window.luau`) emite
  `broadcast("project_created")` e chama `close_window()`. A validação é declarada no `<form>` (ver
  6.1).
- **Novo token do registry (`new_registry_token_window.gvb`).** Aberta por
  `handlers/registry.luau` (`registry_open_token_window`), que semeia a conexão + o host do
  registry. Fluxo: formulário (nome + escopo pull/push) → `RegistryTokenCreate` → o segredo
  aparece em texto **uma vez**, com botão copiar. A janela **não** fecha sozinha (diferente da de
  job), porque o usuário precisa copiar antes de perder o valor. O
  `broadcast("registry_token_created")` dispara na hora da criação (não só ao fechar), para a
  lista da janela principal atualizar mesmo se o usuário nunca clicar em "Fechar".
- **Novo serviço (`new_service_window.gvb` + `new_service.gvb`).** A janela é aberta por
  `handlers/wizard.luau` (`open_new_service_window`), que semeia a conexão + o projeto-alvo, e
  reaproveita o corpo do wizard (`new_service.gvb`, importado como `<NewServiceWizard/>`); a
  lógica vem de `handlers/wizard.luau`, carregada pelo script de entrada. Ao criar, o wizard emite
  `broadcast("service_created")` e chama `close_window()`. O wizard espelha o fluxo do antigo
  remote-client (Application / Database / Compose / Template): tipo → formulário por tipo; o passo
  corrente vive em `{new_service_step}` e os campos são chaves de contexto `ns_*` (os handlers `ns_*`). O
  status do wizard mostra um spinner enquanto o RPC de criação está no ar (`{new_service_busy}`), e o
  texto acompanha `{new_service_message}` ("criando…" / "erro: …").

### 6.7 Os componentes

Os componentes de `views/components/` são pequenos e sem estado próprio; o que mudam vem por
props.

- **`badge`** — variante da célula de estado (mesmo ponto + rótulo, com o espaçamento e o estilo
  de crachá, `badge_lbl`). Usada no card de serviço e no cabeçalho/aside do detalhe do serviço.
- **`loading_row`** — linha "Carregando dados…" com spinner. A **condição** de exibição fica no
  chamador (`if cond="{flag}" equals="true" { LoadingRow }`), não aqui dentro: um componente
  instanciado direto (fora de `for-each`) tem a prop resolvida **uma vez** e não re-avalia quando
  o flag muda — um `if` interno à prop ficava preso em `"true"` e o spinner nunca sumia. O `if`
  inline do chamador é re-avaliado ao vivo (mesmo padrão do `if` que esconde/mostra a grid).
  `note` sobrescreve o texto padrão quando fornecido (prop ausente → vazio) e é a única prop,
  opcional; o `default = ""` reproduz exatamente o comportamento antigo, só que agora declarado. O
  indicador é o `<spinner>` do motor (glacier-ui 0.66+): um anel girando de verdade, não o glifo
  `⟳` estático de antes; o diâmetro sai do `width`/`height` do nó (o `size:` do GSS é corpo de
  fonte e não vale aqui) e a cor cai no `color` da classe.
- **`nav_item`** — item de navegação da sidebar: ícone + rótulo. Fica "ligado" (pílula azul)
  quando a view atual (`{view}`) casa com `target` via **`one_of`** (glacier-ui 0.57.8), não
  `equals`: `target` pode ser mais de uma view separada por espaço (ex.: `"projects
  project_services service"`), para o item continuar aceso nas sub-telas dele; um `target` de uma
  view só funciona igual (`one_of` com um token é comparação de igualdade). Antes da 0.57.8 isso
  não dava para fazer sem inventar gramática de expressão, e o item "Projects" apagava assim que
  se entrava num projeto. Histórico: era um botão-texto puro (`Button` com o rótulo em `text`),
  mas até a 0.57.5 o botão não suportava filhos, então não dava para pôr ícone + texto lado a
  lado; tinha virado uma `row` com `on_press` (o mesmo mecanismo de clique dos handles de
  resize/drag — qualquer nó com `on_press`/cursor vira um `mouse_area`), hack removido na 0.57.6
  (`<Button>` com filhos: um filho vira o conteúdo direto, mais de um vira uma `Row` implícita
  respeitando `spacing`/`align-y` do próprio nó). O `tooltip="{label}"` (glacier-ui 0.37.5)
  mostra o rótulo completo ao pairar o mouse; o *wrap* acontece depois do `mouse_area` em
  `widget.rs`, então o hover funciona na linha inteira, não só no ícone. A prop `view` **não**
  entra em `props`: ela vem do contexto global, não de quem instancia o item, e declarar só o
  que é prop é o que mantém a leitura do contexto funcionando. Não há `@media` de colapso: a
  sidebar virou `<drawer>` (ver 6.3); em janela estreita a gaveta fecha pelo ☰ e, aberta, tem
  sempre 264 px e o rótulo cabe.
- **`job_actions`** — o botão "⋯" de uma linha de job e o painel sobreposto com todas as ações dele
  (rodar ou cancelar, aquecer, ver logs, pausar ou ativar, editar, remover). A linha de job tinha
  seis botões e não cabia; agora ela mostra o estado de aquecimento e este botão. É um `<popup>`
  do glacier (um painel centrado sobre a janela, com Esc e clique fora fechando) dentro do
  componente, instanciado uma vez por linha em `shell/project_jobs.gvb` e em `home/schedules.gvb`.
  O estado de aberto/fechado fica numa chave por linha, `job_actions_open_<job_id>`: o `value` do
  popup é interpolado com o laço (`process_tpl` no eval), e fechar é zerar a chave
  (`close_job_actions` em `handlers/jobs.luau`, que zera todas porque `job_run_cancel` e
  `open_job_logs_window` só recebem o id da execução). Descartados: **uma janela do SO**
  (`open_window`, como os outros diálogos: o pedido era um painel sobreposto) e o **`<dialog>`**
  (modal de verdade, mas só abre pela ação `dialog:nome`, e o Luau só abre os diálogos de corpo
  fixo, como `confirm{}`, então não há como levar o job da linha para dentro dele). O `<popup>` não
  é modal: a tela de trás continua clicável. Não existe componente chamado `Painel`: é o apelido
  de `<popover>` no motor. O texto "quente/frio" vem pronto do daemon (`JobWarmStatus`,
  `docs/plano-aquecer-job.md`), via `format.warm_status`.
- **`picker_row`** — linha de escolha do wizard "Novo serviço": título + subtítulo à esquerda e
  um botão de ação à direita, usada nos passos de tipo, de banco e de template.
- **`project_card` e `service_card`** — são templates **fragment** com dois nós de topo (o *slot*
  vazio e o card): o glacier-ui (0.4.12+) embrulha múltiplas raízes num `Fragment` e aplica os
  filhos no pai, então o par `if`/`else` vira dois irmãos do grid, sem nó *wrapper*. Todas as
  props são obrigatórias, inclusive no modo `filler` (o card vazio que completa a fileira): quem
  chama passa o conjunto inteiro e o próprio template decide o que renderizar pelo `{filler}`. A
  descrição é sempre renderizada (mesmo vazia) para reservar a linha, mantendo os cards da
  fileira mais próximos em altura mesmo quando só um tem descrição. O `service_card` é o análogo do
  `project_card` na aba "Serviços" de um projeto, e o crachá de estado reaproveita o `rp-badge`.
  As regras `.card_ctr_id`/`.card_ctr_extra` (cor `--text-3`) ficaram desativadas.
- **`stat_card`** — tile de KPI do cabeçalho (STATUS/UPTIME/SERVICES/CPU/…). Com `accent = "1"` o
  número usa o realce verde (`stat_num_g`); sem `accent`, o neutro (`stat_num`). A prop ausente
  vira string vazia, então `!= "1"` → neutro; por isso `accent` é opcional com `default = ""`.
- **`state_cell`** — a célula de estado das tabelas: o ponto colorido "●" + o rótulo, ambos na
  mesma cor. Usada em Deployments, Docker (containers/images/volumes/networks) e na lista de
  deployments do serviço. A cor vem da camada GSS: `kind` é um token semântico
  (`ok`/`bad`/`info`/`warn`/`muted`) e a classe `state_<kind>` define a cor (ver `.state_*` em
  `app.gss`), de modo que a paleta fica centralizada no estilo. A largura vem de `.col_state`
  (`app.gss`), junto com a coluna ESTADO do `thead` — inclusive os ajustes de `@media`
  (170/130/110); antes era 170 fixo aqui e a célula desalinhava do cabeçalho em janela estreita.
- **`tab_button`** — botão de aba genérico: "ligado" (`tab_on`) quando o valor atual da aba
  (`{current}`, passado pelo chamador, ex.: `"{docker_tab}"`) é igual ao `target` desta aba;
  caso contrário neutro (`tab`). O `equals` é interpolado, então compara os dois valores
  dinâmicos.
- **`template_row`** — linha do catálogo de templates de aplicação: logo à esquerda (vetor ou
  raster conforme `logo_kind`), nome + descrição e o botão "Escolher" à direita. Os logos vêm de
  `assets/blueprint-logos/<id>/<arquivo>`, pelo `{logo}` data-driven do catálogo do daemon (ver a
  [seção 2]#2-onde-estão-os-assets). A
  caixa do logo é **quadrada, fixa e centralizada**: os logos têm proporções diferentes (quadrado,
  largo, estreito), então reserva-se sempre o mesmo espaço e centraliza-se o logo dentro dele, o
  que mantém a coluna de texto alinhada. A origem da imagem vem no atributo `src` (interpolado do
  `{logo}`): o parser lê o *source* de `<svg>`/`<image>` só de atributo, nunca do conteúdo entre as
  tags. A descrição só é renderizada quando existe — uma linha vazia empurraria o nome para cima e
  o desalinharia do logo (`align-y: center` da linha).