rustploy-gui 0.1.0

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
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
# 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]#4-a-api-de-agente
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/api-agente-no-gui.md` (desenho da API de agente),
`docs/plano-erro-de-deploy-invisivel.md` (de onde veio a motivação da rota de
deploy) 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. o **espelho da sessão** para a API de agente (ver [seção 4]#4-a-api-de-agente);
3. 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. **Sessão compartilhada.** Cria-se o `SharedSession` da API de agente, o
   servidor HTTP local (hyper) que empresta a sessão desta janela a um agente
   rodando na mesma máquina para operar o rustploy **remoto**.
2. **Canal de mão contrária.** `glacier_ui::external::sender()` (glacier-ui
   0.58.6+) deixa a thread do servidor injetar ações no motor desta janela — é o
   que torna login, navegação e qualquer botão alcançáveis por HTTP. Ele é criado
   **antes** de `run()`, porque é nesse momento que o daemon decide registrar a
   subscription que o drena.
3. **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`.
4. **Gancho `on_message`.** Roda depois de **cada** dispatch da janela
   principal. Ele espelha a sessão da GUI (`api_url`/`api_token`/`connected`,
   escritos no contexto por `handlers/connection.luau`) para a API de agente, e
   assim cobre login, logout e troca de servidor sem conhecer nenhum dos três;
   a escrita em disco só acontece quando algo de fato mudou. É também aqui que a
   API de agente sobe: o `.main()` que a subia saiu. O gancho só roda na instância
   **primária** — a instância única encerra a segunda antes de qualquer dispatch,
   então um segundo lançamento não reescreve o handoff da instância viva — e
   `agent::spawn` é idempotente (um `AtomicBool`), de modo que chamá-lo a cada
   dispatch custa uma troca atômica.
5. **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).
6. **Encerramento.** Quando `run` volta, o handoff da API de agente deixa de
   valer: o token morre com o processo, e um arquivo sobrevivente só confundiria
   quem o lesse depois.

### `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`.

---

## 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.
- `luau_sources` devolve os scripts Luau embutidos como `(caminho, conteúdo)`.
  Serve ao índice de ações da API de agente (`agent::actions`): as ações
  dispatcháveis da UI são as funções globais desses arquivos, e um binário de
  release não tem a árvore `views/` no disco para varrer. Em debug o índice lê do
  disco e nem passa por aqui. O caminho embutido vem relativo a `views/`
  (`scripts/x.luau`); `actions.rs` o normaliza para o mesmo formato do modo debug.
