Frontend Visual Verification (beating stale browser cache)
Overview
You changed a stylesheet/template and want to see it worked. The trap: a
browser_vision screenshot can show the old layout even though the server
is already serving your new code. The browser cache (notably for /css/*.css
and other static assets) survives a container rebuild and a re-navigate to the
same URL — the HTML re-renders but the stale stylesheet is reused. The vision
model then confidently describes the OLD layout as if it were real.
Never trust a single screenshot when it contradicts the DOM or the served asset. Cross-check with the triple below.
The triple-check (do this before declaring "it didn't work")
- DOM —
browser_snapshot(). The markup reflects the HTML you served. If your new wrapper/selector is present, the HTML is new. - Computed style — run in
browser_console, read the returned dict:
If this shows OLD values while the DOM shows the new structure, it's cache.(() => { const el = document.querySelector('.your-new-selector'); if (!el) return 'SELECTOR NOT IN DOM'; const cs = getComputedStyle(el); return { display: cs.display, position: cs.position, grid: cs.gridTemplateColumns, borderRight: cs.borderRightWidth }; })() - Served asset — from the terminal:
curl -s http://HOST/css/main.css | grep -c 'your-new-token'Server new + browser computed-style old ⇒ 100% cache, not your code.
Force a fresh stylesheet (no hard-reload in the toolset)
Bust the cache by rewriting the <link> href to a unique query string, wait
~600ms, then re-screenshot:
(() => {
const l = document.querySelector('link[rel="stylesheet"]');
l.href = l.href.split('?')[0] + '?cb=' + Date.now();
return 'busted: ' + l.href;
})()
Now browser_vision reflects the new CSS.
Container / static-asset gotchas
Docker with read-only filesystem: local file edits do NOT reach the running container. Rebuild/restart first:
docker compose -f <file> -p <proj> up -d --buildRepoHANDOFF.md/AGENTS.mdusually state this — read them before debugging "why isn't my change showing up".Autologin demos / protected pages: a nav-guard may bounce you to
/loginor the landing. To reach a protected page in the browser, either hit the auth route that sets the cookie (e.g.POST /api/auth/loginvia curl to grab a cookie jar, then reuse withcurl -b jar), or just fill the login form in the browser with demo credentials. Don't debug layout while stuck in a redirect loop.CSS
<link>without?v== rebuild never reaches the browser (real session, ~20 wasted iterations). If the header serves<link rel=stylesheet href="/css/main.css">with NO query string, the browser caches the file in disk and NO container rebuild updates it — the DOM keeps measuring the old rule (e.g.align-items: stretch) even though the host file already saysstart. Fix: version the link like the footer already does —href="/css/main.css?v=<%= assetVersion %>"(theassetVersionis set inapp.localsby the server; in an EJS partial use the bare global<%= assetVersion %>, NOTapp.locals.assetVersion— that throws a runtime 500). Then changing CSS and bumpingASSET_VERSIONactually busts the cache.Demo visual change invisible until you rebuild BOTH
appandapp-demo. The browser requests/css/main.css(absolute, no/demo/), and nginx routes/css/*→location /→ the app principal, NOT app-demo. So a CSS/header- Demo visual change invisible until you rebuild BOTH
appandapp-demo, AND the browser loads/css/main.cssfrom the PRINCIPAL, not the demo. In the financas-app setup the page/demo/appreferences the stylesheet at/css/main.css(absolute, no/demo/prefix); nginx routes/css/*→location /→ the app principal container, NOTapp-demo. So even after you rebuildapp-demo, the browser keeps showing the OLD CSS because the principal is still serving the previous file. Symptom that cost a full debug cycle: DevToolsdocument.styleSheetslistedhttp://HOST/css/main.css(no/demo), and a computed-style check reported the OLDmarginTopeven thoughcurl /demo/css/main.cssclearly had the new rule — the two URLs are DIFFERENT files. Fix: rebuild BOTH containers (docker compose up -d --build app app-demo) and verify the served bytes of the PRINCIPAL:curl -s http://HOST/css/main.css?v=probe | grep -n 'your-new-token'. If it still shows the old rule,app(principal) wasn't rebuilt — rebuilding onlyapp-demois not enough. - ASSET_VERSION cache-bust is mandatory for the USER's browser, not just yours.
header.ejsalready links/css/main.css?v=<%= assetVersion %>andfooter.ejsversions the JS the same way (app.locals.assetVersion = process.env.ASSET_VERSION || '1'). When you ship a CSS fix, bumpASSET_VERSIONin the compose env (ASSET_VERSION: ${ASSET_VERSION:-1}→ passASSET_VERSION=2 docker compose up -d --build app app-demo) so the user's already-open tab actually re-fetches the new asset instead of using the cached?v=1. For YOUR OWN validation you can also just navigate the browser directly to the versioned URL (/css/main.css?v=2) to force a fresh fetch before measuring computed style. /css/main.css(principal) and/demo/css/main.css(app-demo) are DIFFERENT files — grep the one the browser actually loads. In financas-app the page/demo/apprequests the stylesheet at the absolute/css/main.css(no/demo/), so nginx routes it tolocation /→ the app principal container. Thecurl /demo/css/main.cssyou may be checking is the demo's copy and can have your new rule while the browser still shows the old one. Always cross-check the SAME url the browser fetches:curl -s http://HOST/css/main.css | grep -c 'your-new-token'. If that shows 0 but/demo/css/main.cssshows 2, the principal wasn't rebuilt —docker compose up -d --build app app-demo(both), then re-grep/css/main.css.- Nested-card spacing pattern (financas-app dashboard). Cards that are children of
.container/<main>but whose preceding sibling is a<div class="grid-2">(not a.card) are NOT reached by the global.card + .card { margin-top }, so they "colam" no módulo anterior. Fix:.container > .card { margin-top: 1.5rem }+.container > .card:first-child { margin-top: 0 }. Cards nested inside another card (e.g. "Custo mensal" inside the Veículos card) also miss.card + .card→ add.card > .card { margin-top: 1.25rem }. And restoremargin-topinside@media (max-width: 820px)for.grid-2 > .card + .card/.split > .card + .cardso stacked columns keep their gap. - Rosca (donut) chart labels. In financas-app the SVG donut is built in
app/public/js/financas-lib.js(funçãorosca). It drew a fixed, oversized<text>percent label inside each slice — Pedro called this "feio" / "percentuais muito grandes". Fix: drop the fixed<text>, keep the native<title>(hover showslabel — % (R$ valor)), and rely on the legend<ul>below the chart (already renders label + % + value). Verify by snapshot: center of donut should be empty (or show only the total), legend below carries the %s.
- Demo visual change invisible until you rebuild BOTH
A
patchcan DUPLICATE a CSS rule and the browser silently applies the wrong one. If you patch a.foo { … }block and the old_string didn't fully consume the original (e.g. a comment or adjacent line was left behind), you end up with TWO.foorules — one old, one new. CSS cascades by LAST occurrence, but agrep -cfor the property can hide the stale copy, and a stale container image serves the FIRST one. Symptom this session:.grid-2had bothalign-items: stretch(line 484) andalign-items: start(line 491) — measuredstretchin the DOM even though the file "looked fixed". Fix the source to ONE rule, then verify the served asset shows exactly one base occurrence:# served asset must show ONE base rule with the intended value: curl -s http://HOST/css/main.css?v=probe | grep -o '\.grid-2 { display: grid[^}]*align-items: [a-z]*' | head # expect exactly: .grid-2 { ... align-items: start (no 'stretch' line) # also confirm host + container agree (no stale image): grep -c 'align-items: stretch' app/public/css/main.css # expect 0 after fix docker exec <container> grep -c 'align-items: stretch' /app/public/css/main.css # expect 0Rule of thumb: after any CSS patch,
grep -nthe EXACT selector you changed in BOTH the host file and the served asset; if you see it twice, the patch duplicated it — collapse to one rule before rebuilding.
Server-side templates (EJS / SSR) — verify the RENDERED HTML
For server-rendered templates the bug isn't always "old asset": it can be that the template compiled but produced wrong markup. Cross-check the served HTML, not just the DOM/screenshot:
curl -s http://HOST/ | grep -c 'your-expected-text'
# or dump a section to inspect exact markup:
curl -s http://HOST/ | grep -o 'Veja funcionando[^<]*'
EJS pitfall (cost real time this session): <%= expr %> HTML-escapes its
output. If you put literal tags inside it — e.g.
<%= stats ? \2…` : '…' %>— EJS escapes the</>and the browser shows<strong>2</strong>…as *text*, not markup. Tests that match>2…then fail even thoughstats is correct. **Fix:** keep tags in the template body (<% if (stats) { %><%= val %>…<% } %>) and only interpolate *values* via <%= %>. Use <%- %>` only for already-safe HTML.
Also re-run the suite after any template change — a subtly broken render can flip a previously-green test (e.g. a landing test asserting on rendered copy).
Verify an EJS page WITHOUT standing up the app (no DB / no server)
Many EJS routes pull data from the DB or middleware (i18n locals, auth, etc.),
so you can't just hit the URL to screenshot. Don't stand up MongoDB just to
see the layout. Render the template directly with ejs and open the result in
the browser tool. A ready-to-adapt harness lives in
templates/ejs-standalone-render.js (set APP to your project's app/ dir and
fill localsFor).
const ejs = require(require('path').join(APP, 'node_modules/ejs'));
const fs = require('fs'), path = require('path');
const VIEWS = path.join(APP, 'views');
function render(page, locals) {
return ejs.render(fs.readFileSync(path.join(VIEWS, page), 'utf8'), locals,
{ views: [VIEWS], filename: path.join(VIEWS, page) });
}
const html = render(process.argv[2], localsFor(process.argv[2])); // e.g. 'landing.ejs'
// escreve em /tmp/lp_pub/ com assets em caminho RELATIVO p/ funcionar via file://
fs.mkdirSync('/tmp/lp_pub/css', { recursive: true });
fs.mkdirSync('/tmp/lp_pub/js', { recursive: true });
fs.copyFileSync(path.join(APP,'public/css/main.css'), '/tmp/lp_pub/css/main.css');
fs.copyFileSync(path.join(APP,'public/js/common.js'), '/tmp/lp_pub/js/common.js');
fs.writeFileSync('/tmp/lp_pub/out.html', html.replace('/css/','css/').replace('/js/','js/'));
console.log('open file:///tmp/lp_pub/out.html');
Then browser_navigate to file:///tmp/lp_pub/out.html and browser_vision.
Pitfalls específicos de verificação (custaram tempo real):
- Client-rendered table rows aren't in the served HTML — SSR check gives a false FAIL. Pages like a Projects/Profissionais list deliver an EMPTY
<tbody>from the server (the SSR only emits the shell:<table>+<tbody id=...>); the rows are built bypublic/js/*.jsafter thefetch. Acurl/http.geton the served HTML will NOT find theicon-btn js-editbuttons — but the browser (post-JS) renders them. When verifying, assert (a) the shell in the SSR (id="proj-rows",class="dom-table") and (b) that the on-disk JS generates the buttons (pjs.includes('icon-btn js-edit')), OR navigate in the browser and read the snapshot. Do NOT mark it as a bug just because the raw HTML has no rows. Same root cause as "an ad-hochermes-verify-*.jsthat reads served HTML never sees the table" — it must read the JS or navigate. - Terminal/Node escaping makes CSS quotes look like a file defect.
curlpiped through a shell, andJSON.stringifyin a Node probe, render a CSS file's"as\"(e.g..board-col[data-status=\"em_andamento\"]). That makes it LOOK like the served CSS has backslash-escaped quotes (and a broken selector) — it isn't. The file on disk is correct. To settle it: read the file with Node directly (fs.readFileSync(p,'utf8')and testc.includes('\\"')→ should befalse), or render the DOM in the browser and read computed style. Don't "fix" the CSS based on an escaped-print artifact — you'll corrupt a working file. (Same family as the "suspect the check before the code" rule.) - Não pré-fixe
<!DOCTYPE>no resultado. Se oheader.ejsjá emite<!DOCTYPE html>…<body><main>e ofooter.ejsfecha, renderize apenas a folha-folha (ex.:landing.ejs, que fazincludedo header e footer). Se você também concatenar/prependar um doctype próprio, saem dois topbars / dois<body>— parecia bug de duplicação, mas era o harness. - Caminhos de asset relativos. O browser resolve
/css/main.cssa partir da raiz dofile://, não do seu/tmp/lp_pub. Use caminhos relativos (css/main.css) ou copie os assets para a mesma pasta do HTML. views+filenamesão obrigatórios para que<%- include(...) %>e<%- include('partials/header') %>resolvam. Semfilename, o include falha.- O render standalone não exercita a lógica de rota (auth/i18n middleware). Para
checar conteúdo por idioma/ambiente, monte os
localsno próprio harness lendo os módulos de config (ex.:landingFor(mode, lang)deconfig/landingContent.js) — assim você valida o texto real sem o servidor.
Config-key vs route-key mismatch (silent duplicate content)
When content/config lives in a module keyed one way but the route calls it with
a different key, landingFor(mode) returns undefined and falls back to a
default — so two environments silently render the SAME content. Seen here:
config keys were producao/teste but the route called landingFor('production'…)
and landingFor('test'…), so production AND test both fell back to producao.
Always assert distinct output per key after renaming/mapping content keys:
const titles = ['production','test','demo'].map(m => landingFor(m,'pt').title);
if (new Set(titles).size !== 3) throw new Error('fallback vazando entre ambientes');
Self-hosted fonts
If you switched to a self-hosted font (woff2 served from /fonts/*, no CDN),
confirm the files actually serve — a missing/404 font silently falls back and
the "did the font apply?" question can't be answered by screenshot alone:
curl -s -o /dev/null -w '%{http_code}' http://HOST/fonts/Inter-400.woff2 # expect 200
curl -s http://HOST/css/main.css | grep -c "Inter" # @font-face present
Ad-hoc verification script (when the suite doesn't cover the change)
If the canonical jest/lint run doesn't assert the exact behavior you
changed (e.g. a copy/CTA-text fix already deployed to a live container), write
a throwaway probe under /tmp with the hermes-verify- prefix, run it against
the real server, then delete it. Don't claim "verified" from the diff alone.
cat > "$(mktemp /tmp/hermes-verify-XXXX.sh)" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
H=$(curl -s http://127.0.0.1:4462/)
printf '%s' "$H" | grep -q 'meses de histórico</span>:' || { echo FAIL; exit 1; }
printf '%s' "$H" | grep -q 'meses de histórico de histórico' && { echo FAIL-dup; exit 1; }
echo PASS
EOF
chmod +x /tmp/hermes-verify-*.sh && /tmp/hermes-verify-*.sh; rm -f /tmp/hermes-verify-*.sh
When to use this skill
- After editing CSS and needing visual confirmation at a specific resolution (e.g. Pedro verifies at 1920x1080 before approving).
- When a vision screenshot "lies" about layout that the DOM clearly has.
- Any frontend change served from a container or behind auth.
- When the page is an EJS/SSR view you can't easily hit (DB/auth gating the
route) — render it standalone, see
templates/ejs-standalone-render.js.
References
references/cache-bust-recipe.md— copy-paste commands for the triple-check and the cache-buster, including the curl cookie-jar login pattern.templates/ejs-standalone-render.js— copy/adapt harness to render an EJS view to/tmp/lp_pub/out.htmlwithout standing up the app (no DB/server), for screenshot verification.