diff --git a/README.md b/README.md index b438738..3d39ded 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ Abra **http://127.0.0.1:8000**, cole a pasta dos repos — a detecção é autom │ ├── updater.py # download, troca, backup, rollback │ └── state.py # mapping / state / config por pasta (.alldown/) ├── static/ # front vanilla: index.html, tokens.css, components.css, -│ # shell.css, app.js, theme.js, contract/openapi.json +│ # components-optional.css (fora do crítico), shell.css, +│ # app.js, theme.js, contract/openapi.json ├── tests/js/ # testes do frontend (node --test, sem build) ├── pyproject.toml # lint e pytest └── requirements.txt @@ -109,7 +110,7 @@ Execute estes comandos antes de abrir pull request: ```bash .venv/bin/python -m pytest tests/ -q # 169 testes (backend + contrato) -node --test tests/js/*.test.mjs # 23 testes do frontend +node --test tests/js/*.test.mjs # 38 testes do frontend node --check static/app.js uvx --from ruff==0.16.8 ruff check app.py core tray.py tests/ uvx --from pyright==1.1.414 pyright --pythonpath .venv/bin/python app.py core tray.py diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index b926c69..d834ffa 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -11,11 +11,16 @@ Nenhum valor foi inventado. Se não está no CSS-fonte, não está aqui. | Arquivo | Conteúdo | |---|---| | `static/tokens.css` | Rampas, alfa, cores de estado, semânticos light/dark, elevação, raios, tipografia, movimento | -| `static/components.css` | Primitives com `data-component` / `data-slot` | +| `static/components.css` | Primitives em uso no caminho crítico (`button`, `input`, `switch`, `tag`, `segmented`, `avatar`, `card`, `toast`, `data-table`, `scroll-view`, …) | +| `static/components-optional.css` | Primitives fora do caminho crítico (`textarea`, `select`, `checkbox`, `tooltip`, `drawer`, `dialog`, `menu`) — **não** carregado por padrão, ~7KB gzip poupados | | `static/shell.css` | Grid do app, sidebar, header, page shell, as três telas | +| `static/theme.js` | Aplica o tema antes da primeira pintura (bloqueante, por causa da CSP) | +| `static/index.html` | Markup das três telas e a ordem de carga | | `STYLEGUIDE.md` | Este documento | -Ordem de carga no HTML: `tokens.css` → `components.css` → `shell.css`. +Ordem de carga no HTML: `theme.js` → `tokens.css` → `components.css` → `shell.css`. +Versão única `?v=3` nos quatro assets + `theme.js` (antes `?v=1`/`?v=2`/sem versão, +sempre inconsistente). `Cache-Control: immutable` no servidor para `?v=`. ## Convenção de atributos @@ -52,25 +57,49 @@ Estados de interação são dirigidos por `:hover` **e** por `[data-state=…]`, | `--v2-background-bg-accent` | `#3b5cf6` | `#3b5cf6` | | `--v2-text-text-base` | `#161616` | `#fafafa` | | `--v2-text-text-muted` | `#5c5c5c` | `#aeaeae` | -| `--v2-text-text-faint` | `#808080` | `#808080` | +| `--v2-text-text-faint` | `#6b6b6b` | `#999999` | | `--v2-text-text-accent` | `#3b5cf6` | `#a2bcff` | | `--v2-icon-icon-base` | `#3a3a3a` | `#dbdbdb` | | `--v2-icon-icon-muted` | `#808080` | `#808080` | | `--v2-border-border-muted` | `rgba(0,0,0,.08)` | `rgba(255,255,255,.08)` | | `--v2-border-border-base` | `rgba(0,0,0,.10)` | `rgba(255,255,255,.10)` | | `--v2-border-border-strong` | `rgba(0,0,0,.20)` | `rgba(255,255,255,.20)` | -| `--v2-border-border-focus` | `#7698fd` | `#7698fd` | +| `--v2-border-border-focus` | `#3250df` | `#c3d4fd` | | `--v2-overlay-simple-overlay-hover` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | | `--v2-overlay-simple-overlay-pressed` | `rgba(0,0,0,.08)` | `rgba(255,255,255,.10)` | | `--v2-overlay-simple-overlay-scrim` | `rgba(0,0,0,.4)` | `rgba(0,0,0,.6)` | -| `--v2-state-bg-success` / `fg` | `#e7f9ea` / `#198b43` | `#14361d` / `#6bd586` | -| `--v2-state-bg-warning` / `fg` | `#fefaec` / `#cb9f34` | `#4b4025` / `#f2cf76` | +| `--v2-state-bg-success` / `fg` | `#e7f9ea` / `#1d783c` | `#14361d` / `#6bd586` | +| `--v2-state-bg-warning` / `fg` | `#fefaec` / `#68552b` | `#4b4025` / `#f2cf76` | | `--v2-state-bg-danger` / `fg` | `#fceceb` / `#b82d35` | `#461516` / `#f17471` | | `--v2-state-bg-info` / `fg` | `#ecf1fe` / `#2c47c8` | `#1b2852` / `#7698fd` | -O tema vive em `[data-color-scheme="light" | "dark"]` no ``. Um script inline -antes da primeira pintura lê `localStorage["alldown.theme"]` e cai para -`prefers-color-scheme` — não há flash. +O tema vive em `[data-color-scheme="light" | "dark"]` no ``. O arquivo +`static/theme.js` roda **externo e bloqueante** no `` (sem `defer`), antes da +primeira pintura: lê `localStorage["alldown.theme"]` e cai para `prefers-color-scheme` +— não há flash. + +> **Não mova isso para um ` + + + + + + + + - - +
-
+ +
Cole uma pasta para começar.
-
Itens detectados0pastas + arquivos ZIP
-
Sincronizados0commit local verificado
-
Para atualizar0commits disponíveis
-
Revisão ou erro0sem vínculo ou erro
+ + + +
@@ -217,17 +227,20 @@