- **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 crate
  `rustploy-shared`, que não os carrega mais); o nome de cada um vem do catálogo
  do shared (`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

O módulo `src/agent/` é um servidor HTTP local que empresta a sessão desta janela
a quem opera por fora.

### 4.1 O problema

O daemon do rustploy já expõe tudo por HTTP (`POST /api/rpc`): dá para conduzir
um deploy inteiro por fora da GUI. O que faltava era o **caminho até lá**. Quem
quer operar um rustploy remoto por agente precisa da URL pública do daemon e do
bearer token dele em mãos, na máquina do agente, fora do lugar onde essas
credenciais já vivem — que é este app.

### 4.2 O desenho: a direção se inverte

O app já está logado no daemon remoto (o usuário digitou URL + token na tela de
login). O módulo sobe um servidor HTTP em **loopback** que aceita comandos de um
agente na mesma máquina e os encaminha ao daemon usando a sessão da GUI. O agente
nunca vê o token do daemon, não precisa saber o endereço do servidor remoto, e o
que ele alcança é exatamente o que a janela alcança: trocar de servidor na GUI
troca o alvo do agente junto.

```text
  agente local ──HTTP──> 127.0.0.1:9800 (este módulo) ──HTTPS──> rustploy remoto
                             ▲                                    (POST /api/rpc)
                             └── sessão (url + token) lida do contexto da GUI
```

### 4.3 Descoberta: o arquivo de handoff (`handoff.rs`)

Ao subir, o app grava um JSON (`agent-api.json`) com a URL local, o token de acesso
e o PID. É o **único passo de descoberta**: o agente lê o arquivo e já sabe tudo
para a primeira requisição; `GET /agent/schema` conta o resto.

- **Onde.** No data dir do usuário — o mesmo que a GUI já usa para o `storage` do
  Luau e a geometria da janela (`shared::fallback_data_dir()`) —, então não
  inventa diretório novo nem depende do diretório corrente.
- **É um segredo.** Quem lê o token opera o rustploy remoto inteiro. O arquivo
  nasce `0600` no unix. No Windows, onde não há modo POSIX, o diretório de dados
  do usuário é a fronteira — o mesmo nível de proteção do resto da persistência
  local do app.
- **Token efêmero.** `generate_token` produz 32 bytes de CSPRNG em hex, **novo a
  cada boot** de propósito: não há motivo para ele sobreviver ao processo que o
  serve, e assim um handoff velho esquecido no disco não vale nada.
- **O campo `remote`.** É o daemon ao qual a janela está conectada agora, ou
  `None` na tela de login. Expor isso poupa ao agente uma requisição só para
  descobrir que ainda não há sessão.
- **O campo `note`.** O arquivo pode ter sido escrito por um processo que morreu
  sem limpar; por isso o leitor deve conferir o `pid` (ou simplesmente tentar
  `/agent/health`) antes de concluir que a API está no ar.
- **Escrita atômica.** Grava-se num arquivo temporário (`.json.tmp`) e faz-se
  `rename`. Sem isso, um agente que lesse exatamente durante a regravação — que
  acontece a cada login/logout — poderia pegar um JSON truncado.
- **Remoção.** `remove` apaga o handoff ao encerrar o servidor. Um handoff órfão
  não é perigoso (o token morre com o processo), mas confunde quem for ler depois.

### 4.4 Limites deliberados

- **Só loopback.** O bind é sempre `127.0.0.1`; não há opção de expor na rede.
  Isto é uma ponte para processos da mesma máquina, não um segundo daemon.
- **Token mesmo assim.** Loopback não é fronteira de segurança num desktop
  multiusuário, e o que está do outro lado da ponte derruba produção. O arquivo de
  handoff nasce `0600`.
- **Sem escopo.** Quem tem o token do handoff tem o mesmo poder que a janela — o
  poder do bearer do daemon, hoje sem escopo nenhum. Enquanto o daemon não tiver
  tokens com escopo (ver a nota de segurança em
  `docs/plano-erro-de-deploy-invisivel.md`), esta ponte não tem como inventar um.

### 4.5 Subida, endereço e ciclo de vida (`agent/mod.rs`)

- **Thread própria, runtime próprio.** `spawn` sobe o servidor numa thread
  separada com um runtime tokio `current_thread` próprio. É deliberado: o loop do
  `iced` é dono da thread principal e o executor dele não é lugar onde se possa
  `tokio::spawn` antes de `run()`. Uma thread assim custa quase nada e mantém a API
  viva independente do que a UI esteja fazendo — inclusive com a janela fechada,
  quando o app fica recolhido na bandeja (o motor *headless* continua vivo e a
  sessão junto).
- **Não devolve erro.** Falhar ali não pode impedir o app de abrir; qualquer
  problema vira aviso no stderr e a GUI segue como sempre foi.
- **Idempotência (`STARTED`).** O `.on_message()` do `GlacierDaemon`, de onde
  `spawn` é chamado, roda a **cada** dispatch da principal. Uma segunda thread
  tentaria *bind* na mesma porta e reescreveria o handoff com um token novo —
  invalidando o que o agente já tem. O `AtomicBool` também responde "esta execução
  chegou a subir o servidor?".
- **`cleanup` só de quem subiu.** Apaga o handoff ao encerrar (o token é desta
  execução e não vale depois dela), mas só se `STARTED` está ligado. Sem esse
  guard, um segundo lançamento do app — que o `single_instance` encerra em silêncio
  sem abrir janela — apagaria o arquivo da instância que continua no ar, e o
  agente perderia o caminho de volta sem nada ter acontecido de fato.
- **Endereço.** `DEFAULT_ADDR` é `127.0.0.1:9800`: porta alta e fixa para o
  handoff ser previsível. Se estiver ocupada, `bind` cai para uma porta efêmera no
  mesmo IP (outro app, ou uma instância anterior ainda encerrando) e o handoff diz
  qual foi — por isso o agente lê o arquivo em vez de assumir a porta.
- **Variável de ambiente `RUSTPLOY_AGENT_API`.** `off` desliga a API;
  `127.0.0.1:9910` troca o endereço. A regra de resolução (`resolve_addr`) é
  separada da leitura da variável (`configured_addr`) para poder ser testada. Um
  valor **não-loopback é recusado** e cai no padrão: o desenho todo supõe que só
  processos da mesma máquina alcançam esta porta, e uma variável de ambiente num
  `.desktop` não é lugar de furar isso sem querer. `::1` também é loopback e é
  aceito.

### 4.6 A sessão (`session.rs`)

A thread da UI **escreve** a sessão; a thread do servidor **lê**, a cada
requisição.

Quem escreve é o gancho `on_message` do `GlacierDaemon`: toda mensagem
despachada na janela principal passa por lá com o motor no estado resultante, e o
contexto do motor é onde a camada Luau guarda `api_url`/`api_token`/`connected`
ao conectar (`handlers/connection.luau`). Não há um "evento de login" no glacier
para assinar, e nem faz falta: o `on_message` roda depois de cada dispatch, então
observar o contexto ali é equivalente — e cobre de graça o logout (que apaga o
contexto inteiro) e a troca de servidor, sem que o módulo conheça nenhum dos dois
fluxos.

- **`Session`** é a conexão viva com um daemon, do ponto de vista da API:
  `base_url` sem barra final (`https://rustploy.exemplo.com`) e `token`, que é
  `None` quando o daemon roda sem token.
- **O espelho do contexto.** Além da sessão, guarda-se uma **cópia** do contexto
  do motor, refeita a cada dispatch. Ela existe porque o contexto só é legível de
  dentro do gancho, na thread do iced, e a thread da API precisa dele para
  responder "em que tela a GUI está?", "qual serviço está selecionado?", "qual
  foi o erro do último login?". Copiar em vez de referenciar permite que as duas
  threads sigam sem trava compartilhada. O custo é um clone do mapa por dispatch
  (~120 chaves, algumas com JSON de dezenas de KB), a cada 2s no ritmo do snapshot
  do SSE; numa aplicação de desktop isso é ruído, e a alternativa (comparar campo
  a campo para copiar só o que mudou) custaria a mesma ordem de trabalho.
- **`sync_from_context`** relê o contexto, atualiza o espelho (sempre) e a sessão
  (se algo mudou). Devolve `true` quando a **sessão** mudou — o chamador usa isso
  para regravar o handoff sem escrever em disco a cada tick do snapshot do SSE
  (que dispara um dispatch a cada 2s e, portanto, uma chamada aqui).
- **O gate `connected`.** `from_context` só devolve sessão quando a camada Luau
  marcou `connected`, o que acontece quando o `DaemonStatus` de validação passou
  (`handlers/connection.luau::connect`). Sem isso a API aceitaria requisições
  enquanto a tela de login ainda mostra credenciais que o usuário está digitando:
  o usuário já digitou a URL, mas o *Connect* não validou nada, e isso a API de
  agente não pode usar.
- **`SharedSession`** é o handle compartilhado. Sessão `None` significa que a
  janela não está conectada a daemon nenhum (tela de login, ou logout); aí a API
  responde **503** dizendo exatamente isso, em vez de falhar de um jeito que
  pareça bug de rede.

### 4.7 O cliente do daemon remoto (`client.rs`)

- **hyper cru**, via o `Client` legado do `hyper-util`, e não `reqwest`: é a mesma
  regra do daemon — um cliente HTTP só no workspace, e ele é o hyper. O que se
  precisa é pequeno e conhecido: um `POST` de JSON com bearer.
- **Sem gzip, de propósito.** O daemon comprime a resposta do `/api/rpc` **quando
  o cliente pede** (`Accept-Encoding: gzip`). Este cliente não pede: o ganho é de
  link remoto lento e o custo seria carregar um descompressor aqui para nada.
- **Provider de cripto explícito.** O glacier-ui já instala o provider `ring` como
  padrão do processo, mas depender dessa ordem seria frágil; passa-se o provider
  explicitamente (`rustls::crypto::ring::default_provider`), e assim tanto faz quem
  instalou o quê antes. `new` só falharia se o provider não pudesse ser
  configurado, o que na prática não acontece.
- **`https_or_http`, não `https_only`.** Um rustploy de laboratório na rede local
  roda em HTTP puro, e é a GUI que decide isso ao conectar — não cabe a esta ponte
  recusar o que a janela aceitou.
- **`TIMEOUT` de 60 s por requisição.** Um `Command` pesado (o `Snapshot` faz
  várias idas ao Docker) leva segundos; o que o limite protege é o daemon
  inalcançável, para o agente receber um erro em vez de ficar pendurado.
- **`RemoteError`** já separa o que o agente precisa distinguir: problema de
  **transporte** (`Transport`: não chegou lá — DNS, TLS, conexão recusada,
  timeout) × resposta de **erro do próprio daemon** (`Status`: chegou e ele
  recusou — 401 de token vencido, 404…) × **`Decode`** (a resposta chegou mas não
  é o JSON esperado).
- **`rpc`** executa um `Command` com `POST /api/rpc` e devolve a `Response` como
  JSON cru. Quem chama decide o que fazer com `{"Err":{…}}`, que é resposta 200 do
  ponto de vista HTTP.
- **`upload_archive`** sobe um zip para `POST /api/services/<id>/archive`. Merece
  método próprio porque **não é um `Command`**: é rota HTTP com corpo binário, e
  um agente que só leu `protocol.rs` não descobre que ela existe — era o último
  caminho do fluxo "criar serviço Archive → deployar" que a ponte não alcançava.
  O nome original do arquivo vai em `X-Rustploy-Filename` (o daemon o lê dali; não
  há multipart) e é o que aparece depois na aba do serviço. O timeout é de **600 s**,
  sem o curto do `rpc`: um zip de projeto leva bem mais que uma chamada de
  protocolo, e o custo é de rede, não de espera por um daemon travado.
- **Ler uma `Response`.** O protocolo é *serde externally-tagged*, então "que
  resposta é essa?" é sempre a única chave do objeto (`{"Projects":[…]}` →
  `"Projects"`) — **menos** quando a variante não tem campos, que o serde
  serializa como string pura (`"Ok"`). Esquecer o segundo caso é o erro clássico de
  quem escreve cliente para esta API. `response_kind`, `response_payload` (o
  conteúdo de uma variante com campos, ou `None` para a unitária) e
  `response_error` (`Some((code, message))` quando é `Response::Err`) encapsulam
  isso.

