dataforth/testdatadb UI: fix cert fit (transform-scale) + publish-state chips

- fitCert: replace the flaky CSS `zoom` (Firefox support is recent/inconsistent)
  with transform:scale() measured against the widest line (+ right margin and
  font-load retries) so the cert always scales to fit the inspector with no
  horizontal clip. Validated live on a narrow 5B cert (0.74x) and a wide DSCA45
  cert (0.55x) against the real AD2 dataset.
- inspector Web field -> Published (green) / Not published (amber) chips.
- widen default inspector 480 -> 500px.
- tools/preview-proxy.py: serve the prototype AND reverse-proxy /api to the live
  AD2 server so the cert iframe is same-origin during preview — styleCert/fitCert
  read iframe.contentDocument, which silently no-ops when the iframe is loaded
  cross-origin straight from AD2 (why the fit looked broken in earlier previews).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 09:45:34 -07:00
parent c5643ee419
commit c2335e859d
2 changed files with 72 additions and 9 deletions

View File

@@ -12,7 +12,7 @@
--hover:#f1f5f9; --sel:#e0e7ff; --desk:#e7ecf1;
--mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--r:6px; --insp:480px;
--r:6px; --insp:500px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
@@ -112,6 +112,10 @@
.pill{display:inline-block;font-size:10.5px;font-weight:700;padding:1px 8px;border-radius:4px;font-family:var(--mono)}
.pill.PASS{background:var(--pass-bg);color:var(--pass-ink)}
.pill.FAIL{background:var(--fail-bg);color:var(--fail-ink)}
.tag{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;font-family:var(--sans)}
.tag::before{content:"";width:7px;height:7px;border-radius:50%}
.tag.pub{background:var(--pass-bg);color:var(--pass-ink)} .tag.pub::before{background:var(--pass-ink)}
.tag.unpub{background:#fef3c7;color:#92400e} .tag.unpub::before{background:#d97706;background:none;box-shadow:inset 0 0 0 1.5px #d97706}
.web{font-size:13px}
.pager{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.pager button{font-size:12px;height:28px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
@@ -321,7 +325,7 @@ function select(id,auto){
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></dd>
<dt>Log</dt><dd>${esc(r.log_type)}</dd>
${r.work_order?`<dt>WO</dt><dd>${esc(r.work_order)}</dd>`:''}
<dt>Web</dt><dd>${r.api_uploaded_at?'published '+fmtDate(r.api_uploaded_at):'not published'}</dd></dl>`;
<dt>Web</dt><dd>${r.api_uploaded_at?`<span class="tag pub">Published</span> <span style="color:var(--ink-3)">${fmtDate(r.api_uploaded_at)}</span>`:'<span class="tag unpub">Not published</span>'}</dd></dl>`;
const ds=API+'/api/datasheet/'+id;
$('acts').style.display='flex';
$('acts').innerHTML=`<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
@@ -338,18 +342,23 @@ function select(id,auto){
}
function loadCert(ds){
$('viewer').innerHTML='<iframe id="dsframe" title="datasheet"></iframe>';
const f=$('dsframe'); f.onload=()=>{styleCert();fitCert();}; f.src=ds+'?format=html';
const f=$('dsframe'); f.onload=()=>{styleCert();fitCert();setTimeout(fitCert,120);setTimeout(fitCert,350);}; f.src=ds+'?format=html';
}
function styleCert(){ const f=$('dsframe'); try{ const doc=f.contentDocument; if(!doc)return;
if(!doc.getElementById('_inj')){ const s=doc.createElement('style'); s.id='_inj';
s.textContent='html,body{background:#fff!important;margin:0!important}body{padding:22px 26px!important;color:#0f172a}pre{margin:0;font-family:'+getComputedStyle(document.body).getPropertyValue('--mono')+';font-size:12.5px;line-height:1.32}';
s.textContent='html,body{background:#fff!important;margin:0!important}body{padding:16px 20px!important;color:#0f172a}pre{margin:0;font-family:'+getComputedStyle(document.body).getPropertyValue('--mono')+';font-size:12.5px;line-height:1.32}';
(doc.head||doc.documentElement).appendChild(s); }
}catch(e){} }
function fitCert(){ const f=$('dsframe'); if(!f)return; try{ const doc=f.contentDocument; if(!doc)return;
const root=doc.documentElement; root.style.zoom='';
const nat=Math.max(doc.body?doc.body.scrollWidth:0,root.scrollWidth), av=f.clientWidth-2;
root.style.zoom=(nat>av)?Math.max(.45,av/nat):1;
f.style.height=Math.ceil((doc.body?doc.body.scrollHeight:600)*(nat>av?av/nat:1)+4)+'px';
function fitCert(){ const f=$('dsframe'); if(!f)return; try{ const doc=f.contentDocument; if(!doc||!doc.body)return;
const b=doc.body, root=doc.documentElement;
// measure natural content width (transform doesn't reflow, so text never rewraps)
b.style.transform='none'; b.style.width='max-content'; b.style.transformOrigin='0 0';
root.style.overflow='hidden';
const nat=Math.max(b.scrollWidth,root.scrollWidth), av=f.clientWidth-12; // widest line + right margin
if(!nat){ return; } // not laid out yet — the retry will catch it
const k=nat>av ? Math.max(.4, av/nat) : 1;
b.style.transform='scale('+k+')';
f.style.height=Math.ceil(b.scrollHeight*k+2)+'px'; // size the frame to the scaled cert, no v-scroll-in-frame
}catch(e){} }
function printCert(){ const f=$('dsframe'); if(f&&f.contentWindow){f.contentWindow.focus();f.contentWindow.print();} }
window.addEventListener('resize',()=>{fitCert();});

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Same-origin preview proxy for the testdatadb front-end.
Serves the static prototype from ROOT and reverse-proxies /api/* to the live
AD2 testdatadb server, so the app AND the cert iframe share one origin
(http://127.0.0.1:PORT). That lets the same-origin-only cert styling/fit logic
(styleCert/fitCert read iframe.contentDocument) actually run during preview —
which it can't when the iframe is loaded cross-origin straight from AD2.
Usage: python preview-proxy.py <port> <root-dir> [target]
"""
import http.server, socketserver, urllib.request, urllib.error, os, sys
PORT = int(sys.argv[1])
ROOT = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else "."
TARGET = sys.argv[3] if len(sys.argv) > 3 else "http://192.168.0.6:3000"
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/api/"):
try:
with urllib.request.urlopen(TARGET + self.path, timeout=30) as r:
body = r.read(); ct = r.headers.get("Content-Type", "application/octet-stream")
self._send(200, ct, body)
except urllib.error.HTTPError as e:
self._send(e.code, "application/json", e.read())
except Exception as e:
self._send(502, "text/plain", str(e).encode())
else:
p = self.path.split("?")[0]
p = "/index.html" if p == "/" else p
fp = os.path.join(ROOT, p.lstrip("/"))
if os.path.isfile(fp):
ct = "text/html; charset=utf-8" if fp.endswith(".html") else "application/octet-stream"
self._send(200, ct, open(fp, "rb").read())
else:
self._send(404, "text/plain", b"not found")
def _send(self, code, ct, body):
self.send_response(code)
self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a): pass
class S(socketserver.ThreadingTCPServer):
allow_reuse_address = True
print(f"preview proxy: http://127.0.0.1:{PORT} root={ROOT} -> {TARGET}")
S(("127.0.0.1", PORT), H).serve_forever()