Order tool: fan funding; deck: light and dark, 60/40 shareholding
Deploy test site / deploy (push) Successful in 1m2s

User, 20 Oct 2026: can the deck also be made in a light and dark mode;
the new order tool should also offer fan funding, so the platform
screenshots match and use the same calculator; the main investor holds
at least 60 %, so 40 % is for sale.

Order tool (docs/order.html, /demos/order/):
- New step 15 "How to pay": pay the plant directly (deposit) or let
  fans fund it; step 16 becomes "Review & launch" with the campaign
  simulation and its three outcomes.
- Campaign maths ported from make-a-record (fan price, length, cap, own
  copies, VAT; fans needed = max(100, break-even); keep at the goal and
  with more fans; price hint); R gains fee (3.5 %), VAT and campaign
  length with sources; no price changed.
- Quote card shows fan price, fans needed and the artist's keep on the
  fan route; route and campaign in the #q= hash; deep links
  #s=<step>&p=<preset>&route=fans.
- Parity with make-a-record: 206/206 checks over 40 configurations;
  default quotes unchanged (37,836 / 217 / 102,936 THB; partner
  mastering 43,676).

Investor deck (docs/pitch/):
- Light and dark theme like the other site pages (cnx-theme, T key and
  button), presenter and overview follow; charts from CSS variables;
  text pairs at WCAG AA in both themes; tool screenshots in both themes.
- Two PDFs: dark and light (print-friendly), each ~1.5 MB.
- Platform slide: both pictures from the order tool (Splatter 12" order
  step and its fan-funding view: 100 fans at 1,200 THB, break-even 46,
  ~480 THB a copy kept).
- BOI and financing slide: shareholding bar, lead investor at least 60 %,
  at most 40 % for co-investors (~32M / ~22M THB at the ~54M equity
  floor, arithmetic only); the ask offers up to 40 %.

Docs: artist-platform.md (fan funding in the order tool), README and
landing page entries.
This commit is contained in:
grabowski committed 2026-10-20 14:31:40 +07:00
1 parent c8307f9d5e
commit 744a56d22b
16 files changed
+646 -174

No files matched your search