### 4.8 O servidor e o roteamento (`routes.rs`)

Servidor **e** cliente são hyper: o daemon já serve a própria API assim, e não faz
sentido carregar um segundo framework HTTP num app de desktop só para servir sete
rotas em loopback. Todas as rotas devolvem JSON, **inclusive os erros** — um agente
não deveria precisar distinguir "corpo de erro em texto" de "corpo de resposta em
JSON" no meio de um fluxo. O formato de erro é sempre
`{"error": {"code": "...", "message": "..."}}` (`ApiFail`).

**Constantes.** `MAX_BODY` = 32 MiB: generoso porque um `ManifestApply` ou um
`ServiceUpdate` de fonte Compose carrega YAML de verdade (dezenas de KB) e
mesquinho o bastante para um cliente maluco não comer a RAM do app.
`POLL_INTERVAL` = 2 s é o intervalo com que o `wait` de um deploy reconsulta o
daemon. `MAX_WAIT` = 3600 s é o teto do `timeout_s` aceito em
`POST /agent/deploys`.

**Estado (`Ctx`).** Compartilhado por todas as conexões: o canal `ui` para injetar
ações no motor (glacier-ui 0.58.6+) — sem ele, login e navegação só por clique, é o
que torna a GUI dirigível —, o `token` exigido no `Authorization` (o **desta** API,
não o do daemon) e a sessão.

