@festapp/venue-layout 0.0.0-stage → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/assets/layout_fonts/Roboto-400-italic.ttf +0 -0
- package/assets/layout_fonts/Roboto-400-normal.ttf +0 -0
- package/assets/layout_fonts/Roboto-700-italic.ttf +0 -0
- package/assets/layout_fonts/Roboto-700-normal.ttf +0 -0
- package/assets/layout_fonts/Roboto-OFL.txt +93 -0
- package/assets/layout_fonts/Roboto-Slab-LICENSE.txt +202 -0
- package/assets/layout_fonts/Roboto-Slab.ttf +0 -0
- package/assets/layout_fonts/Roboto.ttf +0 -0
- package/assets/layout_fonts/fixtures.json +1 -0
- package/assets/layout_fonts/manifest.json +51 -0
- package/demo/demo.js +155 -0
- package/demo/index.html +12 -0
- package/demo/seed.js +1 -0
- package/dist/demo.js +937 -0
- package/dist/layout-v2.schema.json +4997 -0
- package/dist/manifest.json +64 -0
- package/dist/venue_layout_kernel.js +8266 -0
- package/package.json +46 -3
- package/src/assets.js +52 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +474 -0
- package/src/kernel.d.ts +2 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol": 2,
|
|
3
|
+
"fontFamilies": {
|
|
4
|
+
"font-family:f7b3a0ebd5b869a2c40433fdc5c003472743d3587c9ff15275ee2eaea54a19c5": {
|
|
5
|
+
"id": "font-family:f7b3a0ebd5b869a2c40433fdc5c003472743d3587c9ff15275ee2eaea54a19c5",
|
|
6
|
+
"hash": "f7b3a0ebd5b869a2c40433fdc5c003472743d3587c9ff15275ee2eaea54a19c5",
|
|
7
|
+
"faces": {
|
|
8
|
+
"400:italic": "gf:cab2f63113328b72dce6b3e1eac7f268a27290911049f79fe760630c7befd861",
|
|
9
|
+
"400:normal": "gf:7f3ec5073a282c666c9a0063573345841229caf50ed34d33017e20d441bf5caf",
|
|
10
|
+
"700:italic": "gf:07b9b53ecf7da2d6c4af6841bb5c9e16087594914c6723a782805a4201b3cdc5",
|
|
11
|
+
"700:normal": "gf:a059c7343a09d6144d964625cb5d7cd9a0692772f981920c253941600447cb8d"
|
|
12
|
+
},
|
|
13
|
+
"family": "Roboto",
|
|
14
|
+
"version": 1
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"fixtures": [
|
|
18
|
+
{
|
|
19
|
+
"id": "gf:cab2f63113328b72dce6b3e1eac7f268a27290911049f79fe760630c7befd861",
|
|
20
|
+
"file": "Roboto-400-italic.ttf",
|
|
21
|
+
"sha256": "cab2f63113328b72dce6b3e1eac7f268a27290911049f79fe760630c7befd861",
|
|
22
|
+
"license": "Roboto-OFL.txt",
|
|
23
|
+
"family": "Roboto",
|
|
24
|
+
"variant": "400:italic"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "gf:7f3ec5073a282c666c9a0063573345841229caf50ed34d33017e20d441bf5caf",
|
|
28
|
+
"file": "Roboto-400-normal.ttf",
|
|
29
|
+
"sha256": "7f3ec5073a282c666c9a0063573345841229caf50ed34d33017e20d441bf5caf",
|
|
30
|
+
"license": "Roboto-OFL.txt",
|
|
31
|
+
"family": "Roboto",
|
|
32
|
+
"variant": "400:normal"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "gf:07b9b53ecf7da2d6c4af6841bb5c9e16087594914c6723a782805a4201b3cdc5",
|
|
36
|
+
"file": "Roboto-700-italic.ttf",
|
|
37
|
+
"sha256": "07b9b53ecf7da2d6c4af6841bb5c9e16087594914c6723a782805a4201b3cdc5",
|
|
38
|
+
"license": "Roboto-OFL.txt",
|
|
39
|
+
"family": "Roboto",
|
|
40
|
+
"variant": "700:italic"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "gf:a059c7343a09d6144d964625cb5d7cd9a0692772f981920c253941600447cb8d",
|
|
44
|
+
"file": "Roboto-700-normal.ttf",
|
|
45
|
+
"sha256": "a059c7343a09d6144d964625cb5d7cd9a0692772f981920c253941600447cb8d",
|
|
46
|
+
"license": "Roboto-OFL.txt",
|
|
47
|
+
"family": "Roboto",
|
|
48
|
+
"variant": "700:normal"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
package/demo/demo.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mount } from '../src/index.js';
|
|
2
|
+
let view, documentData, resources={}, prices={}, selected=new Set(), quantities={}, occupied=false, loadRequest=0, activeGeneration=0;
|
|
3
|
+
const el=id=>document.getElementById(id);
|
|
4
|
+
const previewToken=new URLSearchParams(location.search).get('editor');
|
|
5
|
+
const fromEditor=!!previewToken&&/^[a-f0-9-]{36}$/.test(previewToken);
|
|
6
|
+
if(fromEditor){
|
|
7
|
+
document.documentElement.lang='cs';
|
|
8
|
+
document.querySelector('h1').textContent='Vyberte si místa';
|
|
9
|
+
document.title='Výběr míst - webový náhled';
|
|
10
|
+
el('sample-label').textContent='Náhled vašeho rozpracovaného sálu';
|
|
11
|
+
el('back-to-editor').hidden=false;
|
|
12
|
+
el('back-to-editor').href=`/flutter/?fromPreview=${encodeURIComponent(previewToken)}`;
|
|
13
|
+
el('import-tools').hidden=true;
|
|
14
|
+
el('legend').hidden=false;
|
|
15
|
+
el('reset').textContent='Zrušit výběr';
|
|
16
|
+
el('status').textContent='Připravuji webový náhled…';
|
|
17
|
+
el('preview-note').textContent='Ukázkový výběr - nevytváří rezervaci ani objednávku';
|
|
18
|
+
}
|
|
19
|
+
const status=message=>el('status').textContent=message;
|
|
20
|
+
const maxBytes=16*1024*1024;
|
|
21
|
+
async function digest(data){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',data))].map(b=>b.toString(16).padStart(2,'0')).join('');}
|
|
22
|
+
async function decode(text){
|
|
23
|
+
if(new TextEncoder().encode(text).length>maxBytes)throw Error('Import exceeds 16 MiB');
|
|
24
|
+
const value=JSON.parse(text);
|
|
25
|
+
if(value.schemaVersion===2)return {layout:value,resources};
|
|
26
|
+
if(Object.keys(value).some(k=>!['format','version','protocol','layout','host','resources'].includes(k)))throw Error('Unknown portable bundle field');
|
|
27
|
+
if(value.format!=='venue-layout-demo'||value.version!==2||value.protocol!==1)throw Error('Unsupported file version');
|
|
28
|
+
const loaded={};
|
|
29
|
+
for(const item of value.resources){
|
|
30
|
+
if(Object.keys(item).some(k=>!['path','size','sha256','data'].includes(k))||item.path!=='resources/fixtures.json')throw Error('Unknown portable resource');
|
|
31
|
+
if(!/^resources\/[a-zA-Z0-9_-]+\.json$/.test(item.path)||loaded[item.path])throw Error('Invalid or duplicate resource path');
|
|
32
|
+
const bytes=Uint8Array.from(atob(item.data),c=>c.charCodeAt(0));
|
|
33
|
+
if(bytes.length>maxBytes||bytes.length!==item.size||await digest(bytes)!==item.sha256)throw Error('Resource integrity failed');
|
|
34
|
+
loaded[item.path]=JSON.parse(new TextDecoder().decode(bytes));
|
|
35
|
+
}
|
|
36
|
+
if(!loaded['resources/fixtures.json'])throw Error('Missing bundled resources');
|
|
37
|
+
const resources=loaded['resources/fixtures.json'];
|
|
38
|
+
if(Object.keys(resources).some(k=>!['protocol','fontFamilies','fontFaces','textLayouts','numberSymbols','assets'].includes(k)))throw Error('Unknown portable resource field');
|
|
39
|
+
if('assets' in resources&&(!resources.assets||typeof resources.assets!=='object'||Array.isArray(resources.assets)))throw Error('Embedded assets must be a map');
|
|
40
|
+
for(const [id,asset] of Object.entries(resources.assets??{})){
|
|
41
|
+
if(!id||!asset||typeof asset!=='object'||Array.isArray(asset)||Object.keys(asset).some(k=>!['hash','url','byteLength','mimeType','width','height'].includes(k)))throw Error('Invalid embedded asset fields');
|
|
42
|
+
const prefix=`data:${asset.mimeType};base64,`;
|
|
43
|
+
if(!['image/png','image/jpeg','image/webp'].includes(asset.mimeType)||typeof asset.url!=='string'||!asset.url.startsWith(prefix))throw Error('Embedded image required');
|
|
44
|
+
const bytes=Uint8Array.from(atob(asset.url.slice(prefix.length)),c=>c.charCodeAt(0));
|
|
45
|
+
if(bytes.length>800*1024||bytes.length!==asset.byteLength||await digest(bytes)!==asset.hash)throw Error('Embedded asset integrity failed');
|
|
46
|
+
}
|
|
47
|
+
return {layout:value.layout,resources,host:value.host||{}};
|
|
48
|
+
}
|
|
49
|
+
function price(n){return prices[n.id]??prices[n.parentId]??250;}
|
|
50
|
+
function capacityStates(){return Object.fromEntries(documentData.nodes.filter(n=>n.kind==='capacityArea').map(n=>[n.id,{capacity:n.capacity??20,available:n.capacity??20,selectedQuantity:quantities[n.id]??0}]));}
|
|
51
|
+
function presentation(){return Object.fromEntries(documentData.nodes.filter(n=>n.kind==='seat').map(n=>[n.id,{status:selected.has(n.id)?'selected':occupied?'occupied':'available',selected:selected.has(n.id),label:n.label||'Seat',accessibilityLabel:`${n.label||'Seat'} - ${price(n)} CZK`,price:price(n)}]));}
|
|
52
|
+
async function response(){
|
|
53
|
+
const scenario=el('scenario').value;
|
|
54
|
+
await new Promise(r=>setTimeout(r,scenario==='timeout'?800:100));
|
|
55
|
+
if(scenario!=='success'){status(`Demo ${scenario}: selection preserved. Retry with success.`);return {accepted:false,message:`Demo ${scenario}`};}
|
|
56
|
+
return {accepted:true};
|
|
57
|
+
}
|
|
58
|
+
function units(ids){const result=new Set(ids);for(const unit of documentData.selectionUnits??[]){if(unit.nodeIds.some(id=>result.has(id)))for(const id of unit.nodeIds)result.add(id);}return result;}
|
|
59
|
+
function update(){
|
|
60
|
+
const standing=Object.values(quantities).reduce((a,b)=>a+b,0),total=documentData.nodes.filter(n=>selected.has(n.id)).reduce((sum,n)=>sum+price(n),0);
|
|
61
|
+
el('selected').textContent=fromEditor?`Sezení: ${selected.size} · Stání: ${standing} · Cena sezení: ${total.toLocaleString('cs-CZ')} Kč`:`${selected.size} seats selected; ${standing} standing selected - sample seat total ${total} CZK`;
|
|
62
|
+
view?.update({document:documentData,resources,seatPresentation:presentation(),capacityPresentation:capacityStates()});
|
|
63
|
+
}
|
|
64
|
+
function capacities(){
|
|
65
|
+
el('capacity').replaceChildren();
|
|
66
|
+
for(const area of documentData.nodes.filter(n=>n.kind==='capacityArea')){
|
|
67
|
+
const label=document.createElement('label');label.textContent=`${area.label||'Standing'} - total capacity ${area.capacity}: `;
|
|
68
|
+
const input=document.createElement('input');input.type='number';input.min='0';input.max=String(area.capacity);input.value=String(quantities[area.id]??0);
|
|
69
|
+
input.setAttribute('aria-label',`${area.label||'Standing'} selected quantity`);
|
|
70
|
+
input.onchange=async()=>{const count=Number(input.value);if(!Number.isInteger(count)||count<0||count>area.capacity){input.value=String(quantities[area.id]??0);return;}
|
|
71
|
+
const result=await response();if(result.accepted)quantities[area.id]=count;else input.value=String(quantities[area.id]??0);update();};
|
|
72
|
+
label.append(input);el('capacity').append(label);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function validatePrices(values,layout){
|
|
76
|
+
if(values==null)return;
|
|
77
|
+
if(typeof values!=='object'||Array.isArray(values))throw Error('Invalid demo prices');
|
|
78
|
+
const ids=new Set(layout.nodes.filter(n=>['seat','table','group','furniture','capacityArea'].includes(n.kind)).map(n=>n.id));
|
|
79
|
+
for(const [id,value] of Object.entries(values))if(!ids.has(id)||!Number.isSafeInteger(value)||value<0||value>100000000)throw Error('Invalid or orphaned sample price binding');
|
|
80
|
+
}
|
|
81
|
+
async function load(text){
|
|
82
|
+
const request=++loadRequest,parsed=await decode(text);
|
|
83
|
+
const issues=JSON.parse(VenueLayoutKernel.validate(JSON.stringify(parsed.layout)));
|
|
84
|
+
if(issues.length)throw Error(issues[0].reason);
|
|
85
|
+
const resourceIssues=JSON.parse(VenueLayoutKernel.validateResources(JSON.stringify(parsed.resources)));
|
|
86
|
+
if(resourceIssues.length)throw Error('Invalid font resource: '+resourceIssues[0].reason);
|
|
87
|
+
const host=parsed.host||{},allowed=new Set(['prices','template','revision','label','savedBaseline','savedPrices']);
|
|
88
|
+
if(Object.keys(host).some(k=>!allowed.has(k)))throw Error('Unknown demo metadata');
|
|
89
|
+
validatePrices(host.prices,parsed.layout);
|
|
90
|
+
if(host.savedBaseline){const errors=JSON.parse(VenueLayoutKernel.validate(JSON.stringify(host.savedBaseline)));if(errors.length)throw Error('Invalid saved baseline');validatePrices(host.savedPrices,host.savedBaseline);}
|
|
91
|
+
for(const asset of Object.values(parsed.resources.assets??{})){
|
|
92
|
+
const bytes=Uint8Array.from(atob(asset.url.split(',')[1]),c=>c.charCodeAt(0));
|
|
93
|
+
const image=await createImageBitmap(new Blob([bytes],{type:asset.mimeType}));
|
|
94
|
+
const valid=image.width===asset.width&&image.height===asset.height;image.close();if(!valid)throw Error('Embedded image dimensions differ');
|
|
95
|
+
}
|
|
96
|
+
if(parsed.layout.background&&!parsed.resources.assets?.[parsed.layout.background.assetRef])throw Error('Missing embedded background');
|
|
97
|
+
const scene=JSON.parse(VenueLayoutKernel.compileScene(JSON.stringify(parsed.layout),JSON.stringify(parsed.resources),'{}'));
|
|
98
|
+
if(scene.issues.length)throw Error('Missing or invalid prepared resource: '+scene.issues[0].reason);
|
|
99
|
+
if(request!==loadRequest)return;
|
|
100
|
+
const nextPrices=host.prices||{},nextPresentation=Object.fromEntries(parsed.layout.nodes.filter(n=>n.kind==='seat').map(n=>[n.id,{label:n.label||'Seat',status:'available'}]));
|
|
101
|
+
const staging=document.createElement('div');let next;
|
|
102
|
+
try{next=mount(staging,{document:parsed.layout,resources:parsed.resources,seatPresentation:nextPresentation,
|
|
103
|
+
onQuantityRequested:async(id,count)=>{const result=await response();if(activeGeneration!==request)return{accepted:false,message:'Document changed'};if(result.accepted){quantities[id]=count;update();}return result;},
|
|
104
|
+
onSelectionRequested:async(ids,selecting)=>{const result=await response();if(activeGeneration!==request)return{accepted:false,message:'Document changed'};if(result.accepted){for(const id of units(ids)){if(selecting)selected.add(id);else selected.delete(id);}update();}return result;}});
|
|
105
|
+
}catch(error){next?.dispose();throw error;}
|
|
106
|
+
if(fromEditor){
|
|
107
|
+
const list=staging.querySelector('[role="list"]');
|
|
108
|
+
if(list){
|
|
109
|
+
const details=document.createElement('details'),summary=document.createElement('summary');
|
|
110
|
+
summary.textContent='Přístupný seznam míst';
|
|
111
|
+
list.before(details);details.append(summary,list);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Commit only a completely decoded, validated and mounted candidate.
|
|
115
|
+
view?.dispose();el('picker').replaceChildren(...staging.childNodes);view=next;
|
|
116
|
+
activeGeneration=request;documentData=parsed.layout;resources=parsed.resources;prices=nextPrices;selected=new Set();quantities={};
|
|
117
|
+
capacities();update();status(fromEditor?'Vyberte místo kliknutím. Plánek můžete přibližovat a posouvat.':'Same exported geometry loaded. Select seats or standing quantities.');
|
|
118
|
+
}
|
|
119
|
+
el('import').onchange=async()=>{try{const file=el('import').files[0];if(file)await load(await file.text());}catch(error){status(`Import rejected; current plan preserved: ${error.message}`);}};
|
|
120
|
+
el('reset').onclick=()=>{selected.clear();quantities={};capacities();update();};
|
|
121
|
+
el('patch').onclick=()=>{occupied=!occupied;update();status('Presentation patch applied; geometry unchanged.');};
|
|
122
|
+
// Deterministic artifact harness uses the same public import path as a file import.
|
|
123
|
+
window.venueDemo={load,get document(){return documentData;},get selected(){return [...selected];}};
|
|
124
|
+
|
|
125
|
+
// Build-time seed comes from VenueLayoutTemplates public API, never a JS generator.
|
|
126
|
+
(async()=>{
|
|
127
|
+
if(fromEditor){
|
|
128
|
+
try{
|
|
129
|
+
const text=await new Promise((resolve,reject)=>{
|
|
130
|
+
const request=indexedDB.open('venue-layout-preview',1);
|
|
131
|
+
request.onupgradeneeded=()=>request.result.createObjectStore('drafts');
|
|
132
|
+
request.onerror=()=>reject(Error('Úložiště náhledu není dostupné.'));
|
|
133
|
+
request.onblocked=()=>reject(Error('Úložiště náhledu je zablokované.'));
|
|
134
|
+
request.onsuccess=()=>{
|
|
135
|
+
const database=request.result;
|
|
136
|
+
const transaction=database.transaction('drafts');
|
|
137
|
+
const read=transaction.objectStore('drafts').get(previewToken);
|
|
138
|
+
read.onsuccess=()=>resolve(read.result);
|
|
139
|
+
read.onerror=()=>reject(Error('Plánek se nepodařilo načíst.'));
|
|
140
|
+
transaction.oncomplete=()=>database.close();
|
|
141
|
+
transaction.onabort=()=>{database.close();reject(Error('Načtení plánku bylo přerušeno.'));};
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
if(typeof text!=='string')throw Error('Chybí plánek z editoru. Vraťte se a otevřete náhled znovu.');
|
|
145
|
+
await load(text);
|
|
146
|
+
}catch(error){status(`Náhled se nepodařilo otevřít: ${error.message}`);}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if(globalThis.venueDemoSeed) {
|
|
150
|
+
const {layout,resources}=globalThis.venueDemoSeed;
|
|
151
|
+
const bytes=new TextEncoder().encode(JSON.stringify(resources));
|
|
152
|
+
let binary=""; for(let i=0;i<bytes.length;i+=8192) binary+=String.fromCharCode(...bytes.subarray(i,i+8192));
|
|
153
|
+
const data=btoa(binary);
|
|
154
|
+
load(JSON.stringify({format:'venue-layout-demo',version:2,protocol:1,layout,host:{},resources:[{path:'resources/fixtures.json',size:bytes.length,sha256:await digest(bytes),data}]})).catch(error=>status(`Seed failed: ${error.message}`));
|
|
155
|
+
} })();
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3
|
+
<title>Venue Layout - JavaScript picker</title>
|
|
4
|
+
<style>
|
|
5
|
+
.venue-layout-viewer{height:100%;display:flex;flex-direction:column}.venue-layout-viewer>svg{flex:1;min-height:240px}.venue-layout-viewer>details{flex:none;margin:0;border-top:1px solid #dfe4eb;padding:8px 12px}.venue-layout-viewer>details>summary{font-size:12px;color:#667085}*{box-sizing:border-box}body{font:14px/1.5 system-ui,sans-serif;margin:0;background:#f3f5f8;color:#182230}header,aside{padding:16px 24px;background:#fff;border-bottom:1px solid #dfe4eb}h1{font-size:21px;margin:0;font-weight:650}.heading{display:flex;align-items:center;gap:20px;justify-content:space-between}.subtitle{color:#667085;font-size:12px}.tools{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:12px}button,input,select{font:inherit;padding:8px 12px;border:1px solid #cbd5e1;background:#fff;border-radius:6px;color:inherit}button{cursor:pointer}button:hover{background:#eff4ff}button:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:3px}main{height:66vh;min-height:320px;padding:16px 24px}#status{padding:8px 24px;color:#667085;font-size:12px}label{display:inline-block}summary{cursor:pointer}#picker{height:100%;background:white;border:1px solid #dfe4eb;border-radius:8px;overflow:auto}a{color:#2563eb;text-decoration:none}.back{padding:8px 12px;border:1px solid #dfe4eb;border-radius:6px;white-space:nowrap}.legend{display:flex;gap:20px;font-size:12px;color:#667085}.legend span:before{content:'';display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:3px;background:#267329;border:1px solid #267329}.legend .chosen:before{content:'✓';color:white;text-align:center;font-size:10px;line-height:10px;background:#267329}.legend .taken:before{background:#e2e8f0}.summary{display:flex;gap:20px;justify-content:space-between;align-items:center}.summary strong{font-size:16px}details{margin-top:16px;color:#667085}[hidden]{display:none!important}@media(max-width:650px){header,aside{padding:12px}main{padding:8px;height:60vh}.heading,.summary{align-items:flex-start;flex-direction:column;gap:8px}}
|
|
6
|
+
</style>
|
|
7
|
+
<header><div class="heading"><div><h1>Customer preview</h1><div class="subtitle" id="sample-label">Demo - sample data</div></div><a class="back" id="back-to-editor" href="/flutter/?fromPreview=1" hidden>Zpět do editoru</a></div><div class="tools"><label id="import-tools">Import portable demo or geometry <input id="import" type="file" accept=".json,application/json"></label><button id="reset">Clear selection</button><div class="legend" id="legend" hidden><span>Volné</span><span class="chosen">Vybrané</span><span class="taken">Obsazené</span></div></div></header>
|
|
8
|
+
<div id="status" role="status">Import the file exported by the Flutter editor. Geometry comes from the same Dart kernel.</div>
|
|
9
|
+
<main><div id="picker" aria-label="Venue customer picker"></div></main>
|
|
10
|
+
<aside><div class="summary"><strong id="selected">0 seats selected</strong><span class="subtitle" id="preview-note">Demo - no reservations or payments</span></div><div id="capacity"></div><details><summary>Diagnostics</summary><label>Simulated outcome <select id="scenario"><option>success</option><option>conflict</option><option>timeout</option><option>resource</option></select></label><button id="patch">Toggle sample occupancy patch</button><p>This host simulates outcomes only. It does not create reservations or payments.</p></details></aside>
|
|
11
|
+
<script src="../dist/venue_layout_kernel.js"></script><script src="seed.js"></script>
|
|
12
|
+
<script src="../dist/demo.js"></script></html>
|