Biblioteca

- - - - - + + + + +
-
+ +
@@ -251,7 +264,7 @@

Biblioteca

foi reconstruido por inteiro + let __html = ""; + const makeUrlInput = (index, label) => ({ + dataset: { urlInput: index }, + label, + value: "", + selectionStart: null, + selectionEnd: null, + focused: 0, + focus() { + this.focused += 1; + }, + setSelectionRange(a, b) { + this.selectionStart = a; + this.selectionEnd = b; + }, + }); + Object.defineProperty(repoList, "innerHTML", { + get: () => __html, + set(value) { + __html = String(value); + repoList.__renders += 1; + repoList.__inputs = []; + for (const m of __html.matchAll( + /]*\bdata-url-input="(\d+)"[^>]*\baria-label="([^"]*)"/g + )) { + repoList.__inputs.push(makeUrlInput(m[1], m[2].replace(/^URL do GitHub para /, ""))); + } + }, + configurable: true, + }); + repoList.querySelectorAll = (sel) => (sel === "[data-url-input]" ? repoList.__inputs : []); + repoList.querySelector = (sel) => { + const m = /^\[data-url-input="(\d+)"\]$/.exec(sel); + if (!m) return null; + return repoList.__inputs.find((el) => el.dataset.urlInput === m[1]) ?? null; + }; + // os inputs do formulario moram "dentro" do para efeitos de contains() + repoList.contains = (node) => repoList.__inputs.includes(node) || makeEl.prototype.contains.call(repoList, node); + const timers = []; const matchMedia = () => ({ matches: false, addEventListener() {}, addListener() {} }); @@ -98,6 +165,7 @@ export function loadApp(opts = {}) { document: { documentElement: makeEl("html"), body: makeEl("body"), + activeElement: null, getElementById: (id) => byId.get(id) ?? null, querySelector: (sel) => { if (sel === ".mobile-scrim") return makeEl("button"); @@ -128,6 +196,10 @@ export function loadApp(opts = {}) { AbortController, setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout() {}, + // rAF enfileira no mesmo bucket de timers: o announce() e o initScrollThumb() + // dependem dele, e setImmediate no settle() dá a chance de rodar. + requestAnimationFrame: (fn) => { timers.push(fn); return timers.length; }, + cancelAnimationFrame() {}, setInterval: () => 0, URLSearchParams, fetch: fetchImpl ?? (async (url) => { @@ -139,7 +211,7 @@ export function loadApp(opts = {}) { } return { ok: true, status: 200, json: async () => ({ lines: [] }) }; }), - window: { addEventListener() {}, setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout() {}, matchMedia }, + window: { addEventListener() {}, setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout() {}, requestAnimationFrame: (fn) => { timers.push(fn); return timers.length; }, cancelAnimationFrame() {}, matchMedia }, }; sandbox.globalThis = sandbox; sandbox.self = sandbox; diff --git a/tests/js/render.test.mjs b/tests/js/render.test.mjs index c2037ea..e7c8f69 100644 --- a/tests/js/render.test.mjs +++ b/tests/js/render.test.mjs @@ -128,6 +128,150 @@ test("branch e nome com aspas não quebram os atributos", () => { assert.match(html, /"/); }); +/* --------------------------------------------- rascunho sobrevive ao re-render */ +/** payload de scan com uma linha sem dono, que e quem recebe o formulario de mapear */ +function scanComSemDono(nomes = ["aaa", "bbb"]) { + return { + path: "/base", + items: nomes.map((n) => + item({ name: n, mapped: false, owner: null, repo: null, github_url: null }) + ), + zip_only: false, + backup: true, + }; +} + +test("o que foi digitado no formulário de mapeamento sobrevive ao re-render", async () => { + const { sandbox: s, repoList, settle } = loadApp({ + scan: scanComSemDono(["aaa"]), + path: "/base", + autoScan: true, + }); + await settle(); + + const input = repoList.__inputs[0]; + assert.ok(input, `premissa: o scan sem dono tem que renderizar o formulário:\n${repoList.innerHTML.slice(0, 300)}`); + + // o usuário digita a URL e continua com o cursor no meio dela + const digitado = "https://github.com/acme/widget"; + input.value = digitado; + input.selectionStart = 7; + input.selectionEnd = 7; + s.document.activeElement = input; + + s.render(); // qualquer evento dispara isto: busca, filtro, setBusy, update + + const depois = repoList.__inputs[0]; + assert.equal(depois.value, digitado, "o texto digitado foi apagado pelo re-render"); + assert.equal(depois.focused, 1, "o foco não voltou para o input"); + assert.equal(depois.selectionStart, 7, "o cursor não voltou ao lugar"); + assert.equal(depois.selectionEnd, 7); +}); + +test("o rascunho acompanha a LINHA, não a posição", async () => { + // Se o rascunho fosse chaveado pelo índice, digitar em "aaa" e depois filtrar + // para "bbb" jogaria a URL na linha errada — o pior tipo de bug aqui, porque + // "Salvar" gravaria o repositório na pessoa errada. + const { sandbox: s, repoList, settle } = loadApp({ + scan: scanComSemDono(["aaa", "bbb"]), + path: "/base", + autoScan: true, + }); + await settle(); + + const primeiro = repoList.__inputs.find((el) => el.label === "aaa"); + assert.ok(primeiro, `premissa: as duas linhas têm formulário:\n${repoList.innerHTML.slice(0, 300)}`); + primeiro.value = "https://github.com/acme/aaa"; + s.document.activeElement = primeiro; + + s.setFilter("all"); // força re-render com as duas linhas visíveis + + const deNovo = repoList.__inputs; + assert.equal(deNovo.find((el) => el.label === "aaa").value, "https://github.com/acme/aaa"); + assert.equal(deNovo.find((el) => el.label === "bbb").value, "", "o rascunho vazou para a linha errada"); +}); + +test("re-render repetido não duplica nem degrada o rascunho", async () => { + const { sandbox: s, repoList, settle } = loadApp({ + scan: scanComSemDono(["aaa"]), + path: "/base", + autoScan: true, + }); + await settle(); + repoList.__inputs[0].value = "https://github.com/acme/widget"; + + for (let i = 0; i < 5; i++) s.render(); + + assert.equal(repoList.__inputs.length, 1, "o re-render duplicou inputs"); + assert.equal(repoList.__inputs[0].value, "https://github.com/acme/widget"); +}); + +/* ------------------------------------------- o lote nao reconstroi a tabela toda */ +/** scan + check de N repos pendentes: e o unico jeito de `behind` ser true + * (o ScannedItem do contrato nao tem `behind` — so o CheckItem tem). */ +function cenarioLote(n = 4) { + const nomes = Array.from({ length: n }, (_, i) => `repo${i}`); + return { + scan: { + path: "/base", + items: nomes.map((name) => item({ name, local_sha: "1111111", remote_sha: "2222222" })), + zip_only: false, + backup: true, + }, + check: nomes.map((name) => + item({ name, behind: true, local_sha: "1111111", remote_sha: "2222222" }) + ), + }; +} + +test("o lote nao reconstroi o tres vezes por repo", async () => { + // Antes: cada doUpdate pintava 3x (busy, sucesso, finally) e cada pintura + // reescrevia as N linhas. O render do meio — seguido imediatamente pelo do + // finally, sem nada visível no meio — foi eliminado. + const { scan, check } = cenarioLote(4); + const { sandbox: s, repoList, settle } = loadApp({ scan, check, path: "/base", autoScan: true }); + await settle(); + + const base = repoList.__renders; + await s.updateAll(); + // Overhead fixo do lote: os dois renders de setBusy (abre e fecha o busy). + const overhead = 2; + const porRepo = (repoList.__renders - base - overhead) / 4; + + assert.ok( + porRepo <= 2, + `o lote ainda pinta o ${porRepo.toFixed(1)}x por repo dentro do loop ` + + `(${repoList.__renders - base} reconstrucoes no total; o alvo e 2, o anterior era 3)` + ); +}); + +test("depois do lote, o DOM e identico ao que um render completo produziria", async () => { + // Este e o invariante que importa para o render cirúrgico: se a linha foi + // corrigida na mao e algum campo ficou defasado, o HTML final diverge do que + // render() produziria com o mesmo estado. O lote termina com setBusy(false), + // que faz o render completo — entao a comparacao tem de fechar. + const { scan, check } = cenarioLote(3); + const { sandbox: s, repoList, settle } = loadApp({ scan, check, path: "/base", autoScan: true }); + await settle(); + + await s.updateAll(); + const depoisDoLote = repoList.innerHTML; + s.render(); + assert.equal( + repoList.innerHTML, + depoisDoLote, + "o DOM ficou defasado em relacao ao render completo — totals/summary nao foram repintados" + ); +}); + +test("renderRowInPlace devolve false quando a linha nao esta na vista", () => { + // A queda para o render() completo e o que mantem o lote correto quando o + // cirúrgico nao se aplica (linha filtrada, tbody vazio, sem replaceWith). + const { sandbox: s } = loadApp({}); + assert.equal(s.renderRowInPlace(item({ name: "nao-existe" }), 99), false); +}); + + /* --------------------------------------------------------- empty states */ test("sem pasta escolhida, o empty state oferece escolher a pasta", () => { const { sandbox: s } = loadApp({});
Repositórios detectados na pasta em serviço