**Códigos de erro.**
- `ApiFail::desconectado` → **503**, com uma mensagem que diz o que fazer, porque é
  o erro que um agente mais vai encontrar: o app abriu, mas ninguém logou ainda.
- Falha de transporte para o daemon → **502**: quem falhou foi o salto daqui para o
  daemon, não o pedido do agente, e a distinção importa para ele saber se adianta
  repetir. Pelo mesmo motivo, um `connect` recusado pelo daemon (token errado,
  host inalcançável) é 502.
- `Response::Err` do daemon vira **400** com o código e a mensagem dele — não um
  200 que o agente teria de inspecionar para descobrir que falhou.

**Servir.** `serve` sobe o listener e atende até o processo morrer. Se não
conseguir gravar o handoff, a API sobe do mesmo jeito mas ninguém a descobre — vale
um aviso alto, não um encerramento. `accept_loop` é separado de `serve` para os
testes exercitarem o roteamento sem gerar token nem escrever o handoff no data dir
do usuário; um cliente que desiste no meio é rotina e nada se reporta. `bind` tenta
o endereço pedido e cai para uma porta efêmera (ver 4.5).

**Acompanhar a sessão (`watch_session`).** Mantém `remote_url`/`connected` do
handoff em dia. A sessão muda na thread da UI (login, logout, troca de servidor) e
o handoff é escrito aqui, na thread da API: um *poll* curto é o acoplamento mais
barato entre as duas, e não há nada a perder em detectar a mudança 2 s depois.

**Roteamento (`handle`).** Liveness sem token, depois o gate de token, as rotas de
dados (repassadas ao daemon) e as de controle da janela.

- `GET /agent/health` fica **fora** do gate: serve para o agente saber se o app
  está no ar antes de ter lido o handoff, e não revela nada — nem o endereço do
  daemon remoto, que é dado do usuário.
- `check_token` compara o bearer da API de agente em **tempo constante**: o token é
  curto e local, mas comparar segredo com `==` é o tipo de detalhe que não vale
  economizar.

**Rotas de dados.**

- `GET /agent/status` — a janela, o daemon e a fila de deploys num lugar só.
- `GET /agent/services` — índice achatado projeto→serviço. Existe porque o
  caminho cru para "qual é o id do serviço chamado X?" é `ProjectList` seguido de
  um `ServiceList` por projeto, e a alternativa de uma chamada só (`Snapshot`)
  devolve o dashboard inteiro — Docker, jobs, registry, métricas. Aqui vai só o que
  identifica um serviço. (`snapshot` desembrulha as duas camadas de
  `Response::Snapshot(String)`, que é JSON **dentro** de uma string.)
