← Commit history

Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools

John Lauer ·7acd92dedd ·16d ago ·parent a076415
10 files changed +1156−3
README.md+3
@@ -21,6 +21,9 @@ adom-wiki pkg install adom/component-widgets | `<adom-molecule>` | Ganged part set + nets (hover a net → its parts) | `{ name, components:[…], nets:[…] }` | | `<adom-chip-thumbnail>` | Rendered part image with caption | `src` / `mpn` / `package` | | `<adom-moleculize>` | Board → workcell-molecule preview (reads a dropped `.kicad_pcb` client-side, no backend) + AI-thread generate | drop a `.kicad_pcb`, or `{ name, width_mm, height_mm }` |+| `<adom-verified>` | "Verified by" trust mark + verification log; amber when a verified file changes | `verification.json` (adom/verification@1), `tools/verify_log.py` |+| `<adom-models>` | Every 3D file on the page: STEP / GLB tabs, top + underside previews, what is unique, freshness | `models.json` (adom/models@1), `tools/models_json.py` |+| `<adom-footprint-variants>` | The footprint's variant family (heatsink vias, solder balls, rounded pads) | `variants.json`, `tools/make-variants.py` |  Each is one custom element: Shadow DOM, no dependencies, light + dark (or `theme="dark|light"`), fed by inline `<script type="application/json">`, `el.data`,
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "component-widgets",   "title": "Component Widgets",   "type": "app",-  "version": "1.1.13",+  "version": "1.1.14",   "description": "Reusable component-page widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and image-first LOD caches with an optional zoom-driven 3D preview.",   "license": "MIT",   "dependencies": {},
page.json+2−2
@@ -4,7 +4,7 @@   "type": "app",   "title": "Component Widgets",   "brief": "Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.",-  "version": "1.1.13",+  "version": "1.1.14",   "visibility": {     "public": true   },@@ -97,4 +97,4 @@     "hero",     "readme"   ]-}\ No newline at end of file+}
readme.html+11
@@ -228,6 +228,17 @@ h2 .anchor:hover{opacity:1;color:var(--teal)}   <!-- ADOM-LBR + DATASHEET -->   <section>     <div class="eyebrow">Already covered — don't rebuild</div>+    <h2 id="trust">Verification, 3D files, footprint variants <span class="new">new</span><a class="anchor" href="#trust" aria-label="direct link">#</a></h2>+    <p class="sub">Three widgets every component page can carry, each fed by a JSON file committed in the page's own repo. Embed them in a README with the registered tags; the tools that write the JSON live in <code>tools/</code> here.</p>+    <pre><code><span class="tok-tag">::adom-verified</span>{<span class="tok-attr">src</span>=<span class="tok-str">"/OWNER/SLUG/render/verification.json"</span>}+<span class="tok-tag">::adom-models</span>{<span class="tok-attr">src</span>=<span class="tok-str">"/OWNER/SLUG/render/models.json"</span>}+<span class="tok-tag">::adom-footprint-variants</span>{<span class="tok-attr">src</span>=<span class="tok-str">"/OWNER/SLUG/render/footprint-variants/MPN.variants.json"</span>}</code></pre>+    <ul class="sub">+      <li><b>&lt;adom-verified&gt;</b>: the "verified by" trust mark (big green check, who verified the page and when, a card per ordering variant) and the full verification log with evidence; re-hashes the verified files and turns amber when one changed. Data: <code>verification.json</code> (adom/verification@1), written by <code>tools/verify_log.py</code>.</li>+      <li><b>&lt;adom-models&gt;</b>: every 3D file on the page as a card, STEP and GLB tabs (STEP first), top and underside previews framed on the marked wall, what makes each file different, derived-from, a download, and a freshness check against the page's file listing. Data: <code>models.json</code> (adom/models@1), written by <code>tools/models_json.py</code> (scan, set, thumbs).</li>+      <li><b>&lt;adom-footprint-variants&gt;</b>: the footprint's variant family (baseline, heatsink vias, 300 um solder balls, rounded pads) drawn from the <code>.kicad_mod</code> files in the browser. Data: a <code>variants.json</code> written by <code>tools/make-variants.py</code>.</li>+    </ul>+     <h2 id="cad">Symbol · Footprint · 3D · Datasheet<a class="anchor" href="#cad" aria-label="direct link">#</a></h2>     <p class="sub">The heavy viewers already exist. These widgets sit <b>alongside</b> them on the same <code>lbr-hover</code> bus — they don't reimplement them.</p>     <div class="refgrid">
tools/make-variants.pyadded+176
@@ -0,0 +1,176 @@+#!/usr/bin/env python3+"""make-variants.py — generate the Adom footprint-variant family from a baseline .kicad_mod.++Reproduces the adom-footprint 1.0.22 heatsink / solderballs rules (as recorded in+adom/lan7800's heatsink-vias.json and solder-balls.json) so a part can ship the+family before those verbs are published:++  <base>-HV.kicad_mod        heatsink vias: thru_hole pads under the exposed pad, same pad number+  <base>-SB.kicad_mod        solder balls: filled fp_circle O0.30 mm on User.9 (Adom.SolderBalls), 0.25 mm pitch+  <base>-RR.kicad_mod        rounded pads: every SMD pad -> roundrect (signal 0.25, EP 0.1)+  <base>-HV-SB-RR.kicad_mod  all three+  <base>.variants.json       the manifest <adom-footprint-variants src=...> reads+  <base>.heatsink-vias.json / <base>-SB.solder-balls.json  rule files++usage: make-variants.py <baseline.kicad_mod> --part LAN7800 --package "QFN-48 7x7mm" [--ep 49] [--out DIR]+"""+import argparse, json, math, os, re, sys++VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET, BALL_VIA_CLEAR = 1.2, 0.3, 0.6, 0.45, 0.55+BALL_D, BALL_PITCH = 0.30, 0.25+RR_SIGNAL, RR_EP = 0.25, 0.1++def tokens(text):+    return re.findall(r'"(?:[^"\\]|\\.)*"|[()]|[^\s()"]+', text)++def parse(text):+    toks = tokens(text); pos = 0+    def walk():+        nonlocal pos+        out = []+        while pos < len(toks):+            t = toks[pos]; pos += 1+            if t == '(': out.append(walk())+            elif t == ')': return out+            else: out.append(t)+        return out+    root = []+    while pos < len(toks):+        t = toks[pos]; pos += 1+        if t == '(': root.append(walk())+    return root[0]++def find(node, key):+    return next((x for x in node if isinstance(x, list) and x and x[0] == key), None)++def pads(root):+    out = []+    for n in root:+        if isinstance(n, list) and n and n[0] == 'pad':+            at, size = find(n, 'at'), find(n, 'size')+            out.append({'num': n[1].strip('"'), 'kind': n[2], 'shape': n[3],+                        'x': float(at[1]), 'y': float(at[2]), 'w': float(size[1]), 'h': float(size[2])})+    return out++def ep_pad(ps, ep):+    cands = [p for p in ps if p['num'] == str(ep)] if ep else []+    if not cands:+        cands = [max(ps, key=lambda p: p['w'] * p['h'])]+    return cands[0]++def via_grid(ep):+    half_w, half_h = ep['w'] / 2 - VIA_INSET - VIA_OD / 2, ep['h'] / 2 - VIA_INSET - VIA_OD / 2+    nx = int(math.floor(2 * half_w / VIA_PITCH)) + 1+    ny = int(math.floor(2 * half_h / VIA_PITCH)) + 1+    xs = [ep['x'] + (i - (nx - 1) / 2) * VIA_PITCH for i in range(nx)]+    ys = [ep['y'] + (j - (ny - 1) / 2) * VIA_PITCH for j in range(ny)]+    return [{'x': round(x, 3), 'y': round(y, 3)} for y in ys for x in xs]++BALL_INSET, RING_OUTER = 0.10, 0.80++def ball_grid(p, vias=(), is_ep=False):+    """0.25 mm pitch grid of O0.30 balls inside a pad (0.10 mm inset), centred.+    Signal pads fill completely. The exposed pad follows the adom-solder-ball-layer+    standard: a perimeter ring plus a ring around every heatsink-via position+    (0.55 mm keep-out to the via centre, ring out to 0.80 mm), never a solid fill."""+    def axis(c, span):+        n = int(math.floor((span - 2 * BALL_INSET - BALL_D) / BALL_PITCH)) + 1+        n = max(n, 1)+        return [c + (i - (n - 1) / 2) * BALL_PITCH for i in range(n)]+    xs, ys = axis(p['x'], p['w']), axis(p['y'], p['h'])+    out = []+    for j, y in enumerate(ys):+        for i, x in enumerate(xs):+            d = min((math.hypot(x - v['x'], y - v['y']) for v in vias), default=1e9)+            if d < BALL_VIA_CLEAR:+                continue+            if is_ep:+                perimeter = i in (0, len(xs) - 1) or j in (0, len(ys) - 1)+                if not (perimeter or d < RING_OUTER):+                    continue+            out.append({'x': round(x, 3), 'y': round(y, 3)})+    return out++def strip_footer(text):+    """Return (head, tail) so new nodes go before the closing paren of (footprint ...)."""+    i = text.rstrip().rfind(')')+    return text[:i], text[i:]++def rename(text, suffix):+    return re.sub(r'^\(footprint "([^"]+)"', lambda m: '(footprint "%s%s"' % (m.group(1), suffix), text, count=1)++def with_roundrect(text):+    def fix(m):+        num, kind, shape, body = m.group(1), m.group(2), m.group(3), m.group(4)+        if kind != 'smd' or shape == 'roundrect':+            return m.group(0)+        size = re.search(r'\(size ([\d.]+) ([\d.]+)\)', body)+        big = size and float(size.group(1)) * float(size.group(2)) > 4.0+        rr = RR_EP if big else RR_SIGNAL+        body = re.sub(r'(\(size [^)]*\))', r'\1\n\t\t(roundrect_rratio %s)' % rr, body, count=1)+        return '(pad "%s" %s roundrect%s' % (num, kind, body)+    return re.sub(r'\(pad "([^"]+)" (\w+) (\w+)((?:(?!\n\t\(pad ).)*?\n\t\))', fix, text, flags=re.S)++def via_nodes(vias, num):+    return ''.join('\t(pad "%s" thru_hole circle\n\t\t(at %g %g)\n\t\t(size %g %g)\n\t\t(drill %g)\n\t\t(layers "*.Cu" "*.Mask")\n\t)\n'+                   % (num, v['x'], v['y'], VIA_OD, VIA_OD, VIA_DRILL) for v in vias)++def ball_nodes(balls):+    r = BALL_D / 2+    return ''.join('\t(fp_circle\n\t\t(center %g %g)\n\t\t(end %g %g)\n\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type solid)\n\t\t)\n\t\t(fill yes)\n\t\t(layer "User.9")\n\t)\n'+                   % (b['x'], b['y'], b['x'] + r, b['y']) for b in balls)++def main():+    ap = argparse.ArgumentParser()+    ap.add_argument('baseline'); ap.add_argument('--part', required=True); ap.add_argument('--package', required=True)+    ap.add_argument('--ep', default=None, help='exposed-pad number (default: the largest pad)')+    ap.add_argument('--out', default=None)+    a = ap.parse_args()+    text = open(a.baseline).read()+    root = parse(text); ps = pads(root)+    ep = ep_pad(ps, a.ep)+    vias = via_grid(ep)+    balls_plain = [b for p in ps for b in ball_grid(p, vias if p is ep else (), p is ep)]+    balls_hv = balls_plain+    out = a.out or os.path.dirname(os.path.abspath(a.baseline))+    base = os.path.splitext(os.path.basename(a.baseline))[0]+    head, tail = strip_footer(text)++    def write(suffix, body):+        path = os.path.join(out, '%s%s.kicad_mod' % (base, suffix))+        open(path, 'w').write(body); return os.path.basename(path)++    files = {}+    files['-HV'] = write('-HV', rename(head, '-HV') + via_nodes(vias, ep['num']) + tail)+    files['-SB'] = write('-SB', rename(head, '-SB') + ball_nodes(balls_plain) + tail)+    files['-RR'] = write('-RR', with_roundrect(rename(text, '-RR')))+    rr_head, rr_tail = strip_footer(with_roundrect(rename(text, '-HV-SB-RR')))+    files['-HV-SB-RR'] = write('-HV-SB-RR', rr_head + via_nodes(vias, ep['num']) + ball_nodes(balls_hv) + rr_tail)++    via_rule = '%g mm pitch, %g mm drill, %g mm annular diameter, %g mm inset from the pad edge; paste balls are kept %g mm off every via centre' % (VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET, BALL_VIA_CLEAR)+    json.dump({'count': len(vias), 'exposed_pad': {'number': ep['num'], 'x': ep['x'], 'y': ep['y'], 'w': ep['w'], 'h': ep['h']},+               'footprint': base, 'pitch': VIA_PITCH, 'inset_from_pad_edge': VIA_INSET, 'units': 'mm',+               'origin': 'footprint origin; KiCad Y-down', 'rule': 'adom-footprint: ' + via_rule,+               'via': {'annularDiameter': VIA_OD, 'drill': VIA_DRILL, 'net': 'same as pad ' + ep['num'], 'plated': True, 'ringWidth': (VIA_OD - VIA_DRILL) / 2},+               'vias': vias}, open(os.path.join(out, base + '.heatsink-vias.json'), 'w'), indent=2)+    json.dump({'ball': {'diameter': BALL_D, 'pitch': BALL_PITCH, 'shape': 'hemisphere, flat face on the pad', 'layer': 'User.9 (Adom.SolderBalls)'},+               'count': len(balls_plain), 'layer': 'User.9', 'layerName': 'Adom.SolderBalls', 'rule': 'john/adom-solder-ball-layer-standard: O0.30 mm at 0.25 mm pitch (intentional overlap) on every paste aperture, 0.55 mm keep-out around heatsink vias, a ring around each plated through-hole pad; the EP gets a perimeter ring plus a ring around every via position', 'balls': balls_plain}, open(os.path.join(out, base + '-SB.solder-balls.json'), 'w'), indent=2)+    manifest = {+        'part': a.part, 'package': a.package, 'generator': 'footprint-widgets/tools/make-variants.py (adom-footprint 1.0.22 heatsink / solderballs rules)',+        'variants': [+            {'suffix': '', 'device': 'baseline', 'kicad_mod': os.path.basename(a.baseline), 'footprintName': base,+             'changed': 'nothing; the page footprint as-is (%d SMD pads)' % len(ps)},+            {'suffix': '-HV', 'device': 'heatsink vias', 'kicad_mod': files['-HV'], 'footprintName': base + '-HV', 'viaPadNumber': ep['num'],+             'changed': '+%d thru_hole pads under pad %s (%g mm pitch, %g mm drill, %g mm OD, %g mm inset), on copper + mask layers only (*.Cu *.Mask)' % (len(vias), ep['num'], VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET)},+            {'suffix': '-SB', 'device': 'solder balls', 'kicad_mod': files['-SB'], 'footprintName': base + '-SB',+             'changed': '+%d filled fp_circle O%.2f mm on KiCad layer User.9 (Adom.SolderBalls), one per 300um jet-paste ball, %g mm pitch; copper untouched' % (len(balls_plain), BALL_D, BALL_PITCH)},+            {'suffix': '-RR', 'device': 'rounded pads', 'kicad_mod': files['-RR'], 'footprintName': base + '-RR',+             'changed': 'every SMD pad shape -> KiCad roundrect: signal pads roundrect_rratio %g (the KiCad/IPC default), EP %g; positions and sizes unchanged' % (RR_SIGNAL, RR_EP)},+            {'suffix': '-HV-SB-RR', 'device': 'heatsink vias + solder balls + rounded pads', 'kicad_mod': files['-HV-SB-RR'], 'footprintName': base + '-HV-SB-RR',+             'changed': '= -RR pads + the %d heatsink vias as thru_hole pads on %s + the %d-ball Adom.SolderBalls layer (balls kept %g mm off every via centre)' % (len(vias), ep['num'], len(balls_hv), BALL_VIA_CLEAR)},+        ]}+    json.dump(manifest, open(os.path.join(out, base + '.variants.json'), 'w'), indent=2)+    print(json.dumps({'ep': ep['num'], 'vias': len(vias), 'balls': len(balls_plain), 'balls_with_vias': len(balls_hv), 'files': list(files.values()) + [base + '.variants.json']}, indent=1))++if __name__ == '__main__':+    main()
tools/models_json.pyadded+161
@@ -0,0 +1,161 @@+#!/usr/bin/env python3+"""Write or refresh a component page's models.json (schema adom/models@1): every 3D file on the page, what makes+each one different, and how they relate, for people (the ::adom-models widget) and for AIs (read this, not file names).++Run it inside a clone of the component page:++  models_json.py scan                 # add every .step/.stp/.glb not yet listed (roles guessed from page.json + names),+                                      # refresh sha256 / bytes of all, drop records whose file is gone+  models_json.py set PATH key=value [key=value ...]+                                      # describe one file: title, role, variant, status, unique, derived_from, use,+                                      # marking.text, marking.surface, marking.colour, colours, source, slot+  models_json.py thumbs               # render previews: each STEP converted on the step2glb service and shot TOP (iso,+                                      # framed on the wall the part number faces: models.json "front", a STEP-frame+                                      # vector, default [0,-1,0]) and BOTTOM (pads, polarity mark); each GLB one view+  models_json.py show++Record fields:+  path, format (step|glb), title, status (current|previous), variant (etched|plain|hero|showcase|insertion|lod|original),+  role (manufacturer|datasheet-built|photo-matched|derived), slot (the page.json component.parts key that points at it,+  if any), marking {text, surface, colour} or null, colours (true when dies/materials are coloured), unique (one line:+  what this file has that its siblings do not), use (list: board-cad|render|wiki-hero|board-insertion|archive),+  derived_from (path), source (where it came from), sha256, git_blob (the git blob SHA-1 the wiki's file listing reports,+so a viewer can check freshness without downloading), bytes, thumb (top preview PNG), thumb_bottom (STEP only: underside preview PNG).+Top level: schema, part, updated, front (STEP-frame unit vector of the face the part number is on, for framing)."""+import glob, hashlib, json, os, re, subprocess, sys, tempfile, datetime++PATH = "models.json"+HERE = os.path.dirname(os.path.abspath(__file__))+++def load():+    if os.path.exists(PATH): return json.load(open(PATH))+    pj = json.load(open("page.json")) if os.path.exists("page.json") else {}+    return {"schema": "adom/models@1", "part": (pj.get("component") or {}).get("mpn") or pj.get("title") or "", "models": []}+++def save(d):+    d["updated"] = datetime.date.today().isoformat()+    json.dump(d, open(PATH, "w"), indent=1, ensure_ascii=False); open(PATH, "a").write("\n")+++def sha(p): return hashlib.sha256(open(p, "rb").read()).hexdigest()+++def git_blob(p):+    b = open(p, "rb").read(); return hashlib.sha1(b"blob %d\0" % len(b) + b).hexdigest()   # what the wiki file listing reports+++def guess(path, slots):+    n = os.path.basename(path).lower(); fmt = "glb" if n.endswith(".glb") else "step"+    r = dict(path=path, format=fmt, title=os.path.basename(path), status="previous" if path.startswith("provenance/") else "current")+    r["slot"] = next((k for k, v in slots.items() if v == path), None)+    if path.startswith("provenance/"): r.update(variant="original", use=["archive"])+    elif "hero" in n: r.update(variant="hero", use=["wiki-hero"])+    elif "insertion" in n: r.update(variant="insertion", use=["board-insertion"])+    elif "/lod/" in "/" + path: r.update(variant="lod", use=["render"])+    elif re.search(r"(uncoloured|plain|bare|-part\.glb)", n): r.update(variant="plain", use=["board-cad"] if fmt == "step" else ["render"])+    elif "etched" in n or r["slot"] in ("model_3d_step", "step"): r.update(variant="etched", use=["board-cad"] if fmt == "step" else ["render"])+    else: r.update(variant="showcase" if fmt == "glb" else "original", use=["render"] if fmt == "glb" else ["board-cad"])+    r.setdefault("unique", ""); r.setdefault("marking", None)+    return r+++def scan(d):+    slots = ((json.load(open("page.json")).get("component") or {}).get("parts") or {}) if os.path.exists("page.json") else {}+    have = {m["path"]: m for m in d["models"]}+    files = sorted(p for p in glob.glob("**/*", recursive=True) if re.search(r"\.(step|stp|glb)$", p, re.I) and not p.startswith(("node_modules/", ".")))+    out = []+    for p in files:+        m = have.get(p) or guess(p, slots)+        m["sha256"] = sha(p); m["git_blob"] = git_blob(p); m["bytes"] = os.path.getsize(p)+        m["slot"] = next((k for k, v in slots.items() if v == p), m.get("slot"))+        out.append(m)+    gone = sorted(set(have) - set(files))+    d["models"] = out+    return dict(models=len(out), added=sorted(set(files) - set(have)), dropped=gone)+++def setkv(d, path, kvs):+    m = next((x for x in d["models"] if x["path"] == path), None)+    if not m: sys.exit("not listed (run scan first): " + path)+    for kv in kvs:+        k, v = kv.split("=", 1)+        if k == "use": v = [x.strip() for x in v.split(",") if x.strip()]+        elif k == "colours": v = v.lower() in ("1", "true", "yes")+        elif v in ("null", "none", ""): v = None+        if k.startswith("marking."):+            m["marking"] = m.get("marking") or {}; m["marking"][k.split(".", 1)[1]] = v+        else: m[k] = v+    return m+++GLB_HTML = """<!doctype html><html><body style="margin:0;background:#0d1117">+<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"}}</script>+<script type="module">+import * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js';+const V=VIEWSPEC, W=640,H=480,r=new THREE.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});r.setSize(W,H);r.setClearColor(0x0d1117);+r.outputColorSpace=THREE.SRGBColorSpace;document.body.appendChild(r.domElement);+const s=new THREE.Scene();s.environment=new THREE.PMREMGenerator(r).fromScene(new RoomEnvironment(),0.04).texture;s.add(new THREE.HemisphereLight(0xffffff,0x667788,1.2));const dl=new THREE.DirectionalLight(0xffffff,2.0);s.add(dl);+new GLTFLoader().load('GLB_URL',g=>{const o=g.scene;s.add(o);o.traverse(n=>{if(/LED_lit|glow/i.test(n.name||''))n.visible=false;});+const b=new THREE.Box3().setFromObject(o),c=b.getCenter(new THREE.Vector3()),sz=b.getSize(new THREE.Vector3()),z=sz.length();+// up = the model's thinnest axis (a chip); glTF from a Z-up STEP is Y-up, so map the STEP-frame 'front' (x,y,z) -> (x,z,-y)+const zup=(sz.z<=sz.y&&sz.z<=sz.x), up=zup?new THREE.Vector3(0,0,1):new THREE.Vector3(0,1,0);+const f=V.front, front=zup?new THREE.Vector3(f[0],f[1],f[2]):new THREE.Vector3(f[0],f[2],-f[1]);+const side=new THREE.Vector3().crossVectors(up,front).normalize();+let dir, camUp=up.clone();+if(V.view==='bottom'){dir=up.clone().multiplyScalar(-1).add(front.clone().multiplyScalar(0.18));camUp=front.clone();const bl=new THREE.DirectionalLight(0xffffff,1.6);bl.position.copy(up).multiplyScalar(-10);s.add(bl);}+else{dir=front.clone().multiplyScalar(0.85).add(up.clone().multiplyScalar(0.62)).add(side.clone().multiplyScalar(-0.42));}+dir.normalize();dl.position.copy(dir).multiplyScalar(z*3).add(up.clone().multiplyScalar(z));+const cam=new THREE.PerspectiveCamera(28,W/H,z/100,z*100);cam.up.copy(camUp);cam.position.copy(c).add(dir.multiplyScalar(z*(V.view==='bottom'?1.6:1.5)));cam.lookAt(c);r.render(s,cam);document.title='done';},undefined,e=>{document.title='error '+e});+</script></body></html>"""+CHROME = os.path.expanduser("~/.cache/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell")+++def render_glb(glb_path, outp, view, front):+    import base64, shutil+    tmp = tempfile.mkdtemp(prefix="mthumb-"); html = os.path.join(tmp, "t.html")    # file:// cannot fetch, so inline the GLB+    url = "data:model/gltf-binary;base64," + base64.b64encode(open(glb_path, "rb").read()).decode()+    open(html, "w").write(GLB_HTML.replace("GLB_URL", url).replace("VIEWSPEC", json.dumps(dict(view=view, front=front))))+    subprocess.run([CHROME, "--headless", "--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--allow-file-access-from-files",+                    "--virtual-time-budget=15000", "--window-size=640,480", "--screenshot=" + os.path.abspath(outp), "file://" + html], capture_output=True, text=True)+    shutil.rmtree(tmp, ignore_errors=True)+    return os.path.exists(outp) and os.path.getsize(outp) > 4000+++def thumbs_for(m, stem, front):+    """STEP: convert on the step2glb service, then a TOP view framed on the marked wall and a BOTTOM view (pads, polarity+    mark). GLB: one view of the file as published."""+    if m["format"] == "step":+        tmp = tempfile.mkdtemp(prefix="mstep-"); g = os.path.join(tmp, "m.glb")+        r = subprocess.run(["step2glb", "convert", m["path"], "-o", g], capture_output=True, text=True)+        if r.returncode or not os.path.exists(g): return {}+        for post in sorted(glob.glob("provenance/tools/glb_post_*.py")):   # the page's own GLB finishing (e.g. metal), as published GLBs get it+            subprocess.run(["python3", post, g], capture_output=True)+        out = {}+        for view, key in (("top", "thumb"), ("bottom", "thumb_bottom")):+            p = stem + "." + view + ".png"+            if render_glb(g, p, view, front): out[key] = p+        return out+    p = stem + ".png"+    return {"thumb": p} if render_glb(m["path"], p, "top", front) else {}+++def main():+    a = sys.argv[1:] or ["show"]; d = load()+    if a[0] == "scan": print(json.dumps(scan(d))); save(d)+    elif a[0] == "set": print(json.dumps(setkv(d, a[1], a[2:]))); save(d)+    elif a[0] == "thumbs":+        os.makedirs("docs/models", exist_ok=True); res = {}+        for f in glob.glob("docs/models/*.png"): os.remove(f)+        front = d.get("front") or [0, -1, 0]+        for m in d["models"]:+            stem = "docs/models/" + re.sub(r"[^A-Za-z0-9._-]+", "_", m["path"])+            m.pop("thumb", None); m.pop("thumb_bottom", None)+            got = thumbs_for(m, stem, front); m.update(got); res[m["path"]] = sorted(got)+        save(d); print(json.dumps(res, indent=1))+    else: print(json.dumps(d, indent=1))+++if __name__ == "__main__":+    main()
tools/verify_log.pyadded+66
@@ -0,0 +1,66 @@+#!/usr/bin/env python3+"""Add a record to a component page's verification log (verification.json, schema adom/verification@1).++Run it inside a clone of the component page. Every --file is hashed (sha256) so the ::adom-verified widget+can tell readers, byte for byte, whether what was verified is still what the page serves.++  verify_log.py add --by "John Lauer" --handle john --title "Footprint matches the datasheet" \\+      --scope footprint --variant '*' --check "0.7 x 0.7 mm pads" --check "note:the datasheet gives no reason" \\+      --evidence "pad proof=docs/img/pad-proof.png" --file WS2812B-2020.kicad_mod [--status open] [--date 2026-10-04]+  verify_log.py variants --set "WS2812B-2020-V6=12 mA, 3.3-5.3 V" --stocked WS2812B-2020-V6+  verify_log.py show++A check prefixed "fail:" or "note:" records that result; the default is pass."""+import argparse, datetime, hashlib, json, os, sys++PATH = "verification.json"+++def load():+    if os.path.exists(PATH): return json.load(open(PATH))+    pj = json.load(open("page.json")) if os.path.exists("page.json") else {}+    return {"schema": "adom/verification@1", "part": (pj.get("component") or {}).get("mpn") or pj.get("title"), "variants": [], "entries": []}+++def save(d): json.dump(d, open(PATH, "w"), indent=1, ensure_ascii=False); open(PATH, "a").write("\n")+++def main():+    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)+    a = sub.add_parser("add")+    a.add_argument("--by", required=True); a.add_argument("--handle", default="")+    a.add_argument("--title", required=True); a.add_argument("--status", default="verified", choices=["verified", "open", "superseded"])+    a.add_argument("--date", default=datetime.date.today().isoformat())+    a.add_argument("--scope", action="append", default=[]); a.add_argument("--variant", action="append", default=[])+    a.add_argument("--check", action="append", default=[]); a.add_argument("--evidence", action="append", default=[])+    a.add_argument("--file", action="append", default=[])+    v = sub.add_parser("variants"); v.add_argument("--set", action="append", default=[]); v.add_argument("--stocked", action="append", default=[])+    sub.add_parser("show")+    o = ap.parse_args(); d = load()+    if o.cmd == "add":+        checks = []+        for c in o.check:+            res = "pass"+            for p in ("fail:", "note:"):+                if c.startswith(p): res, c = p[:-1], c[len(p):]+            checks.append({"text": c.strip(), "result": res})+        ev = [{"label": e.split("=", 1)[0], "href": e.split("=", 1)[1]} for e in o.evidence]+        files = {}+        for f in o.file:+            if not os.path.exists(f): sys.exit("no such file: " + f)+            files[f] = hashlib.sha256(open(f, "rb").read()).hexdigest()+        d["entries"].append({"date": o.date, "by": {"name": o.by, "handle": o.handle}, "status": o.status, "title": o.title,+                             "scope": o.scope, "variants": o.variant or ["*"], "checks": checks, "evidence": ev, "files": files})+        save(d); print(json.dumps({"entries": len(d["entries"]), "added": o.title, "files": len(files)}))+    elif o.cmd == "variants":+        by = {x["mpn"]: x for x in d["variants"]}+        for s in o.set:+            mpn, note = (s.split("=", 1) + [""])[:2]; by.setdefault(mpn, {"mpn": mpn})["note"] = note+        for x in by.values(): x["stocked"] = x["mpn"] in o.stocked if o.stocked else x.get("stocked", False)+        d["variants"] = list(by.values()); save(d); print(json.dumps(d["variants"]))+    else:+        print(json.dumps(d, indent=1))+++if __name__ == "__main__":+    main()
widgets/footprint-variants-widget.jsadded+362
@@ -0,0 +1,362 @@+/*!+ * <adom-footprint-variants> — a reusable, dependency-free footprint-variant viewer.+ *+ * Renders a part's baseline footprint and its Adom-generated variants (heatsink+ * vias, 300 µm solder-ball placement, rounded-rect pads, combined) straight from+ * the page's .kicad_mod files: no pre-baked geometry JSON, the widget parses the+ * s-expressions in the browser. Anything the variant adds or changes vs the+ * baseline is highlighted.+ *+ * Drop-in:+ *   <script src="footprint-variants-widget.js"></script>+ *   <adom-footprint-variants src="/OWNER/SLUG/render/part.variants.json"></adom-footprint-variants>+ *+ * Data contract: the adom-footprint variants manifest (what `adom-footprint`'s+ * heatsink / solderballs verbs emit next to the mods):+ *   {+ *     "part": "LAN7800", "package": "QFN-48 7x7mm",+ *     "variants": [+ *       { "suffix": "", "device": "baseline", "kicad_mod": "part.kicad_mod",+ *         "changed": "nothing; the page footprint as-is" },+ *       { "suffix": "-HV", "device": "heatsink vias", "kicad_mod": "part-HV.kicad_mod", ... }+ *     ]+ *   }+ * Each variant's kicad_mod is fetched relative to the manifest's own URL, so one+ * absolute `src` is all a README supplies. Solder balls are read from filled+ * fp_circles on layer User.9 (Adom.SolderBalls); heatsink vias are the+ * thru_hole pads.+ *+ * Attributes: src (manifest URL), variant (initial: a suffix like "-HV-SB-RR"+ * or a device name like "solder balls"), heading (override title).+ * Self-contained (Shadow DOM), themes to the host via prefers-color-scheme,+ * cross-highlights pads on the shared adom-lbr hover bus.+ */+(() => {+  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const num = v => parseFloat(v);++  // --- minimal s-expression reader: text -> nested arrays, strings unquoted ---+  function sexpr(text) {+    const toks = text.match(/"(?:[^"\\]|\\.)*"|[()]|[^\s()"]+/g) || [];+    let i = 0;+    const walk = () => {+      const out = [];+      while (i < toks.length) {+        const t = toks[i++];+        if (t === '(') out.push(walk());+        else if (t === ')') return out;+        else out.push(t[0] === '"' ? t.slice(1, -1).replace(/\\(.)/g, '$1') : t);+      }+      return out;+    };+    const root = [];+    while (i < toks.length) { const t = toks[i++]; if (t === '(') root.push(walk()); }+    return root;+  }++  // --- .kicad_mod -> { pads, gfx (silk/fab/courtyard), balls (User.9) } ---+  const BALL_LAYER = 'User.9'; // Adom.SolderBalls+  function parseMod(text) {+    const root = sexpr(text)[0] || [];+    const find = (node, key) => node.find(x => Array.isArray(x) && x[0] === key);+    const pads = [], gfx = [], balls = [];+    for (const node of root) {+      if (!Array.isArray(node)) continue;+      if (node[0] === 'pad') {+        const at = find(node, 'at'), size = find(node, 'size');+        if (!at || !size) continue;+        const drill = find(node, 'drill'), rr = find(node, 'roundrect_rratio');+        const layers = (find(node, 'layers') || []).slice(1);+        const cu = !layers.length || layers.some(l => /Cu$/.test(l));+        pads.push({+          n: node[1], kind: node[2], shape: node[3],+          x: num(at[1]), y: num(at[2]), rot: at[3] != null ? num(at[3]) : 0,+          w: num(size[1]), h: num(size[2]),+          drill: drill ? num(drill[1]) : 0, rr: rr ? num(rr[1]) : 0,+          cu, paste: layers.some(l => /Paste$/.test(l)),+        });+      } else if (node[0] === 'fp_circle') {+        const layer = (find(node, 'layer') || [])[1] || '';+        const c = find(node, 'center'), e = find(node, 'end');+        if (!c || !e) continue;+        const r = Math.hypot(num(e[1]) - num(c[1]), num(e[2]) - num(c[2]));+        const filled = (find(node, 'fill') || [])[1] === 'yes';+        if (layer === BALL_LAYER) balls.push({ x: num(c[1]), y: num(c[2]), r });+        else gfx.push({ t: 'c', x: num(c[1]), y: num(c[2]), r, layer, filled });+      } else if (node[0] === 'fp_line' || node[0] === 'fp_rect') {+        const layer = (find(node, 'layer') || [])[1] || '';+        const s = find(node, 'start'), e = find(node, 'end');+        if (!s || !e) continue;+        const stroke = find(node, 'stroke');+        const w = stroke ? num((find(stroke, 'width') || [0, '0.12'])[1]) : 0.12;+        gfx.push({ t: node[0] === 'fp_line' ? 'l' : 'r', x1: num(s[1]), y1: num(s[2]), x2: num(e[1]), y2: num(e[2]), layer, w });+      }+    }+    return { name: root[1] || '', pads, gfx, balls };+  }++  // A stable identity for "same pad as the baseline": number + centre + geometry.+  const padKey = p => `${p.n}@${p.x},${p.y}|${p.kind}:${p.shape}:${p.w}x${p.h}:${p.rr}:${p.drill}`;++  class AdomFootprintVariants extends HTMLElement {+    static parseMod = parseMod; // exposed for tests / structured consumers++    async connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      this._mods = Object.create(null); // suffix -> parsed mod (or Promise)+      this._busInit();+      const src = this.getAttribute('src');+      this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">loading footprint variants…</div></div>`;+      if (!src) { this._fail('no src: point me at a .variants.json'); return; }+      this._base = src.slice(0, src.lastIndexOf('/') + 1);+      try { this._data = await (await fetch(src)).json(); }+      catch (e) { this._fail('could not load ' + src); return; }+      const vars = (this._data && this._data.variants) || [];+      if (!vars.length) { this._fail('no variants in ' + src); return; }+      const want = (this.getAttribute('variant') || '').trim().toLowerCase();+      this._sel = Math.max(0, vars.findIndex(v =>+        want && (String(v.suffix).toLowerCase() === want || String(v.device).toLowerCase() === want)));+      this._render();+      this._show(this._sel);+    }++    _fail(msg) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">${esc(msg)}</div></div>`; }++    async _mod(i) {+      const v = this._data.variants[i], k = String(v.suffix);+      if (!this._mods[k]) {+        this._mods[k] = (async () => parseMod(await (await fetch(this._base + v.kicad_mod)).text()))();+      }+      return this._mods[k];+    }++    _render() {+      const d = this._data;+      const heading = this.getAttribute('heading') || d.part || 'Footprint variants';+      this.shadowRoot.innerHTML = `<style>${this._css()}</style>+        <div class="card">+          <div class="hd"><span class="mpn">${esc(heading)}</span>${d.package ? `<span class="pkg">${esc(d.package)}</span>` : ''}<span class="count">${d.variants.length} variants</span><span class="tools"><button class="tbtn" data-act="copy" title="Copy the variant manifest as JSON">copy</button><button class="tbtn" data-act="dl" title="Download the variant manifest as JSON">JSON&nbsp;↓</button></span></div>+          <div class="chips">${d.variants.map((v, i) => `<button class="chip" data-i="${i}" title="${esc(v.kicad_mod)}">${esc(v.device || v.suffix || 'baseline')}</button>`).join('')}</div>+          <div class="readout" data-readout></div>+          <div class="stage" data-stage><div class="empty">…</div></div>+          <div class="changed" data-changed></div>+          <div class="legend" data-legend></div>+        </div>`;+      const root = this.shadowRoot;+      root.querySelectorAll('.chip').forEach(b => { b.onclick = () => this._show(+b.dataset.i); });+      const dl = root.querySelector('.tbtn[data-act="dl"]'); if (dl) dl.onclick = () => this._download();+      const cp = root.querySelector('.tbtn[data-act="copy"]'); if (cp) cp.onclick = () => this._copy(cp);+    }++    async _show(i) {+      const root = this.shadowRoot, d = this._data;+      this._sel = i;+      root.querySelectorAll('.chip').forEach(b => b.classList.toggle('on', +b.dataset.i === i));+      const v = d.variants[i];+      const ch = root.querySelector('[data-changed]');+      ch.textContent = v.changed ? v.changed : '';+      ch.hidden = !v.changed;+      let mod, base;+      try {+        [mod, base] = await Promise.all([this._mod(i), this._mod(this._baselineIndex())]);+      } catch (e) {+        root.querySelector('[data-stage]').innerHTML = `<div class="empty">could not load ${esc(v.kicad_mod)}</div>`;+        return;+      }+      if (this._sel !== i) return; // a faster click won+      const baseKeys = new Set(base.pads.map(padKey));+      const isBaseline = i === this._baselineIndex();+      root.querySelector('[data-stage]').innerHTML = this._svg(mod, baseKeys, isBaseline);+      this._wireStage(mod);+      this._legend(mod, isBaseline);+      this._setReadout(null);+    }++    _baselineIndex() {+      const vars = this._data.variants;+      const j = vars.findIndex(v => !v.suffix || /baseline/i.test(v.device || ''));+      return j < 0 ? 0 : j;+    }++    _svg(mod, baseKeys, isBaseline) {+      // bbox from the courtyard when present, else pads + margin (mm, KiCad y-down = SVG y-down)+      let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;+      const grow = (x, y) => { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); };+      const cy = mod.gfx.filter(g => g.layer === 'F.CrtYd');+      if (cy.length) cy.forEach(g => { if (g.t === 'c') { grow(g.x - g.r, g.y - g.r); grow(g.x + g.r, g.y + g.r); } else { grow(g.x1, g.y1); grow(g.x2, g.y2); } });+      else mod.pads.forEach(p => { grow(p.x - p.w, p.y - p.h); grow(p.x + p.w, p.y + p.h); });+      const M = 0.7; x0 -= M; y0 -= M; x1 += M; y1 += M;+      let s = `<svg viewBox="${x0} ${y0} ${x1 - x0} ${y1 - y0}" class="fp" preserveAspectRatio="xMidYMid meet">`;+      s += `<rect x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}" rx="0.3" class="board"/>`;+      // graphics: fab (faint) under silk; courtyard dashed+      const line = (g, cls) => g.t === 'c'+        ? `<circle cx="${g.x}" cy="${g.y}" r="${g.r}" class="${cls}" stroke-width="${g.w || 0.1}" fill="${g.filled ? 'currentColor' : 'none'}"/>`+        : g.t === 'l'+          ? `<line x1="${g.x1}" y1="${g.y1}" x2="${g.x2}" y2="${g.y2}" class="${cls}" stroke-width="${g.w || 0.1}"/>`+          : `<rect x="${Math.min(g.x1, g.x2)}" y="${Math.min(g.y1, g.y2)}" width="${Math.abs(g.x2 - g.x1)}" height="${Math.abs(g.y2 - g.y1)}" class="${cls}" stroke-width="${g.w || 0.1}" fill="none"/>`;+      mod.gfx.forEach(g => {+        if (g.layer === 'F.Fab') s += line(g, 'fab');+        else if (g.layer === 'F.CrtYd') s += line(g, 'crtyd');+      });+      // pads: SMD gold; thru-hole (heatsink vias) copper ring + drill; changed-vs-baseline glows+      const smd = mod.pads.filter(p => p.kind !== 'thru_hole' && p.cu);+      const th = mod.pads.filter(p => p.kind === 'thru_hole');+      const apertures = mod.pads.filter(p => p.kind !== 'thru_hole' && !p.cu && p.paste);+      smd.sort((a, b) => (b.w * b.h) - (a.w * a.h)); // EP first so small pads stay hoverable+      for (const p of smd) {+        const hot = !isBaseline && !baseKeys.has(padKey(p)) ? ' diff' : '';+        const rx = p.shape === 'oval' || p.shape === 'circle' ? Math.min(p.w, p.h) / 2+          : p.shape === 'roundrect' ? p.rr * Math.min(p.w, p.h) : 0.02;+        const tr = p.rot ? ` transform="rotate(${-p.rot} ${p.x} ${p.y})"` : '';+        s += `<g class="pad${hot}" data-pad="${esc(p.n)}" data-info="${esc(`PAD ${p.n} · ${p.shape} ${p.w.toFixed(2)} × ${p.h.toFixed(2)} mm${p.rr ? ` · r=${p.rr}` : ''}`)}"${tr}>`+          + `<rect x="${p.x - p.w / 2}" y="${p.y - p.h / 2}" width="${p.w}" height="${p.h}" rx="${rx}" class="cu"/></g>`;+      }+      for (const p of th) {+        const hot = !isBaseline && !baseKeys.has(padKey(p)) ? ' diff' : '';+        s += `<g class="pad via${hot}" data-pad="${esc(p.n)}" data-info="${esc(`VIA on pad ${p.n} · Ø${p.w.toFixed(2)} mm, drill ${p.drill.toFixed(2)} mm`)}">`+          + `<circle cx="${p.x}" cy="${p.y}" r="${p.w / 2}" class="ring"/>`+          + `<circle cx="${p.x}" cy="${p.y}" r="${p.drill / 2}" class="hole"/></g>`;+      }+      // paste-only apertures (a split EP stencil): outlined, not copper+      for (const p of apertures) {+        s += `<rect x="${p.x - p.w / 2}" y="${p.y - p.h / 2}" width="${p.w}" height="${p.h}" rx="${p.shape === 'roundrect' ? p.rr * Math.min(p.w, p.h) : 0.02}" class="aperture"/>`;+      }+      // solder balls (User.9): where Adom jets the 300 µm balls+      for (const b of mod.balls) s += `<circle cx="${b.x}" cy="${b.y}" r="${b.r}" class="ball"/>`;+      // pin-1 marker: silk circle if the mod drew one, else corner dot+      const p1 = mod.gfx.find(g => g.layer === 'F.SilkS' && g.t === 'c');+      mod.gfx.forEach(g => { if (g.layer === 'F.SilkS') s += line(g, 'silk'); });+      if (!p1) s += `<circle cx="${x0 + 0.55}" cy="${y0 + 0.55}" r="0.16" class="pin1"/>`;+      return s + `</svg>`;+    }++    _legend(mod, isBaseline) {+      const items = [['cu', 'copper pad']];+      if (mod.pads.some(p => p.kind === 'thru_hole')) items.push(['ring', 'heatsink via']);+      if (mod.pads.some(p => p.kind !== 'thru_hole' && !p.cu && p.paste)) items.push(['aperture', 'paste aperture']);+      if (mod.balls.length) items.push(['ball', `solder ball × ${mod.balls.length}`]);+      items.push(['silk', 'silkscreen']);+      if (!isBaseline) items.push(['diff', 'changed vs baseline']);+      this.shadowRoot.querySelector('[data-legend]').innerHTML =+        items.map(([c, t]) => `<span><i class="sw ${c}"></i>${esc(t)}</span>`).join('');+    }++    _setReadout(el) {+      const ro = this.shadowRoot.querySelector('[data-readout]');+      if (!ro) return;+      ro.innerHTML = el ? `<b>${esc(el.dataset.info)}</b>` : 'hover a pad&nbsp;→';+    }++    _wireStage(mod) {+      const root = this.shadowRoot;+      root.querySelectorAll('.pad[data-pad]').forEach(g => {+        g.onmouseenter = () => { this._setReadout(g); this._hl(g.dataset.pad); this._emitBus(g.dataset.pad); };+        g.onmouseleave = () => { this._setReadout(null); this._hl(''); this._emitBus(null); };+      });+    }++    _hl(n) {+      this.shadowRoot.querySelectorAll('.pad[data-pad]').forEach(g =>+        g.classList.toggle('hot', n !== '' && g.dataset.pad === String(n)));+    }++    /** Public: drive the highlight from outside (e.g. the pinout widget's hover). */+    highlight(n) { this._hl(n == null ? '' : n); }++    /** Public: the variant manifest with live geometry counts — the structured handle. */+    toJSON() {+      const d = this._data || {};+      return {+        ...(d.part ? { part: d.part } : {}), ...(d.package ? { package: d.package } : {}),+        variants: (d.variants || []).map(v => ({ suffix: v.suffix, device: v.device, kicad_mod: v.kicad_mod, ...(v.changed ? { changed: v.changed } : {}) })),+      };+    }+    _exportJson() { return JSON.stringify(this.toJSON(), null, 2); }+    _download() {+      // data: URL, not blob: — the wiki widget frame's CSP has no blob:.+      const a = document.createElement('a');+      a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(this._exportJson());+      a.download = ((this._data && this._data.part) || 'footprint') + '.variants.json';+      document.body.appendChild(a); a.click(); a.remove();+    }+    async _copy(btn) {+      const txt = this._exportJson(), done = () => { const o = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = o; btn.classList.remove('ok'); }, 1100); };+      try { await navigator.clipboard.writeText(txt); done(); return; } catch (e) { /* fall back */ }+      try { const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); }+      catch (e2) { this._download(); }+    }++    // adom-lbr shared cross-highlight bus (same protocol as adom-pinout).+    _busInit() {+      this._self = 'w' + Math.random().toString(36).slice(2);+      this._bus = ('BroadcastChannel' in window) ? new BroadcastChannel('lbr-hover') : null;+      const on = e => { const d = e.data || {}; if (d.type === 'lbr-hover' && d.src !== this._self) this._hl(d.pin == null ? '' : d.pin); };+      if (this._bus) this._bus.onmessage = on;+      this._winmsg = on; window.addEventListener('message', on);+    }+    disconnectedCallback() { try { this._bus && this._bus.close(); } catch (_) { /*noop*/ } if (this._winmsg) window.removeEventListener('message', this._winmsg); }+    _emitBus(n) {+      const m = { type: 'lbr-hover', pin: (n === '' || n == null) ? null : String(n), mpn: (this._data && this._data.part) || '', src: this._self };+      try { this._bus && this._bus.postMessage(m); } catch (_) { /*noop*/ }+      try { if (window.parent && window.parent !== window) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }+    }++    _css() {+      return `+      :host{display:block;+        --bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc;+        --board:#173a24;--gold:#d6b25e;--gold2:#a8873e;--copper:#c98a4b;--ball:#c3ccd6;--silk:#e8ecf2}+      @media (prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88;+        --board:#1d4a2e;--silk:#f2f5f8}}+      :host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc}+      :host([theme="light"]){--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88}+      *{box-sizing:border-box}+      .card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);+        font:14px/1.5 -apple-system,'Segoe UI',Roboto,Arial,sans-serif;container-type:inline-size}+      .hd{display:flex;align-items:baseline;gap:10px;margin-bottom:10px;flex-wrap:wrap}+      .hd .mpn{font:700 17px ui-monospace,'SF Mono',Menlo,monospace;letter-spacing:-.2px}+      .hd .pkg{font:600 11px ui-monospace,monospace;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:2px 8px;border-radius:6px}+      .hd .count{margin-left:auto;font:12px ui-monospace,monospace;color:var(--dim2)}+      .hd .tools{display:flex;gap:6px;align-self:center}+      .tbtn{font:600 10.5px ui-monospace,monospace;letter-spacing:.03em;color:var(--dim);background:var(--panel);+        border:1px solid var(--line);border-radius:6px;padding:3px 8px;cursor:pointer;white-space:nowrap;transition:color .12s,border-color .12s}+      .tbtn:hover{color:var(--text);border-color:var(--line2)} .tbtn.ok{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}+      .chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:10px}+      .chip{font:600 12px ui-monospace,monospace;color:var(--dim);background:var(--panel);border:1px solid var(--line);+        border-radius:999px;padding:4px 12px;cursor:pointer;transition:color .12s,border-color .12s,background .12s}+      .chip:hover{color:var(--text);border-color:var(--line2)}+      .chip.on{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 55%,transparent);background:color-mix(in srgb,var(--accent) 10%,transparent)}+      .readout{height:24px;display:flex;align-items:center;font:600 13px ui-monospace,monospace;color:var(--dim);padding:0 4px;margin-bottom:6px}+      .readout b{color:var(--accent);font-weight:600}+      .stage{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}+      svg.fp{width:100%;height:auto;display:block;max-height:520px}+      .board{fill:var(--board);stroke:color-mix(in srgb,var(--board) 60%,#000);stroke-width:0.05}+      .cu{fill:var(--gold);stroke:var(--gold2);stroke-width:0.02}+      .pad{cursor:default}+      .pad:hover .cu,.pad.hot .cu{fill:#f0d489;stroke:var(--accent);stroke-width:0.06}+      .pad.diff .cu{stroke:var(--accent);stroke-width:0.05}+      .via .ring{fill:var(--copper);stroke:color-mix(in srgb,var(--copper) 60%,#000);stroke-width:0.02}+      .via .hole{fill:#0b0e11}+      .via:hover .ring,.via.hot .ring{fill:#e6a866;stroke:var(--accent);stroke-width:0.06}+      .via.diff .ring{stroke:var(--accent);stroke-width:0.05}+      .aperture{fill:none;stroke:color-mix(in srgb,var(--silk) 55%,transparent);stroke-width:0.03;stroke-dasharray:0.08 0.06;pointer-events:none}+      .ball{fill:var(--ball);fill-opacity:.9;pointer-events:none}+      .silk{stroke:var(--silk);fill:none;color:var(--silk)} circle.silk{fill:var(--silk)}+      .fab{stroke:color-mix(in srgb,var(--silk) 30%,transparent);fill:none;color:transparent}+      .crtyd{stroke:color-mix(in srgb,var(--accent) 35%,transparent);stroke-dasharray:0.12 0.12;fill:none}+      .pin1{fill:var(--accent)}+      .changed{font:12px/1.5 ui-monospace,monospace;color:var(--dim);background:color-mix(in srgb,var(--accent) 6%,transparent);+        border:1px solid color-mix(in srgb,var(--accent) 18%,transparent);border-radius:8px;padding:7px 10px;margin-top:10px}+      .changed[hidden]{display:none}+      .legend{display:flex;flex-wrap:wrap;gap:13px;margin:9px 2px 0;font-size:11.5px;color:var(--dim)}+      .legend .sw{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:middle}+      .legend .sw.cu{background:var(--gold)} .legend .sw.ring{background:var(--copper)}+      .legend .sw.ball{background:var(--ball);border-radius:50%} .legend .sw.silk{background:var(--silk)}+      .legend .sw.diff{background:transparent;border:1.6px solid var(--accent)}+      .legend .sw.aperture{background:transparent;border:1.4px dashed color-mix(in srgb,var(--silk) 70%,transparent)}+      .empty{color:var(--dim2);padding:30px;text-align:center;font-family:ui-monospace,monospace}`;+    }+  }+  if (!customElements.get('adom-footprint-variants')) customElements.define('adom-footprint-variants', AdomFootprintVariants);+})();
widgets/models-widget.jsadded+174
@@ -0,0 +1,174 @@+/*!+ * <adom-models> - every 3D file a component page carries, one card each: what it is, what makes it different,+ * how it relates to the others, and whether the page still serves exactly the file that was described.+ *+ * Drop-in (wiki README):+ *   ::adom-models{src="/OWNER/SLUG/render/models.json"}+ *+ * Data contract (adom/models@1), committed in the component page's repo as models.json and written by+ * tools/models_json.py on adom/component-widgets:+ *   { "schema": "adom/models@1", "part": "WS2812B-2020", "updated": "2026-10-04",+ *     "models": [ { "path": "WS2812B-2020.step", "format": "step" | "glb", "title": "...",+ *       "status": "current" | "previous", "variant": "etched" | "plain" | "hero" | "showcase" | "insertion" | "lod" | "original",+ *       "role": "manufacturer" | "datasheet-built" | "photo-matched" | "derived", "slot": "model_3d_step" | null,+ *       "marking": { "text": "WS2812B-2020", "surface": "side wall", "colour": "dark grey" } | null,+ *       "colours": true, "unique": "one line: what this file has that its siblings do not",+ *       "use": ["board-cad", "render", "wiki-hero", "board-insertion", "archive"], "derived_from": "path" | null,+ *       "source": "...", "sha256": "...", "git_blob": "<git blob SHA-1>", "bytes": 7190828, "thumb": "docs/models/x.png" } ] }+ * Freshness: the page's recursive file listing (/api/pages/OWNER/SLUG/files?recursive=1) reports each file's git+ * blob hash and last commit, so nothing is downloaded to check it. A file whose hash differs is marked "changed+ * since described"; a listed path that is gone is "missing". Downloads go through the files API, which serves+ * large STEPs the render route refuses.+ * Self-contained (Shadow DOM), Adom Studio brand tokens (adom-theme), the wiki's own fonts.+ */+(() => {+  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];+  const fmtTs = t => { const d = new Date(t * 1000); return isNaN(d) ? '' : `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; };+  const fmtSize = b => b == null ? '' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(b / 1024)) + ' KB';+  const VARIANT = { etched: 'Etched', plain: 'Unmarked', hero: 'Wiki hero', showcase: 'Showcase scene', insertion: 'Assembly scene',+                    lod: 'Level of detail', original: 'Original' };+  const ROLE = { manufacturer: "Maker's model", 'datasheet-built': 'Built from the datasheet', 'photo-matched': 'Matched to photos', derived: 'Derived' };+  const SLOT = { step: 'Page STEP', model_3d_step: 'Page STEP', model_3d_step_plain: 'Page plain STEP', model_3d: 'Page 3D viewer',+                 model_3d_plain: 'Page plain GLB', model_3d_showcase: 'Page showcase' };+  const USE = { 'board-cad': 'Board CAD', render: 'Renders', 'wiki-hero': 'Wiki viewer', 'board-insertion': 'Assembly', archive: 'Archive' };++  class AdomModels extends HTMLElement {+    async connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      const src = this.getAttribute('src');+      this._paint('<div class="empty">loading 3D models…</div>');+      if (!src) return this._paint('<div class="empty">no src: point me at a models.json</div>');+      const abs = new URL(src, location.href).href;+      this._base = abs.slice(0, abs.lastIndexOf('/') + 1);+      const m = abs.match(/^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)\/render\//);+      this._api = m ? `${m[1]}/api/pages/${m[2]}/${m[3]}` : null;+      this._page = m ? `${m[1]}/${m[2]}/${m[3]}` : null;+      try { this._d = await (await fetch(abs)).json(); }+      catch (e) { return this._paint('<div class="empty">could not load ' + esc(src) + '</div>'); }+      this._live = null; this._showPrev = false;+      this._render();+      if (this._api) {+        try {+          const r = await fetch(this._api + '/files?recursive=1');+          const L = await r.json(); const list = Array.isArray(L) ? L : (L.files || []);+          this._live = Object.fromEntries(list.filter(f => f.type === 'blob').map(f => [f.path, f]));+        } catch (_) { this._live = {}; }+        this._render();+      }+    }++    _abs(p) { try { return new URL(p, this._base).href; } catch (_) { return p; } }+    _dl(p) { return this._api ? `${this._api}/files/${p.split('/').map(encodeURIComponent).join('/')}` : this._abs(p); }++    _state(m) {+      if (!this._live) return 'checking';+      const f = this._live[m.path];+      if (!f) return Object.keys(this._live).length ? 'missing' : 'unknown';+      if (!m.git_blob) return 'unknown';+      return f.hash === m.git_blob ? 'current' : 'changed';+    }++    _card(m) {+      const st = this._state(m), f = this._live && this._live[m.path];+      const mark = m.marking && m.marking.text+        ? `<span class="chip mark" title="${esc(m.marking.colour || '')}">${esc(m.marking.text)} on the ${esc(m.marking.surface || 'part')}</span>`+        : '';+      const chips = [`<span class="chip fmt ${esc(m.format)}">${esc((m.format || '').toUpperCase())}</span>`,+        `<span class="chip var">${esc(VARIANT[m.variant] || m.variant || '')}</span>`, mark,+        m.colours ? '<span class="chip">Coloured dies</span>' : '',+        m.slot ? `<span class="chip slot" title="page.json component.parts.${esc(m.slot)}">${esc(SLOT[m.slot] || 'Page file')}</span>` : ''].join('');+      const fresh = st === 'current' ? `<span class="fresh ok" title="git blob ${esc(m.git_blob)}">✓ as described${f && f.lastCommit ? ' · ' + fmtTs(f.lastCommit.timestamp) : ''}</span>`+        : st === 'changed' ? `<span class="fresh bad" title="the page now serves a different file than the one described">changed since described</span>`+        : st === 'missing' ? '<span class="fresh bad">missing from the page</span>'+        : st === 'checking' ? '<span class="fresh">checking…</span>' : '';+      const uses = (m.use || []).map(u => esc(USE[u] || u)).join(' · ');+      const from = m.derived_from ? `<div class="from">from <code>${esc(m.derived_from)}</code></div>` : '';+      return `<div class="card ${st}">+        <a class="thumb" href="${esc(this._page ? this._page + '/files/' + m.path : this._abs(m.path))}" target="_blank" rel="noopener" title="Open ${esc(m.path)}">+          ${m.thumb ? `<img loading="lazy" src="${esc(this._abs(m.thumb))}" alt="${esc(m.title || m.path)}, top">` : '<div class="nothumb">no preview</div>'}+          ${m.thumb ? '<span class="vlabel">Top</span>' : ''}</a>+        ${m.thumb_bottom ? `<a class="thumb under" href="${esc(this._abs(m.thumb_bottom))}" target="_blank" rel="noopener" title="Underside of ${esc(m.path)}">+          <img loading="lazy" src="${esc(this._abs(m.thumb_bottom))}" alt="${esc(m.title || m.path)}, underside"><span class="vlabel">Underside</span></a>` : ''}+        <div class="body">+          <div class="title">${esc(m.title || m.path)}</div>+          <div class="path"><code>${esc(m.path)}</code> · ${fmtSize(m.bytes)}</div>+          <div class="chips">${chips}</div>+          ${m.unique ? `<div class="unique">${esc(m.unique)}</div>` : ''}+          <div class="meta">${ROLE[m.role] ? `<span>${esc(ROLE[m.role])}</span>` : ''}${uses ? `<span>For: ${uses}</span>` : ''}</div>+          ${from}+          <div class="foot">${fresh}<a class="dl" href="${esc(this._dl(m.path))}" download="${esc(m.path.split('/').pop())}">Download</a></div>+        </div></div>`;+    }++    _render() {+      const d = this._d, all = d.models || [];+      const nStep = all.filter(m => m.format === 'step').length, nGlb = all.filter(m => m.format === 'glb').length;+      this._tab = this._tab || (nStep ? 'step' : 'glb');+      const ms = all.filter(m => (m.format === 'step') === (this._tab === 'step'));+      const cur = ms.filter(m => m.status !== 'previous'), prev = ms.filter(m => m.status === 'previous');+      const order = ['etched', 'plain', 'original', 'hero', 'showcase', 'insertion', 'lod'];+      cur.sort((a, b) => order.indexOf(a.variant) - order.indexOf(b.variant)); prev.sort((a, b) => String(b.path).localeCompare(String(a.path)));+      const states = this._live ? all.map(m => this._state(m)) : [];+      const bad = states.filter(s => s === 'changed' || s === 'missing').length;+      const head = `<div class="head"><div class="h">${esc(this.getAttribute('heading') || '3D models')}<span class="part">${esc(d.part || '')}</span></div>+        <div class="sub">${d.updated ? 'described ' + esc(d.updated) : ''}${+          !this._live ? ' · checking files…' : bad ? ` · <span class="warn">${bad} changed since described</span>` : states.length ? ' · <span class="okt">all files as described</span>' : ''}</div>+        <div class="tabs" role="tablist">+          <button role="tab" data-tab="step" class="${this._tab === 'step' ? 'on' : ''}"${nStep ? '' : ' disabled'}>STEP <span class="n">${nStep}</span></button>+          <button role="tab" data-tab="glb" class="${this._tab === 'glb' ? 'on' : ''}"${nGlb ? '' : ' disabled'}>GLB <span class="n">${nGlb}</span></button>+        </div>+        <div class="tabnote">${this._tab === 'step' ? 'CAD models for your board tools: open them in KiCad, Fusion, Altium or any MCAD tool.' : 'Meshes for viewers and renders: the wiki hero, scenes and plain chip GLBs.'}</div></div>`;+      const prevBlock = prev.length ? `<details class="prev"${this._showPrev ? ' open' : ''}><summary>Previous versions (${prev.length})</summary>+        <div class="grid">${prev.map(m => this._card(m)).join('')}</div></details>` : '';+      this._paint(`${head}<div class="grid">${cur.map(m => this._card(m)).join('') || '<div class="empty">none</div>'}</div>${prevBlock}`);+      this.shadowRoot.querySelectorAll('.tabs button').forEach(b => b.addEventListener('click', () => { this._tab = b.dataset.tab; this._render(); }));+      const det = this.shadowRoot.querySelector('details.prev');+      if (det) det.addEventListener('toggle', () => { this._showPrev = det.open; });+    }++    _paint(inner) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="wrap">${inner}</div>`; }++    _css() {+      return `+      :host{display:block;--bg:#161b22;--panel:#0d1117;--line:#30363d;--text:#e6edf3;--body:#c9d1d9;--dim:#8b949e;+        --ok:#3fb950;--warn:#d29922;--accent:#00b8b1;--accent-bright:#00e6dc;--blue:#64abff;--purple:#8c6bf7;+        --prose:var(--font-body,Satoshi,-apple-system,'Segoe UI',sans-serif);--display:var(--font-display,'Familjen Grotesk',Satoshi,sans-serif);+        --mono:var(--font-mono,'JetBrains Mono',Consolas,ui-monospace,monospace)}+      *{box-sizing:border-box}+      .wrap{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px/1.5 var(--prose)}+      .empty{color:var(--dim);padding:8px}+      .h{font:700 21px/1.2 var(--display);letter-spacing:-.01em}.part{font:500 14px var(--prose);color:var(--dim);margin-left:8px}+      .sub{color:var(--dim);margin:2px 0 12px}.warn{color:var(--warn)}.okt{color:var(--ok)}+      .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}+      .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}+      .card.changed,.card.missing{border-color:color-mix(in srgb,var(--warn) 55%,transparent)}+      .thumb{display:block;position:relative;background:#0d1117;aspect-ratio:4/3;border-bottom:1px solid var(--line)}+      .vlabel{position:absolute;left:8px;top:6px;font-size:11px;color:var(--dim);background:rgba(13,17,23,.7);padding:0 6px;border-radius:6px}+      .tabs{display:flex;gap:6px;margin:10px 0 4px}.tabs button{font:600 13px var(--prose);color:var(--body);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 14px;cursor:pointer}+      .tabs button.on{color:#05221f;background:var(--accent);border-color:var(--accent)}.tabs button:disabled{opacity:.4;cursor:default}+      .tabs .n{font-weight:500;opacity:.75;margin-left:2px}.tabnote{color:var(--dim);font-size:12px;margin-bottom:12px}+      .thumb img{width:100%;height:100%;object-fit:contain;display:block}+      .nothumb{display:flex;align-items:center;justify-content:center;height:100%;color:var(--dim);font-size:12px}+      .body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1}+      .title{font-weight:700;line-height:1.3}+      .path{font-size:12px;color:var(--dim)}code{font-family:var(--mono);font-size:12px;color:var(--body)}+      .chips{display:flex;flex-wrap:wrap;gap:4px}+      .chip{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid var(--line);color:var(--body)}+      .chip.fmt.step{color:var(--accent);border-color:var(--accent)}.chip.fmt.glb{color:var(--blue);border-color:var(--blue)}+      .chip.var{color:var(--text)}.chip.mark{color:var(--purple);border-color:color-mix(in srgb,var(--purple) 60%,transparent)}+      .chip.slot{color:var(--dim)}+      .unique{color:var(--body);font-size:13px}+      .meta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--dim)}+      .from{font-size:12px;color:var(--dim)}+      .foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:4px}+      .fresh{font-size:12px;color:var(--dim)}.fresh.ok{color:var(--ok)}.fresh.bad{color:var(--warn)}+      .dl{font-size:12px;font-weight:600;color:#05221f;background:var(--accent);border-radius:8px;padding:4px 10px;text-decoration:none;white-space:nowrap}+      .dl:hover{background:var(--accent-bright)}+      details.prev{margin-top:14px}details.prev summary{cursor:pointer;color:var(--body);font-weight:600;margin-bottom:10px}+      a{color:var(--accent)}`;+    }+  }+  if (!customElements.get('adom-models')) customElements.define('adom-models', AdomModels);+})();
widgets/verified-widget.jsadded+200
@@ -0,0 +1,200 @@+/*!+ * <adom-verified> - the component "verified by" trust mark and verification log.+ *+ * Drop-in (wiki README):+ *   ::adom-verified{src="/OWNER/SLUG/render/verification.json"}+ *+ * Data contract (adom/verification@1), committed in the component page's repo:+ *   {+ *     "schema": "adom/verification@1",+ *     "part": "WS2812B-2020",+ *     "variants": [ { "mpn": "WS2812B-2020-V6", "note": "12 mA", "stocked": false } ],+ *     "entries": [ {+ *       "date": "2026-10-04", "by": { "name": "John Lauer", "handle": "john" },+ *       "status": "verified" | "open" | "superseded",+ *       "title": "Footprint matches Worldsemi's PCB Solder Pad drawing",+ *       "scope": ["footprint", "3d-model", "hero", "specs", "stock"],+ *       "variants": ["*"],                       // "*" = every variant on the page+ *       "checks": [ { "text": "0.7 x 0.7 mm pads, 0.40 mm gap", "result": "pass" | "fail" | "note" } ],+ *       "evidence": [ { "label": "pad proof", "href": "docs/img/ws2812-pad-proof.png" } ],+ *       "files": { "WS2812B-2020.kicad_mod": "<sha256 hex>" }   // what was verified, byte for byte+ *     } ]+ *   }+ * Relative evidence hrefs and file paths resolve against the manifest's own URL (the page's+ * render route). Each entry's files are fetched and hashed in the browser: if any differs,+ * the entry and the badge turn amber ("changed since verified").+ * Self-contained (Shadow DOM), Adom Studio brand tokens (adom-theme), matching the wiki's dark theme.+ */+(() => {+  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];+  const fmtDate = d => { const m = /^(\d{4})-(\d\d)-(\d\d)/.exec(d || ''); return m ? `${+m[3]} ${MONTHS[+m[2] - 1]} ${m[1]}` : esc(d); };+  const SCOPE = { footprint: 'Footprint', '3d-model': '3D model', hero: 'Hero', specs: 'Specs', stock: 'Stock', symbol: 'Symbol', pinout: 'Pinout', datasheet: 'Datasheet' };++  async function sha256(buf) {+    const h = await crypto.subtle.digest('SHA-256', buf);+    return [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join('');+  }++  class AdomVerified extends HTMLElement {+    async connectedCallback() {+      if (this._init) return; this._init = true;+      this.attachShadow({ mode: 'open' });+      const src = this.getAttribute('src');+      this._paint('<div class="empty">loading verification log…</div>');+      if (!src) return this._paint('<div class="empty">no src: point me at a verification.json</div>');+      this._base = new URL(src.slice(0, src.lastIndexOf('/') + 1), location.href).href;+      try { this._d = await (await fetch(src)).json(); }+      catch (e) { return this._paint('<div class="empty">could not load ' + esc(src) + '</div>'); }+      this._fileState = {};            // path -> 'ok' | 'changed' | 'missing' | 'checking'+      this._render();+      this._checkFiles();+    }++    _abs(href) { try { return new URL(href, this._base).href; } catch (_) { return href; } }++    async _checkFiles() {+      const want = {};+      for (const e of this._d.entries || []) for (const [p, h] of Object.entries(e.files || {})) (want[p] = want[p] || new Set()).add(h);+      const paths = Object.keys(want);+      for (const p of paths) this._fileState[p] = { state: 'checking' };+      this._render();+      await Promise.all(paths.map(async p => {+        // /render/ refuses large files (413 "File too large to render", e.g. a 7 MB STEP); the page's files API+        // serves the same bytes, so fall back to it. Not fetchable at all is "unchecked", never "changed".+        const urls = [this._abs(p)], m = this._base.match(/^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)\/render\//);+        if (m) urls.push(`${m[1]}/api/pages/${m[2]}/${m[3]}/files/${p.split('/').map(encodeURIComponent).join('/')}`);+        let status = 0;+        for (const u of urls) {+          try {+            const r = await fetch(u);+            if (r.status === 404) { status = status || 404; continue; }+            if (!r.ok) { status = r.status; continue; }+            this._fileState[p] = { state: 'hashed', sha: await sha256(await r.arrayBuffer()) }; return;+          } catch (_) { status = status || -1; }+        }+        this._fileState[p] = { state: status === 404 ? 'missing' : 'unreachable' };+      }));+      this._render();+    }++    _entryState(e) {+      const files = Object.entries(e.files || {});+      if (!files.length) return 'unpinned';+      let checking = false, unchecked = false;+      for (const [p, h] of files) {+        const f = this._fileState[p];+        if (!f || f.state === 'checking') { checking = true; continue; }+        if (f.state === 'unreachable') { unchecked = true; continue; }+        if (f.state === 'missing' || f.sha !== h) return 'changed';+      }+      return checking ? 'checking' : unchecked ? 'unchecked' : 'current';+    }++    _render() {+      const d = this._d, entries = [...(d.entries || [])].sort((a, b) => String(b.date).localeCompare(String(a.date)));+      const verified = entries.filter(e => e.status === 'verified');+      const open = entries.filter(e => e.status === 'open');+      const latest = verified[0];+      const states = verified.map(e => this._entryState(e));+      const stale = states.includes('changed');+      const checking = states.includes('checking');+      const people = [...new Map(verified.map(e => [e.by && (e.by.handle || e.by.name), e.by])).values()].filter(Boolean);+      const scopes = [...new Set(verified.flatMap(e => e.scope || []))];+      const tone = !latest ? 'none' : stale ? 'stale' : 'ok';+      const icon = tone === 'ok'+        ? '<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="22"/><path d="M14 25l7 7 13-15" fill="none" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>'+        : '<svg viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="22"/><path d="M24 13v14M24 34v1" fill="none" stroke-width="5" stroke-linecap="round"/></svg>';+      const headline = !latest ? 'Not verified yet' : stale ? 'Verified, but files changed since' : 'Verified';+      const sub = latest+        ? `by ${people.map(p => esc(p.name)).join(', ')} · last ${fmtDate(latest.date)} · ${verified.length} record${verified.length === 1 ? '' : 's'}${checking ? ' · checking files…' : stale ? '' : states.includes('unchecked') ? ' · some files could not be fetched to check' : ' · files match'}`+        : 'no verification records on this page';+      const scopeChips = scopes.map(s => `<span class="chip">${esc(SCOPE[s] || s)}</span>`).join('');++      const vs = d.variants || [];+      const covered = mpn => verified.filter(e => (e.variants || ['*']).some(v => v === '*' || v === mpn));+      const varRows = vs.length ? `<div class="vars">${vs.map(v => {+        const c = covered(v.mpn), sc = new Set(c.flatMap(e => e.scope || []));+        return `<div class="var${v.stocked ? ' stocked' : ''}"><div class="vm">${esc(v.mpn)}${v.stocked ? '<span class="tag">Adom Basic Part</span>' : ''}</div>+          <div class="vn">${esc(v.note || '')}</div>+          <div class="vs">${sc.size ? [...sc].map(s => `<span class="mini ok">${esc(SCOPE[s] || s)}</span>`).join('') : '<span class="mini none">not verified</span>'}</div></div>`;+      }).join('')}</div>` : '';++      const log = entries.map(e => {+        const st = e.status === 'verified' ? this._entryState(e) : e.status;+        const badge = e.status === 'open' ? '<span class="pill open">open</span>'+          : e.status === 'superseded' ? '<span class="pill sup">superseded</span>'+          : st === 'changed' ? '<span class="pill stale">changed since</span>'+          : st === 'checking' ? '<span class="pill">checking</span>'+          : st === 'current' ? '<span class="pill ok">files match</span>' : '<span class="pill ok">verified</span>';+        const checks = (e.checks || []).map(c => `<li class="${esc(c.result || 'pass')}"><span class="mk">${c.result === 'fail' ? '✕' : c.result === 'note' ? '•' : '✓'}</span>${esc(c.text)}</li>`).join('');+        const ev = (e.evidence || []).map(x => `<a href="${esc(this._abs(x.href))}" target="_blank" rel="noopener">${esc(x.label)}</a>`).join('');+        const files = Object.entries(e.files || {}).map(([p, h]) => {+          const f = this._fileState[p] || {}, ok = f.state === 'hashed' && f.sha === h;+          const un = f.state === 'unreachable';+          return `<span class="file ${ok ? 'ok' : f.state === 'checking' || un ? '' : 'bad'}" title="${un ? 'could not be fetched to check; ' : ''}sha256 ${esc(h)}">${ok ? '✓' : f.state === 'checking' ? '…' : un ? '?' : '!'} ${esc(p)}</span>`;+        }).join('');+        const vars = (e.variants || ['*']).map(v => v === '*' ? 'all variants' : v).join(', ');+        return `<details class="entry ${esc(e.status)}"${e === entries[0] ? ' open' : ''}><summary>+            <span class="date">${fmtDate(e.date)}</span><span class="who">${esc(e.by && e.by.name)}</span>+            <span class="title">${esc(e.title)}</span>${badge}</summary>+          <div class="body"><div class="meta">${(e.scope || []).map(s => `<span class="chip">${esc(SCOPE[s] || s)}</span>`).join('')}<span class="for">for ${esc(vars)}</span></div>+          ${checks ? `<ul class="checks">${checks}</ul>` : ''}+          ${ev ? `<div class="ev"><span class="lbl">Evidence</span>${ev}</div>` : ''}+          ${files ? `<div class="files"><span class="lbl">Verified files</span>${files}</div>` : ''}</div></details>`;+      }).join('');++      this._paint(`<div class="badge ${tone}"><div class="icon">${icon}</div><div class="hb">+          <div class="h">${headline}${d.part ? ` <span class="part">${esc(d.part)}</span>` : ''}</div>+          <div class="sub">${sub}</div><div class="scopes">${scopeChips}${open.length ? `<span class="chip warn">${open.length} open item${open.length === 1 ? '' : 's'}</span>` : ''}</div></div></div>+        ${varRows}+        <div class="logh">Verification log</div><div class="log">${log || '<div class="empty">no entries</div>'}</div>`);+    }++    _paint(inner) { this.shadowRoot.innerHTML = `<style>${this._css()}</style><div class="card">${inner}</div>`; }++    _css() {+      return `+      :host{display:block;--bg:#161b22;--panel:#0d1117;--line:#30363d;--text:#e6edf3;--body:#c9d1d9;--dim:#8b949e;--ok:#3fb950;--okbg:rgba(63,185,80,.10);--warn:#d29922;--warnbg:rgba(210,153,34,.10);--bad:#f85149;--accent:#00b8b1;--accent-bright:#00e6dc;--prose:var(--font-body,Satoshi,-apple-system,'Segoe UI',sans-serif);--display:var(--font-display,'Familjen Grotesk',Satoshi,sans-serif);--mono:var(--font-mono,'JetBrains Mono',Consolas,ui-monospace,monospace)}+      *{box-sizing:border-box}+      .card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px/1.5 var(--prose)}+      .empty{color:var(--dim);padding:8px}+      .badge{display:flex;gap:16px;align-items:center;padding:14px;border-radius:12px;border:1px solid var(--line);background:var(--panel)}+      .badge.ok{background:var(--okbg);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}+      .badge.stale{background:var(--warnbg);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}+      .icon svg{width:56px;height:56px;display:block}+      .badge.ok .icon circle{fill:var(--ok)} .badge.ok .icon path{stroke:#fff}+      .badge.stale .icon circle,.badge.none .icon circle{fill:var(--warn)} .badge.stale .icon path,.badge.none .icon path{stroke:#fff}+      .h{font:700 21px/1.2 var(--display);letter-spacing:-.01em}.part{font-weight:500;color:var(--dim);font-size:14px;margin-left:6px}+      .sub{color:var(--dim);margin:2px 0 8px}+      .scopes,.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}+      .chip{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);background:var(--bg)}+      .chip.warn{border-color:var(--warn);color:var(--warn)}+      .vars{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin:14px 0}+      .var{border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--panel)}+      .var.stocked{border-color:var(--ok)}+      .vm{font-weight:600;display:flex;gap:6px;align-items:center;flex-wrap:wrap}+      .tag{font-size:11px;font-weight:600;color:#0d1117;background:var(--ok);border-radius:6px;padding:1px 6px}+      .vn{color:var(--dim);font-size:12px;margin:2px 0 6px}+      .vs{display:flex;flex-wrap:wrap;gap:4px}+      .mini{font-size:11px;padding:1px 6px;border-radius:6px}.mini.ok{background:var(--okbg);color:var(--ok)}.mini.none{background:var(--warnbg);color:var(--warn)}+      .logh{font:700 16px/1.2 var(--display);margin:16px 0 6px}+      .entry{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin:6px 0}+      .entry.open{border-color:color-mix(in srgb,var(--warn) 55%,transparent)}+      summary{cursor:pointer;list-style:none;display:flex;gap:10px;align-items:center;padding:9px 12px;flex-wrap:wrap}+      summary::-webkit-details-marker{display:none}+      .date{font-family:var(--mono);font-size:12px;color:var(--dim);min-width:86px}.who{font-weight:600}.title{flex:1;min-width:200px}+      .pill{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);color:var(--dim)}+      .pill.ok{color:var(--ok);border-color:var(--ok)}.pill.open,.pill.stale{color:var(--warn);border-color:var(--warn)}+      .body{padding:0 12px 12px}+      .for{font-size:12px;color:var(--dim)}+      .checks{margin:8px 0;padding:0;list-style:none}.checks li{display:flex;gap:8px;padding:2px 0}+      .mk{color:var(--ok);font-weight:700;width:14px;flex:none}.checks li.fail .mk{color:var(--bad)}.checks li.note .mk{color:var(--dim)}+      .ev,.files{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;margin-top:6px;font-size:13px}+      .lbl{color:var(--dim);font-size:12px}+      a{color:var(--accent)}a:hover{color:var(--accent-bright)}+      .file{font-family:var(--mono);font-size:12px}.file.ok{color:var(--ok)}.file.bad{color:var(--warn)}`;+    }+  }+  if (!customElements.get('adom-verified')) customElements.define('adom-verified', AdomVerified);+})();