+7 -5
View File
@@ -211,8 +211,9 @@ The detail lives in dedicated documents:
enough have pre-ordered, with refunds if not
- [Order a record](docs/order.html): the order tool as a step-by-step
configurator after the Mobineko pattern (step rail, choices, a large live
record picture, running price and ship date, review with what is
missing); same price list as the artist demos
record picture, running price and ship date, a "how to pay" step with
direct payment or a fan-funded campaign on make-a-record's calculator,
review with what is missing); same price list as the artist demos
- [Artist ordering demo](docs/artist-order.html): the artist platform as a
clickable single page: configure a record (jacket with a 3 or 5 mm
spine), type the track list or drop the master files and artwork
@@ -322,9 +323,10 @@ The detail lives in dedicated documents:
caveats behind the numbers
- [Investor deck](docs/pitch/index.html) (October 2026): the plant in 16
web slides for BOI, bank and institutional readers, proposing Plan B
(~215M THB) with Plan A as the comparison, with a
presenter view (press P) and a
[PDF](docs/pitch/CNX-Vinyl-Investor-Deck.pdf); every figure carries its
(~215M THB) with Plan A as the comparison, in a light and a dark theme
(press T), with a presenter view (press P) and a PDF of each theme
([dark](docs/pitch/CNX-Vinyl-Investor-Deck.pdf),
[light](docs/pitch/CNX-Vinyl-Investor-Deck-light.pdf)); every figure carries its
source in the business plan or the owning doc
- Business plan, draft 0.3 (October 2026), in two versions:
[Plan A, ~180M THB](docs/business-plan/CNX-Vinyl-Business-Plan-A.pdf)
+21 -5
View File
@@ -57,11 +57,27 @@ demo, so a rate change goes into both pages.
plant's online order form: a rail of numbered steps (order type, size,
discs, vinyl colour and effects, labels, inner sleeves, jacket and
finish, extras, mastering and cutting, test pressings, shipping, track
list, artwork, review), each step's choices beside a large live picture
of the record, and a running price and ship date. The review lists what
is still missing before an order can be placed and links to fan funding
in the detailed demo. It carries the same price list, so a rate change
goes into all three pages.
list, artwork, how to pay, review), each step's choices beside a large
live picture of the record, and a running price and ship date. The review
lists what is still missing before an order can be placed. It carries the
same price list, so a rate change goes into all three pages.
**Fan funding in the order tool** (user, 20 October 2026: the new tool
offers fan funding too, so the investor deck's platform pictures come
from one tool and one calculator). Step 15 "How to pay" chooses between
paying the plant directly (the deposit, the rest before dispatch) and a
fan-funded campaign with the same inputs and the same campaign maths as
[make-a-record](make-a-record.html): the artist sets the fan price
(default 1,200 THB) and the campaign length, the fans needed are the
larger of break-even and the 100-copy minimum run, and the tool shows
what the artist keeps at the goal and with more fans, after the payment
fees (~3.5 %, to confirm with the provider), the one-off setup and the
pressing. Step 16 becomes "Review & launch" and simulates the campaign's
outcome. For the same record both pages give the same numbers (checked
headless over 40 combinations, October 2026): the default record at
1,200 THB needs 100 fans (break-even 41) and keeps ~548 THB a copy; the
Splatter 12" preset needs 100 fans (break-even 46) and keeps ~480 THB a
copy. Indicative demo prices.
**Mastering as a choice** (user, October 2026): the order tool lets the
customer pick the mastering: none (the master is final and only prepared
+381 -54
View File
@@ -60,12 +60,12 @@ document.documentElement.setAttribute("data-theme",t);})();
.app.wide { grid-template-columns: 250px minmax(0, 1fr); }
.app.wide .stage { display: none; }
.app.wide .px { visibility: hidden; }
.rail { overflow-y: auto; padding: 10px 10px 16px; background: var(--panel); border-right: 1px solid var(--stroke); display: flex; flex-direction: column; gap: 3px; }
.ri { display: flex; align-items: center; gap: 9px; width: 100%; background: transparent; border: 1px solid transparent; border-radius: 9px; padding: 7px 8px; color: var(--mute); text-align: left; cursor: pointer; min-height: 46px; flex: none; }
.rail { overflow-y: auto; padding: 10px 10px 12px; background: var(--panel); border-right: 1px solid var(--stroke); display: flex; flex-direction: column; gap: 2px; }
.ri { display: flex; align-items: center; gap: 9px; width: 100%; background: transparent; border: 1px solid transparent; border-radius: 9px; padding: 5px 8px; color: var(--mute); text-align: left; cursor: pointer; min-height: 42px; flex: none; }
.ri:hover { background: var(--panel2); color: var(--fg); }
.ri .rn { flex: none; display: inline-grid; place-items: center; width: 23px; height: 23px; border-radius: 50%; background: var(--line); color: var(--mute); font-size: 12px; font-weight: 700; }
.ri .ric { flex: none; width: 18px; height: 18px; color: var(--mute); }
.ri .rt { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
.ri .rt { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.ri .rt b { font-weight: 500; font-size: 14px; }
.ri .rt small { font-size: 11.5px; color: var(--accent); opacity: 0.55; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ri .rch { flex: none; color: var(--mute); opacity: 0.6; font-size: 15px; }
@@ -297,6 +297,7 @@ document.documentElement.setAttribute("data-theme",t);})();
.sumcard { border: 1.5px solid var(--stroke); border-radius: 14px; padding: 16px 18px; background: var(--panel2); }
.sumhd { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
.sumhd h3 { margin: 0; font-size: 22px; font-weight: 500; letter-spacing: 0.01em; }
.sumhd > div { flex: 1; min-width: 180px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
.chips span { background: var(--panel); border: 1px solid var(--stroke); border-radius: 5px; padding: 1px 8px; font-size: 12px; color: var(--mute); }
.rvgrid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 16px; align-items: start; }
@@ -316,6 +317,82 @@ document.documentElement.setAttribute("data-theme",t);})();
.conf code { font-size: 12.5px; }
details.pl summary { cursor: pointer; font-weight: 600; margin: 16px 0 6px; }
.toast { position: fixed; top: 64px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--act); border-radius: 8px; padding: 8px 14px; font-size: 13.5px; z-index: 80; max-width: calc(100% - 30px); }
/* how to pay: route cards and the fan campaign (same maths as make-a-record) */
.tiles.route .tile { min-height: 112px; padding-left: 9px; padding-right: 9px; }
.tiles.route .tile .pr { font-weight: 600; }
fieldset.camp { border: 0; padding: 0; margin: 2px 0 0; }
fieldset.camp:disabled { opacity: 0.5; }
fieldset.camp:disabled .chip, fieldset.camp:disabled input { cursor: not-allowed; }
input[type=range] { -webkit-appearance: none; appearance: none; height: 6px; border-radius: 3px; background: linear-gradient(to right, var(--act) var(--p, 50%), var(--stroke) var(--p, 50%)); outline: none; cursor: pointer; margin: 8px 0; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--act); border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--act), 0 1px 3px rgba(0,0,0,0.3); }
input[type=range]::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--act); border: 3px solid var(--panel); box-shadow: 0 0 0 1px var(--act); }
input[type=range]:focus-visible { box-shadow: 0 0 0 3px var(--actring); }
input[type=range]:disabled { cursor: not-allowed; }
.fprow { display: flex; align-items: center; gap: 12px; margin: 4px 0 6px; }
.fprow input[type=range] { flex: 1; min-width: 0; }
.stepper.fp input { width: 70px; }
.chip.t { padding: 3px 11px; font-variant-numeric: tabular-nums; }
.chip.on { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 2px var(--actring); }
.chip:disabled { opacity: 0.6; cursor: not-allowed; }
.need { font-size: 14.5px; margin: 8px 0 0; }
.need b { font-size: 16px; background: var(--actbg); border-bottom: 2px solid var(--act); padding: 0 4px; border-radius: 3px; }
.bad-t { color: var(--bad); font-weight: 600; }
.camprow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 14px; margin: 4px 0 0; }
.camprow .fld { margin: 6px 0; }
.rngrow { display: flex; align-items: center; gap: 10px; min-height: 40px; }
.rngrow input[type=range] { flex: 1; min-width: 0; }
.daysv { font-weight: 700; color: var(--fg); font-size: 15px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.limrow { display: flex; align-items: center; gap: 6px 12px; flex-wrap: wrap; margin: 4px 0; }
.limrow .chk { margin: 4px 0; }
.capf { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--fg); }
.capf input { width: 80px; padding: 4px 7px; }
.capf.off { opacity: 0.45; }
.kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 12px 0 4px; }
.kpis.k3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.kpi { background: var(--panel2); border: 1.5px solid var(--stroke); border-radius: 11px; padding: 8px 11px 9px; min-width: 0; }
.kpi small { display: block; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kpi b { display: block; font-size: 21px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kpi em { display: block; font-style: normal; font-size: 11.5px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kpi.hl { border-color: var(--act); background: var(--actbg); box-shadow: 0 0 0 3px var(--actring); }
.keep { margin: 2px 0 4px; }
.krow { display: grid; grid-template-columns: 70px minmax(0, 1fr) 118px; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; border-bottom: 1px solid var(--line); }
.krow:last-child { border-bottom: 0; }
.krow > span { color: var(--mute); white-space: nowrap; font-variant-numeric: tabular-nums; }
.krow .kb { height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; }
.krow .kb i { display: block; height: 100%; background: var(--accent); border-radius: 5px; }
.krow.goal > span { color: var(--fg); font-weight: 700; }
.krow.goal .kb i { background: var(--act); }
.krow .kv2 { text-align: right; line-height: 1.2; }
.krow .kv2 b { display: block; font-variant-numeric: tabular-nums; }
.krow .kv2 small { font-size: 11px; white-space: nowrap; }
.safe { display: grid; gap: 9px; margin: 6px 0 4px; }
.safe > div { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; line-height: 1.35; }
.safe .si { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: var(--okbg); color: var(--accent); border: 1px solid var(--line); }
.safe b { display: block; font-weight: 600; }
.safe small { display: block; font-size: 12.5px; }
.launch { display: flex; flex-direction: column; gap: 4px; margin: 14px 0 2px; }
.launch .btn { width: 100%; padding: 10px 14px; font-size: 15px; }
.launch .hint { margin: 2px 0 0; text-align: center; }
/* quote card on the fan route */
.qn.info { color: var(--mute); }
/* review: the launched campaign */
.cgrid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 16px; align-items: start; margin-bottom: 14px; }
.cgrid .conf { margin-bottom: 0; }
.simhd { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; }
.simhd h3 { margin: 0; font-size: 18px; }
.simhd h3 small { font-weight: 400; }
.simrow { display: flex; align-items: center; gap: 10px; margin: 12px 0 6px; }
.simrow input[type=range] { flex: 1; min-width: 0; }
.simrow .hint { white-space: nowrap; margin: 0; }
.bar.big { height: 12px; border-radius: 6px; }
.ocard { border: 1.5px solid var(--accent); background: var(--okbg); border-radius: 12px; padding: 12px 14px; margin-top: 12px; }
.ocard.warn { border-color: var(--warn); background: var(--warnbg); }
.ocard.plain { border-color: var(--stroke); background: var(--panel); }
.ocard h3 { margin: 0 0 4px; font-size: 16px; }
.ocard p { font-size: 13.5px; margin: 4px 0; }
.opts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; margin-top: 8px; }
.opt { border: 1px solid var(--stroke); border-radius: 10px; padding: 10px 12px; background: var(--panel); display: flex; flex-direction: column; align-items: flex-start; }
.opt p { font-size: 13px; color: var(--mute); margin: 4px 0 8px; flex: 1; }
/* phone bottom bar */
.pbar { display: none; }
/* tablet: compact rail */
@@ -330,7 +407,7 @@ document.documentElement.setAttribute("data-theme",t);})();
.qt { font-size: 21px; }
.sinfo { max-width: calc(100% - 240px); }
}
@media (max-width: 1100px) and (min-width: 900px) { .rvgrid { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 1100px) and (min-width: 900px) { .rvgrid, .cgrid { grid-template-columns: minmax(0, 1fr); } }
/* phone */
@media (max-width: 899px) {
body { overflow: visible; display: block; height: auto; padding-bottom: 74px; }
@@ -363,6 +440,10 @@ document.documentElement.setAttribute("data-theme",t);})();
.sumcard { padding: 12px 12px; }
.grid2f { grid-template-columns: minmax(0, 1fr); }
.tiles.c4 { --cols: 2; }
.cgrid, .opts { grid-template-columns: minmax(0, 1fr); }
.kpi b { font-size: 18px; }
.krow { grid-template-columns: 62px minmax(0, 1fr) 112px; gap: 8px; }
.simrow { flex-wrap: wrap; }
.pbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--panel); border-top: 1px solid var(--stroke); padding: 8px 10px; gap: 8px; align-items: center; box-shadow: 0 -6px 20px rgba(0,0,0,0.2); }
.pbar .pt { flex: 1; min-width: 0; line-height: 1.2; }
.pbar .pt b { font-size: 16px; display: block; white-space: nowrap; font-variant-numeric: tabular-nums; }
@@ -389,7 +470,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="pb" id="pBody"></div>
<div class="pf" id="pFoot">
<button type="button" class="nextb" data-act="next" id="nextB">Next step &#x2192;</button>
<div class="row2"><span class="foot">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</span><button type="button" class="linkb" data-act="go" data-s="15" id="priceLink">Just show me the price &#x2192;</button></div>
<div class="row2"><span class="foot">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</span><button type="button" class="linkb" data-act="go" data-s="16" id="priceLink">Just show me the price &#x2192;</button></div>
</div>
<div class="rvbar" id="rvBar" hidden>
<button type="button" class="btn sec" data-act="share">&#x2934; Share quote</button>
@@ -404,12 +485,13 @@ document.documentElement.setAttribute("data-theme",t);})();
<aside class="qcard" id="qcard">
<button type="button" class="qmin" data-act="qmin" title="Make the card smaller">&#x2212;</button>
<div class="qa qhide"><button type="button" class="btn qsave" data-act="save" title="Save this quote in this browser">Save quote</button><button type="button" class="btn sec" data-act="share" title="Copy a link that opens this configuration">&#x2934; Share</button></div>
<div class="ql">Estimated price</div>
<div class="ql" id="qL1">Estimated price</div>
<div class="qt" id="qTot"></div>
<div class="qs" id="qPer"></div>
<div class="qhide"><div class="ql g">Estimated ship date</div>
<div class="qhide"><div class="ql g" id="qL2">Estimated ship date</div>
<div class="qd" id="qShip"></div>
<div class="qs" id="qShipN"></div></div>
<div class="qhide" id="qKeepW" hidden><div id="qKeep"></div></div>
<hr>
<div class="qq"><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">&#x2212;</button><input type="number" class="qtyIn" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
<div class="qn" id="qNote"></div>
@@ -473,7 +555,10 @@ const R={
wCopy:{v:{12:0.30,10:0.22,7:0.07,x12:0.16,x10:0.12,x7:0.05,g180:0.04},u:"kg per copy",t:"Shipping weight per copy",k:"est",src:"Estimate, new on this page: a 12\" LP with its jacket weighs about 0.3 kg; each further disc in its sleeve adds about 0.16 kg (12\"), 180 g vinyl 0.04 kg a disc."},
carton:{v:{12:25,10:30,7:50},u:"copies per carton",t:"Copies per shipping carton",k:"est",src:"Estimate, new on this page: one mailer carton of about 8 kg."},
minRun:{v:100,u:"copies",t:"Minimum run for an order",k:"doc",src:"Planning figure, to be decided (artist platform, open decision 4). Smaller runs are quoted, at a higher price per copy."},
deposit:{v:0.5,u:"of a direct order",t:"Deposit on a direct order",k:"est",src:"Placeholder; deposit terms are not set yet."}
deposit:{v:0.5,u:"of a direct order",t:"Deposit on a direct order",k:"est",src:"Placeholder; deposit terms are not set yet."},
fee:{v:0.035,u:"of the fan price",t:"Payment fees on fan pre-orders",k:"doc",src:"About 3 to 4 % on cards, less on PromptPay, to be confirmed with the provider (artist platform). Same rate as artist-order and make-a-record."},
vatPct:{v:7,u:"% inside a fan price",t:"Thai VAT in a fan price",k:"doc",src:"With CNX as seller of record (artist platform, open decision 1) a fan price includes 7/107 of VAT, which comes out of the artist's margin; the switch on the fan funding step shows the numbers with it taken out, as make-a-record does. New on this page as a list entry."},
campDays:{v:{min:14,max:45,def:30},u:"days",t:"Campaign length the artist chooses",k:"doc",src:"14 to 45 days, an estimate (artist platform, The campaign page); 30 days is the demo default, as in make-a-record. New on this page as a list entry."}
};
const tierIdx=q=>q>=1000?3:q>=500?2:q>=300?1:0;
/* side length per format, minutes [comfortable, at most]: comfortable = lower end of the microcut.studio format guide;
@@ -546,7 +631,10 @@ const S={
srpm:{},stype:{},gaps:"files",genre:"pop",leadout:"",
ship:"pickup",split:false,splitQty:50,ship2:"th",duty:"dap",same:true,
addr:{b:Object.assign(blankAddr(),{country:"Thailand"}),s:Object.assign(blankAddr(),{country:"Thailand"}),x:Object.assign(blankAddr(),{country:"Thailand"})},
notes:"",view:"record",viewMan:false,closed:false,qmin:false,placed:null,visited:{1:true}
notes:"",view:"record",viewMan:false,closed:false,qmin:false,placed:null,visited:{1:true},
/* how to pay: direct order or fan campaign; the campaign fields and defaults are make-a-record's */
route:"direct",fanPrice:1200,days:R.campDays.v.def,limited:false,capN:300,artistCopies:0,vat:false,
launched:false,launchDate:null,scen:"make",simDay:0,outcome:null,outcomeAct:null,finalFans:0
};
let tid=0;
[["Doi Suthep at Dawn",262],["Ping River",241],["Nimman Rain",198],["Songthaew",225],["Old City Walls",284],["Lanna Lights",233],["Mae Rim Drive",256],["Last Train North",301]]
@@ -661,6 +749,44 @@ function leadInfo(campaign){
}
function shipWindow(){const L=leadInfo(false);return [addWD(TODAY,L.tw[0]*5),addWD(TODAY,L.tw[1]*5)];}
/* ---- fan funding: campEcon() as in make-a-record.html (same maths, same rule: fans needed = max(minimum run, break-even)) ---- */
const fansOn=()=>S.route==="fans";
const capOn=()=>S.limited?S.capN:0; /* the effective edition cap, 0 when the limited edition is off (make-a-record's S.cap) */
function campEcon(){
const p=S.fanPrice,fee=R.fee.v,vat=S.vat?p*R.vatPct.v/(100+R.vatPct.v):0,net1=p-p*fee-vat,tSum=quote(R.minRun.v).tSum,ac=S.artistCopies;
const cAt=n=>quote(Math.max(1,n)).cSum;
let be=null;
for(let n=1;n<=5000;n++){if(n*net1-tSum-(n+ac)*cAt(n+ac)>=0){be=n;break;}}
/* the artist sets the fan price; the fans needed follow: the plant minimum, or the break-even if that is higher */
const min=R.minRun.v,goal=be==null?null:Math.max(min,be);
const at=n=>{const run=n+ac,c=cAt(run),net=n*net1,cost=tSum+run*c;return {n,run,c,net,cost,keep:net-cost,per:(net-cost)/n};};
return {p,fee,vat,net1,tSum,be,min,goal,at,cAt};
}
/* one campEcon per state: render passes ask for it several times */
let ECK="",ECV=null;
function econ(){const k=JSON.stringify(cfg())+"|"+audioTracks().length;if(k!==ECK||!ECV){ECK=k;ECV=campEcon();}return ECV;}
/* the lowest fan price at which the plant minimum covers the costs; below it the break-even sets the fans needed (make-a-record pRaw) */
function priceHint(E){E=E||campEcon();const ac=S.artistCopies,keepShare=1-R.fee.v-(S.vat?R.vatPct.v/(100+R.vatPct.v):0),pRaw=(E.tSum+(E.min+ac)*E.cAt(E.min+ac))/E.min/keepShare;
const pMin=S.cur==="THB"?Math.ceil(pRaw/10)*10:Math.ceil(pRaw/rate())*rate();return {pRaw,pMin,t:money(pMin,{dec:0})};}
function keepRows(E){const cap=capOn();if(E.goal==null)return [];return [...new Set([E.goal,200,300,500,1000])].filter(n=>n>=E.goal&&(!cap||n<=cap)).sort((a,b)=>a-b).map(E.at);}
const ROUTEW="Choose \"Let fans fund it\" above to set up a campaign";
/* why a campaign cannot launch: the route, a price below the cost per copy, an edition cap below the goal */
function campWhy(E){E=E||econ();if(!fansOn())return ROUTEW;if(E.goal==null)return "Raise the fan price: it does not cover the cost per copy";const c=capOn();if(c&&c<E.goal)return "The edition cap of "+fmtInt(c)+" is below the goal of "+fmtInt(E.goal)+" fans";return "";}
/* the fan price is kept in THB; it is typed and shown in the chosen currency (make-a-record) */
const toTHB=v=>Math.round(v*rate()*100)/100;
const rngP=el=>{const a=+el.min||0,b=+el.max||100;el.style.setProperty("--p",(Math.max(0,Math.min(1,(+el.value-a)/((b-a)||1)))*100).toFixed(1)+"%");};
const fanIn=()=>S.cur==="THB"?Math.round(S.fanPrice):Math.round(S.fanPrice/rate()*100)/100;
const FAN_IN={THB:[300,5000,50],USD:[10,150,1],EUR:[8,130,1]},FAN_RNG={THB:[300,3000,50],USD:[10,90,1],EUR:[8,80,1]},FP={THB:[800,1000,1200,1500,2000],USD:[25,30,35,45,60],EUR:[20,25,30,40,50]};
/* campaign simulation (make-a-record): final pledges per scenario, share of them in by time t */
function finalPledges(E){
if(!E.goal)return 0;const g=E.goal;
let P=S.scen==="sell"?Math.ceil(g*1.6):S.scen==="make"?Math.ceil(g*1.08):Math.floor(g*0.68);
const c=capOn();if(c>0)P=Math.min(P,c);return P;
}
const frac=t=>0.38*(1-Math.exp(-t/0.07))/(1-Math.exp(-1/0.07))+0.37*t+0.25*Math.pow(t,6);
function pledgesNow(E){const P=finalPledges(E);return S.simDay>=S.days?P:Math.round(P*frac(S.simDay/S.days));}
function campWindow(){const close=addDays(S.launchDate||TODAY,S.days),L=leadInfo(true);return {close,L,ship:[addWD(close,L.tw[0]*5),addWD(close,L.tw[1]*5)]};}
/* ---- tracks and sides ---- */
function parseDur(s){s=String(s).trim();let m=s.match(/^(\d+):(\d{1,2})$/);if(m)return +m[1]*60+ +m[2];m=s.match(/^\d+(\.\d+)?$/);if(m)return Math.round(parseFloat(s)*60);return null;}
const gapSec=n=>S.gaps==="add"?Math.max(0,n-1)*2:0;
@@ -1149,7 +1275,9 @@ function renderStage(){
}
/* ---- steps ---- */
const STEPS=[["type","Order type"],["size","Size"],["discs","Discs"],["colour","Vinyl colour"],["labels","Centre labels"],["inner","Inner sleeves"],["jacket","Jacket"],["finish","Jacket finish"],["extras","Extras"],["cut","Mastering & cutting"],["tp","Test pressings"],["ship","Shipping"],["tracks","Tracklist & audio"],["art","Artwork"],["review","Review & order"]];
const STEPS=[["type","Order type"],["size","Size"],["discs","Discs"],["colour","Vinyl colour"],["labels","Centre labels"],["inner","Inner sleeves"],["jacket","Jacket"],["finish","Jacket finish"],["extras","Extras"],["cut","Mastering & cutting"],["tp","Test pressings"],["ship","Shipping"],["tracks","Tracklist & audio"],["art","Artwork"],["pay","How to pay"],["review","Review & order"]];
const PAY=STEPS.findIndex(s=>s[0]==="pay")+1,RV=STEPS.length;
const stT=n=>STEPS[n-1][0]==="review"&&fansOn()?"Review & launch":STEPS[n-1][1];
const JVIEW=[6,7,8,9,14];
const IC={
type:'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2.5"/>',
@@ -1167,6 +1295,12 @@ const IC={
tracks:'<path d="M3 6h12M3 11h12M3 16h7"/><path d="M17 17V7l4-1"/><circle cx="15.5" cy="17" r="1.8"/>',
art:'<rect x="3" y="4" width="18" height="16" rx="1.5"/><circle cx="8.5" cy="9" r="1.8"/><path d="M3 17l5-4 4 3 4-5 5 5"/>',
review:'<path d="M5 12.5l4.5 4.5L19 7.5"/>',
pay:'<rect x="2.5" y="6" width="19" height="13" rx="2"/><path d="M2.5 10.5h19"/><circle cx="16.5" cy="15" r="1.6"/>',
fans:'<circle cx="8.5" cy="8" r="3"/><path d="M2.5 20c0-3.4 2.7-6 6-6s6 2.6 6 6"/><circle cx="17" cy="9" r="2.4"/><path d="M15.6 14.3c3 .2 5.9 2.4 5.9 5.7"/>',
plant:'<path d="M3 20V10.5l5 3v-3l5 3V5h4.5v15z"/><path d="M2 20h20M17.5 5h3v15"/>',
vault:'<rect x="4.5" y="10" width="15" height="10.5" rx="2"/><path d="M8 10V7.5a4 4 0 0 1 8 0V10"/><circle cx="12" cy="15" r="1.4"/>',
refund:'<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
card:'<rect x="2.5" y="5.5" width="19" height="13" rx="2"/><path d="M2.5 9.5h19M6 15h4"/>',
loop:'<path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3"/><path d="M18 3v4h-4M6 21v-4h4"/>',
print:'<path d="M7 9V3h10v6M7 17H4v-7h16v7h-3"/><rect x="7" y="14" width="10" height="7"/>',
file:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 13h6M9 17h6"/>',
@@ -1235,6 +1369,7 @@ function stepHTML(){
<details class="faq"><summary>Does the plant check the records anyway?</summary><p>Yes. Every record is inspected optically after pressing, and a sample is played and compared with the master automatically (quality automation). Your test pressing approval is about how it sounds to you.</p></details>
<details class="faq"><summary>Does it slow the order down?</summary><p>The quoted lead time is counted from your test-pressing approval, because the plant cannot be held to a clock that waits for approvals (production timeline). Listen within a few days and the order keeps its slot.</p></details>`;
H[12]=`<div class="expl">Where the records go. Pick-up at the plant is free; delivery rates are estimates until carriers quote. Addresses are needed to order, not for a quote.</div>
<div class="note info" id="shipFan" hidden><b>Fan-funded (step ${PAY}):</b> every fan pays shipping at checkout, at cost, and gets one tracked parcel. The fans decide how many are pressed; the choice below is for your own copies and the test pressings.</div>
<div class="row"><span class="lbl">Copies</span><div class="stepper"><button type="button" data-act="qty" data-d="-1" aria-label="Fewer copies">&#x2212;</button><input type="number" class="qtyIn" id="qtyShip" min="25" max="5000" aria-label="Copies"><small>pcs</small><button type="button" data-act="qty" data-d="1" aria-label="More copies">+</button></div></div>
<div class="tiles" data-k="ship">${tile("pickup",icon("pickup",30)+"<b>Pick-up</b><small>At the plant, Chiang Mai</small>"+ptag("sh_pickup"))}${tile("th",icon("ship",30)+"<b>Thailand</b><small>Courier, per carton</small>"+ptag("sh_th"))}${tile("intl",icon("globe",30)+"<b>International</b><small>Air parcel, per kg</small>"+ptag("sh_intl"))}</div>
<div class="note info" id="shipCalc"></div>
@@ -1261,12 +1396,30 @@ function stepHTML(){
<label class="drop" id="dropInner"><input type="file" id="fileInner" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="di">&#x2B07;</span><span><b>Inner sleeve</b>: the sleeve front, square, 300 dpi.</span></label>
<div id="artRes"></div>
<p class="hint">PDF previews are drawn with pdf.js. Template sizes follow published plant templates until the packaging partner's dielines replace them.</p>`;
H[15]=`<div class="rv"><div id="rvConf"></div><div id="rvMiss"></div><div id="rvSoft"></div>
H[15]=`<div class="expl"><b>Pay the plant</b> for the run yourself, or <b>let your fans fund it</b>: they pre-order into a separate account and are refunded automatically if the goal is missed.</div>
<div class="tiles c2 route" data-k="route">${tile("direct",icon("plant",32)+"<b>Pay the plant directly</b><small>A deposit to start, the rest before dispatch</small>"+ptag("rt_direct"))}${tile("fans",icon("fans",32)+"<b>Let fans fund it</b><small>Fans pre-order; refunded if the goal is missed</small>"+ptag("rt_fans"))}</div>
<fieldset class="camp" id="fsCamp">
<h4>Your price to fans, per copy</h4>
<div class="fprow"><div class="stepper fp"><button type="button" data-act="fp" data-d="-1" aria-label="Lower fan price">&#x2212;</button><input type="number" id="fanPrice" aria-label="Fan price"><small id="fpCur">THB</small><button type="button" data-act="fp" data-d="1" aria-label="Higher fan price">+</button></div><input type="range" id="fanR" aria-label="Fan price"></div>
<div class="try" id="fpChips"></div>
<div class="need" id="need"></div><p class="hint" id="needHint"></p>
<div id="fundKpi"></div>
<h4>Campaign</h4>
<div class="camprow"><div class="fld"><span>Campaign length</span><div class="rngrow"><input type="range" id="days" min="${R.campDays.v.min}" max="${R.campDays.v.max}" step="1" aria-label="Campaign length in days"><b class="daysv" id="daysV"></b></div></div>
<div class="fld"><span>Copies for yourself</span><div class="rngrow"><div class="stepper"><button type="button" data-act="acop" data-d="-1" aria-label="Fewer copies for yourself">&#x2212;</button><input type="number" id="acop" min="0" max="200" step="5" aria-label="Copies for yourself"><small>pcs</small><button type="button" data-act="acop" data-d="1" aria-label="More copies for yourself">+</button></div></div></div></div>
<div class="limrow"><label class="chk"><input type="checkbox" id="limT"> <span><b>Limited edition</b></span></label><span class="capf" id="capF">of <input type="number" id="cap" min="1" max="5000" step="50" aria-label="Edition size"> numbered copies</span></div>
<label class="chk"><input type="checkbox" id="vatT"> <span>Show the numbers with ${R.vatPct.v} % VAT taken out of the fan price</span></label>
<div id="fundEcon"></div>
<h4>Your fans' money is safe</h4>
<div class="safe"><div><span class="si">${icon("vault",17)}</span><div><b>Held apart</b><small>Pre-orders sit in a separate account and are never used to run the plant until the campaign succeeds.</small></div></div><div><span class="si">${icon("refund",17)}</span><div><b>Refunded automatically</b><small>If the goal is missed, every fan gets their money back. Nobody ends up with a garage full of records.</small></div></div><div><span class="si">${icon("card",17)}</span><div><b>Pay the way fans pay</b><small>PromptPay QR, cards and wallets. Card details never touch the plant's servers.</small></div></div></div>
<div class="launch"><button type="button" class="btn" data-act="launch" id="bLaunch">Launch the campaign &#x2192;</button><p class="hint" id="launchMsg"></p></div>
</fieldset>`;
H[16]=`<div class="rv"><div id="rvConf"></div><div id="rvMiss"></div><div id="rvSoft"></div>
<div class="rvgrid"><div class="sumcard"><div class="sumhd"><span id="rvDisc"></span><div><h3>Order summary</h3><div class="chips" id="rvChips"></div></div></div><div class="tscroll"><table id="rvTab"></table></div><p class="hint">Excludes 7 % VAT. <span class="pass">&#x2197; pass-through</span> lines are the partner's price plus the handling margin shown. Badges give each rate's basis; hover for the source.</p></div>
<div><div class="sumcard"><h4 style="margin-top:0">When it ships</h4><div id="rvLead"></div></div>
<div class="sumcard" style="margin-top:14px"><h4 style="margin-top:0">Catalogue number</h4><input type="text" id="catno2" maxlength="15" class="mono" style="width:100%;text-transform:uppercase" spellcheck="false" autocomplete="off" aria-label="Catalogue number"><p class="hint">Your reference for this release, scribed in the dead wax: A to Z, 0 to 9 and hyphen, up to 15. Needed to order, not for a quote.</p>
<h4>Notes for the plant</h4><textarea id="notes" rows="3" style="width:100%" placeholder="Anything we should know: deadlines, a release date, a pressing to match"></textarea>
<h4>Let fans fund it instead?</h4><p class="hint">Fans pre-order on a campaign page; the record is pressed if enough of them do, and every fan is refunded if not.</p><a class="btn sec" href="../artist-order/">Fund it with fans instead &#x2192;</a></div></div></div>
<h4>How you pay</h4><div id="rvPay"></div></div></div></div>
<details class="pl"><summary>How we price: price list ${PLV} and where every number comes from</summary><div id="plBox"></div></details>
<p class="foot" style="margin:18px 0 6px">Demo page from docs/order.html; prices are indicative and not an offer; the platform's name is not decided yet.</p></div>`;
return H.map((h,i)=>i?`<div class="stp" data-step="${i}" hidden>${h}</div>`:"").join("");
@@ -1277,7 +1430,7 @@ const ENUM={otype:["new","repress","tponly","print"],size:["12","10","7"],weight
jk:["none","plain","printed3","printed5","gatefold"],innerT:["paper-white","paper-black","poly-white","poly-black","printed"],inner:["paper","poly","printed"],innerCol:["white","black"],
jacket:["none","plain","printed","gatefold"],spine:["thin","thick"],finish:["uncoated","matt","gloss","spotuv"],source:["digital","tape","lacquer"],master:["none","inhouse","partner"],
cutStyle:["fidelity","balanced","loud"],tps:[0,3,5,10],ship:["pickup","th","intl"],ship2:["th","intl"],duty:["dap","ddp"],gaps:["files","add","none"],cleaning:[true,false],
cur:["THB","USD","EUR"],view:["record","jacket"],genre:GENRES.map(g=>g[0]),discs:[1,2,3,4],rpm:["33","45"]};
cur:["THB","USD","EUR"],view:["record","jacket"],genre:GENRES.map(g=>g[0]),discs:[1,2,3,4],rpm:["33","45"],route:["direct","fans"],scen:["sell","make","miss"]};
const REPW="A repress presses from the archived metal: nothing new is mastered or cut";
function why(k,v){
switch(k){
@@ -1322,6 +1475,8 @@ function setK(k,v){
else if(k in S){if(S[k]===v){renderAll();return true;}S[k]=v;}
else return false;
if(k==="discs"){S.vd=Math.min(S.vd,v-1);normalize();autoSplit();renderTracks();}
if(k==="route"){resetCampaign();S.placed=null;}
if(k==="scen")resetSim();
if(k==="size"||k==="cutStyle"||k==="ship"||k==="ship2")normalize();
if(k==="size")renderTracks();
if(k==="cur")renderPL();
@@ -1350,7 +1505,9 @@ const PT={
src_digital:()=>"included",src_tape:()=>plus(R.tape.v),src_lacquer:()=>"no blanks, cut or mastering",
m_none:()=>"no mastering line",m_inhouse:()=>plus(R.master.v)+" / side",m_partner:()=>{const n=audioTracks().length;return money(n*R.masterTrack.v)+" for "+n+" tracks";},
tp0:()=>"no charge",tp3:()=>money(3*R.tp.v),tp5:()=>money(5*R.tp.v),tp10:()=>money(10*R.tp.v),
sh_pickup:()=>"free",sh_th:()=>money(R.shipTH.v)+" / carton, est.",sh_intl:()=>money(R.shipIntl.v)+" / kg, est."
sh_pickup:()=>"free",sh_th:()=>money(R.shipTH.v)+" / carton, est.",sh_intl:()=>money(R.shipIntl.v)+" / kg, est.",
rt_direct:()=>pctT(R.deposit.v)+" deposit: "+money(quote(S.qty).total*R.deposit.v),
rt_fans:()=>{const E=econ();return E.goal?fmtInt(E.goal)+" fans at "+money(S.fanPrice):"Raise the fan price";}
};
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag"].forEach(k=>PT["x_"+k]=()=>perC(R[k].v));
@@ -1391,6 +1548,7 @@ function sync(){
const G=GENRES.find(g=>g[0]===S.genre)||GENRES[0];$("#genreChk").innerHTML=G[2]==="bass"?(S.cutStyle==="loud"?"Bass-heavy music with the Loud style: keep sides short (limits at 60 %).":"Bass-heavy music: consider the <b>Loud</b> cutting style (step 10) and 45 rpm where a side allows."):G[2]==="dyn"?"Dynamic music: the <b>Fidelity</b> style keeps the most headroom.":"The level check below looks at each side's speed for this genre.";
const aw=rep()?"A repress plays the archived master":"";$("#dropAudio").classList.toggle("off",!!aw);$("#fileAudio").disabled=!!aw;$("#dropAudio").title=aw;
[["jacket","#dropJacket","#fileJacket"],["label","#dropLabel","#fileLabel"],["inner","#dropInner","#fileInner"]].forEach(([k,d,f])=>{const w=tplOff(k);$(d).classList.toggle("off",!!w);$(f).disabled=!!w;$(d).title=w;});
$("#shipFan").hidden=!fansOn();
}
function renderFx(){
const d=S.dl[Math.min(S.vd,S.discs-1)],open=S.fxOpen===undefined?d.fx:S.fxOpen;
@@ -1418,40 +1576,57 @@ function summ(n){
case "ship":return fmtInt(S.qty)+" pcs → "+({pickup:"pick-up",th:"Thailand",intl:"international"}[S.ship])+(S.split?" + 1":"");
case "tracks":return S.tracks.length+" tracks, "+audioSides().length+" side"+(audioSides().length===1?"":"s");
case "art":{const ks=["jacket","label","inner"].filter(k=>!tplOff(k)),n=ks.filter(k=>S.art[k]).length;return ks.length?n+" of "+ks.length+" files":"No artwork needed";}
case "pay":{if(!fansOn())return "Direct, "+pctT(R.deposit.v)+" deposit";const E=econ();return E.goal?"Fans: "+fmtInt(E.goal)+" at "+money(S.fanPrice):"Fans: raise the price";}
case "review":{const m=missing().length;return m?m+" to complete":"Ready to order";}
}
return "";
}
function renderRail(){
const M=missing(),rv=!!S.visited[15];
$("#rail").innerHTML=STEPS.map(([id,t],i)=>{const n=i+1,on=n===S.step,vis=!!S.visited[n],miss=rv&&M.some(m=>m.s===n);
const M=missing(),rv=!!S.visited[RV];
$("#rail").innerHTML=STEPS.map(([id],i)=>{const n=i+1,t=stT(n),on=n===S.step,vis=!!S.visited[n],miss=rv&&M.some(m=>m.s===n);
return `<button type="button" class="ri${on?" on":""}${vis?" vis":""}${vis&&!on&&!miss?" done":""}${miss?" miss":""}" data-act="go" data-s="${n}" title="${esc(t+": "+summ(n))}"><span class="rn">${n}</span><span class="ric">${icon(id,18)}</span><span class="rt"><b>${esc(t)}</b><small>${esc(summ(n))}</small></span><span class="rch">&#x203A;</span></button>`;}).join("");
const on=$("#rail .ri.on"),c=$("#rail");
if(on&&c&&c.scrollWidth>c.clientWidth+4){const o=on.offsetLeft-c.offsetLeft;if(o<c.scrollLeft||o+on.offsetWidth>c.scrollLeft+c.clientWidth)c.scrollLeft=Math.max(0,o-30);}
}
function renderHead(){
const n=S.step,app=$("#app");
$("#pNum").textContent=n;$("#pIcon").innerHTML=icon(STEPS[n-1][0],20);$("#pTitle").textContent=STEPS[n-1][1];
$("#pNum").textContent=n;$("#pIcon").innerHTML=icon(STEPS[n-1][0],20);$("#pTitle").textContent=stT(n);
$$(".stp").forEach(x=>x.hidden=+x.dataset.step!==n);
app.classList.toggle("wide",n===15);app.classList.toggle("closed",S.closed&&n!==15);
$("#reopenB").hidden=!(S.closed&&n!==15);
$("#pFoot").hidden=n===15;$("#rvBar").hidden=n!==15;
$("#nextB").innerHTML=n<15?"Next: "+esc(STEPS[n].slice(1)[0])+" &#x2192;":"";
app.classList.toggle("wide",n===RV);app.classList.toggle("closed",S.closed&&n!==RV);
$("#reopenB").hidden=!(S.closed&&n!==RV);
$("#pFoot").hidden=n===RV;$("#rvBar").hidden=n!==RV;
$("#nextB").innerHTML=n<RV?"Next: "+esc(stT(n+1))+" &#x2192;":"";
}
const QTYW="Fan-funded: the fans decide how many are pressed; the goal follows from the fan price (step "+PAY+")";
function renderCard(){
const Q=quote(S.qty),sw=shipWindow(),L=leadInfo(false),M=missing();
$("#qTot").textContent=money(Q.total);
$("#qPer").textContent=money(Q.per)+" per copy"+(Q.hSum?", plus shipping":"")+", excl. VAT";
$("#qShip").textContent=dRange(sw[0],sw[1]);
$("#qShipN").textContent=L.repress?"Repress, quoted 2 to 3 weeks":"If ordered today; "+L.qw[0]+" to "+L.qw[1]+" weeks quoted from your test-pressing approval";
$("#qNote").textContent=S.qty<R.minRun.v?"Below the "+R.minRun.v+"-copy minimum run: a quote only. Small runs cost more per copy.":"";
const Q=quote(S.qty),sw=shipWindow(),L=leadInfo(false),M=missing(),F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,cw=F?campWhy(E):"";
if(F){
$("#qL1").textContent="Fan price";$("#qTot").textContent=money(S.fanPrice);
$("#qPer").textContent="per copy, plus shipping";
$("#qL2").textContent="Fans needed";$("#qShip").textContent=E.goal?fmtInt(E.goal)+" fans":"Price too low";
$("#qShipN").textContent=E.goal?money(E.goal*S.fanPrice)+" in pre-orders in "+S.days+" days"+(capOn()?", edition of "+fmtInt(capOn()):""):"The fan price does not cover the cost per copy";
$("#qKeepW").hidden=false;
$("#qKeep").innerHTML=G?`<div class="ql g">You keep at the goal</div><div class="qd">${money(G.per)} a copy</div><div class="qs">${money(G.keep)} in all, after the ${money(E.tSum)} one-off setup, pressing and fees</div>`:`<div class="ql g">You keep at the goal</div><div class="qd">${money(0)}</div><div class="qs">Raise the fan price in step ${PAY}.</div>`;
$("#qNote").textContent=cw||"The run is the fans' pre-orders plus your own copies.";$("#qNote").classList.toggle("info",!cw);
}else{
$("#qL1").textContent="Estimated price";$("#qTot").textContent=money(Q.total);
$("#qPer").textContent=money(Q.per)+" per copy"+(Q.hSum?", plus shipping":"")+", excl. VAT";
$("#qL2").textContent="Estimated ship date";$("#qShip").textContent=dRange(sw[0],sw[1]);
$("#qShipN").textContent=L.repress?"Repress, quoted 2 to 3 weeks":"If ordered today; "+L.qw[0]+" to "+L.qw[1]+" weeks quoted from your test-pressing approval";
$("#qKeepW").hidden=true;
$("#qNote").textContent=S.qty<R.minRun.v?"Below the "+R.minRun.v+"-copy minimum run: a quote only. Small runs cost more per copy.":"";$("#qNote").classList.remove("info");
}
$("#qcard").classList.toggle("min",S.qmin);
let sv=null;try{sv=JSON.parse(localStorage.getItem("cnx-order-saved")||"null");}catch(e){}
$("#qSaved").innerHTML=sv&&sv.h?`Saved ${new Date(sv.at).toLocaleString("en-GB",{day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"})} · <button type="button" class="linkb" data-act="restore">Restore</button>`:"";
$$(".qtyIn").forEach(i=>{if(document.activeElement!==i)i.value=S.qty;});
$("#pbTot").textContent=money(Q.total);$("#pbPer").textContent=money(Q.per)+" / copy · ships "+fmtDs(sw[0]);$("#pbPer").title="Ships "+dRange(sw[0],sw[1]);
const pb=$("#pbBtn");if(S.step===15){pb.innerHTML="Place order &#x2192;";pb.disabled=M.length>0||!!S.placed;pb.title=M.length?"Complete the items in the yellow box first":"";}else{pb.innerHTML="Review &#x2192;";pb.disabled=false;pb.title="";}
const plb=$("#placeB");plb.disabled=M.length>0||!!S.placed;plb.title=M.length?"Complete the items in the yellow box first":S.placed?"Already placed":"";
/* copies: set by the fans on the fan route, so the steppers stay in place, disabled, and show the run at the goal */
$$(".qtyIn").forEach(i=>{i.disabled=F;i.title=F?QTYW:"";if(document.activeElement!==i)i.value=F?(E.goal?E.goal+S.artistCopies:""):S.qty;});
$$('button[data-act="qty"]').forEach(b=>{b.disabled=F;b.title=F?QTYW:"";});
if(F){$("#pbTot").textContent=money(S.fanPrice)+(E.goal?" · "+fmtInt(E.goal)+" fans":"");$("#pbPer").textContent=G?"keep "+money(G.per)+" a copy":"raise the fan price";$("#pbPer").title="";}
else{$("#pbTot").textContent=money(Q.total);$("#pbPer").textContent=money(Q.per)+" / copy · ships "+fmtDs(sw[0]);$("#pbPer").title="Ships "+dRange(sw[0],sw[1]);}
const done=F?S.launched:!!S.placed,wy=M.length?"Complete the items in the yellow box first":cw?cw:done?(F?"Already launched":"Already placed"):"";
const pb=$("#pbBtn");if(S.step===RV){pb.innerHTML=F?"Launch &#x2192;":"Place order &#x2192;";pb.disabled=!!wy;pb.title=wy;}else{pb.innerHTML="Review &#x2192;";pb.disabled=false;pb.title="";}
const plb=$("#placeB");plb.dataset.act=F?"launch":"place";plb.innerHTML=F?"Launch the campaign &#x2192;":"Place order &#x2192;";plb.disabled=!!wy;plb.title=wy;
}
/* ---- step 13: sides, tracks, checks ---- */
@@ -1503,7 +1678,7 @@ const AF={name:"name",email:"e-mail",phone:"phone",line1:"address line",city:"ci
function addrMiss(g,fields){const a=S.addr[g];return fields.filter(f=>!String(a[f]||"").trim()||(f==="email"&&!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(a[f]).trim())));}
function missing(){
const M=[];
if(S.qty<R.minRun.v)M.push({s:12,t:"Copies: the minimum run for an order is "+R.minRun.v,f:"qtyShip"});
if(!fansOn()&&S.qty<R.minRun.v)M.push({s:12,t:"Copies: the minimum run for an order is "+R.minRun.v,f:"qtyShip"});
const ss=sideStats(),as=ss.filter(s=>s.type==="audio");
if(!as.length)M.push({s:13,t:"Tracklist: no side has audio"});
as.filter(s=>!s.count).forEach(s=>M.push({s:13,t:"Side "+s.n+": no tracks added"}));
@@ -1527,22 +1702,47 @@ function soft(){
const badge=id=>{const r=R[id];return r?`<span class="badge b-${r.k}" title="${esc(r.src)}">${{doc:"doc",team:"team",est:"est."}[r.k]}</span>`:"";};
function orderNo(){let h=0;for(const c of stateHash())h=(h*31+c.charCodeAt(0))>>>0;return 1000+h%9000;}
function renderReview(){
const Q=quote(S.qty),M=missing(),L=leadInfo(false),sw=shipWindow(),q=S.qty;
$("#rvMiss").innerHTML=M.length?`<div class="missbox"><h3>&#x26A0; Please complete the following before ordering:</h3><ul>${M.map(m=>`<li><button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${m.f||(m.fs?m.fs[0][0]:"")}">${esc(m.t)}</button>${m.fs?": missing "+m.fs.map(([f,n])=>`<button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${f}">${n}</button>`).join(", "):""}</li>`).join("")}</ul></div>`
:`<div class="missbox ok"><h3>&#x2713; Everything needed to order is filled in.</h3><span class="hint">Place the order with the button at the bottom.</span></div>`;
$("#rvSoft").innerHTML=`<div class="softbox"><b>Can follow after the order</b><ul>${soft().map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`;
const F=fansOn(),E=F?econ():null,G=E&&E.goal?E.at(E.goal):null,M=missing(),L=leadInfo(F),sw=shipWindow(),q=F?(E.goal||E.min)+S.artistCopies:S.qty,Q=quote(q),W=F?campWindow():null,cap=capOn();
$("#rvMiss").innerHTML=M.length?`<div class="missbox"><h3>&#x26A0; Please complete the following before ${F?"launching":"ordering"}:</h3><ul>${M.map(m=>`<li><button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${m.f||(m.fs?m.fs[0][0]:"")}">${esc(m.t)}</button>${m.fs?": missing "+m.fs.map(([f,n])=>`<button type="button" class="linkb" data-act="miss" data-s="${m.s}" data-f="${f}">${n}</button>`).join(", "):""}</li>`).join("")}</ul></div>`
:`<div class="missbox ok"><h3>&#x2713; Everything needed to ${F?"launch":"order"} is filled in.</h3><span class="hint">${F?(S.launched?"The campaign is running above (simulated).":"Launch the campaign with the button at the bottom."):"Place the order with the button at the bottom."}</span></div>`;
$("#rvSoft").innerHTML=`<div class="softbox"><b>Can follow after the ${F?"launch":"order"}</b><ul>${soft().map(x=>`<li>${esc(x)}</li>`).join("")}</ul></div>`;
$("#rvDisc").innerHTML=miniDisc(S.dl[0],92,{label:S.labels});
$("#rvChips").innerHTML=[rep()?"Repress":"New release","Vinyl",S.size+"\"",S.discs>1?S.discs+" discs":"1 disc","Qty: "+fmtInt(q),PLV].map(c=>`<span>${esc(c)}</span>`).join("");
$("#rvChips").innerHTML=[rep()?"Repress":"New release","Vinyl",S.size+"\"",S.discs>1?S.discs+" discs":"1 disc"].concat(F?["Fan-funded"].concat(E.goal?["Goal: "+fmtInt(E.goal)+" fans"]:[]):["Qty: "+fmtInt(q)],[PLV]).map(c=>`<span>${esc(c)}</span>`).join("");
const tr=x=>`<tr><td>${esc(x.t)} ${badge(x.id)}${x.pass!=null?` <span class="pass">&#x2197; partner ${money(x.pass)} + ${pctT(R.handling.v)}</span>`:""}<small class="qrm">${x.u?fmtInt(x.n)+" "+x.u+" × ":""}${money(x.r)}</small></td><td class="num qr">${x.u?fmtInt(x.n)+" "+x.u:""}</td><td class="num qr">${money(x.r)}</td><td class="num">${money(x.a)}</td></tr>`;
$("#rvTab").innerHTML=`<tbody><tr class="hd"><th>One-off, for this release</th><th class="num qr">Qty</th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">One-off total</td><td class="num">${money(Q.tSum)}</td></tr>
<tr class="hd"><th>Per copy × ${fmtInt(q)}</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">${money(Q.cSum)} per copy × ${fmtInt(q)} copies</td><td class="num">${money(Q.cSum*q)}</td></tr>
const head=`<tbody><tr class="hd"><th>One-off, for this release</th><th class="num qr">Qty</th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.T.map(tr).join("")}<tr class="sub"><td colspan="3">One-off total</td><td class="num">${money(Q.tSum)}</td></tr>
<tr class="hd"><th>Per copy × ${fmtInt(q)}${F?(E.goal?", the goal: "+fmtInt(E.goal)+" fans + "+fmtInt(S.artistCopies)+" for you":", the minimum run"):""}</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Per copy</th></tr>${Q.C.map(tr).join("")}<tr class="sub"><td colspan="3">${money(Q.cSum)} per copy × ${fmtInt(q)} copies</td><td class="num">${money(Q.cSum*q)}</td></tr>`;
$("#rvTab").innerHTML=F?head+`
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr"></th><th class="num"></th></tr><tr><td colspan="3">One tracked parcel per fan, paid by the fan at checkout, at cost</td><td class="num"><small>paid by fans</small></td></tr>
<tr class="tot"><td colspan="3">Cost of the run${E.goal?" at the goal":""}, excl. VAT</td><td class="num">${money(Q.tSum+q*Q.cSum)}</td></tr>`+(G?`<tr class="tot"><td colspan="3">Fan money after payment fees${S.vat?" and VAT":""}, ${fmtInt(E.goal)} × ${money(E.net1)} ${badge("fee")}</td><td class="num">${money(G.net)}</td></tr>
<tr class="tot"><td colspan="3">You keep at the goal</td><td class="num">${money(G.keep)}</td></tr><tr><td colspan="3">That is, a copy</td><td class="num">${money(G.per)}</td></tr>
<tr><td colspan="4"><small>Nothing is paid up front: the plant is paid from the fans' money when the campaign succeeds. More fans than the goal spread the one-off setup over more copies (step ${PAY}).</small></td></tr></tbody>`
:`<tr><td colspan="4"><div class="note bad" style="margin:4px 0">At ${money(S.fanPrice)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price in step ${PAY}.</div></td></tr></tbody>`)
:head+`
<tr class="hd"><th>Shipping</th><th class="num qr"></th><th class="num qr">Rate</th><th class="num">Amount</th></tr>${Q.H.map(tr).join("")}
<tr class="tot"><td colspan="3">Order total, excl. VAT</td><td class="num">${money(Q.total)}</td></tr><tr class="tot"><td colspan="3">Per copy, before shipping</td><td class="num">${money(Q.per)}</td></tr>
<tr><td colspan="3">Deposit to start, ${pctT(R.deposit.v)} ${badge("deposit")}</td><td class="num">${money(Q.total*R.deposit.v)}</td></tr><tr><td colspan="3">Balance before dispatch</td><td class="num">${money(Q.total*(1-R.deposit.v))}</td></tr></tbody>`;
$("#rvLead").innerHTML=(L.repress?`<div class="kv"><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.</p>`
:`<div class="kv"><span>Before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">The quote counts from your last approval: the plant cannot be held to a clock that waits for approvals. ${L.printed?"A printed jacket is on the critical path; supplying your own saves 1 to 2 weeks.":"No printed jacket to wait for, which saves 1 to 2 weeks."} Stage times: mature-plant column of the production timeline.</p>`)+
const closeT=(S.launched?"Closes ":"If launched today, closes ");
$("#rvLead").innerHTML=(F?(L.repress?`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline); it starts when the campaign succeeds.</p>`
:`<div class="kv"><span>Campaign</span><b>${S.days} days</b><span>${closeT}</span><b>${fmtD(W.close)}</b><span>Then, before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>If funded, ships about</span><b>${dRange(W.ship[0],W.ship[1])}</b></div><p class="hint">Cutting starts only when the campaign succeeds, because a lacquer has to be plated within one to three days (artist platform). The quote counts from your last approval. Stage times: mature-plant column of the production timeline.</p>`)
:(L.repress?`<div class="kv"><span>Repress from the archived mother</span><b>2 to 3 weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">A repress skips mastering, cutting and all three approvals (production timeline): about 7 to 11 working days of work, quoted with queue and buffer.</p>`
:`<div class="kv"><span>Before your test-pressing approval</span><b>typically ${L.pw[0]} to ${L.pw[1]} weeks</b><span>From your test-pressing approval</span><b>${L.qw[0]} to ${L.qw[1]} weeks, quoted</b><span>Ordered today, ships about</span><b>${dRange(sw[0],sw[1])}</b></div><p class="hint">The quote counts from your last approval: the plant cannot be held to a clock that waits for approvals. ${L.printed?"A printed jacket is on the critical path; supplying your own saves 1 to 2 weeks.":"No printed jacket to wait for, which saves 1 to 2 weeks."} Stage times: mature-plant column of the production timeline.</p>`))+
`<details><summary class="hint">Stages${L.repress?"":" before your test-pressing approval"}</summary><table>${L.pre.map(s=>`<tr><td>${s.gate?"&#x25C6; ":""}${esc(s.t)}</td><td class="num">${s.d[0]} to ${s.d[1]} days</td></tr>`).join("")}</table></details>`;
if(S.placed){const P=S.placed,no=P.no,yr=P.at.getFullYear(),nm=S.addr.b.name||"the customer",soItems=Q.T.concat(Q.C.map(x=>Object.assign({},x,{n:x.n*q}))).concat(Q.H.filter(x=>x.r));
$("#rvPay").innerHTML=F?`<div class="kv"><span>Fan-funded, fan price</span><b>${money(S.fanPrice)}</b><span>Fans needed</span><b>${E.goal?fmtInt(E.goal):"price too low"}</b><span>Your break-even</span><b>${E.be!=null?fmtInt(E.be)+" fans":"none"}</b><span>You keep at the goal</span><b>${G?money(G.per)+" a copy":money(0)}</b><span>Campaign</span><b>${S.days} days</b><span>Limited edition</span><b>${cap?fmtInt(cap)+" copies":"no"}</b><span>Your own copies</span><b>${fmtInt(S.artistCopies)}</b><span>Payment fees ${badge("fee")}</span><b>${pctT(R.fee.v)}</b></div><p class="hint">Set in step ${PAY}. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Change the campaign or pay the plant directly</button></p>`
:`<div class="kv"><span>Direct order, deposit ${pctT(R.deposit.v)} ${badge("deposit")}</span><b>${money(Q.total*R.deposit.v)}</b><span>Balance before dispatch</span><b>${money(Q.total*(1-R.deposit.v))}</b></div><p class="hint">Or let your fans pre-order it: the record is pressed when enough of them have, and every fan is refunded if not. <button type="button" class="linkb" data-act="go" data-s="${PAY}">Fan funding in step ${PAY}</button></p>`;
if(F&&S.launched){const no=orderNo(),yr=S.launchDate.getFullYear(),nm=S.addr.b.name||"the artist";
$("#rvConf").innerHTML=`<div class="cgrid"><div class="sumcard"><div class="simhd"><h3>Your campaign <small class="hint">simulated</small></h3><div class="seg cmp" data-k="scen"><button type="button" data-v="sell">Sells out</button><button type="button" data-v="make">Just makes it</button><button type="button" data-v="miss">Falls short</button></div></div>
<p class="hint" style="margin:6px 0 0">Pick how it goes and press play: ${S.days} days in about 8 seconds, or drag the slider to any day.</p>
<div class="simrow"><button type="button" class="btn sec sm" data-act="play" id="playBtn"></button><input type="range" id="simDay" min="0" max="${S.days}" step="0.1" aria-label="Campaign day"><span class="hint" id="dayLbl"></span></div>
<div class="bar big"><i id="simBar"></i></div><div class="kpis k3" id="simKpis"></div><p id="simStatus" style="font-size:13.5px;margin:8px 0 0"></p><div id="simOut"></div></div>
<div class="conf"><h3>&#x2713; Campaign launched (simulated): CNX-${yr}-${no}</h3><p>No page went live and nobody was charged. In the real platform the campaign is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (artist platform, digital factory).</p>
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: the artist, matched by e-mail or created, with the billing address.</li>
<li><b>Campaign</b> <code>CMP-${yr}-0${no}</code>, a custom document from launch: ${money(S.fanPrice)} a copy, goal ${fmtInt(E.goal||0)} fans, closes ${fmtD(W.close)}${cap?", limited to "+fmtInt(cap):""}.</li>
<li><b>Payment Entry</b> for each pledge: an advance into the ring-fenced account as it arrives, with the fan as a customer. A missed campaign's refunds are posted as payment entries too.</li>
<li>If it succeeds: a <b>Sales Order</b> the advances are allocated to, for the fans' copies${S.artistCopies?" plus your "+fmtInt(S.artistCopies):""}, and a <b>Delivery Note</b> for each fan's parcel.</li>
<li><b>Release</b> ${esc(S.catno)} with ${sideNames().length} <b>Side</b> records (custom document types in the <code>cnx_erp</code> app) and three <b>Artist Gate</b> records: master plan, master, test pressing.</li></ol>
<p><button type="button" class="btn sec" data-act="unlaunch">Back to the campaign settings</button></p></div></div>`;
updateSim();}
else if(!F&&S.placed){const P=S.placed,no=P.no,yr=P.at.getFullYear(),nm=S.addr.b.name||"the customer",soItems=Q.T.concat(Q.C.map(x=>Object.assign({},x,{n:x.n*q}))).concat(Q.H.filter(x=>x.r));
$("#rvConf").innerHTML=`<div class="conf"><h3>&#x2713; Order placed (simulated): CNX-${yr}-${no}</h3><p>Nothing was sent or charged. In the real platform the order is written to <b>ERPNext</b>, the plant's only system of record, through the plant's integration layer and its outbox; the public side never talks to ERPNext directly (digital factory, artist platform).</p>
<ol><li><b>Customer</b> <code>${esc(nm)}</code>: matched by e-mail or created, with the billing${S.ship!=="pickup"?" and shipping":""} address${S.split?" and the second address":""} as Address records.</li>
<li><b>Quotation</b> <code>SAL-QTN-${yr}-0${no}</code>: this configuration priced from the ERPNext price list ${PLV} (this page's price list is its prototype), ${soItems.length} items, valid 30 days.</li>
@@ -1553,6 +1753,83 @@ function renderReview(){
<p><button type="button" class="btn sec" data-act="unplace">Back to the order</button></p></div>`;}
else $("#rvConf").innerHTML="";
}
/* ---- step 15: how to pay, the fan campaign panel ---- */
function renderFund(){
const F=fansOn(),E=econ(),fi=$("#fanPrice"),frg=$("#fanR"),fr=FAN_RNG[S.cur],cap=capOn(),cw=campWhy(E);
$("#fsCamp").disabled=!F;$("#fsCamp").title=F?"":ROUTEW;
if(fi.dataset.cur!==S.cur){const a=FAN_IN[S.cur];fi.dataset.cur=S.cur;fi.min=a[0];fi.max=a[1];fi.step=a[2];frg.min=fr[0];frg.max=fr[1];frg.step=fr[2];$("#fpCur").textContent=S.cur;
$("#fpChips").innerHTML=`<span>Try:</span>`+FP[S.cur].map(v=>`<button type="button" class="chip t" data-act="fpc" data-fp="${v}">${fmtInt(v)}</button>`).join("");}
if(document.activeElement!==fi)fi.value=fanIn();frg.value=Math.min(fr[1],Math.max(fr[0],fanIn()));rngP(frg);
$$("#fpChips .chip").forEach(c=>c.classList.toggle("on",Math.abs(toTHB(+c.dataset.fp)-S.fanPrice)<0.01));
$("#days").value=S.days;rngP($("#days"));$("#daysV").textContent=S.days+" days";
const ai=$("#acop");if(document.activeElement!==ai)ai.value=S.artistCopies;
$("#limT").checked=S.limited;const ci=$("#cap");if(document.activeElement!==ci)ci.value=S.capN;
ci.disabled=!F||!S.limited;ci.title=S.limited?"":"Tick the limited edition to set its size";$("#capF").classList.toggle("off",!S.limited);$("#capF").title=ci.title;
$("#vatT").checked=S.vat;
const H=priceHint(E);
$("#need").innerHTML=E.goal==null?`<span class="bad-t">This price does not cover the cost per copy.</span>`:`You need <b>${fmtInt(E.goal)} fans</b> to press it, ${money(E.goal*S.fanPrice)} in pre-orders`;
$("#needHint").textContent=E.goal==null?"Raise the price.":E.be>E.min?"Your break-even is "+E.be+" fans at this price, above the plant's "+E.min+"-copy minimum. From "+H.t+" a copy, "+E.min+" fans are enough."
:"The plant presses from "+E.min+" copies. Below about "+H.t+" a copy you would need more fans to cover the costs.";
let h="",hk="";
if(E.goal==null)hk=`<div class="note bad">At ${money(E.p)} a copy, what is left after payment fees${S.vat?" and VAT":""} does not cover the cost per copy. Raise the fan price.</div>`;
else{const g=E.at(E.goal),rows=keepRows(E),mx=Math.max(...rows.map(r=>r.keep),1);
hk=`<div class="kpis"><div class="kpi hl"><small>Fans needed</small><b>${fmtInt(E.goal)} fans</b><em>${money(E.goal*S.fanPrice)} in pre-orders</em></div><div class="kpi"><small>Your break-even</small><b>${fmtInt(E.be)} fans</b><em>every cost covered</em></div><div class="kpi"><small>You keep at the goal</small><b>${money(g.per)}</b><em>a copy</em></div><div class="kpi"><small>That is</small><b>${money(g.keep)}</b><em>in total</em></div></div>
${cap&&cap<E.goal?`<div class="note bad">The edition is capped at ${fmtInt(cap)}, below the goal of ${fmtInt(E.goal)}: this campaign cannot succeed.</div>`:""}`;
h=`<h4>What you keep if more fans join</h4><div class="keep">${rows.map(r=>`<div class="krow${r.n===E.goal?" goal":""}"><span>${fmtInt(r.n)} fans</span><div class="kb"><i style="width:${Math.max(2,r.keep/mx*100).toFixed(1)}%"></i></div><div class="kv2"><b>${money(r.keep)}</b><small>${money(r.per)} a copy</small></div></div>`).join("")}</div>
<p class="hint">What you keep is after payment fees of about ${(R.fee.v*100).toFixed(1)} %${S.vat?" and VAT":""}, the one-off setup and the pressing. More fans than needed is good news: the one-off setup spreads over more copies. Shipping is paid by each fan at cost.</p>`;}
$("#fundKpi").innerHTML=hk;$("#fundEcon").innerHTML=h;
const b=$("#bLaunch"),n=missing().length;b.disabled=!!cw;b.title=cw;
$("#launchMsg").textContent=cw&&F?cw+".":n?"Launching opens the review first: "+n+" item"+(n>1?"s":"")+" to complete there.":"Simulated: no page goes live and nobody is charged.";
}
/* ---- the campaign simulation (make-a-record): launch, play, outcome ---- */
let TIMER=null;
function stopSim(){if(TIMER){clearInterval(TIMER);TIMER=null;}}
function resetSim(){stopSim();S.simDay=0;S.outcome=null;S.outcomeAct=null;S.finalFans=0;}
function resetCampaign(){resetSim();S.launched=false;S.launchDate=null;}
function checkEnd(){
if(S.simDay>=S.days){if(!S.outcome){const E=econ(),P=finalPledges(E);S.finalFans=P;S.outcome=P>=E.goal?"funded":"missed";}}
else if(S.outcome){S.outcome=null;S.outcomeAct=null;}
}
function startSim(){
stopSim();if(S.simDay>=S.days)resetSim();
TIMER=setInterval(()=>{S.simDay=Math.min(S.days,S.simDay+S.days/80);checkEnd();if(S.simDay>=S.days)stopSim();updateSim();},100);
}
function toggleSim(){if(TIMER)stopSim();else startSim();updateSim();}
function launch(){
if(campWhy(econ()))return;
if(missing().length){S.visited[RV]=true;go(RV);toast("Complete the items in the yellow box first");return;}
resetCampaign();S.launched=true;S.launchDate=new Date(TODAY);S.visited[RV]=true;go(RV);$("#pBody").scrollTop=0;window.scrollTo(0,0);
}
function campChanged(){if(S.launched)resetCampaign();renderAll();}
function setFanPrice(v){S.fanPrice=v;campChanged();}
function updateSim(){
if(!S.launched||!$("#simKpis"))return;
const E=econ(),ended=S.simDay>=S.days,n=pledgesNow(E),goal=E.goal||0,W=campWindow(),cap=capOn();
$$('#rvConf .seg[data-k="scen"] button').forEach(b=>b.classList.toggle("on",b.dataset.v===S.scen));
const sl=$("#simDay");sl.max=S.days;if(document.activeElement!==sl)sl.value=S.simDay;rngP(sl);
$("#dayLbl").textContent="Day "+Math.floor(S.simDay)+" of "+S.days;
$("#playBtn").innerHTML=TIMER?"&#x275A;&#x275A; Pause":ended?"&#x21BA; Replay":"&#x25B6; Play";
const funded=ended&&n>=goal,soldOut=cap&&n>=cap,bi=$("#simBar");
bi.style.width=(goal?Math.min(100,n/goal*100):0).toFixed(1)+"%";bi.className=ended&&!funded?"bad":"";
$("#simKpis").innerHTML=`<div class="kpi hl"><small>Fans</small><b>${fmtInt(n)} / ${fmtInt(goal)}</b><em>${n>goal?Math.round(n/goal*100)+" % of the goal":"pre-orders so far"}</em></div><div class="kpi"><small>Held apart</small><b>${money(n*S.fanPrice)}</b><em>in the separate account</em></div><div class="kpi"><small>${ended?"Closed":"Days left"}</small><b>${ended?fmtDs(W.close):Math.max(0,Math.ceil(S.days-S.simDay))}</b><em>${ended?"campaign over":"closes "+fmtDs(W.close)}</em></div>`;
$("#simStatus").innerHTML=!ended?"If fewer than "+fmtInt(goal)+" fans pre-order by "+fmtD(W.close)+", everyone is refunded automatically."
:funded?`<b style="color:var(--accent)">&#x2713; Funded by ${fmtInt(n)} fans${soldOut?", sold out":""}.</b> Pressing starts now.`
:S.outcomeAct==="min"?"<b>Going ahead at the "+E.min+"-copy minimum.</b>":S.outcomeAct==="refund"?"<b>Goal missed.</b> Every fan has been refunded.":"<b>Goal missed.</b> Refunds go out unless the artist covers the gap.";
let oc="";
if(ended&&goal){
if(funded){const A=E.at(n);
oc=`<div class="ocard"><h3>Funded</h3><p>${fmtInt(n)} fans pre-ordered. Their money leaves the separate account for production: the campaign becomes a production order and ships about ${dRange(W.ship[0],W.ship[1])}.</p><div class="kv"><span>Fan money after fees${S.vat?" and VAT":""}</span><b>${money(A.net)}</b><span>One-off setup</span><b>${money(-E.tSum)}</b><span>Pressing and packaging, ${fmtInt(A.run)} copies</span><b>${money(-A.run*A.c)}</b><span><b style="color:var(--fg)">You keep</b></span><b style="color:var(--accent);font-size:16px">${money(A.keep)}</b></div></div>`;}
else{const run=Math.max(n,E.min)+S.artistCopies,c=E.cAt(run),cost=E.tSum+run*c,net=n*E.net1,diff=net-cost,spare=Math.max(0,E.min-n);
if(S.outcomeAct==="refund")oc=`<div class="ocard plain"><h3>Refunded</h3><p>${fmtInt(n)} refunds of ${money(S.fanPrice)} went back to each fan's original payment method. Nothing was pressed; your master and artwork stay in your vault for another try.</p><button type="button" class="btn sec sm" data-act="retry">Run it again</button></div>`;
else if(S.outcomeAct==="min")oc=`<div class="ocard"><h3>Going ahead at the minimum</h3><p>${diff<0?"You paid the gap of <b>"+money(-diff)+"</b>":"The fans' money covered the minimum run; you keep <b>"+money(diff)+"</b>"}, and you receive the ${fmtInt(spare)} copies no fan claimed.</p></div>`;
else oc=`<div class="ocard warn"><h3>Goal missed: ${fmtInt(n)} of ${fmtInt(goal)}</h3><p>Two ways out. The artist chooses.</p><div class="opts"><div class="opt"><b>Refund everyone</b><p>Automatic, from the separate account. Nothing is pressed; your files stay in your vault.</p><button type="button" class="btn sec sm" data-act="refund">Refund all ${fmtInt(n)} fans</button></div><div class="opt"><b>Go ahead at the ${E.min}-copy minimum</b><p>${diff<0?"You pay the gap of <b>"+money(-diff)+"</b>":"Costs you nothing: the fans' money covers it and you keep "+money(diff)}. You receive the ${fmtInt(spare)} unclaimed copies.</p><button type="button" class="btn sm" data-act="goMin">Go ahead</button></div></div></div>`;
}
}
$("#simOut").innerHTML=oc;
}
function rateText(k){
const v=R[k].v,m=x=>money(x,{bare:true});
if(k==="press"||k==="innerPrint"||k==="carton")return `12" ${k==="carton"?v[12]:m(v[12])} · 10" ${k==="carton"?v[10]:m(v[10])} · 7" ${k==="carton"?v[7]:m(v[7])}${k==="carton"?"":" "+S.cur}`;
@@ -1563,7 +1840,9 @@ function rateText(k){
if(k==="spine")return `12" ${m(v[12])} · 10" ${m(v[10])} ${S.cur}`;
if(k==="finish")return `matt or gloss ${m(v.matt[12])} / ${m(v.matt[10])} / ${m(v.matt[7])}, gatefold ${m(v.matt.gf)}; spot UV ${m(v.spotuv[12])} / ${m(v.spotuv[10])} / ${m(v.spotuv[7])}, gatefold ${m(v.spotuv.gf)} ${S.cur}`;
if(k==="wCopy")return `12" ${v[12]}, 10" ${v[10]}, 7" ${v[7]} kg; extra disc ${v.x12} / ${v.x10} / ${v.x7} kg; 180 g +${v.g180} kg a disc`;
if(k==="handling"||k==="deposit")return pctT(v);
if(k==="handling"||k==="deposit"||k==="fee")return pctT(v);
if(k==="vatPct")return v+" % (a fan price includes "+v+"/"+(100+v)+" of VAT)";
if(k==="campDays")return v.min+" to "+v.max+" days, "+v.def+" by default";
if(k==="minRun")return v+" copies";
return money(v);
}
@@ -1584,18 +1863,19 @@ function renderAll(){
const n=S.step;
if(n===13){renderSides();renderChecks();}
if(n===14)renderArt();
if(n===15)renderReview();
renderFund();
if(n===RV)renderReview();
renderCard();renderStage();
}
function normalizeView(){if(!S.viewMan)S.view=JVIEW.includes(S.step)?"jacket":"record";}
function go(n){
if(n<1||n>15)return;const ch=n!==S.step;S.step=n;S.visited[n]=true;S.closed=false;if(ch)S.viewMan=false;
if(n<1||n>STEPS.length)return;const ch=n!==S.step;S.step=n;S.visited[n]=true;S.closed=false;if(ch)S.viewMan=false;
if(n===13)renderTracks();
renderAll();if(ch){$("#pBody").scrollTop=0;if(window.innerWidth<900){const p=$("#panel").getBoundingClientRect().top+window.scrollY-60;if(window.scrollY>p)window.scrollTo(0,Math.max(0,p-330));}}
}
/* ---- share links: configuration only (no addresses), in the URL hash ---- */
const CFG_KEYS=["otype","size","discs","weight","labels","inner","innerCol","jacket","spine","finish","qty","shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","source","master","cutStyle","tps","cleaning","dinked","catno","artist","title","gaps","genre","leadout","ship","split","splitQty","ship2","duty","cur","rpm"];
const CFG_KEYS=["otype","size","discs","weight","labels","inner","innerCol","jacket","spine","finish","qty","shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","source","master","cutStyle","tps","cleaning","dinked","catno","artist","title","gaps","genre","leadout","ship","split","splitQty","ship2","duty","cur","rpm","route","fanPrice","days","limited","capN","artistCopies","vat"];
function cfg(){const o={v:1};CFG_KEYS.forEach(k=>o[k]=S[k]);o.dl=discsOn().map(d=>({fx:d.fx,c:d.c[d.fx].slice()}));o.srpm=Object.assign({},S.srpm);o.stype=Object.assign({},S.stype);if(!S.demoTracks)o.tr=S.tracks.map(t=>[t.title,Math.round(t.sec||0),t.side]);return o;}
const b64e=s=>{const u=new TextEncoder().encode(s);let b="";u.forEach(c=>b+=String.fromCharCode(c));return btoa(b).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");};
const b64d=s=>new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g,"+").replace(/_/g,"/")),c=>c.charCodeAt(0)));
@@ -1606,7 +1886,13 @@ function applyCfg(o){
if(!o||typeof o!=="object")return;
const en=(k,v)=>ENUM[k]&&ENUM[k].some(x=>x===v);
["otype","size","weight","labels","inner","innerCol","jacket","spine","finish","source","master","cutStyle","tps","ship","ship2","duty","gaps","genre","cur","rpm"].forEach(k=>{if(en(k,o[k]))S[k]=o[k];});
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","cleaning","dinked","split"].forEach(k=>{if(typeof o[k]==="boolean")S[k]=o[k];});
["shrink","insert","sticker","numbered","dlcard","barcode","obi","bag","cleaning","dinked","split","limited","vat"].forEach(k=>{if(typeof o[k]==="boolean")S[k]=o[k];});
if(en("route",o.route))S.route=o.route;
if(Number.isFinite(o.fanPrice)&&o.fanPrice>=100&&o.fanPrice<=20000)S.fanPrice=Math.round(o.fanPrice*100)/100;
if(Number.isInteger(o.days)&&o.days>=R.campDays.v.min&&o.days<=R.campDays.v.max)S.days=o.days;
if(Number.isInteger(o.capN)&&o.capN>=1&&o.capN<=5000)S.capN=o.capN;
if(Number.isInteger(o.artistCopies)&&o.artistCopies>=0&&o.artistCopies<=200)S.artistCopies=o.artistCopies;
resetCampaign();S.placed=null;
if(en("discs",o.discs))S.discs=o.discs;
if(Number.isFinite(o.qty))S.qty=Math.max(25,Math.min(5000,Math.round(o.qty)));
if(Number.isFinite(o.splitQty))S.splitQty=Math.max(1,Math.min(4999,Math.round(o.splitQty)));
@@ -1630,8 +1916,24 @@ function syncInputs(){
/* ---- actions ---- */
function qStep(q,dir){const st=dir>0?(q<100?25:q<1000?50:q<2000?100:250):(q<=100?25:q<=1000?50:q<=2000?100:250);const n=dir>0?Math.floor(q/st)*st+st:Math.ceil(q/st)*st-st;return Math.max(25,Math.min(5000,n));}
function setQty(v){v=Math.round(+v);if(!Number.isFinite(v))return;S.qty=Math.max(25,Math.min(5000,v));if(S.splitQty>=S.qty)S.splitQty=Math.max(1,Math.floor(S.qty/2));syncInputs();renderAll();}
function applyPreset(k){const P=PRESETS[k];if(!P)return;S.size=P.size;S.discs=P.discs;if(P.jacket)S.jacket=P.jacket;P.dl.forEach((x,i)=>{S.dl[i]=pDisc(x);});
S.vd=0;S.fxOpen=P.dl[0].fx;S.viewMan=false;normalize();autoSplit();renderTracks();renderAll();toast("Preset: "+P.t);}
function applyPreset(k,quiet){const P=PRESETS[k];if(!P)return;S.size=P.size;S.discs=P.discs;if(P.jacket)S.jacket=P.jacket;P.dl.forEach((x,i)=>{S.dl[i]=pDisc(x);});
S.vd=0;S.fxOpen=P.dl[0].fx;S.viewMan=false;normalize();autoSplit();renderTracks();renderAll();if(!quiet)toast("Preset: "+P.t);}
/* short deep links for screenshots and demos: #s=<step>&p=<preset>&route=fans|direct&fp=<fan price THB>&theme=light|dark;
#fans is short for #s=15&route=fans, #fans=splat adds the preset. Example: order.html#s=15&p=splat&route=fans */
function applyShort(h){
if(!h||h.length<2||h.indexOf("#q=")===0)return false;
const P={};h.slice(1).split("&").forEach(x=>{if(!x)return;const i=x.indexOf("="),k=i<0?x:x.slice(0,i);let v="";try{v=i<0?"":decodeURIComponent(x.slice(i+1));}catch(e){}P[k]=v;});
if("fans" in P){if(!P.route)P.route="fans";if(!P.s)P.s=String(PAY);if(P.fans&&!P.p)P.p=P.fans;}
let any=false;
if((P.theme==="light"||P.theme==="dark")&&setTheme){setTheme(P.theme,false);any=true;}
if(P.p&&PRESETS[P.p]){applyPreset(P.p,true);any=true;}
if(P.route&&ENUM.route.includes(P.route)){if(S.route!==P.route){S.route=P.route;resetCampaign();S.placed=null;}any=true;}
if(P.fp){const v=Math.round(+P.fp);if(v>=100&&v<=20000){S.fanPrice=v;resetCampaign();any=true;}}
const st=Math.round(+P.s);
if(st>=1&&st<=STEPS.length){for(let i=1;i<st;i++)S.visited[i]=true;go(st);any=true;}
else if(any)renderAll();
return any;
}
const ACT={
go:b=>go(+b.dataset.s),
next:()=>go(S.step+1),
@@ -1657,9 +1959,18 @@ const ACT={
tpl:b=>downloadTpl(b.dataset.k),
artRm:b=>{S.art[b.dataset.k]=null;renderAll();},
miss:b=>{go(+b.dataset.s);const f=b.dataset.f;if(f)setTimeout(()=>{const e=$("#"+f);if(e){e.focus();e.scrollIntoView({block:"center"});if(e.tagName==="INPUT"&&!e.value)e.classList.add("err");}},60);},
place:()=>{if(missing().length){S.visited[15]=true;go(15);toast("Complete the items in the yellow box first");return;}if(S.placed)return;S.placed={no:orderNo(),at:new Date(TODAY)};renderAll();$("#pBody").scrollTop=0;window.scrollTo(0,0);},
place:()=>{if(fansOn())return launch();if(missing().length){S.visited[RV]=true;go(RV);toast("Complete the items in the yellow box first");return;}if(S.placed)return;S.placed={no:orderNo(),at:new Date(TODAY)};renderAll();$("#pBody").scrollTop=0;window.scrollTo(0,0);},
unplace:()=>{S.placed=null;renderAll();},
pbgo:()=>{if(S.step===15)ACT.place();else go(15);}
pbgo:()=>{if(S.step===RV)ACT.place();else go(RV);},
launch:()=>launch(),
unlaunch:()=>{resetCampaign();go(PAY);},
play:()=>toggleSim(),
refund:()=>{S.outcomeAct="refund";updateSim();},
goMin:()=>{S.outcomeAct="min";updateSim();},
retry:()=>{resetSim();updateSim();},
fp:b=>{const a=FAN_IN[S.cur],st=a[2],c=fanIn(),d=+b.dataset.d;let v=d>0?Math.floor(c/st+1e-9)*st+st:Math.ceil(c/st-1e-9)*st-st;v=Math.max(a[0],Math.min(a[1],v));setFanPrice(toTHB(v));},
fpc:b=>setFanPrice(toTHB(+b.dataset.fp)),
acop:b=>{S.artistCopies=Math.max(0,Math.min(200,S.artistCopies+5*(+b.dataset.d)));campChanged();}
};
document.addEventListener("click",e=>{
const b=e.target.closest("button");if(!b||b.disabled)return;
@@ -1677,6 +1988,12 @@ document.addEventListener("input",e=>{
else if(/^a_[bsx]_[a-z0-9]+$/.test(id)){const p=id.split("_");S.addr[p[1]][p[2]]=t.value;t.classList.remove("err");renderRail();renderCard();if(S.step===15)renderReview();}
else if(id==="splitQty"){const v=Math.round(+t.value);if(v>=1&&v<=4999){S.splitQty=v;renderAll();}}
else if(t.classList.contains("tt")){trkOf(t).title=t.value;S.demoTracks=false;}
else if(id==="fanPrice"){const v=toTHB(+t.value);if(v>=100&&v<=20000){S.fanPrice=v;campChanged();}}
else if(id==="fanR"){S.fanPrice=toTHB(+t.value);campChanged();}
else if(id==="days"){const v=Math.round(+t.value);if(v>=R.campDays.v.min&&v<=R.campDays.v.max){S.days=v;campChanged();}}
else if(id==="cap"){const v=Math.round(+t.value)||0;if(v>=1&&v<=5000){S.capN=v;campChanged();}}
else if(id==="acop"){const v=Math.round(+t.value);if(t.value!==""&&v>=0&&v<=200){S.artistCopies=v;campChanged();}}
else if(id==="simDay"){stopSim();S.simDay=+t.value;checkEnd();updateSim();}
});
document.addEventListener("change",e=>{
const t=e.target;
@@ -1687,6 +2004,13 @@ document.addEventListener("change",e=>{
else if(t.id==="splitQty"){S.splitQty=Math.max(1,Math.min(Math.max(1,S.qty-1),Math.round(+t.value)||1));t.value=S.splitQty;renderAll();}
else if(t.classList.contains("tdur")){const v=parseDur(t.value);if(v==null||v<=0){t.classList.add("err");return;}t.classList.remove("err");trkOf(t).sec=v;S.demoTracks=false;renderTracks();renderAll();}
else if(t.classList.contains("tside")){trkOf(t).side=t.value;S.demoTracks=false;renderTracks();renderAll();}
/* a change event only commits what the input event has not (a blur after launching must not reset the campaign) */
else if(t.id==="fanPrice"){const v=toTHB(+t.value),n=Math.min(20000,Math.max(100,v>0?(S.cur==="THB"?Math.round(v):v):1200)),ch=n!==S.fanPrice;S.fanPrice=n;t.value=fanIn();if(ch)campChanged();}
else if(t.id==="cap"){const n=Math.max(1,Math.min(5000,Math.round(+t.value)||S.capN)),ch=n!==S.capN;S.capN=n;t.value=n;if(ch)campChanged();}
else if(t.id==="acop"){const n=Math.max(0,Math.min(200,Math.round(+t.value)||0)),ch=n!==S.artistCopies;S.artistCopies=n;t.value=n;if(ch)campChanged();}
/* switching the limited edition on ticks the hand-numbered extra, as make-a-record does */
else if(t.id==="limT"){S.limited=t.checked;if(S.limited&&!S.numbered){S.numbered=true;toast("Limited edition: hand-numbered copies added to the extras (step 9)");}campChanged();}
else if(t.id==="vatT"){S.vat=t.checked;campChanged();}
});
document.addEventListener("keydown",e=>{if(e.key==="Enter"&&e.target.classList&&e.target.classList.contains("qtyIn")){setQty(e.target.value);e.target.blur();}});
/* drag to reorder tracks */
@@ -1718,10 +2042,11 @@ async function handleAudio(files){
}
/* ---- theme ---- */
let setTheme=null;
(function(){
const b=$("#themeBtn"),de=document.documentElement;
function set(th,save){de.setAttribute("data-theme",th);if(save){try{localStorage.setItem("cnx-theme",th)}catch(e){}}b.textContent=th==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(th==="light"?"dark":"light")+" mode";}
set(de.getAttribute("data-theme")||"dark",false);
set(de.getAttribute("data-theme")||"dark",false);setTheme=set;
b.addEventListener("click",()=>{set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
const mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
if(mq&&mq.addEventListener)mq.addEventListener("change",e=>{let s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
@@ -1735,9 +2060,11 @@ dropZone($("#dropLabel"),$("#fileLabel"),f=>handleArt("label",f));
dropZone($("#dropInner"),$("#fileInner"),f=>handleArt("inner",f));
if(location.hash.indexOf("#q=")===0){lastHash=location.hash;let o=null;try{o=JSON.parse(b64d(location.hash.slice(3)));}catch(e){}if(o)applyCfg(o);}
syncInputs();renderTracks();renderPL();renderAll();
window.addEventListener("hashchange",()=>{if(location.hash!==lastHash){lastHash=location.hash;applyHash(location.hash);}});
if(location.hash&&location.hash.indexOf("#q=")!==0){lastHash=location.hash;applyShort(location.hash);}
window.addEventListener("hashchange",()=>{if(location.hash!==lastHash){lastHash=location.hash;if(!applyHash(location.hash))applyShort(location.hash);}});
if(window.ResizeObserver)new ResizeObserver(()=>renderStage()).observe($("#stage"));else window.addEventListener("resize",renderStage);
window.__ord={S,R,quote,leadInfo,go,setK,setSide,missing,stateHash,cfg,applyCfg,sideStats,checkList,why,spineMM,shipWindow,ACT,renderAll};
window.__ord={S,R,quote,leadInfo,go,setK,setSide,missing,stateHash,cfg,applyCfg,sideStats,checkList,why,spineMM,shipWindow,ACT,renderAll,
campEcon,econ,priceHint,keepRows,finalPledges,pledgesNow,campWhy,launch,startSim,stopSim,updateSim,fanIn,toTHB,setFanPrice,applyShort,PAY,RV};
})();
</script>
</body>
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

+230 -105
View File
@@ -5,24 +5,75 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>CNX Vinyl: investment proposal</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
var q=/[?&]th=(light|dark)(&|$)/.exec(location.search);if(q)t=q[1];
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root{
--bg:#0d0f14; --panel:#131720; --panel2:#171c26;
color-scheme:dark;
--outer:#07080b; --bg:#0d0f14; --panel:#131720; --panel2:#171c26;
--line:rgba(236,230,218,.15); --line2:rgba(236,230,218,.08);
--tx:#efe9dd; --tx2:#d2cbbd; --mu:#a59e91; --dim:#827b70;
--ac:#e8b04a; --acs:rgba(232,176,74,.14); --b:#8eaac3; --bs:rgba(142,170,195,.16);
--neg:#d6876c; --negs:rgba(214,135,108,.14);
--tx:#efe9dd; --tx2:#d2cbbd; --mu:#a59e91; --dim:#8a8377;
/* --ac: accent for text, --acf: accent for fills (the same in the dark theme) */
--ac:#e8b04a; --acf:#e8b04a; --acs:rgba(232,176,74,.14); --acl:rgba(232,176,74,.45); --acb:rgba(232,176,74,.35);
--b:#8eaac3; --bf:#8eaac3; --bs:rgba(142,170,195,.16);
--neg:#d6876c; --negf:#d6876c; --negs:rgba(214,135,108,.14);
/* surfaces */
--card-a:rgba(255,255,255,.035); --card-b:rgba(255,255,255,.015); --card-sh:none;
--fs-a:rgba(255,255,255,.04); --fs-b:rgba(255,255,255,.012);
--warn-a:rgba(232,176,74,.07); --warn-b:rgba(232,176,74,.025);
--fig-bg:#101319; --fig-sh:0 24px 70px rgba(0,0,0,.5); --stage-sh:0 0 0 1px rgba(255,255,255,.03);
--groove:rgba(232,176,74,.07); --chain-a:rgba(232,176,74,.7); --chain-b:rgba(232,176,74,.25);
--disc-hole:#0d0f14; --disc-rim:#0d0f14; --disc-g1:#2b2f37; --disc-g2:#16191f; --disc-ring:rgba(255,255,255,.1);
/* chart series */
--zero:rgba(236,230,218,.35); --whisk:rgba(236,230,218,.6); --rev:rgba(236,230,218,.32); --elec:rgba(236,230,218,.35);
--track:rgba(236,230,218,.12); --cmpline:rgba(236,230,218,.4); --cmpleg:rgba(236,230,218,.45); --dash:rgba(236,230,218,.22); --par:rgba(142,170,195,.55);
/* tags */
--tag-hi:#eaa58d; --tag-hi-bd:rgba(214,135,108,.5); --tag-md-bd:rgba(232,176,74,.45);
--tag-ok:#a9cdb1; --tag-ok-bd:rgba(156,192,164,.45); --tag-ok-bg:rgba(156,192,164,.1);
--tag-no:#c7c0b3; --tag-no-bd:rgba(236,230,218,.25);
/* chrome: control bar, overview, presenter */
--ctl-bg:rgba(18,21,28,.86); --ctl-bd:rgba(236,230,218,.12); --ctl-sh:0 10px 30px rgba(0,0,0,.4); --hov:rgba(255,255,255,.08);
--jump-bg:rgba(18,21,28,.9); --th-bd:rgba(236,230,218,.14); --th-hov:rgba(232,176,74,.6); --lab-a:rgba(7,8,11,.92); --lab-b:rgba(7,8,11,0);
--pv-bg:#0a0b0f; --btn:rgba(255,255,255,.06); --btn-h:rgba(255,255,255,.12); --nav-btn:rgba(255,255,255,.05); --well:rgba(255,255,255,.02);
--serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
/* light theme: warm paper, ink text, the accents darkened for text contrast (WCAG AA, pairs in the skill note) */
html[data-theme=light]{
color-scheme:light;
--outer:#e2dacb; --bg:#f4eee3; --panel:#faf6ee; --panel2:#efe7d8;
--line:rgba(60,45,20,.18); --line2:rgba(60,45,20,.10);
--tx:#1c1914; --tx2:#38332b; --mu:#5a5348; --dim:#675f53;
--ac:#875606; --acf:#b97e17; --acs:rgba(196,137,31,.15); --acl:rgba(185,126,23,.55); --acb:rgba(160,108,18,.38);
--b:#2c5677; --bf:#557ea3; --bs:rgba(85,126,163,.14);
--neg:#9b3d24; --negf:#b3563a; --negs:rgba(179,86,58,.10);
--card-a:rgba(255,255,255,.62); --card-b:rgba(255,255,255,.40); --card-sh:0 1px 2px rgba(60,45,20,.05);
--fs-a:rgba(255,255,255,.66); --fs-b:rgba(255,255,255,.40);
--warn-a:rgba(196,137,31,.12); --warn-b:rgba(196,137,31,.05);
--fig-bg:#ffffff; --fig-sh:0 18px 46px rgba(70,50,20,.16); --stage-sh:0 0 0 1px rgba(60,45,20,.08),0 20px 60px rgba(60,45,20,.12);
--groove:rgba(150,104,24,.09); --chain-a:rgba(185,126,23,.85); --chain-b:rgba(185,126,23,.3);
--disc-hole:#f4eee3; --disc-rim:#2a2621; --disc-g1:#4a443b; --disc-g2:#2a2621; --disc-ring:rgba(60,45,20,.18);
--zero:rgba(28,25,20,.45); --whisk:rgba(28,25,20,.6); --rev:#a59a87; --elec:#a59a87;
--track:rgba(60,45,20,.12); --cmpline:rgba(60,45,20,.55); --cmpleg:rgba(60,45,20,.55); --dash:rgba(60,45,20,.3); --par:rgba(85,126,163,.6);
--tag-hi:#9b3d24; --tag-hi-bd:rgba(155,61,36,.4); --tag-md-bd:rgba(135,86,6,.4);
--tag-ok:#2f6a3f; --tag-ok-bd:rgba(47,106,63,.4); --tag-ok-bg:rgba(47,106,63,.08);
--tag-no:#4a443a; --tag-no-bd:rgba(60,45,20,.28);
--ctl-bg:rgba(251,248,241,.92); --ctl-bd:rgba(60,45,20,.16); --ctl-sh:0 10px 30px rgba(60,45,20,.16); --hov:rgba(60,45,20,.08);
--jump-bg:rgba(251,248,241,.95); --th-bd:rgba(60,45,20,.16); --th-hov:rgba(185,126,23,.7); --lab-a:rgba(244,238,227,.95); --lab-b:rgba(244,238,227,0);
--pv-bg:#ebe4d7; --btn:rgba(60,45,20,.06); --btn-h:rgba(60,45,20,.12); --nav-btn:rgba(255,255,255,.55); --well:rgba(255,255,255,.5);
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
html,body{margin:0;height:100%;background:#07080b;overflow:hidden}
html,body{margin:0;height:100%;background:var(--outer);overflow:hidden}
body{font-family:var(--sans);color:var(--tx);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
button{font:inherit;color:inherit}
/* ---------- canvas: 1920 x 1080, scaled to fit, letterboxed ---------- */
.stage{position:fixed;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;background:var(--bg);overflow:hidden;box-shadow:0 0 0 1px rgba(255,255,255,.03)}
.stage{position:fixed;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;background:var(--bg);overflow:hidden;box-shadow:var(--stage-sh)}
.slide{position:absolute;inset:0;width:1920px;height:1080px;padding:80px 104px 150px;display:flex;flex-direction:column;background:var(--bg);
font-variant-numeric:tabular-nums lining-nums;opacity:0;visibility:hidden;transform:translateX(26px);
transition:opacity .42s ease,transform .42s ease,visibility 0s linear .42s}
@@ -32,7 +83,7 @@ button{font:inherit;color:inherit}
/* vinyl groove motif: fine concentric rings, faded out with a mask */
.slide.grooves::before{content:"";position:absolute;inset:0;pointer-events:none;
background:repeating-radial-gradient(circle at var(--gx,104%) var(--gy,108%),rgba(232,176,74,.07) 0 1px,transparent 1.4px 9px);
background:repeating-radial-gradient(circle at var(--gx,104%) var(--gy,108%),var(--groove) 0 1px,transparent 1.4px 9px);
-webkit-mask-image:radial-gradient(circle at var(--gx,104%) var(--gy,108%),#000 0,#000 18%,transparent var(--gr,44%));
mask-image:radial-gradient(circle at var(--gx,104%) var(--gy,108%),#000 0,#000 18%,transparent var(--gr,44%))}
.slide > *{position:relative}
@@ -41,8 +92,8 @@ button{font:inherit;color:inherit}
.kicker{font-size:17px;letter-spacing:.2em;text-transform:uppercase;color:var(--ac);font-weight:600;display:flex;align-items:center;gap:14px;height:24px}
.kicker .no{color:var(--dim);font-weight:500}
.disc{width:22px;height:22px;border-radius:50%;flex:none;
background:radial-gradient(circle,#0d0f14 0 7%,var(--ac) 8% 30%,#0d0f14 31% 35%,transparent 36%),
repeating-radial-gradient(circle,#2b2f37 0 1px,#16191f 1px 2.2px);box-shadow:0 0 0 1px rgba(255,255,255,.1)}
background:radial-gradient(circle,var(--disc-hole) 0 7%,var(--acf) 8% 30%,var(--disc-rim) 31% 35%,transparent 36%),
repeating-radial-gradient(circle,var(--disc-g1) 0 1px,var(--disc-g2) 1px 2.2px);box-shadow:0 0 0 1px var(--disc-ring)}
h1{font-family:var(--serif);font-weight:500;font-size:132px;line-height:1.12;margin:28px 0 0;letter-spacing:-.01em}
h2{font-family:var(--serif);font-weight:500;font-size:54px;line-height:1.2;margin:14px 0 0;max-width:1712px;letter-spacing:-.005em}
h3{font-size:16px;letter-spacing:.16em;text-transform:uppercase;color:var(--mu);font-weight:600;margin:0 0 14px}
@@ -64,8 +115,8 @@ p{margin:0}
.ft .pg{color:var(--mu);letter-spacing:.12em}
/* building blocks */
.card{background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.015));border:1px solid var(--line2);border-radius:14px;padding:24px 28px}
.card.warn{border-color:rgba(232,176,74,.35);background:linear-gradient(180deg,rgba(232,176,74,.07),rgba(232,176,74,.025))}
.card{background:linear-gradient(180deg,var(--card-a),var(--card-b));border:1px solid var(--line2);border-radius:14px;padding:24px 28px;box-shadow:var(--card-sh)}
.card.warn{border-color:var(--acb);background:linear-gradient(180deg,var(--warn-a),var(--warn-b))}
.stat{display:flex;flex-direction:column;gap:8px}
.stat .v{font-family:var(--serif);font-size:58px;line-height:1.16;color:var(--ac);white-space:nowrap}
.stat .v.b{color:var(--b)}
@@ -73,19 +124,19 @@ p{margin:0}
.rule{height:1px;background:var(--line);margin:22px 0}
.list{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:14px}
.list li{position:relative;padding-left:22px;font-size:20px;line-height:1.42;color:var(--tx2)}
.list li::before{content:"";position:absolute;left:0;top:12px;width:8px;height:8px;border-radius:50%;background:var(--ac);opacity:.85}
.list li::before{content:"";position:absolute;left:0;top:12px;width:8px;height:8px;border-radius:50%;background:var(--acf);opacity:.85}
.list.sm li{font-size:18px}
.list.sm li::before{top:10px}
.dl{display:grid;grid-template-columns:150px 1fr;gap:14px 22px;margin:0}
.dl dt{font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--ac);font-weight:600;padding-top:4px}
.dl dd{margin:0;font-size:19px;line-height:1.42;color:var(--tx2)}
.tag{display:inline-block;font-size:13px;letter-spacing:.1em;text-transform:uppercase;padding:3px 10px;border-radius:99px;border:1px solid var(--line);color:var(--mu);font-weight:600;white-space:nowrap}
.tag.hi{color:#eaa58d;border-color:rgba(214,135,108,.5);background:var(--negs)}
.tag.md{color:var(--ac);border-color:rgba(232,176,74,.45);background:var(--acs)}
.tag.ok{color:#a9cdb1;border-color:rgba(156,192,164,.45);background:rgba(156,192,164,.1)}
.tag.no{color:#c7c0b3;border-color:rgba(236,230,218,.25)}
.tag.hi{color:var(--tag-hi);border-color:var(--tag-hi-bd);background:var(--negs)}
.tag.md{color:var(--ac);border-color:var(--tag-md-bd);background:var(--acs)}
.tag.ok{color:var(--tag-ok);border-color:var(--tag-ok-bd);background:var(--tag-ok-bg)}
.tag.no{color:var(--tag-no);border-color:var(--tag-no-bd)}
figure{margin:0}
.fig{border-radius:14px;overflow:hidden;border:1px solid var(--line);box-shadow:0 24px 70px rgba(0,0,0,.5);background:#101319;line-height:0}
.fig{border-radius:14px;overflow:hidden;border:1px solid var(--line);box-shadow:var(--fig-sh);background:var(--fig-bg);line-height:0}
.fig img{display:block;width:100%;height:auto}
figcaption{font-size:15px;line-height:1.4;color:var(--dim);margin-top:12px}
table.tb{width:100%;border-collapse:collapse;font-size:18px;line-height:1.35}
@@ -99,7 +150,7 @@ svg.chart{display:block;overflow:visible}
svg.chart text{font-family:var(--sans);fill:var(--mu);font-size:16px}
svg.chart .ax{stroke:var(--line);stroke-width:1}
svg.chart .gr{stroke:var(--line2);stroke-width:1}
svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
svg.chart .z{stroke:var(--zero);stroke-width:1}
/* ---------- slide 1 ---------- */
.s1{--gx:72%;--gy:52%;--gr:62%}
@@ -124,7 +175,7 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.chain{display:grid;grid-template-columns:repeat(8,1fr);gap:0;margin-top:34px;position:relative}
.chain .st{position:relative;padding:0 14px 0 0}
.chain .dot{width:30px;height:30px;border-radius:50%;border:2px solid var(--ac);background:var(--bg);display:grid;place-items:center;font-size:14px;font-weight:700;color:var(--ac);position:relative;z-index:1}
.chain .st::before{content:"";position:absolute;left:30px;right:0;top:15px;height:2px;background:linear-gradient(90deg,rgba(232,176,74,.7),rgba(232,176,74,.25))}
.chain .st::before{content:"";position:absolute;left:30px;right:0;top:15px;height:2px;background:linear-gradient(90deg,var(--chain-a),var(--chain-b))}
.chain .st:last-child::before{display:none}
.chain .nm{font-family:var(--serif);font-size:25px;color:var(--tx);margin-top:16px;line-height:1.22}
.chain .vd{font-size:16px;line-height:1.4;color:var(--mu);margin-top:8px}
@@ -136,11 +187,11 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
/* ---------- slide 4 ---------- */
.flow{display:flex;align-items:stretch;margin-top:34px;gap:0}
.flow .fs{flex:1 1 0;min-width:0;border:1px solid var(--line2);background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.012));border-radius:12px;padding:16px 14px 16px}
.flow .fs{flex:1 1 0;min-width:0;border:1px solid var(--line2);background:linear-gradient(180deg,var(--fs-a),var(--fs-b));border-radius:12px;padding:16px 14px 16px}
.flow .fs .n{font-size:13px;letter-spacing:.14em;color:var(--ac);font-weight:700}
.flow .fs .nm{font-family:var(--serif);font-size:21px;line-height:1.22;color:var(--tx);margin-top:6px}
.flow .fs .ck{font-size:14.5px;line-height:1.38;color:var(--mu);margin-top:8px}
.flow .ar{flex:0 0 18px;align-self:center;height:2px;background:rgba(232,176,74,.45)}
.flow .ar{flex:0 0 18px;align-self:center;height:2px;background:var(--acl)}
.flow .gate{flex:0 0 96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px}
.flow .gate .dm{width:34px;height:34px;transform:rotate(45deg);border:2px solid var(--ac);background:var(--acs)}
.flow .gate .gl{font-size:13px;line-height:1.3;text-align:center;color:var(--ac);font-weight:600}
@@ -155,11 +206,11 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.kp4 .v{font-family:var(--serif);font-size:46px;line-height:1.16;color:var(--ac);white-space:nowrap}
.kp4 .l{font-size:17px;line-height:1.4;color:var(--mu);margin-top:10px}
/* ---------- slide 5 ---------- */
.s5 .bd{grid-template-columns:960px 1fr;gap:52px;margin-top:34px}
.feat{display:flex;flex-direction:column;gap:22px}
.feat .h{font-family:var(--serif);font-size:24px;line-height:1.22;color:var(--tx)}
.feat .x{font-size:17.5px;line-height:1.42;color:var(--tx2);margin-top:4px}
/* ---------- slide 5: two views of the same order tool ---------- */
.s5 .bd{grid-template-columns:1fr 1fr;gap:56px;margin-top:26px;align-content:start}
.pf{display:flex;flex-direction:column;min-width:0}
.pf .h{font-family:var(--serif);font-size:26px;line-height:1.22;color:var(--tx);margin-top:24px;padding-bottom:.1em}
.pf .x{font-size:18px;line-height:1.45;color:var(--tx2);margin-top:6px}
/* ---------- slide 6 ---------- */
.s6 .bd{grid-template-columns:1000px 1fr;gap:52px;margin-top:36px}
@@ -171,7 +222,7 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.s7 .card{display:flex;flex-direction:column;gap:14px;padding:24px 24px}
.s7 .card .t{font-size:19px;line-height:1.45}
.s7 .stat .v{font-size:50px}
.note7{margin-top:22px;font-size:17px;line-height:1.45;color:var(--mu);padding-left:18px;border-left:2px solid rgba(232,176,74,.5)}
.note7{margin-top:22px;font-size:17px;line-height:1.45;color:var(--mu);padding-left:18px;border-left:2px solid var(--acl)}
/* ---------- slide 8 ---------- */
.s8 .bd{grid-template-columns:1000px 1fr;gap:56px;margin-top:30px}
@@ -195,7 +246,7 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.kp th{padding:0 10px 8px}
/* ---------- slide 11 ---------- */
.s11 .bd{grid-template-columns:1fr 720px;gap:56px;margin-top:32px}
.s11 .bd{grid-template-columns:1fr 720px;gap:56px;margin-top:26px}
.boi{display:flex;flex-direction:column;gap:0}
.boi .r{display:grid;grid-template-columns:170px 1fr 150px;gap:20px;padding:13px 0;border-bottom:1px solid var(--line2);align-items:start}
.boi .r .k{font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--ac);font-weight:600;padding-top:3px}
@@ -205,7 +256,18 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.fin .need .card{padding:18px 22px}
.fin .need .v{font-family:var(--serif);font-size:44px;line-height:1.16;white-space:nowrap}
.fin .need .l{font-size:15.5px;color:var(--mu);margin-top:6px;line-height:1.4}
.fin .tbd{margin-top:18px;font-family:var(--serif);font-size:26px;line-height:1.3;color:var(--tx);padding:16px 22px;border-left:3px solid var(--ac);background:var(--acs);border-radius:0 10px 10px 0}
.share{margin-top:18px}
.share h3{margin-bottom:12px}
.share .bar,.share .lbl{display:grid;grid-template-columns:60fr 40fr;gap:6px}
.share .bar i{display:block;height:14px;border-radius:4px}
.share .bar .a{background:var(--acf)}
.share .bar .c{background:var(--acs);border:1px dashed var(--acb)}
.share .lbl{margin-top:10px}
.share .lbl b{display:block;font-family:var(--serif);font-weight:500;font-size:25px;line-height:1.2;color:var(--tx);padding-bottom:.1em}
.share .lbl b.ac{color:var(--ac)}
.share .lbl span{display:block;font-size:15.5px;line-height:1.4;color:var(--mu);margin-top:2px}
.share .nt{font-size:15px;line-height:1.4;color:var(--dim);margin-top:8px}
.fin .tbd{margin-top:18px;font-family:var(--serif);font-size:23px;line-height:1.3;color:var(--tx);padding:16px 22px;border-left:3px solid var(--acf);background:var(--acs);border-radius:0 10px 10px 0}
/* ---------- slide 12 ---------- */
.s12 .bd{grid-template-rows:auto auto;gap:16px;margin-top:20px}
@@ -232,7 +294,7 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
/* ---------- slide 15 ---------- */
.s15{--gx:100%;--gy:0%;--gr:40%}
.s15 .bd{grid-template-columns:1fr 600px;grid-template-rows:auto 1fr;gap:30px 56px;margin-top:30px}
.s15 .bd{grid-template-columns:1fr 600px;grid-template-rows:auto 1fr;gap:30px 56px;margin-top:24px}
.unl{grid-column:1 / -1;display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--line);align-self:end}
.unl .u{padding:20px 30px 0 0}
.unl .u + .u{padding-left:30px;border-left:1px solid var(--line2)}
@@ -251,7 +313,7 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
.tb th.ac{color:var(--ac)}
.tb tr.tot td.neg{color:var(--neg)}
.tot2 .v .usd{font-family:var(--sans);font-size:19px;color:var(--mu);margin-left:14px;letter-spacing:.02em;vertical-align:.35em}
.cmpA{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:14px;padding:12px 22px;border:1px dashed rgba(236,230,218,.22);border-radius:12px;font-size:16px;line-height:1.4;color:var(--mu)}
.cmpA{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:14px;padding:12px 22px;border:1px dashed var(--dash);border-radius:12px;font-size:16px;line-height:1.4;color:var(--mu)}
.cmpA b{font-family:var(--serif);font-size:28px;line-height:1.16;font-weight:500;color:var(--tx2);white-space:nowrap;padding-bottom:.1em}
.s8b .lede{font-size:22px;max-width:1640px}
.s8b .bd{grid-template-columns:1fr 760px;gap:60px;margin-top:40px}
@@ -271,51 +333,59 @@ svg.chart .z{stroke:rgba(236,230,218,.35);stroke-width:1}
h1,h2,.stat .v,.s1 .keys .v,.big3 .v,.tot2 .v,.fin .need .v,.ask .v,.kp4 .v,.chain .nm,.flow .fs .nm,.ppl .nm,.pill4 .h,.feat .h,.unl .h{padding-bottom:.1em}
h1{padding-bottom:.14em}
/* screenshots of the web tools: a dark and a light version, swapped with the theme */
.thl{display:none!important}
html[data-theme=light] .thl{display:block!important}
html[data-theme=light] .thd{display:none!important}
.ctl .ic-moon{display:none}
html[data-theme=light] .ctl .ic-sun{display:none}
html[data-theme=light] .ctl .ic-moon{display:block}
/* notes are for the presenter only */
.notes{display:none}
/* ---------- controls ---------- */
.ctl{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:2px;padding:5px 6px;border-radius:99px;
background:rgba(18,21,28,.86);border:1px solid rgba(236,230,218,.12);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
box-shadow:0 10px 30px rgba(0,0,0,.4);opacity:0;transition:opacity .5s ease;z-index:20;font-size:14px;color:var(--tx2)}
background:var(--ctl-bg);border:1px solid var(--ctl-bd);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
box-shadow:var(--ctl-sh);opacity:0;transition:opacity .5s ease;z-index:20;font-size:14px;color:var(--tx2)}
body.ui .ctl,.ctl:focus-within,.ctl:hover{opacity:1}
.ctl button,.ctl a{width:36px;height:34px;border:0;background:transparent;border-radius:99px;display:grid;place-items:center;cursor:pointer;color:var(--tx2);text-decoration:none}
.ctl button:hover,.ctl a:hover{background:rgba(255,255,255,.08);color:var(--tx)}
.ctl button:hover,.ctl a:hover{background:var(--hov);color:var(--tx)}
.ctl svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.ctl .cnt{min-width:66px;text-align:center;font-variant-numeric:tabular-nums;letter-spacing:.04em;color:var(--tx)}
.ctl .sep{width:1px;height:20px;background:rgba(236,230,218,.14);margin:0 4px}
.ctl .sep{width:1px;height:20px;background:var(--th-bd);margin:0 4px}
.ctl a.pdf{width:auto;padding:0 12px;font-size:12.5px;font-weight:700;letter-spacing:.1em}
.jump{position:fixed;left:50%;top:18px;transform:translateX(-50%);background:rgba(18,21,28,.9);border:1px solid var(--line);border-radius:10px;padding:8px 16px;font-size:18px;color:var(--ac);z-index:30;font-variant-numeric:tabular-nums}
.jump{position:fixed;left:50%;top:18px;transform:translateX(-50%);background:var(--jump-bg);border:1px solid var(--line);border-radius:10px;padding:8px 16px;font-size:18px;color:var(--ac);z-index:30;font-variant-numeric:tabular-nums}
/* ---------- overview ---------- */
.ov{position:fixed;inset:0;background:#07080b;z-index:40;overflow:auto;padding:28px 32px 40px}
.ov{position:fixed;inset:0;background:var(--outer);z-index:40;overflow:auto;padding:28px 32px 40px}
.ov h4{margin:0 0 18px;font-size:14px;letter-spacing:.2em;text-transform:uppercase;color:var(--mu);font-weight:600}
.ov .grid{display:grid;grid-template-columns:repeat(5,1fr);gap:18px}
.ov .th{position:relative;aspect-ratio:16/9;border-radius:8px;overflow:hidden;border:1px solid rgba(236,230,218,.14);cursor:pointer;background:var(--bg);padding:0}
.ov .th{position:relative;aspect-ratio:16/9;border-radius:8px;overflow:hidden;border:1px solid var(--th-bd);cursor:pointer;background:var(--bg);padding:0}
.ov .th.cur{outline:2px solid var(--ac);outline-offset:2px}
.ov .th:hover{border-color:rgba(232,176,74,.6)}
.ov .th .lab{position:absolute;left:0;right:0;bottom:0;padding:6px 10px;font-size:12px;text-align:left;background:linear-gradient(0deg,rgba(7,8,11,.92),rgba(7,8,11,0));color:var(--tx2);z-index:2}
.ov .th:hover{border-color:var(--th-hov)}
.ov .th .lab{position:absolute;left:0;right:0;bottom:0;padding:6px 10px;font-size:12px;text-align:left;background:linear-gradient(0deg,var(--lab-a),var(--lab-b));color:var(--tx2);z-index:2}
.mini{position:absolute;left:0;top:0;width:1920px;height:1080px;transform-origin:0 0;pointer-events:none;overflow:hidden;background:var(--bg)}
.mini .slide{transition:none!important;transform:none!important;opacity:1!important;visibility:visible!important}
@media (max-width:900px){.ov .grid{grid-template-columns:repeat(2,1fr)}}
/* ---------- presenter view ---------- */
.pv{position:fixed;inset:0;background:#0a0b0f;display:grid;grid-template-rows:56px 1fr;color:var(--tx);font-family:var(--sans)}
.pv{position:fixed;inset:0;background:var(--pv-bg);display:grid;grid-template-rows:56px 1fr;color:var(--tx);font-family:var(--sans)}
.pv header{display:flex;align-items:center;gap:22px;padding:0 22px;border-bottom:1px solid var(--line2);font-size:15px;color:var(--mu)}
.pv header .ttl{letter-spacing:.16em;text-transform:uppercase;font-weight:600;color:var(--ac);font-size:13px}
.pv header .cn{font-size:20px;color:var(--tx);font-variant-numeric:tabular-nums}
.pv header .tm{font-size:24px;color:var(--tx);font-variant-numeric:tabular-nums;margin-left:auto;font-family:var(--serif)}
.pv header button{background:rgba(255,255,255,.06);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;color:var(--tx2)}
.pv header button:hover{background:rgba(255,255,255,.12)}
.pv header button{background:var(--btn);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;color:var(--tx2)}
.pv header button:hover{background:var(--btn-h)}
.pv .body{display:grid;grid-template-columns:1.55fr 1fr;gap:20px;padding:18px 22px;min-height:0}
.pv .col{display:flex;flex-direction:column;gap:10px;min-height:0}
.pv .lb{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);font-weight:600}
.pv .frame{position:relative;aspect-ratio:16/9;width:100%;border-radius:8px;overflow:hidden;border:1px solid var(--line);background:var(--bg)}
.pv .frame.next{opacity:.92}
.pv .frame .end{position:absolute;inset:0;display:grid;place-items:center;color:var(--dim);font-size:16px}
.pv .nt{flex:1;min-height:0;overflow:auto;font-size:19px;line-height:1.55;color:var(--tx2);padding:14px 18px;border:1px solid var(--line2);border-radius:8px;background:rgba(255,255,255,.02)}
.pv .nt{flex:1;min-height:0;overflow:auto;font-size:19px;line-height:1.55;color:var(--tx2);padding:14px 18px;border:1px solid var(--line2);border-radius:8px;background:var(--well)}
.pv .nav{display:flex;gap:10px}
.pv .nav button{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--line);border-radius:8px;padding:10px;cursor:pointer;color:var(--tx2)}
.pv .nav button{flex:1;background:var(--nav-btn);border:1px solid var(--line);border-radius:8px;padding:10px;cursor:pointer;color:var(--tx2)}
body.pvmode .stage,body.pvmode .ctl{display:none}
/* ---------- print / PDF: one slide per page, backgrounds on ---------- */
@@ -466,7 +536,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
</div>
</div>
<figure>
<div class="fig"><img src="img/record-passport.webp" width="1600" height="819" alt="Record passport demo: the production history of one pressed copy"></div>
<div class="fig"><img class="thd" src="img/record-passport-dark.webp" width="1590" height="816" alt="Record passport demo: the production history of one pressed copy"><img class="thl" src="img/record-passport-light.webp" width="1590" height="816" alt="Record passport demo: the production history of one pressed copy"></div>
<figcaption>Record passport demo: what a fan sees from the code on the sleeve (IDs simulated, make-a-record.html).</figcaption>
</figure>
</div>
@@ -478,24 +548,28 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<!-- 5 -->
<section class="slide s5" data-title="The platform">
<div class="kicker"><i class="disc"></i>The platform</div>
<h2>Online ordering with itemised quotes, and fan-funded pressing</h2>
<h2>One order tool: an itemised quote, or a pressing funded by fans</h2>
<div class="bd">
<figure>
<div class="fig"><img src="img/order-splatter.webp" width="1800" height="1125" alt="Order page, step 4: a 12 inch splatter record configured, with the estimated price and ship date"></div>
<figcaption>Step-by-step order page, step 4 with the "Splatter 12"" preset: the picture follows every option. Demo; prices and dates indicative, not an offer (order.html).</figcaption>
</figure>
<div class="feat">
<div><div class="h">An itemised offer for every customer</div><div class="x">Every line priced from one price list, per title and per copy, with audio and artwork checked at upload. ERPNext is the system of record: the order, the three gates and the invoice live there. Public early with indicative prices and an interest list, so every configuration is market data.</div></div>
<div><div class="h">Fan-funded campaigns</div><div class="x">Fans pre-order into a ring-fenced account and are refunded automatically if the goal is missed (the recommended model). Minimum run 100 copies, a planning figure. Campaigns open only after the first releases ship.</div></div>
<div class="pf">
<figure>
<div class="fig"><img src="img/make-a-record-fans.webp" width="1400" height="760" alt="Investor demo: the fan funding step with fan price, fans needed and break-even"></div>
<figcaption>Investor demo, fan-funding step (make-a-record.html). The platform's name is not decided.</figcaption>
<div class="fig"><img class="thd" src="img/order-splatter-dark.webp" width="1800" height="1125" alt="Order tool, step 4: a 12 inch splatter record configured, with the estimated price and ship date"><img class="thl" src="img/order-splatter-light.webp" width="1800" height="1125" alt="Order tool, step 4: a 12 inch splatter record configured, with the estimated price and ship date"></div>
<figcaption>Step 4 with the "Splatter 12"" preset: the picture follows every option, the quote card every line. Prices and dates indicative, not an offer.</figcaption>
</figure>
<div class="h">An itemised offer for every customer</div>
<div class="x">Every line priced from one price list, per title and per copy, with audio and artwork checked at upload. ERPNext is the system of record: the order, the three gates and the invoice live there. Public early with indicative prices, so every configuration is market data.</div>
</div>
<div class="pf">
<figure>
<div class="fig"><img class="thd" src="img/order-fans-dark.webp" width="1800" height="1125" alt="Order tool, fan-funding view: fan price, fans needed and what the artist keeps"><img class="thl" src="img/order-fans-light.webp" width="1800" height="1125" alt="Order tool, fan-funding view: fan price, fans needed and what the artist keeps"></div>
<figcaption>The same record funded by fans: at 1,200 THB a copy it needs 100 fans (break-even 46), and the artist keeps ~480 THB a copy after setup, pressing and fees. Indicative; the platform's name is not decided.</figcaption>
</figure>
<div class="h">Fan-funded campaigns</div>
<div class="x">Fans pre-order into a ring-fenced account and are refunded automatically if the goal is missed (the recommended model). The fans needed are the larger of break-even and the 100-copy minimum run, a planning figure. Campaigns open only after the first releases ship.</div>
</div>
</div>
<p class="src"><b>Source:</b> artist-platform.md; business plan B, sections 4.8 and 5.1; digital-factory.md "ERPNext: the plant's ERP". Public side ~USD 40k to 90k <i>est.</i>, inside the software block.</p>
<p class="src"><b>Source:</b> artist-platform.md ("The goal", campaigns and payments); business plan B, sections 4.8 and 5.1; digital-factory.md "ERPNext: the plant's ERP". Screenshots: order.html, the step-by-step order tool (demo). Public side ~USD 40k to 90k <i>est.</i>, inside the software block.</p>
<footer class="ft"><span>CNX Vinyl · Investment proposal, Plan B (~215M THB) · Draft for discussion, October 2026</span><span class="pg"></span></footer>
<aside class="notes">The platform is the plant's front door. Anyone, from a bedroom artist to a label, configures a record and gets an itemised offer from the same price list that drives the invoice in ERPNext. The fan-funding mode lets an artist pre-sell a run: fans pay into a ring-fenced account and are refunded automatically if the goal is missed; the rule comes from Qrates, whose service was suspended with funded projects caught in the wind-down. A Thai legal opinion comes before the first campaign, and whether CNX is seller of record is still an open decision. The name is not decided. The public side is an estimated USD 40k to 90k. Sources: artist-platform.md; business plan B, section 4.8.</aside>
<aside class="notes">The platform is the plant's front door, and both pictures are the same tool. Anyone, from a bedroom artist to a label, configures a record and gets an itemised offer from the price list that drives the invoice in ERPNext. The same order can instead be funded by fans: the artist sets the fan price, and the fans needed follow as the larger of break-even and the 100-copy minimum. Fans pay into a ring-fenced account and are refunded automatically if the goal is missed; the rule comes from Qrates, whose wind-down caught funded projects. A Thai legal opinion comes first, and seller of record is open. Sources: artist-platform.md; business plan B, section 4.8.</aside>
</section>
<!-- 6 -->
@@ -565,7 +639,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<h2>Plan B: ~217M THB at the midpoint, the launch plant with everything in</h2>
<div class="bd">
<div>
<div class="legend" style="margin-bottom:12px"><span><i style="background:var(--ac)"></i>Plan B, block midpoint</span><span><i style="background:transparent;height:2px;border-top:2px solid rgba(236,230,218,.6);border-radius:0;vertical-align:4px"></i>low to high end of the block</span><span>M THB <span class="e">est.</span></span></div>
<div class="legend" style="margin-bottom:12px"><span><i style="background:var(--acf)"></i>Plan B, block midpoint</span><span><i style="background:transparent;height:2px;border-top:2px solid var(--whisk);border-radius:0;vertical-align:4px"></i>low to high end of the block</span><span>M THB <span class="e">est.</span></span></div>
<svg class="chart" id="chInvest" width="1000" height="600" viewBox="0 0 1000 600" role="img" aria-label="Plan B investment by block group, low, midpoint and high"></svg>
</div>
<div>
@@ -632,7 +706,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<h2>Plan B's central case works; the adverse case loses money</h2>
<div class="bd">
<div>
<div class="legend" style="margin-bottom:10px"><span><i style="background:rgba(236,230,218,.32)"></i>Revenue</span><span><i style="background:var(--ac)"></i>EBITDA</span><span><i style="background:var(--b)"></i>Operating profit, after Plan B's depreciation</span><span>M USD a year</span></div>
<div class="legend" style="margin-bottom:10px"><span><i style="background:var(--rev)"></i>Revenue</span><span><i style="background:var(--acf)"></i>EBITDA</span><span><i style="background:var(--bf)"></i>Operating profit, after Plan B's depreciation</span><span>M USD a year</span></div>
<svg class="chart" id="chOp" width="900" height="560" viewBox="0 0 900 560" role="img" aria-label="Plan B revenue, EBITDA and operating profit by scenario"></svg>
</div>
<div>
@@ -669,7 +743,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<h2>Ten years of cash: Plan B back to zero in year ~4.2, IRR ~22&nbsp;%</h2>
<div class="bd">
<div>
<div class="legend" style="margin-bottom:10px"><span><i style="background:var(--ac)"></i>Plan B, cumulative cash</span><span><i style="background:transparent;height:2px;border-top:2px dashed rgba(236,230,218,.45);border-radius:0;vertical-align:4px"></i>Plan A, for comparison</span><span>M USD, year 0 to 10</span></div>
<div class="legend" style="margin-bottom:10px"><span><i style="background:var(--acf)"></i>Plan B, cumulative cash</span><span><i style="background:transparent;height:2px;border-top:2px dashed var(--cmpleg);border-radius:0;vertical-align:4px"></i>Plan A, for comparison</span><span>M USD, year 0 to 10</span></div>
<svg class="chart" id="chCash" width="1000" height="640" viewBox="0 0 1000 640" role="img" aria-label="Cumulative cash over ten years, Plan B, with Plan A faint for comparison"></svg>
</div>
<div>
@@ -688,7 +762,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
</div>
<p class="src"><b>Source:</b> business plan B, section 9.4 and figure 12; fleet-calculator.html, presets planB and planA, cash view (values read from the page, October 2026); revenue-model.md. Estimates.</p>
<footer class="ft"><span>CNX Vinyl · Investment proposal, Plan B (~215M THB) · Draft for discussion, October 2026</span><span class="pg"></span></footer>
<aside class="notes">This curve is the calculator's, not the operating table's: 850,000 records at the team's faster Duplex planning rate, its own cost build-up including biomass fuel, no PPA bill because Plan B owns the array, and a ramp of 35, 70 and 90&nbsp;% before the mature year. Year 0 is the midpoint investment plus a 15M THB reserve, 6.56M USD. On that basis Plan B is back to zero in year 4.2, with an IRR of about 22&nbsp;% and an NPV of about 4.7M USD at 10&nbsp;%. Pre-tax, pre-financing, no terminal value. Why not A? Its curve, faint here, turns earlier on less capital. Put the adverse case beside it: about 37 years. Sources: fleet-calculator.html; business plan B, section 9.4.</aside>
<aside class="notes">This curve is the calculator's, not the operating table's: 850,000 records at the team's faster Duplex planning rate, its own cost build-up including biomass fuel, no PPA bill because Plan B owns the array, and a ramp of 35, 70 and 90&nbsp;% before the mature year. Year 0 is the midpoint investment plus a 15M THB reserve, 6.56M USD. On that basis Plan B is back to zero in year 4.2, with an IRR of about 22&nbsp;% and an NPV of about 4.7M USD at 10&nbsp;%. Pre-tax, pre-financing, no terminal value. Why not A? Its curve turns earlier on less capital. Put the adverse case beside it: about 37 years. Sources: fleet-calculator.html; business plan B, section 9.4.</aside>
</section>
<!-- 12 -->
@@ -703,6 +777,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<div class="r"><div class="k">Solar</div><div class="x">No longer promoted since 1 July 2025 (Announcement 3/2568): Plan B's owned array earns the bill saving, no tax benefit. The 271 kWh battery may still qualify, capped at 12M THB per MW.</div><div class="st"><span class="tag hi">Solar dropped</span></div></div>
<div class="r"><div class="k">Conditions</div><div class="x">Debt-to-equity no higher than 3:1 for the life of the privileges; minimum 1M THB excluding land and working capital; modern machinery; progress and annual reports; machinery imported within ~30 months, full operation by ~36 months.</div><div class="st"><span class="tag md">Binding</span></div></div>
<div class="r"><div class="k">Process</div><div class="x">e-Investment filing and analyst meeting; ~40 working days to a decision under 200M THB, 60 to 90 above; 4 to 6 months in practice. No machinery order before the certificate.</div><div class="st"><span class="tag no">Phase 0</span></div></div>
<div class="r"><div class="k">Without BOI</div><div class="x">~5&nbsp;% duty on ~2.3M USD of imported machinery (~0.11M USD) and ~0.17M USD of import VAT up front <span class="e">est.</span></div><div class="st"><span class="tag no">Avoided</span></div></div>
</div>
<div class="fin">
<h3>Funding need, Plan B: reserve in, no contingency</h3>
@@ -710,18 +785,25 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<div class="card warn"><div class="v ac">~217M THB</div><div class="l">~6.56M USD at the midpoint, labelled ~215M; ~156M to 277M low to high</div></div>
<div class="card"><div class="v">~54M THB</div><div class="l">equity at least: a quarter of the funding at the 3:1 ceiling (arithmetic)</div></div>
</div>
<dl class="dl" style="margin-top:22px;grid-template-columns:130px 1fr;gap:12px 18px">
<div class="share">
<h3>Shareholding</h3>
<div class="bar"><i class="a"></i><i class="c"></i></div>
<div class="lbl">
<div><b class="ac">At least 60&nbsp;%</b><span>the lead investor: at least ~32M THB</span></div>
<div><b>At most 40&nbsp;%</b><span>co-investors (Ploy Printing as intended strategic co-investor, the founders, others): at most ~22M THB</span></div>
</div>
<p class="nt">At the ~54M THB equity floor and one share price, arithmetic only: share price, valuation and in-kind shares not agreed. Terms in the shareholders' agreement.</p>
</div>
<dl class="dl" style="margin-top:16px;grid-template-columns:130px 1fr;gap:12px 18px">
<dt>Collateral</dt><dd style="font-size:17.5px">The machinery and equipment, in Plan B including the owned PV array and battery. The building only if the lease grants its ownership and the right to mortgage it. No land collateral: the site is a long lease of raw land.</dd>
<dt>Equity</dt><dd style="font-size:17.5px">The lead investor; Ploy Printing as intended strategic co-investor (terms not yet discussed); the founders.</dd>
<dt>Use of funds</dt><dd style="font-size:17.5px">Equipment (presses, metal path, metrology, lab) ~85M THB; building, crane, workshop and corridor ~40M; utilities ~35M; solar and battery ~15M; software, cassette and CD-R, freight, working capital and phase 0 ~28M (block midpoints <span class="e">est.</span>), plus the reserve, in tranches (slide 16).</dd>
<dt>Without BOI</dt><dd style="font-size:17.5px">~5&nbsp;% duty on ~2.3M USD of imported machinery (~0.11M USD) and ~0.17M USD of import VAT up front <span class="e">est.</span></dd>
</dl>
<p class="tbd">Financing structure to be agreed. No loan terms, interest rate or debt-service figures are on file.</p>
<p class="tbd">Financing structure to be agreed: no loan terms, interest rate or debt-service figures are on file.</p>
</div>
</div>
<p class="src"><b>Source:</b> boi.md; business plan B, sections 1 ("The ask"), 6.1, 7.1, 8.1, 8.4 and 12.2; total-investment.md "Launch plant". Equity floor and use-of-funds groups are arithmetic on the plan's figures, before financing costs.</p>
<p class="src"><b>Source:</b> boi.md; business plan B, sections 1 ("The ask"), 6.1, 7.1, 8.1, 8.4 and 12.2; total-investment.md "Launch plant" and "Two plans" (shareholding, 20 October 2026). Equity floor, the 60/40 amounts and use-of-funds groups are arithmetic on the plan's figures, before financing costs.</p>
<footer class="ft"><span>CNX Vinyl · Investment proposal, Plan B (~215M THB) · Draft for discussion, October 2026</span><span class="pg"></span></footer>
<aside class="notes">Vinyl pressing lands in BOI Group B: no tax holiday on the core activity, but duty-free machinery with the import VAT, full foreign ownership and work permits. Tax value comes from the upgrade measures. Solar is no longer promoted since 1 July 2025, so Plan B owns its array for the bill alone; the battery may qualify. The condition that shapes financing is debt-to-equity no higher than 3:1, so at least a quarter of Plan B's 217M, about 54M THB, is equity. No loan structure, rate or debt-service figure is on file; the structure is to be agreed. Collateral is the machinery, the array and the battery; the land is leased. Sources: boi.md; business plan B, sections 7.1 and 12.2.</aside>
<aside class="notes">Vinyl pressing lands in BOI Group B: duty-free machinery, full foreign ownership and work permits, no tax holiday on the core activity. Solar is no longer promoted; Plan B owns its array for the bill. The condition that shapes financing is debt-to-equity no higher than 3:1: at least a quarter of Plan B's 217M, about 54M THB, is equity. The lead investor holds at least 60&nbsp;% of the company, so at most 40&nbsp;% is open to co-investors; at that floor and one share price, about 32M and 22M THB, arithmetic only. No loan terms are on file. Collateral: machinery, array, battery; the land is leased. Sources: boi.md; business plan B, sections 7.1 and 12.2.</aside>
</section>
<!-- 13 -->
@@ -794,7 +876,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
</div>
<div class="ppl">
<h3>Partners</h3>
<div><div class="nm">Ploy Printing</div><div class="ro">Chiang Mai · packaging</div><div class="x">Jackets, sleeves and inserts; intends to co-invest alongside the lead investor, terms not yet discussed. Supply priced at arm's length, Ploy abstaining on its own contract.</div></div>
<div><div class="nm">Ploy Printing</div><div class="ro">Chiang Mai · packaging</div><div class="x">Jackets, sleeves and inserts; intends to co-invest within the 40&nbsp;% open beside the lead investor, terms not yet discussed. Supply priced at arm's length, Ploy abstaining on its own contract.</div></div>
<div><div class="nm">Payap University</div><div class="ro">Chiang Mai · under discussion</div><div class="x">Joint student programmes in mastering, lathe cutting, IT and business. Nothing agreed yet.</div></div>
<div><div class="nm">Advisers, phase 0</div><div class="ro">To engage</div><div class="x">A BOI-registered consultant; Thai counsel for the lease, the company and the shareholder agreement.</div></div>
</div>
@@ -822,11 +904,11 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<h2>Approve phase 0 now; commit to Plan B in tranches once the gates clear</h2>
<div class="bd">
<div>
<div class="ask" style="display:grid;grid-template-columns:500px 1fr;gap:32px;align-items:start">
<div><h3>Now: phase 0</h3><div class="v">1.3M to 2.6M THB</div><p class="s" style="margin-top:10px">~USD 40k to 80k over 3 to 6 months, part of the plan price</p></div>
<p class="t" style="padding-top:34px">BOI consultant, Thai counsel for the lease and zoning, a soil boring test, and the quotes that settle most of the band. Then, on the gates clearing, a commitment in principle to Plan B at ~215M THB (~217M at the midpoint), released in tranches.</p>
<div class="ask" style="display:grid;grid-template-columns:1fr 1fr;gap:44px;align-items:start">
<div><h3>Now: phase 0</h3><div class="v">1.3M to 2.6M THB</div><p class="s" style="margin-top:8px">~USD 40k to 80k over 3 to 6 months, part of the plan price: the BOI consultant, Thai counsel for the lease, zoning and the shareholders' agreement, a soil test, the quotes.</p></div>
<div><h3>On offer to co-investors</h3><div class="v">Up to 40&nbsp;%</div><p class="s" style="margin-top:8px">of the company, beside the lead investor's at least 60&nbsp;%: Ploy Printing, the founders, others. Share price and valuation not agreed; terms in the shareholders' agreement.</p></div>
</div>
<h3 style="margin-top:26px">Plan B tranches, share of the investment before the reserve <span class="e">est.</span></h3>
<h3 style="margin-top:24px">Then Plan B at ~215M THB in tranches: share of the investment before the reserve <span class="e">est.</span></h3>
<table class="tb tr">
<tr><th>#</th><th>Trigger</th><th>Covers</th><th class="n">Share</th><th class="n">M THB</th></tr>
<tr><td>0</td><td>Now</td><td>Phase 0 work</td><td class="n">1 to 2&nbsp;%</td><td class="n">1.3 to 2.6</td></tr>
@@ -849,6 +931,7 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<h3 style="margin-top:26px">Decisions for the investor</h3>
<ul class="list sm">
<li>The equity and debt mix within 3:1 (equity at least ~54M THB)</li>
<li>The shareholders' agreement: the split of the 40&nbsp;%, board, reserved matters and tranche releases</li>
<li>How quotes landing above the plan price are funded</li>
<li>Each Plan B item weighed against its lever; CD replication before the BOI filing; hedging of the EUR orders</li>
</ul>
@@ -859,9 +942,9 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<div class="u"><div class="h">Revenue then funds</div><div class="x">The second Duplex system (~15.4M THB at the midpoint <span class="e">est.</span>), PV on the PV-ready office roof and canopies (~120 kWp, ~2.4M to 3.1M THB <span class="e">est.</span>), the phase 2 building.</div></div>
</div>
</div>
<p class="src"><b>Source:</b> business plan B, sections 8.3, 14.1 and 14.2; roadmap.md phases 0 to 2. Tranche amounts are the shares applied to Plan B's ~201.6M THB before the reserve (arithmetic). Gates before any order: market validation, the BOI certificate, and land (zoning, lease terms, soil).</p>
<p class="src"><b>Source:</b> business plan B, sections 8.3, 12.2, 14.1 and 14.2; total-investment.md "Two plans" (shareholding); roadmap.md phases 0 to 2. Tranche amounts are the shares applied to Plan B's ~201.6M THB before the reserve (arithmetic). Gates before any order: market validation, the BOI certificate, and land (zoning, lease terms, soil).</p>
<footer class="ft"><span>CNX Vinyl · Investment proposal, Plan B (~215M THB) · Draft for discussion, October 2026</span><span class="pg"></span></footer>
<aside class="notes">Now: approve phase 0, about 1.3M to 2.6M THB over three to six months, part of the plan price, for the BOI consultant, counsel, the soil test and the quotes. Then, once market validation, the BOI certificate and the land gates clear, a commitment in principle to Plan B at about 215M THB, released in tranches so nothing is ordered before the certificate and validated pricing. Decisions: the equity and debt mix within 3:1, how quotes landing high are funded, and CD replication before the BOI filing. Why not A? It stays on file as the lowest-capital variant. Sources: business plan B, sections 8.3 and 14.</aside>
<aside class="notes">Now: approve phase 0, about 1.3M to 2.6M THB over three to six months, for the BOI consultant, counsel for the lease and the shareholders' agreement, the soil test and the quotes. Then, once validation, the BOI certificate and the land gates clear, a commitment in principle to Plan B at about 215M THB, released in tranches. On offer to co-investors: up to 40&nbsp;% of the company, as the lead investor holds at least 60&nbsp;%; share price and valuation are not agreed. Decisions: the equity and debt mix within 3:1, the shareholders' agreement, funding quotes that land high. Why not A? It stays on file as the lowest-capital variant. Sources: business plan B, sections 8.3, 12.2 and 14.</aside>
</section>
</div>
@@ -874,7 +957,8 @@ body.pvmode .stage,body.pvmode .ctl{display:none}
<button type="button" id="bOv" title="Overview (O)" aria-label="Overview"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="6.5" height="6.5" rx="1"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1"/></svg></button>
<button type="button" id="bPv" title="Presenter view (P)" aria-label="Presenter view"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg></button>
<button type="button" id="bFs" title="Fullscreen (F)" aria-label="Fullscreen"><svg viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>
<a class="pdf" id="bPdf" href="CNX-Vinyl-Investor-Deck.pdf" download title="Download the PDF">PDF</a>
<button type="button" id="bTh" title="Light / dark (T)" aria-label="Light or dark theme"><svg class="ic-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.4 5.4l1.6 1.6M17 17l1.6 1.6M5.4 18.6L7 17M17 7l1.6-1.6"/></svg><svg class="ic-moon" viewBox="0 0 24 24"><path d="M19.5 14.6A7.8 7.8 0 0 1 9.4 4.5a7.8 7.8 0 1 0 10.1 10.1z"/></svg></button>
<a class="pdf" id="bPdf" href="CNX-Vinyl-Investor-Deck.pdf" download title="Download the PDF, dark theme">PDF</a>
</nav>
<script>
@@ -956,9 +1040,12 @@ const DATA = {
Charts: inline SVG built from DATA
====================================================================== */
const NS = "http://www.w3.org/2000/svg";
/* fill and stroke go through inline style so they can take var(): the charts follow the theme
without a redraw (SVG presentation attributes cannot take var()) */
const STY = { fill: 1, stroke: 1 };
function el(p, tag, a, txt) {
const e = document.createElementNS(NS, tag);
for (const k in a) e.setAttribute(k, a[k]);
for (const k in a) { if (STY[k]) e.style.setProperty(k, a[k]); else e.setAttribute(k, a[k]); }
if (txt != null) e.textContent = txt;
p.appendChild(e); return e;
}
@@ -966,7 +1053,9 @@ const f1 = x => x.toFixed(1);
const f2 = x => (x < 0 ? "-" : "") + Math.abs(x).toFixed(2);
const fr = x => (Math.round(x * 100) % 10 ? x.toFixed(2) : x.toFixed(1));
const mid = r => (r[0] + r[1]) / 2;
const C = { ac: "#e8b04a", b: "#8eaac3", tx: "#efe9dd", tx2: "#d2cbbd", mu: "#a59e91", dim: "#827b70", neg: "#d6876c", line: "rgba(236,230,218,.15)", line2: "rgba(236,230,218,.08)" };
/* every chart colour is a CSS variable (see :root and html[data-theme=light]); ac/b/neg for text, acf/bf/negf for fills */
const C = {};
["ac", "acf", "acs", "b", "bf", "neg", "negf", "tx", "tx2", "mu", "dim", "bg", "line", "line2", "rev", "elec", "track", "cmpline", "par"].forEach(k => { C[k] = "var(--" + k + ")"; });
function chPrice() {
const s = document.getElementById("chPrice"); if (!s) return;
@@ -976,7 +1065,7 @@ function chPrice() {
el(s, "text", { x: X(t), y: 318, "text-anchor": t === 0 ? "start" : t === 1000 ? "end" : "middle", "font-size": 15 }, t === 1000 ? "1,000 THB" : t.toLocaleString("en-US"));
}
DATA.price.forEach((r, i) => {
const y = 36 + i * 92, col = r.k === "cnx" ? C.ac : r.k === "th" ? C.neg : C.b;
const y = 36 + i * 92, col = r.k === "cnx" ? C.acf : r.k === "th" ? C.negf : C.bf;
el(s, "text", { x: x0, y: y, fill: C.tx2, "font-size": 17 }, r.label);
if (r.hi - r.lo < 1) {
el(s, "circle", { cx: X(r.lo), cy: y + 30, r: 9, fill: col });
@@ -1002,7 +1091,7 @@ function chInvest() {
B.forEach((b, i) => {
const y = top + i * rh, res = b.k === "res", r = b.b, m = mid(r), yy = y + 14;
el(s, "text", { x: 0, y: y + 29, fill: res ? C.mu : C.tx2, "font-size": 16.5 }, b.label);
el(s, "rect", { x: X(0), y: yy, width: X(m) - X(0), height: 19, rx: 3, fill: C.ac, "fill-opacity": res ? 0.3 : 0.92, stroke: res ? C.ac : "none", "stroke-dasharray": res ? "4 3" : "" });
el(s, "rect", { x: X(0), y: yy, width: X(m) - X(0), height: 19, rx: 3, fill: C.acf, "fill-opacity": res ? 0.3 : 0.92, stroke: res ? C.acf : "none", "stroke-dasharray": res ? "4 3" : "" });
const yc = yy + 9.5, wk = { stroke: C.tx, "stroke-opacity": .6, "stroke-width": 1.5 };
el(s, "line", Object.assign({ x1: X(r[0]), x2: X(r[1]), y1: yc, y2: yc }, wk));
el(s, "line", Object.assign({ x1: X(r[0]), x2: X(r[0]), y1: yc - 6, y2: yc + 6 }, wk));
@@ -1029,12 +1118,12 @@ function chOp() {
el(s, "text", { x: L - 12, y: Y(t) + 5, "text-anchor": "end", "font-size": 14 }, t + "M");
}
const gw = (R - L) / 3, bw = 62, gap = 16;
const ser = [["revenue", "rgba(236,230,218,.32)", C.tx2], ["ebitda", C.ac, C.ac], ["op", C.b, C.b]];
const ser = [["revenue", C.rev, C.tx2], ["ebitda", C.acf, C.ac], ["op", C.bf, C.b]];
D.cases.forEach((c, i) => {
const gx = L + gw * i + (gw - (bw * ser.length + gap * (ser.length - 1))) / 2;
ser.forEach(([k, fill, tcol], j) => {
const v = D[k][i], x = gx + j * (bw + gap), y0 = Y(0), y1 = Y(v);
el(s, "rect", { x: x, y: Math.min(y0, y1), width: bw, height: Math.max(1, Math.abs(y1 - y0)), rx: 3, fill: v < 0 ? C.neg : fill });
el(s, "rect", { x: x, y: Math.min(y0, y1), width: bw, height: Math.max(1, Math.abs(y1 - y0)), rx: 3, fill: v < 0 ? C.negf : fill });
el(s, "text", { x: x + bw / 2, y: v < 0 ? y1 + 20 : y1 - 8, "text-anchor": "middle", fill: v < 0 ? C.neg : tcol, "font-size": 15, "font-weight": 600 }, f2(v));
});
el(s, "text", { x: L + gw * i + gw / 2, y: B + 40, "text-anchor": "middle", fill: C.tx, "font-size": 18, "font-weight": 600 }, c);
@@ -1054,15 +1143,15 @@ function chCash() {
D.years.forEach(y => el(s, "text", { x: X(y), y: B + 28, "text-anchor": "middle", "font-size": 14 }, y === 0 ? "Y0" : String(y)));
const a = D.a.cum, b = D.b.cum;
// Plan A, faint
el(s, "polyline", { points: a.map((v, i) => X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" "), fill: "none", stroke: "rgba(236,230,218,.4)", "stroke-width": 2, "stroke-dasharray": "7 6", "stroke-linejoin": "round" });
el(s, "polyline", { points: a.map((v, i) => X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" "), fill: "none", stroke: C.cmpline, "stroke-width": 2, "stroke-dasharray": "7 6", "stroke-linejoin": "round" });
el(s, "text", { x: X(10) + 12, y: Y(a[10]) - 4, fill: C.mu, "font-size": 14 }, "A " + f2(a[10]));
// Plan B payback marker
const px = X(D.b.pay);
el(s, "line", { x1: px, x2: px, y1: Y(0), y2: Y(-4.6), stroke: C.ac, "stroke-dasharray": "3 4", "stroke-width": 1.5 });
el(s, "line", { x1: px, x2: px, y1: Y(0), y2: Y(-4.6), stroke: C.acf, "stroke-dasharray": "3 4", "stroke-width": 1.5 });
el(s, "text", { x: px + 8, y: Y(-5.4), fill: C.ac, "font-size": 15.5, "font-weight": 700 }, "back to zero, year ~" + D.b.pay);
// Plan B
el(s, "polyline", { points: b.map((v, i) => X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" "), fill: "none", stroke: C.ac, "stroke-width": 4, "stroke-linejoin": "round" });
b.forEach((v, i) => el(s, "circle", { cx: X(i), cy: Y(v), r: 5.5, fill: "#0d0f14", stroke: C.ac, "stroke-width": 2.8 }));
el(s, "polyline", { points: b.map((v, i) => X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" "), fill: "none", stroke: C.acf, "stroke-width": 4, "stroke-linejoin": "round" });
b.forEach((v, i) => el(s, "circle", { cx: X(i), cy: Y(v), r: 5.5, fill: C.bg, stroke: C.acf, "stroke-width": 2.8 }));
el(s, "text", { x: X(0) + 12, y: Y(b[0]) + 26, fill: C.ac, "font-size": 16, "font-weight": 700 }, f2(b[0]));
el(s, "text", { x: X(10) + 12, y: Y(b[10]) + 18, fill: C.ac, "font-size": 16, "font-weight": 700 }, f2(b[10]));
}
@@ -1070,7 +1159,7 @@ function chCash() {
function chSteam() {
const s = document.getElementById("chSteam"); if (!s) return;
const D = DATA.steam, L = 0, R = 228, X = v => L + v / 5 * (R - L);
[["Biomass fuel", D.biomass, C.ac], ["Electric steam", D.electric, "rgba(236,230,218,.35)"]].forEach(([lab, v, col], i) => {
[["Biomass fuel", D.biomass, C.acf], ["Electric steam", D.electric, C.elec]].forEach(([lab, v, col], i) => {
const y = 6 + i * 62;
el(s, "text", { x: 0, y: y + 14, fill: C.tx2, "font-size": 15 }, lab);
el(s, "rect", { x: X(0), y: y + 22, width: X(v) - X(0), height: 20, rx: 3, fill: col });
@@ -1081,8 +1170,8 @@ function chSteam() {
function chSolar() {
const s = document.getElementById("chSolar"); if (!s) return;
const D = DATA.solar, W = 370;
el(s, "rect", { x: 0, y: 24, width: W - 2, height: 18, rx: 3, fill: "rgba(236,230,218,.12)" });
el(s, "rect", { x: 0, y: 24, width: (W - 2) * D.used / D.plant, height: 18, rx: 3, fill: C.ac });
el(s, "rect", { x: 0, y: 24, width: W - 2, height: 18, rx: 3, fill: C.track });
el(s, "rect", { x: 0, y: 24, width: (W - 2) * D.used / D.plant, height: 18, rx: 3, fill: C.acf });
el(s, "text", { x: 0, y: 16, fill: C.tx2, "font-size": 14 }, "Share of the plant's electricity");
el(s, "text", { x: 0, y: 64, fill: C.ac, "font-size": 15, "font-weight": 600 }, "~" + D.share + "\u00a0%");
el(s, "text", { x: W - 2, y: 64, "text-anchor": "end", fill: C.mu, "font-size": 14 }, "~" + D.used + " of ~" + D.plant.toLocaleString("en-US") + " MWh");
@@ -1109,15 +1198,15 @@ function chGantt() {
el(s, "text", { x: 112, y: y + 17, fill: r.crit ? C.tx : C.tx2, "font-size": 15 }, r.label);
if (r.open) {
const x = X(r.s);
el(s, "rect", { x: x, y: y + 6, width: X(r.e) - x, height: 13, rx: 3, fill: "rgba(232,176,74,.12)", stroke: C.ac, "stroke-dasharray": "4 3" });
el(s, "path", { d: `M${x} ${y + 3} l7 9.5 -7 9.5 -7 -9.5z`, fill: C.ac });
el(s, "rect", { x: x, y: y + 6, width: X(r.e) - x, height: 13, rx: 3, fill: C.acs, stroke: C.acf, "stroke-dasharray": "4 3" });
el(s, "path", { d: `M${x} ${y + 3} l7 9.5 -7 9.5 -7 -9.5z`, fill: C.acf });
el(s, "text", { x: x - 14, y: y + 17, "text-anchor": "end", fill: C.ac, "font-size": 14.5, "font-weight": 600 }, "Q4 2028 to mid-2029, open");
return;
}
el(s, "rect", { x: X(r.s), y: y + 6, width: Math.max(4, X(r.e) - X(r.s)), height: 13, rx: 3, fill: r.crit ? C.ac : "rgba(142,170,195,.55)" });
el(s, "rect", { x: X(r.s), y: y + 6, width: Math.max(4, X(r.e) - X(r.s)), height: 13, rx: 3, fill: r.crit ? C.acf : C.par });
(r.ms || []).forEach(([dt, lab]) => {
const x = X(dt);
el(s, "path", { d: `M${x} ${y + 3} l6 9.5 -6 9.5 -6 -9.5z`, fill: C.tx, stroke: "#0d0f14", "stroke-width": 1.5 });
el(s, "path", { d: `M${x} ${y + 3} l6 9.5 -6 9.5 -6 -9.5z`, fill: C.tx, stroke: C.bg, "stroke-width": 1.5 });
});
});
// milestone labels for the BOI row, placed beside the bar
@@ -1129,9 +1218,9 @@ function chGantt() {
el(s, "line", { x1: 0, x2: R, y1: Bt, y2: Bt, stroke: C.line });
// legend
const ly = Bt + 26;
el(s, "rect", { x: 112, y: ly - 11, width: 26, height: 12, rx: 3, fill: C.ac });
el(s, "rect", { x: 112, y: ly - 11, width: 26, height: 12, rx: 3, fill: C.acf });
el(s, "text", { x: 146, y: ly, "font-size": 14 }, "critical path");
el(s, "rect", { x: 260, y: ly - 11, width: 26, height: 12, rx: 3, fill: "rgba(142,170,195,.55)" });
el(s, "rect", { x: 260, y: ly - 11, width: 26, height: 12, rx: 3, fill: C.par });
el(s, "text", { x: 294, y: ly, "font-size": 14 }, "parallel track with float");
el(s, "text", { x: R, y: ly, "text-anchor": "end", "font-size": 14, fill: C.dim }, "August 2026 calendar, optimistic end drawn; every duration an estimate");
}
@@ -1173,28 +1262,60 @@ function go(n, opt) {
}
const next = () => go(cur + 1), prev = () => go(cur - 1);
/* --- sync between the audience window and the presenter window --- */
/* --- sync between the audience window and the presenter window: slide and theme --- */
const KEY = "cnx-pitch-sync";
let bc = null;
try { bc = new BroadcastChannel(KEY); bc.onmessage = e => recv(e.data); } catch (e) { bc = null; }
function send(n) {
const m = { n: n, from: ME, t: Date.now() + Math.random() };
function post(m) {
m.from = ME; m.t = Date.now() + Math.random();
lastMsg = m.from + ":" + m.t;
try { if (bc) bc.postMessage(m); } catch (e) {}
try { localStorage.setItem(KEY, JSON.stringify(m)); } catch (e) {}
try { if (window.opener && !window.opener.closed) window.opener.postMessage({ cnxPitch: m }, "*"); } catch (e) {}
try { if (pvWin && !pvWin.closed) pvWin.postMessage({ cnxPitch: m }, "*"); } catch (e) {}
}
const send = n => post({ n: n });
function recv(m) {
if (!m || m.from === ME || typeof m.n !== "number") return;
if (!m || m.from === ME) return;
const id = m.from + ":" + m.t; if (id === lastMsg) return; lastMsg = id;
if (m.n !== cur) go(m.n, { remote: true });
if (m.th) { setTheme(m.th, { remote: true }); return; }
if (typeof m.n === "number" && m.n !== cur) go(m.n, { remote: true });
}
window.addEventListener("storage", e => { if (e.key === KEY && e.newValue) { try { recv(JSON.parse(e.newValue)); } catch (err) {} } });
window.addEventListener("storage", e => {
if (e.key === KEY && e.newValue) { try { recv(JSON.parse(e.newValue)); } catch (err) {} }
if (e.key === "cnx-theme" && (e.newValue === "light" || e.newValue === "dark")) setTheme(e.newValue, { remote: true });
});
window.addEventListener("message", e => { if (e.data && e.data.cnxPitch) recv(e.data.cnxPitch); });
/* --- light / dark: localStorage cnx-theme, else prefers-color-scheme (followed live until the user picks) --- */
const de = document.documentElement;
const PDFS = { dark: ["CNX-Vinyl-Investor-Deck.pdf", "Download the PDF, dark theme"],
light: ["CNX-Vinyl-Investor-Deck-light.pdf", "Download the PDF, light theme (print-friendly)"] };
const theme = () => de.getAttribute("data-theme") === "light" ? "light" : "dark";
function setTheme(th, opt) {
opt = opt || {};
if (th !== "light" && th !== "dark") return;
if (opt.save) { try { localStorage.setItem("cnx-theme", th); } catch (e) {} }
const changed = th !== theme();
de.setAttribute("data-theme", th);
themeUi();
if (changed && !opt.remote) post({ th: th });
}
function themeUi() {
const th = theme(), a = document.getElementById("bPdf"), b = document.getElementById("bTh"), pb = document.getElementById("pvTheme");
if (a) { a.setAttribute("href", PDFS[th][0]); a.title = PDFS[th][1]; }
if (b) b.setAttribute("aria-pressed", th === "light" ? "true" : "false");
if (pb) pb.textContent = th === "light" ? "Dark (T)" : "Light (T)";
}
const toggleTheme = () => setTheme(theme() === "light" ? "dark" : "light", { save: true });
const mq = window.matchMedia && matchMedia("(prefers-color-scheme: light)");
if (mq && mq.addEventListener) mq.addEventListener("change", e => {
let s = null; try { s = localStorage.getItem("cnx-theme"); } catch (_) {}
if (s !== "light" && s !== "dark") setTheme(e.matches ? "light" : "dark", { remote: true });
});
function openPresenter() {
const url = location.href.split("#")[0].split("?")[0] + "?presenter#" + (cur + 1);
const url = location.href.split("#")[0].split("?")[0] + "?presenter&th=" + theme() + "#" + (cur + 1);
pvWin = window.open(url, "cnxPitchPresenter", "width=1280,height=800");
}
function fullscreen() {
@@ -1216,7 +1337,7 @@ function cloneSlide(i) {
}
function buildOverview() {
ov = document.createElement("div"); ov.className = "ov"; ov.id = "ov"; ov.hidden = true;
ov.innerHTML = '<h4>All slides · click to open · O or Esc to close</h4><div class="grid"></div>';
ov.innerHTML = '<h4>All slides · click to open · O or Esc to close · T light / dark</h4><div class="grid"></div>';
const g = ov.querySelector(".grid");
slides.forEach((s, i) => {
const b = document.createElement("button"); b.type = "button"; b.className = "th"; b.dataset.i = i;
@@ -1247,7 +1368,7 @@ function buildPresenter() {
pv = document.createElement("div"); pv.className = "pv"; pv.id = "pv";
pv.innerHTML =
'<header><span class="ttl">CNX Vinyl · presenter view</span><span class="cn" id="pvCnt"></span><span id="pvTitle"></span>' +
'<span class="tm" id="pvTime">00:00:00</span><button type="button" id="pvPause">Pause</button><button type="button" id="pvReset">Reset timer</button></header>' +
'<span class="tm" id="pvTime">00:00:00</span><button type="button" id="pvPause">Pause</button><button type="button" id="pvReset">Reset timer</button><button type="button" id="pvTheme" title="Light / dark for both windows (T)">Light (T)</button></header>' +
'<div class="body"><div class="col"><div class="lb">Current slide</div><div class="frame" id="pvCur"></div>' +
'<div class="nav"><button type="button" id="pvPrev">Previous</button><button type="button" id="pvNext">Next</button></div></div>' +
'<div class="col"><div class="lb">Next</div><div class="frame next" id="pvNext2"></div><div class="lb">Speaker notes</div><div class="nt" id="pvNotes"></div></div></div>';
@@ -1259,6 +1380,7 @@ function buildPresenter() {
};
document.getElementById("pvPrev").onclick = prev;
document.getElementById("pvNext").onclick = next;
document.getElementById("pvTheme").onclick = toggleTheme;
setInterval(tick, 500); tick();
}
function tick() {
@@ -1287,7 +1409,7 @@ function onKey(e) {
if (k === "Enter" && buf) { const n = parseInt(buf, 10); buf = ""; showJump(); if (n >= 1) go(n - 1); e.preventDefault(); return; }
if (k === "Backspace" && buf) { buf = buf.slice(0, -1); showJump(); return; }
if (k === "Escape") { if (ovOpen) toggleOverview(false); buf = ""; showJump(); return; }
if (ovOpen && k !== "o" && k !== "O") return;
if (ovOpen && !/^[oOtT]$/.test(k)) return;
switch (k) {
case "ArrowRight": case "ArrowDown": case "PageDown": next(); break;
case " ": e.shiftKey ? prev() : next(); break;
@@ -1297,6 +1419,7 @@ function onKey(e) {
case "f": case "F": if (!PRESENTER) fullscreen(); break;
case "o": case "O": if (!PRESENTER) toggleOverview(); break;
case "p": case "P": if (!PRESENTER) openPresenter(); break;
case "t": case "T": toggleTheme(); break;
default: return;
}
e.preventDefault();
@@ -1322,6 +1445,7 @@ function init() {
document.getElementById("bOv").onclick = () => toggleOverview();
document.getElementById("bPv").onclick = openPresenter;
document.getElementById("bFs").onclick = fullscreen;
document.getElementById("bTh").onclick = toggleTheme;
document.addEventListener("mousemove", wake);
// touch: swipe left or right
let sx = 0, sy = 0, st = 0;
@@ -1331,6 +1455,7 @@ function init() {
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.3 && Date.now() - st < 800) { dx < 0 ? next() : prev(); }
}, { passive: true });
if (PRESENTER) buildPresenter();
themeUi();
cur = -1; go(fromHash(), { remote: true });
if (!PRESENTER) wake();
}
+7 -5
View File
@@ -78,8 +78,9 @@ document.documentElement.setAttribute("data-theme",t);})();
chain under one roof, the platform, site and utilities, Plan B by
block and why Plan B, the operating picture with its adverse case,
cash and returns, BOI promotion and its conditions, schedule,
risks, team and the ask. Press P for the presenter view; a PDF is
linked in the control bar.</span>
risks, team and the ask. Light and dark (press T), P for the
presenter view; a PDF of each theme is linked in the control
bar.</span>
</a>
</li>
</ul>
@@ -155,9 +156,10 @@ document.documentElement.setAttribute("data-theme",t);})();
extras, mastering and cutting, test pressings, shipping, track list
and artwork, each step with its choices, beside a large live picture
of the record that changes with every choice and a running price and
ship date. Ends in a review that lists what is still missing and the
itemised quote. Indicative demo prices; nothing is uploaded or
sent.</span>
ship date. Then how to pay: directly, or a fan-funded campaign with
the fans needed and what the artist keeps. Ends in a review that
lists what is still missing and the itemised quote. Indicative demo
prices; nothing is uploaded or sent.</span>
</a>
</li>
<li>