- `GET /agent/deploys` — últimos deploys com o desfecho já resolvido.
- `POST /agent/deploys` — dispara um deploy e, com `wait`, só responde quando ele
  terminou. **É a rota que motivou este módulo.** Sem ela, "o deploy funcionou?"
  custa ao agente um `DeployStart`, um laço de `DeployHistory` filtrando por id, um
  `GetBuildLogs` inteiro e o conhecimento de que a causa da falha mora no
  `states_log` e não no estado — conhecimento que ninguém tem na primeira vez.
  - `resolve_service` aceita o `service_id` direto **ou o nome** do serviço: o nome
    é o que o usuário diz ("sobe o stand-imob"), o ULID não. O nome é único por
    projeto, não globalmente: havendo ambiguidade, a rota não escolhe por conta
    própria e responde erro.
  - `wait_for_outcome` faz *poll* até um estado terminal (ou o prazo acabar). É
    poll e não SSE porque manter uma conexão de eventos aberta significaria
    consumir e reemitir o *firehose* do daemon só para observar um id; o deploy
    mais rápido leva segundos, e 2 s de granularidade não custam nada. Se expirar,
    devolve o **último estado conhecido** em vez de um erro seco — "ainda em
    `BuildingImage` depois de 15 min" é informação útil, e o agente decide se espera
    mais ou investiga.
- `GET /agent/deploys/<id>/logs` — build log com cursor. O daemon só sabe devolver
  o log inteiro (`GetBuildLogs` não tem cursor), e um build de verdade passa de mil
  linhas. A fatia acontece aqui: o tráfego caro é o desta ponte para o agente, não o
  da ponte para o daemon na mesma sessão. `after` é o índice da última linha já
  vista — a tabela do daemon só recebe *append* e é ordenada por timestamp, então o
  índice é um cursor estável. `build_log_lines` extrai o texto de cada linha, na
  ordem de gravação.
- `POST /agent/rpc` — qualquer `Command` do protocolo, sem tradução. É a válvula de
  escape que mantém as rotas de conveniência honestas: elas existem para os
  caminhos frequentes, não para virarem a única porta. Tudo o que a GUI faz, um
  agente faz por aqui. O passthrough encaminha o comando cru e devolve a `Response`
  *verbatim*.
- `GET /agent/ingress` — a tabela de rotas viva do proxy reverso. Responde à
  pergunta de um domínio que devolve 502: existe rota para ele, e para qual
  `ip:porta` ela aponta? Um `backends` vazio, ou apontando para a porta errada, é a
  resposta.
- `POST /agent/ingress/reconcile` — recalcula as rotas a partir dos containers que
  existem de fato, sem redeployar, e devolve a tabela já corrigida. O corpo é
  opcional (`{"service_id":"svc_…"}`); corpo vazio é válido e reconcilia tudo.
- `GET /agent/servers` — os servidores já usados nesta máquina (ver 4.10).
- `POST /agent/services/<id>/archive` — sobe um zip local para o serviço. Recebe o
  **caminho** do arquivo, não os bytes: quem chama está na mesma máquina (a ponte é
  loopback), e mandar dezenas de MB em base64 por HTTP para a ponte remontar seria
  custo puro. O corpo é `{"path": "/caminho/app.zip"}`.

**Controle da janela (ver 4.9).**
- `POST /agent/connect` — entra na sessão **pela própria tela de login**. Era o
  último ponto cego de verdade: sem isso a ponte só servia depois que um humano
  tivesse clicado *Connect*, e um agente numa máquina sem ninguém na frente ficava
  preso no 503. O `token` é opcional: omitido, a ponte busca o salvo para aquela
  URL (`servers.rs`), de modo que o segredo nunca precisa atravessar a rede.
- `POST /agent/disconnect` — sai da sessão. Não espera: `disconnect()` na Luau é
  síncrono e não tem como falhar, e a consequência (a sessão sumindo) fica visível
  em `GET /agent/ui`.
- `GET /agent/ui` — o que a janela está mostrando agora.
- `POST /agent/ui/action` — dispara qualquer ação da UI pelo nome. É a
  **chave-mestra**: todo botão, aba e formulário da GUI é uma função Luau global
  (`views/scripts/handlers/*.luau`), e este endpoint chama qualquer uma pelo mesmo
  caminho de um clique. Cobre as ~154 ações existentes e as futuras sem uma lista
  aqui, que envelheceria na primeira tela nova. Com `value` o efeito é o de um
  `onChange` de campo; sem `value`, o de um clique de botão — a mesma distinção que
  os templates fazem, então a ação recebe exatamente o que receberia da UI. A
  resposta é **202**, não 200: a ação foi **entregue** ao motor e o efeito dela é
  assíncrono (várias fazem RPC); prometer "deu certo" ali seria mentira.
