From a101c9f7db181ea8060215792b70ee69a85c0777 Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 16 Oct 2026 11:37:10 +0700 Subject: [PATCH] Factory console: ERPNext link page and ERPNext documents on orders Follows the decision that the whole plant runs ERPNext and every tool we build interacts with it (e12ad2e). - New page #erp (nav: ERPNext): every posting the floor makes, derived from the same event log: sales orders in by webhook, work orders, job cards per station, serial numbers for lacquers, fathers and mothers (warehouse Archive), purchase receipts, quality inspections and stock entries per compound batch, manufacture entries per run, downtime entries per stop, delivery notes and invoices. Outbox status per row (sent within a minute, held during the nightly backup 01:00 to 01:15), KPIs, who-owns-what table, counts by document type. Rows use fixed ch grid columns; on phones a two-line row. - Order page: ERPNext card (customer, sales order, work order with produced quantity, job cards completed, batches, delivery note and invoice). Work order page: its job card, quality inspection or asset maintenance log. - Names follow ERPNext's default naming series and are simulated; nothing uses the simulation's random stream, so the seed is unchanged. - Phones: the nav strip scrolls the current tab into view. Work order IDs on the order page no longer wrap (wider fixed column). digital-factory.md demo paragraph. Checked: node --check, Edge dark and light at 1440/1024/390 (no overflow, no page errors, rows aligned), and the pw_fc, pw_wo and pw_feed regressions. --- docs/digital-factory.md | 4 ++ docs/factory-console.html | 91 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/docs/digital-factory.md b/docs/digital-factory.md index 2d111d3..d6df96a 100644 --- a/docs/digital-factory.md +++ b/docs/digital-factory.md @@ -521,6 +521,10 @@ simulated plant day: the press floor with live counters per press, work orders per station in the kiosk form described below (badge in, scan what the step asks for, confirm), the optical inspection stream, audio samples, the material lab gate with a lot on hold, stamper metrology and genealogy, the hash-chained event log, +the ERPNext link (every posting the floor makes: sales orders in by +webhook, job cards, batches and quality inspections, stock entries, +downtime entries, delivery notes and invoices, through the outbox, with +the ERPNext documents on each order and work order), and the customer portal of the same orders (progress through the three gates, copies pressed so far, the lab certificate of the compound lot, listening checks). All orders, values and IDs are simulated; limits are diff --git a/docs/factory-console.html b/docs/factory-console.html index ac5e291..3af2429 100644 --- a/docs/factory-console.html +++ b/docs/factory-console.html @@ -155,6 +155,15 @@ document.documentElement.setAttribute("data-theme",t);})(); .fr.hd > span { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; } .feed.cf .fr { grid-template-columns: 8.4ch 7ch 10.6ch 5ch minmax(0, 1fr); } .fr.bad .v { color: var(--bad); font-weight: 650; } + /* ERPNext postings: fixed ch columns, IDs never shift the row */ + .er { display: grid; grid-template-columns: 8.4ch 18ch 19ch minmax(0, 1fr) 10ch; grid-template-areas: "t d n e s"; column-gap: 14px; padding: 4px 0; border-bottom: 1px dashed var(--line); align-items: baseline; font-family: var(--mono); font-size: 12.5px; font-variant-numeric: tabular-nums; } + .er:last-child { border-bottom: 0; } + .er > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .er .t { grid-area: t; } .er .d { grid-area: d; color: var(--mute); } .er .n { grid-area: n; } .er .e { grid-area: e; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } .er .s { grid-area: s; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ok); } + .er .s.q { color: var(--warn); } .er .s.in { color: var(--mute); } + .er.hd { color: var(--faint); border-bottom: 1px solid var(--line); padding-top: 0; } + .er.hd > span { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--faint); } + .own td:first-child { font-weight: 650; white-space: nowrap; } .al { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); color: var(--ink); text-decoration: none; } .al:last-child { border-bottom: 0; } @@ -260,8 +269,9 @@ document.documentElement.setAttribute("data-theme",t);})(); .scan::before { content: "▦ "; } li.now .scan { border-color: var(--acc); color: var(--ink); } .wol { display: flex; flex-direction: column; } - .wol a { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); color: var(--ink); font-size: 13px; text-decoration: none; } + .wol a { display: grid; grid-template-columns: 112px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); color: var(--ink); font-size: 13px; text-decoration: none; } .wol a:last-child { border-bottom: 0; } + .wol .mono { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .wol a:hover b { text-decoration: underline; } .wol b { font-weight: 560; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -358,6 +368,9 @@ document.documentElement.setAttribute("data-theme",t);})(); .fr .fo { display: none; } .feed.cf .fr { grid-template-columns: 8.4ch 6.4ch 10.6ch minmax(0, 1fr); } .feed.cf .fr .pr { display: none; } + .er { grid-template-columns: 8.4ch minmax(0, 1fr) 9ch; grid-template-areas: "t n s" ". e e"; column-gap: 9px; } + .er .d, .er.hd .e { display: none; } + .er .e { white-space: normal; color: var(--mute); font-size: 12px; } .pipe a { grid-template-columns: 120px minmax(0, 1fr) 34px; } .pareto div { grid-template-columns: 112px minmax(0, 1fr) 40px; } ol.ks li { grid-template-columns: 30px minmax(0, 1fr); } @@ -391,7 +404,7 @@ document.documentElement.setAttribute("data-theme",t);})();
@@ -470,7 +483,7 @@ const PST={run:"Running",warm:"Warm-up",change:"Changeover",stop:"Stopped",off:" const NOW0=(()=>{const d=new Date();d.setHours(10,5,0,0);return d.getTime();})(); /* ================= state ================= */ -const S={t:NOW0-60*DAY,speed:20,run:true,warm:true,orders:[],byId:{},presses:[],lots:[],use:{black:null,clear:null},pend:{black:0,clear:0},regrind:[],feed:[],aud:[],led:[],ledN:0,day:{k:0},days:[],nextArr:0,seq:{o:1040,p:300,lot:2400,rg:880}, +const S={t:NOW0-60*DAY,speed:20,run:true,warm:true,orders:[],byId:{},presses:[],lots:[],use:{black:null,clear:null},pend:{black:0,clear:0},regrind:[],feed:[],aud:[],led:[],ledN:0,erp:[],erpN:0,eseq:{},day:{k:0},days:[],nextArr:0,seq:{o:1040,p:300,lot:2400,rg:880}, ui:{view:"factory",fpage:"floor",arg:null,fhash:"floor",filter:"active",q:"",cust:null}}; function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0,stops:[]};} /* demo fleet for now: one Duplex system (two presses on one extruder set) and one Classic */ @@ -481,9 +494,51 @@ const pById=id=>S.presses.find(p=>p.id===id); function log(t,k,txt,actor,oid){ S.ledN++;const prev=S.led.length?S.led[S.led.length-1].h:"0000000000000000"; const e={t,k,txt,actor,oid,p:prev};e.h=hx(prev+t+k+txt);S.led.push(e); + erpFrom(t,k,txt,oid); if(oid&&S.byId[oid]){const o=S.byId[oid];o.ev.push(e);if(o.ev.length>60)o.ev.shift();} if(!S.warm&&S.led.length>900)S.led.splice(0,S.led.length-800); } +/* ================= ERPNext link: business documents go to ERPNext through an outbox (names simulated, ERPNext default naming series) ================= */ +const p5=n=>String(n).padStart(5,"0"),yr=t=>new Date(t).getFullYear(); +const jcName=key=>"PO-JOB"+p5(10000+fnv(key,2166136261)%89990); +const erpO=(o,k)=>({so:"SAL-ORD-",wo:"MFG-WO-",dn:"MAT-DN-",si:"ACC-SINV-"})[k]+yr(o.created)+"-"+p5(o.n); +const eNum=(k,base)=>(S.eseq[k]=(S.eseq[k]||base)+1); +let STF=null; +function ePost(t,dir,dt,name,txt,oid){S.erpN++;S.erp.push({t,dir,dt,name,txt,oid});if(S.erp.length>900)S.erp.splice(0,S.erp.length-600);} +/* when a posting left the outbox: within a minute, or after the nightly ERPNext backup window 01:00 to 01:15 (hash-based, never the simulation's random stream) */ +function eSent(e){if(e.dir==="in")return e.t;const d=new Date(e.t),lag=(15+fnv(e.name+e.t,2166136261)%45)*SEC; + if(d.getHours()===1&&d.getMinutes()<15){d.setMinutes(15,0,0);return d.getTime()+lag;}return e.t+lag;} +function erpFrom(t,k,txt,oid){ + if(!STF)STF=Object.fromEntries(STAGES.map(s=>[s.f,s.id])); + const o=oid&&S.byId[oid],Y=yr(t);let m; + if(o){ + if(k==="order"&&/ received: /.test(txt)){ePost(t,"in","Sales Order",erpO(o,"so"),`submitted by the order platform: ${fI(o.qty)} × ${fmtS(o)}`,o.id); + ePost(t+SEC,"out","Work Order",erpO(o,"wo"),`created from ${erpO(o,"so")}${o.repress?", repress routing, no cut":""}`,o.id);return;} + if(k==="order"&&(m=/ → (.+)$/.exec(txt))){const nid=STF[m[1]],h=o.hist,prev=h.length>1?h[h.length-2].id:null; + if(prev&&STG_WO[prev]&&prev!=="run")ePost(t,"out","Job Card",jcName(`WO-${o.n}-${prev}`),`completed: ${STAGES[SI[prev]].f}, time logs from the kiosk`,o.id); + if(prev==="plate"&&o.mo&&!o.repress)ePost(t,"out","Serial No",o.mo[0].id,`fathers and mothers ${o.fa.map(x=>x.id).join(", ")}, ${o.mo.map(x=>x.id).join(", ")} into warehouse Archive`,o.id); + if(nid==="ship"){ePost(t,"out","Delivery Note",erpO(o,"dn"),`${fI(o.good||o.qty)} records, against ${erpO(o,"so")}`,o.id);ePost(t+2*SEC,"out","Sales Invoice",erpO(o,"si"),`from ${erpO(o,"dn")}`,o.id);} + return;} + if(k==="metal"&&/: lacquers /.test(txt)){ePost(t,"out","Serial No",o.lac[0].id,`lacquers ${o.lac.map(x=>x.id).join(", ")} registered, warehouse Cutting room`,o.id);return;} + if(k==="press"&&(m=/^(\S+) set up for /.exec(txt))){ePost(t,"out","Job Card",jcName(`WO-${o.n}-run`),`started on ${m[1]}, stamper set ${o.setIdx+1}`,o.id);return;} + if(k==="press"&&(m=/^(\S+) finished /.exec(txt))){ePost(t,"out","Job Card",jcName(`WO-${o.n}-run`),`completed on ${m[1]}: ${fI(o.good)} good, ${fI(o.rej)} rejected`,o.id); + ePost(t+SEC,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Manufacture: ${fI(o.good)} into Finished goods, rejects to regrind`,o.id);return;} + if(k==="press"&&(m=/^(\S+) stopped: (.+)$/.exec(txt))){ePost(t,"out","Downtime Entry","DT-"+Y+"-"+p5(eNum("dt",200)),`${m[1]}: ${m[2]}`,o.id);return;} + return;} + if(k==="lab"&&(m=/^(PVC-\d+) (.*)$/.exec(txt))){const id=m[1],r=m[2],L=S.lots.find(x=>x.id===id); + if(/^received/.test(r))ePost(t,"out","Purchase Receipt","MAT-PRE-"+Y+"-"+p5(eNum("pre",400)),`batch ${id}${L?", "+fI(L.kg)+" kg":""}, warehouse Quarantine`,null); + else if(/^on hold/.test(r)||/^passed the lab/.test(r)){const qi="MAT-QA-"+Y+"-"+p5(eNum("qa",500));if(L)L.qi=qi;ePost(t,"out","Quality Inspection",qi,/^on hold/.test(r)?`Rejected, batch ${id} held: ${r.replace(/^on hold: /,"")}`:`Accepted, batch ${id}: incoming lab checks`,null);} + else if(/^released/.test(r))ePost(t,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Material Transfer: batch ${id} Quarantine to Stores, released by test pressing`,null); + else if(/^into the/.test(r))ePost(t,"out","Stock Entry","MAT-STE-"+Y+"-"+p5(eNum("ste",3100)),`Material Transfer for Manufacture: batch ${id} to the ${/black/.test(r)?"black":"colour"} hopper`,null); + } +} +function erpWO(w){ + if(w.o)return `Job Card ${jcName(w.id)} of ${erpO(w.o,"wo")}`; + if(w.L)return w.L.qi?`Quality Inspection ${w.L.qi}, batch ${w.L.id}`:`Batch ${w.L.id}; the Quality Inspection is posted with the result`; + if(w.p)return `Asset Maintenance Log ACC-AML-${yr(w.start||S.t)}-${p5(1000+fnv(w.id,2166136261)%8999)}`; + return "-";} +function erpCard(o){const wos=orderWOs(o),dn=wos.filter(w=>w.status==="done").length; + return `
Customer
${esc(o.artist)}
Sales order
${erpO(o,"so")}, submitted ${fDT(o.created)}
Work order
${erpO(o,"wo")}, ${fI(Math.min(o.good,o.qty))} of ${fI(o.qty)} produced
Job cards
${dn} of ${wos.length} completed
Batches
${o.lots.length?o.lots.join(", "):"-"}
Delivery
${o.si>=SI.ship?`${erpO(o,"dn")}, invoice ${erpO(o,"si")}`:'note and invoice when it ships'}

ERPNext link: what this order posted. Document names are simulated.

`;} function rechain(){S.led.sort((a,b)=>a.t-b.t);if(S.led.length>800)S.led=S.led.slice(-800);let prev=hx("genesis "+(S.ledN-S.led.length));for(const e of S.led){e.p=prev;e.h=hx(prev+e.t+e.k+e.txt);prev=e.h;}} /* ================= orders ================= */ @@ -700,7 +755,7 @@ function backfill(o,untilId,tUntil,fix){ const seq=[];for(let i=0;i<=SI[untilId];i++)if(stageOn(o,STAGES[i]))seq.push(i); const ds=seq.slice(0,-1).map(i=>{const s=STAGES[i];if(fix[s.id]!=null)return fix[s.id]*DAY;const d=s.d||[0.5,1.5];return U(d[0],d[1])*DAY*(s.gate?0.45:1);}); let t=tUntil-ds.reduce((a,b)=>a+b,0); - o.hist=[];o.ev=[];o.created=t;o.ss=t; + o.hist=[];o.ev=[];o.created=t;o.ss=t;S.erp=S.erp.filter(e=>e.oid!==o.id); log(t,"order",`${o.id} received: ${o.title}, ${fI(o.qty)} × ${fmtS(o)}`,"Customer portal",o.id); seq.forEach((i,k)=>{if(k>0&&STAGES[seq[k-1]].gate)approve(o,STAGES[seq[k-1]].gate,t,"Customer portal");enter(o,i,t);if(kThe presses ar const kpi=(l,v,s,c)=>`
${l}${v}${s||" "}
`; /* ================= factory pages ================= */ -const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]]; +const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"],["erp","⇄","ERPNext"]]; function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage==="wo"?"work":S.ui.fpage; const bd={floor:S.presses.filter(p=>p.st==="stop"||p.st==="nopvc").length,orders:cnt(o=>o.si!==SI.done),lab:S.lots.filter(L=>L.st==="hold").length,quality:S.aud.filter(x=>x.a.rev&&x.a.rt<=S.t&&S.t-x.a.rt<6*HR).length}; return NAV.map(([k,ic,l])=>{const b=bd[k];const bad=(k==="lab"||k==="floor"||k==="quality")&&b>0;return `${ic}${l}${b?`${fI(b)}`:""}`;}).join("");} @@ -941,6 +996,7 @@ PAGES.order={

Compound lot

lab gate
${b.comp}

Packing and shipping

${b.pack}

Audio comparison, sampled

first-off per stamper set, about every 50th record, every 25th near end of life
${audioTable(o.audio,false)}
+

ERPNext

business documents of this order
${erpCard(o)}

Work orders

one per station

Events for this order

from the hash-chained log
${ledgerTable(o.ev.slice(-14).reverse(),false)}
` }; @@ -1003,6 +1059,27 @@ PAGES.ledger={ blocks:()=>({gL:ledgerTable(S.led.slice(-60).reverse(),true)+`

Showing the latest 60 of ${fI(S.ledN)} events since the simulation started. The demo uses a short hash; the factory application uses SHA-256.

`}) }; +const OWN=[["Orders","Sales Order, Delivery Note, Sales Invoice","the three artist gates, the portal view"],["Production","Work Order, a Job Card per station, Downtime Entry","cycles, telemetry, kiosk scans, stamper changes"],["Materials","Batch, Purchase Receipt, Quality Inspection, Stock Entry","instrument files, CoA scans, which lot fed which cycle"],["Metal parts","Serial No in a warehouse: archive, rack, press","genealogy, metrology, play counts, sets per mother"],["Quality","inspection results per batch and run","every record's optical result, audio samples"],["Plant","Asset, maintenance schedule, accounts, payroll","run hours, alarms, the hash-chained log"]]; +PAGES.erp={ + layout:()=>`
Factory console

ERPNext link

The whole plant runs ERPNext. It owns orders, stock, inspections, deliveries and money; this application owns the floor and posts what happens there in business-sized pieces, through an outbox, so no press waits for the ERP.

+
+

Postings and webhooks

latest 40, newest first
+

Who owns what

+

Today by document type

since 00:00
`, + blocks(){ + const T=S.t,d0=S.day.t0,L=S.erp.filter(e=>e.t<=T).sort((a,b)=>b.t-a.t),today=L.filter(e=>e.t>=d0); + const q=L.filter(e=>e.dir==="out"&&eSent(e)>T),inT=today.filter(e=>e.dir==="in").length; + const rc=new Date(T);rc.setHours(2,0,0,0);if(rc.getTime()>T)rc.setDate(rc.getDate()-1); + const by={};for(const e of today)by[e.dt]=(by[e.dt]||0)+1;const BL=Object.entries(by).sort((a,b)=>b[1]-a[1]),mx=BL.length?BL[0][1]:1; + const row=e=>{const qd=e.dir==="out"&&eSent(e)>T;return `
${fTs(e.t)}${esc(e.dt)}${esc(e.name)}${e.oid?`${e.oid} `:""}${esc(e.txt)}${qd?"… outbox":e.dir==="in"?"← webhook":"✓ posted"}
`;}; + return { + eK:kpi("Posted today",fI(today.filter(e=>e.dir==="out").length),"documents created or updated")+kpi("In the outbox",fI(q.length),q.length?"sent within a minute":"held only in the nightly backup, 01:00 to 01:15",q.length>40?"warn":"")+kpi("Webhooks in today",fI(inT),"sales orders from the order platform")+kpi("Reconciled",fT(rc.getTime()),"work order and batch totals match","ok"), + eL:L.length?`
TimeDocument typeDocumentEventStatus
${L.slice(0,40).map(row).join("")}
`:'

Nothing posted yet.

', + eO:`${OWN.map(([a,b,c])=>``).join("")}
ERPNextThis application
${a}${b}${c}
`, + eD:(BL.length?`
${BL.map(([r,n])=>`
${esc(r)}${bar(n/mx,"acc")}${fI(n)}
`).join("")}
`:'

Nothing posted today yet.

')+`

Postings are per order, run, lot, stop and shipment, never per record or cycle. Document names follow ERPNext's default naming series and are simulated; the real link is the Frappe REST API with an integration user per tool, and a nightly reconciliation of totals. ${fI(S.erpN)} postings since the simulation started.

` + }; + } +}; /* ================= work orders: derived from the same records the stations write ================= */ const WST=[["mast","Mastering suite","g-pre"],["cut","Cutting room","g-metal"],["galv","Galvanics","g-metal"],["prep","Stamper prep","g-metal"],["press","Press floor","g-press"],["qc","Audio QC","g-out"],["lab","Material lab","g-pre"],["pack","Packing","g-out"],["ship","Dispatch","g-out"],["maint","Maintenance","g-gate"]]; const WSTN=Object.fromEntries(WST.map(x=>[x[0],x])); @@ -1131,7 +1208,7 @@ PAGES.wo={ const act=w.L&&w.L.st==="hold"&&w.status==="todo"?``:""; return { kH:`
${w.id}

${esc(w.title)}

${stChip(w.st)}${w.status==="doing"?"In progress":w.status==="done"?"Done":"Next up"}${esc(woTime(w))}
${act}${link}
`, - kG:`

${w.o?"Order":w.L?"Compound lot":"Press"}

${esc(w.ref)}
${ref}
Station
${WSTN[w.st][1]}${w.where?" · "+esc(w.where):""}
Assigned
${esc(w.who||"next free operator")}
${w.start?`
Started
${fDT(w.start)}
`:""}${w.plan&&w.status==="todo"&&!w.prio?`
Planned
${fDT(w.plan)}
`:""}${w.end&&isFinite(w.end)?`
${w.status==="done"?"Done":"Done about"}
${fDT(w.end)}
`:""}
${w.status==="doing"?`
${bar(w.pr,"acc")}
`:""}

Writes to the log: ${esc(WO_LOG[w.st])}.

+ kG:`

${w.o?"Order":w.L?"Compound lot":"Press"}

${esc(w.ref)}
${ref}
Station
${WSTN[w.st][1]}${w.where?" · "+esc(w.where):""}
Assigned
${esc(w.who||"next free operator")}
ERPNext
${erpWO(w)}
${w.start?`
Started
${fDT(w.start)}
`:""}${w.plan&&w.status==="todo"&&!w.prio?`
Planned
${fDT(w.plan)}
`:""}${w.end&&isFinite(w.end)?`
${w.status==="done"?"Done":"Done about"}
${fDT(w.end)}
`:""}
${w.status==="doing"?`
${bar(w.pr,"acc")}
`:""}

Writes to the log: ${esc(WO_LOG[w.st])}.

Steps

kiosk view: badge in, scan, confirm
${steps}

Steps follow the simulation in this demo. At the station each confirmation is an event in the hash-chained log, so the order history and the customer portal update as the work is done.

` }; } @@ -1235,6 +1312,8 @@ function route(){ else{S.ui.view="factory";S.ui.fpage=PAGES[k]?k:"floor";S.ui.arg=v;S.ui.fhash=h||"floor";} if(prev!==S.ui.view+S.ui.fpage+S.ui.arg+S.ui.cust)window.scrollTo(0,0); layout(); + /* phones: the nav is a horizontal strip; bring the current page's tab into view */ + requestAnimationFrame(()=>{const n=$("#fnav"),a=n&&n.querySelector("a.on");if(a&&n.scrollWidth>n.clientWidth)n.scrollLeft=a.offsetLeft-(n.clientWidth-a.offsetWidth)/2;}); } function toast(msg){const t=$("#toast");t.textContent=msg;t.hidden=false;clearTimeout(toast.h);toast.h=setTimeout(()=>{t.hidden=true;},4200);} const ACT={