- `POST /agent/ui/context` — escreve chaves no contexto da janela. É o par de
  baixo nível do `ui/action`: preenche campo de formulário, marca seleção, muda de
  aba; útil quando a ação que se quer disparar espera algo já escrito no contexto.
  Número e booleano viram texto, porque o contexto do motor é sempre chave→string
  e recusar por tipo seria pedantismo inútil.

**Moldando as respostas do daemon.** O objetivo é devolver ao agente o que ele
precisa, não o que o daemon tem.

- `outcome_ok` resolve o desfecho de um deploy: `true` = no ar, `false` = falhou,
  `null` = ainda não decidiu. Só `Live` e `Failed` decidem. `Stopped` e `Pruning`
  são terminais **sem serem desfecho**: o primeiro é o serviço derrubado de
  propósito, o segundo é um deployment antigo que outro mais novo substituiu.
  Chamar qualquer um dos dois de "falha" seria mentira.
- `failure_reason` tira a causa da falha do `states_log`, que é onde o daemon a
  grava: a transição que **entrou** em `RollingBack` carrega a mensagem do step que
  quebrou (o texto do `docker build`, o healthcheck que não passou…). Mensagens de
  outras transições são ignoradas de propósito — `Pruning` traz "superseded by newer
  deployment", que não é erro nenhum.
- `compact_summary`/`compact_deployment` reduzem `DeploymentSummary`/`Deployment`
  ao que interessa a quem só quer saber como acabou.
- `service_index` achata o snapshot no índice que um agente precisa para agir: id,
  nome, projeto, status e de onde a imagem vem. `status_label` transforma o
  `ServiceStatus` (externally-tagged, só a variante `Error` tem campo) em
  `"Running"` ou `"Error: <causa>"`; depois da correção do log de deploy essa causa
  é o motivo real da falha, não mais a string fixa "deploy failed". `source_label`
  achata o `ServiceSource` no que identifica a origem, sem arrastar o
  `compose.content` inteiro (dezenas de KB) para dentro de uma listagem — e a
  credencial de um repositório git nunca vaza para uma listagem.

**Parâmetros da query.** `num_param` e `str_param` não fazem *percent-decoding*:
nenhum parâmetro numérico precisa, e os campos de texto livre (nome de serviço)
vão no corpo, justamente para não depender disso. Os únicos usos textuais são
listas de nomes de chave (`keys=screen,view`) e flags (`all=1`).

### 4.9 Controlando a própria janela (`ui.rs`)

A ponte de `routes.rs` encaminha `Command`s ao daemon remoto, e isso cobre tudo o
que é **dado**. Sobrava o que é **janela**: entrar na sessão, sair dela, navegar
entre telas, abrir uma janela-filha, marcar um serviço como selecionado. Nada disso
é um `Command` — mora na camada Luau, e só era disparado por um evento do loop do
iced.

O canal `external` do glacier-ui (0.58.6+) fecha a lacuna: a thread do servidor
injeta no motor da janela principal o **mesmo** tipo de mensagem que um clique
produz. Como o vocabulário é o dos templates, toda ação declarada na UI já é
alcançável — as 154 de hoje e as que vierem, sem lista para manter em dia. O par de
leitura é o espelho do contexto em `session.rs`: **escrever é pelo canal, ler é
pelo espelho**.

- **`connect`.** Preenche o formulário de login e aciona o botão *Connect*,
  exatamente como um usuário faria, e espera o desfecho. Preencher e clicar (em vez
  de só escrever a sessão na ponte) é deliberado: assim a GUI **acompanha**. O
  `connect()` da Luau valida com um `DaemonStatus`, abre o SSE, carrega as
  configurações do daemon, troca para a tela `shell` e salva o servidor na lista de
  conhecidos; uma sessão escrita só do lado da ponte teria o agente operando um
  servidor que a janela do usuário nem sabe que existe. Limpa-se o erro anterior
  antes, para não confundir uma falha velha com esta. O motivo de uma recusa vem de
  `error` (falha de transporte/401) ou de `erro_url` (URL malformada) — o
  `connect()` escreve ali e volta sem conectar. `TIMEOUT_CONNECT` (30 s) é o quanto
  se espera: ele faz um `DaemonStatus` de validação contra o daemon remoto, então o
  teto é de rede, não de UI. `POLL` (100 ms) é de quanto em quanto tempo se
  reconsulta o espelho.
- **`disconnect`.** O `disconnect()` da Luau fecha o SSE, apaga o contexto inteiro e
  volta para a tela de login; a ponte perde a sessão junto, por construção.
- **`state`.** O estado da janela que interessa a quem a dirige de fora. É
  **curado**, não o contexto cru: o contexto tem ~120 chaves, várias com o JSON
  inteiro de uma tela (todos os serviços, todas as imagens Docker), e devolver tudo
  por padrão faria a resposta custar mais que a informação. Para as demais há o
  parâmetro `keys`. `screen` é a janela toda (`login` | `shell`); `view` é a seção
  da sidebar.
- **`keys` e `all_keys`.** Chaves avulsas do contexto, para o que o resumo curado
  não cobre; `all_keys` despeja todas, só sob pedido explícito (`?all=1`), com os
  segredos redigidos. Uma chave pedida e inexistente volta com `null`: isso diz
  "perguntei e não tem"; omiti-la deixaria o chamador sem saber se errou o nome.
- **`REDIGIDAS`** são as chaves que **nunca** saem pela API: `api_token` (o bearer do
  daemon remoto — o desenho inteiro da ponte é o agente operar sem nunca vê-lo) e
  `token` (o campo do formulário de login, que carrega o mesmo segredo enquanto o
  usuário digita). O resumo curado nunca carrega o bearer do daemon.

### 4.10 Os servidores conhecidos (`servers.rs`)

A camada Luau salva todo login bem-sucedido no `storage` do glacier-ui — um JSON no
data dir do usuário (`handlers/connection.luau`, `remember_server`). É de lá que o
formulário de login nasce preenchido. Para a API de agente isso resolve um problema
concreto: **conectar sem precisar do token**. O agente pede a URL, a ponte encontra o
token salvo e preenche o formulário — o segredo nunca atravessa a rede em nenhum
sentido, nem na ida (o agente não o manda) nem na volta (a listagem só diz se
existe).

- `list` lê a lista salva. Fica vazia quando o arquivo não existe (nenhum login
  ainda), está corrompido ou mudou de formato — nunca é erro fatal: a consequência é
  só o agente ter de informar o token, que é o caminho normal mesmo.
- `token_for` compara a URL de forma tolerante à barra final — a mesma normalização
  que a sessão faz — para `https://x.dev/` e `https://x.dev` não virarem servidores
  diferentes.
- `as_json` é a listagem que sai pela API: URL e se há token guardado, **nunca o
  token**.

### 4.11 O índice de ações (`actions.rs`)

`POST /agent/ui/action` dispara qualquer ação da GUI pelo nome. Mas uma chave-mestra
sem chaveiro só serve a quem já sabe os nomes, e saber os nomes exigia ter o
repositório aberto (`grep "^function " views/scripts/handlers/*.luau`) — o que
deixaria a rota mais poderosa da ponte fora do alcance de um agente que só tem a
máquina do usuário e o arquivo de handoff.

Este módulo devolve a lista **em runtime**, lida da mesma árvore de scripts que o
motor executa: do disco em debug, da árvore embutida no binário em release — nunca
de uma lista escrita à mão, que envelheceria na primeira tela nova.

- **O que conta como ação:** uma função global do Luau, escrita na **coluna 1**
  (`function nome(...)`), que é exatamente o que os templates referenciam em
  `on_click`/`onChange`/`on_submit`. `local function` (auxiliar do módulo), métodos
  (`function M:algo`) e um `function` indentado (dentro de outro bloco) ficam de
  fora, e o teste correspondente existe porque anunciar auxiliares daria ao agente
  nomes que respondem 202 e não fazem nada.
- **Debug × release.** Em debug os assets são lidos do disco pelo motor (com
  hot-reload) e o diretório corrente já é a base dos assets
  (`assets::locate_and_chdir`), então a varredura segue o mesmo caminho. Em release
  não há árvore no disco: vem do binário (`embedded::luau_sources`).
- A lista sai **ordenada por nome**: é para ser lida e procurada, não para preservar
  a ordem de declaração de cada arquivo. `Acao.origem` é o arquivo relativo a
  `views/scripts/`, ex.: `handlers/services.luau`.

### 4.12 O catálogo de descoberta (`catalog.rs`)

`GET /agent/schema` é o documento de descoberta. Sem ele, montar a primeira chamada
exige ler `src/protocol.rs` do rustploy-shared e `models.rs` e deduzir a codificação
serde na mão — viável para quem tem o repositório aberto, inviável para um agente
diante de um daemon remoto. É o atrito mais caro relatado em
`docs/plano-erro-de-deploy-invisivel.md` (2.1).

**O catálogo é curado, não gerado.** As rotas desta API estão descritas por inteiro;
a lista de `Command` cobre o que aparece em runbook, não as ~90 variantes do enum. A
fonte da verdade continua sendo `protocol.rs`, e o passthrough (`POST /agent/rpc`)
aceita qualquer comando, esteja ele listado ou não — inclusive os adicionados depois
deste arquivo.

Por ser escrito à mão, um `json!` malformado só apareceria em runtime; os testes
garantem que o catálogo ao menos existe e cita cada rota que o roteador realmente
serve, e que cada exemplo de comando é JSON de verdade — copiar um exemplo quebrado
é pior do que não ter exemplo.

---

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

### 5.1 Testes da API de agente

São testes unitários pequenos, cada um ligado a uma decisão de desenho:

- **`resolve_addr`** — `::1` também é loopback; e o que importa de verdade: pedir
  *bind* público **não** abre a ponte para a rede, cai no padrão de loopback.
- **`session`** — repetir o mesmo contexto não pode contar como mudança (o tick do
  SSE dispara um dispatch a cada 2 s; senão o handoff seria reescrito em disco o
  tempo todo); logout apaga o contexto inteiro e a sessão cai junto (impede o agente
  de continuar operando um servidor do qual o usuário acabou de sair); trocar de
  servidor na GUI troca o alvo do agente sem ele saber.
- **`ui`** — o token do daemon é o segredo que a ponte existe para **não** expor:
  nem por chave avulsa, nem no despejo completo.
- **`client`** — `Response::Ok` vira a string nua `"Ok"`, não um objeto: o caso que
  quebra cliente ingênuo.
- **`routes` (unitários)** — o desfecho: só `Live` e `Failed` decidem, e um
  deployment substituído por outro mais novo (`Pruning`) não é falha; e a
  **regressão que o módulo inteiro serve**: a causa da falha sai do `states_log` e
  não se deixa confundir pela mensagem de "superseded".
- **`actions`** — auxiliares de módulo e métodos não são dispatcháveis.

**O teste ponta a ponta da ponte** (`routes::tests`) é o que separa "compila" de
"funciona". Ele monta um daemon rustploy **de mentira** de um lado, a API de agente
no meio e um cliente HTTP cru do outro (hyper direto sobre TCP, sem TLS — tudo é
loopback), e exercita o roteamento, o gate de token, o gate de sessão e — o
principal — o `POST /agent/deploys` com `wait`.

- O daemon de mentira responde o subconjunto do protocolo que as rotas usam. Uma
  variante unitária chega como string nua e a com campos como objeto de uma chave,
  igualzinho ao daemon de verdade. `HISTORY_HITS` conta quantas vezes ele já
  respondeu um `DeployHistory`: o deploy só "termina" na **segunda** consulta, para
  o teste passar mesmo pelo caminho de espera em vez de acertar terminal de primeira
  (e a asserção `>= 2` prova que passou por ele — o primeiro `DeployHistory` ainda
  estava em `BuildingImage`).
- O canal `external` dos testes não tem motor do outro lado: as mensagens caem num
  receptor que ninguém drena, o que é exatamente o certo aqui — os testes
  exercitam o HTTP e a conversa com o daemon, não o efeito na janela.
- Com o token certo, o que barra agora é a falta de sessão — o que prova que a
  requisição passou do gate de token.
- **O caso do plano, ponta a ponta:** dispara o deploy, espera, e a resposta já traz
  `ok=false` com a causa que o Docker deu, sem o agente precisar saber que ela mora
  no `states_log`. O serviço é pedido por **nome**, não por id (o daemon de mentira
  resolve via `Snapshot`), e `log_tail` traz só o fim, com o cursor dizendo onde
  continuar.
- O passthrough encaminha o comando cru e devolve a `Response` *verbatim*;
  `Response::Err` vira 400 com código e mensagem; daemon inalcançável (porta
  fechada de propósito) é **502**, não 500 — a distinção diz ao agente se adianta
  repetir. Credencial de repositório não vaza para uma listagem de serviços.

### 5.2 Os testes de `fmt` em Luau (`tests/fmt_*.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("fmt/…")` 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/fmt.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/templates_render.rs`)

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 `njob_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` `np_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
`np_apontar` 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_row_on` é 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 `fmt/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("fmt")` 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
(`dep_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 = @proj_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 tem overlay/z-stack, 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 (`eng_tab`: fila, executando, histórico; handler `eng_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 (`fmt.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 `gp_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 `iac_export`/`iac_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 `dc_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 `fmt.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
    `dc_time` é `"HH:MM"`; `fmt.hm_join`/`fmt.hm_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 `svc_action_msg`, 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 (`prov_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** (`svc_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 `adv_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`, `lw_title`, `lw_stream_url`
  (endpoint SSE dedicado) e `lw_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
    `njob_create`.
  - *Recorrência diária:* os pares HORA/MINUTO de texto livre viraram um `<timeedit>` só (chave
    `njob_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 `{ns_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 (`{ns_busy}`), e o
  texto acompanha `{ns_msg}` ("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.
- **`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).