Post Step 4 Completion

This commit is contained in:
2026-09-14 07:57:18 -07:00
parent db973a86f3
commit 446bf3533e
52 changed files with 32034 additions and 3 deletions
@@ -0,0 +1,54 @@
/*
* Aquarium — composition root.
*
* The last file to load, and the only one that knows about all the others.
* It builds the exhibit, builds the UI, then hands both to the contract
* adapter. If the adapter were removed, the first two lines would still
* produce a complete, working aquarium.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var announcer = new Shell.Announcer({
node: Shell.el('announcement'),
idleText: 'Tank settled \u2014 awaiting instruction.',
maxLength: 120
});
var exhibit = new window.AquariumExhibit({
canvas: Shell.el('tank-canvas'),
announcer: announcer
});
var ui = new window.AquariumUI(exhibit, announcer);
/* Contract layer. Optional by construction: everything above already
* works without it. */
var core = window.AquariumContract.create(exhibit, ui);
/* Transport layer. Also optional: with no host attached, nothing here
* ever fires and the exhibit is unchanged. */
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function (request, response) {
if (response.type === 'hello.result') {
Shell.setText(Shell.el('contract-status'), 'host attached');
}
}
});
exhibit.start();
window.addEventListener('resize', function () { exhibit.resize(); });
/* Exposed for the conformance harness and for manual console poking. */
window.__exhibit = {
core: core,
exhibit: exhibit,
ui: ui,
transport: transport,
announcer: announcer
};
})();
@@ -0,0 +1,217 @@
/*
* Aquarium — XZBT Exhibit Contract 5.2 adapter.
*
* This file is the only place in the exhibit that knows the contract exists.
* It does three things and nothing else:
*
* 1. declares the public target catalog against the exhibit's real model
* 2. binds each target to a real exhibit service (setter / reader / action)
* 3. routes the native UI through the canonical mutation path
*
* It holds no state of its own. Every value it reports is read live from the
* exhibit, so there is no shadow copy that can drift (Authoring Guide §W).
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var IDENTITY = {
product: 'Aquarium',
version: '1.0.0',
build: 'reference-exhibit'
};
/* ------------------------------------------------------------------ *
* Public target catalog.
*
* IDs are canonical and domain-shaped. They are not DOM ids, CSS
* selectors, or widget names (Authoring Guide §W).
* ------------------------------------------------------------------ */
var DESCRIPTORS = [
{
id: 'tank.species',
kind: 'selection',
label: 'Species',
description: 'The species currently stocked in the tank.',
options: [
{ value: 'clownfish', label: 'Clownfish' },
{ value: 'blue-tang', label: 'Blue Tang' },
{ value: 'neon-tetra', label: 'Neon Tetra' },
{ value: 'angelfish', label: 'Angelfish' },
{ value: 'guppy', label: 'Guppy' }
],
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.population',
kind: 'range',
label: 'Population',
description: 'Number of fish in the tank.',
min: 1, max: 40, step: 1, unit: 'fish',
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.lighting',
kind: 'selection',
label: 'Lighting',
description: 'Lighting programme for the tank.',
options: [
{ value: 'daylight', label: 'Daylight' },
{ value: 'twilight', label: 'Twilight' },
{ value: 'moonlight', label: 'Moonlight' },
{ value: 'blackwater', label: 'Blackwater' }
],
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.current',
kind: 'range',
label: 'Water current',
description: 'Strength of the circulation pump, 0 (still) to 1 (strong).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.temperature',
kind: 'range',
label: 'Temperature',
description: 'Heater setting, 0 (18 C) to 1 (28 C).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'tank.paused',
kind: 'state',
valueType: 'boolean',
label: 'Paused',
description: 'Whether the simulation is frozen.',
readable: true, writable: true, restorable: true,
category: 'tank', requires: []
},
{
id: 'action.feed',
kind: 'impulse',
label: 'Feed',
description: 'Scatter food across the surface.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.startle',
kind: 'impulse',
label: 'Startle',
description: 'Send a pressure wave through the tank.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.clean-glass',
kind: 'impulse',
label: 'Clean glass',
description: 'Run the glass-cleaning cycle.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'telemetry.fish-count',
kind: 'state',
valueType: 'number',
label: 'Fish count',
description: 'Live number of fish currently rendered.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.food-level',
kind: 'state',
valueType: 'number',
label: 'Food level',
description: 'Remaining food in the water column, 0 to 1.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.water-temp',
kind: 'state',
valueType: 'number',
unit: 'C',
label: 'Water temperature',
description: 'Actual water temperature in degrees Celsius.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
}
];
/* ------------------------------------------------------------------ *
* Wiring
* ------------------------------------------------------------------ */
function createAquariumContract(exhibit, ui) {
var catalog = new Core.Catalog(DESCRIPTORS);
/* The exhibit has no capability that can fail: it is pure canvas and
* needs no device, no network, and no permission. Reporting `ready`
* once is honest; inventing loading/error transitions would not be
* (Authoring Guide §M). */
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
var setters = {
'tank.species': function (v) { return exhibit.setSpecies(v); },
'tank.population': function (v) { return exhibit.setPopulation(v); },
'tank.lighting': function (v) { return exhibit.setLighting(v); },
'tank.current': function (v) { return exhibit.setCurrent(v); },
'tank.temperature': function (v) { return exhibit.setTemperature(v); },
'tank.paused': function (v) { return exhibit.setPaused(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return exhibit.read(id); };
})(ids[i]);
}
var actions = {
'action.feed': function () { return exhibit.feed(); },
'action.startle': function () { return exhibit.startle(); },
'action.clean-glass': function () { return exhibit.cleanGlass(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
setters: setters,
readers: readers,
actions: actions
});
/* Route the native UI through the canonical path. From here on, a
* slider drag and a host `set` are the same transaction, and both are
* reported with the same source semantics (Authoring Guide §H). */
ui.dispatchOverride = function (targetId, value) {
core.applyMutation(targetId, value, 'ui');
ui.sync();
};
ui.invokeOverride = function (targetId) {
core.invokeAction(targetId, {}, 'ui');
ui.sync();
};
/* Keep the controls honest when the exhibit changes for any reason. */
exhibit.onChange = function () { ui.sync(); };
return core;
}
window.AquariumContract = { create: createAquariumContract, IDENTITY: IDENTITY };
})();
@@ -0,0 +1,694 @@
/*
* Aquarium — exhibit behaviour.
*
* This file is the exhibit. It owns the tank model, the simulation, the
* canvas rendering, and the native controls. It has no contract awareness
* whatsoever: it does not know what a target ID is, it never emits an event,
* and it would work identically if the contract layer were deleted.
*
* That is deliberate. The contract adapter is a layer added on top of a
* working exhibit (Authoring Guide §X), not a scaffold the exhibit is built
* inside of.
*
* The one thing this file does provide for the layer above it is a set of
* absolute, idempotent setters and real actions. Those are the exhibit's own
* services — the same functions the native UI calls — so UI-originated and
* host-originated changes share exactly one code path (Authoring Guide §H).
*/
(function () {
'use strict';
var TAU = Math.PI * 2;
var SPECIES = [
{ id: 'clownfish', label: 'Clownfish', color: '#ff9a3c', size: 9, speed: 1.15, schooling: 0.35 },
{ id: 'blue-tang', label: 'Blue Tang', color: '#3f8cff', size: 12, speed: 0.95, schooling: 0.55 },
{ id: 'neon-tetra', label: 'Neon Tetra', color: '#4fe3ff', size: 6, speed: 1.45, schooling: 0.85 },
{ id: 'angelfish', label: 'Angelfish', color: '#f2e06a', size: 14, speed: 0.7, schooling: 0.2 },
{ id: 'guppy', label: 'Guppy', color: '#ff6fae', size: 7, speed: 1.3, schooling: 0.45 }
];
var LIGHTING = [
{ id: 'daylight', label: 'Daylight', tint: '#bfe9ff', ambient: 1.0, surface: 0.85 },
{ id: 'twilight', label: 'Twilight', tint: '#6f7fd0', ambient: 0.62, surface: 0.5 },
{ id: 'moonlight', label: 'Moonlight', tint: '#3d5a9c', ambient: 0.34, surface: 0.28 },
{ id: 'blackwater', label: 'Blackwater', tint: '#2a1f3d', ambient: 0.18, surface: 0.12 }
];
var FEED_AMOUNT = 0.22;
var FEED_DECAY_PER_SECOND = 0.035;
var MAX_FISH = 40;
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
function findById(list, id) {
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
/* ------------------------------------------------------------------ *
* Tank model
* ------------------------------------------------------------------ */
function Tank() {
this.species = 'clownfish';
this.population = 12;
this.lighting = 'daylight';
this.current = 0.35;
this.temperature = 0.5;
this.foodLevel = 0.0;
this.paused = false;
this.fish = [];
this.bubbles = [];
this.plants = [];
this.time = 0;
this._spawnAll();
this._seedScenery();
}
Tank.prototype._spawnAll = function () {
this.fish = [];
for (var i = 0; i < this.population; i++) this.fish.push(this._makeFish(i));
};
Tank.prototype._makeFish = function (index) {
var species = findById(SPECIES, this.species);
var lane = (index % 5) / 5;
return {
x: Math.random(),
y: 0.18 + lane * 0.62 + (Math.random() - 0.5) * 0.08,
vx: (Math.random() < 0.5 ? -1 : 1) * (0.02 + Math.random() * 0.03),
vy: (Math.random() - 0.5) * 0.012,
phase: Math.random() * TAU,
wobble: 0.6 + Math.random() * 0.8,
size: species.size * (0.8 + Math.random() * 0.45),
energy: 0.5 + Math.random() * 0.5
};
};
Tank.prototype._seedScenery = function () {
this.plants = [];
for (var i = 0; i < 7; i++) {
this.plants.push({
x: 0.06 + i * 0.14 + (Math.random() - 0.5) * 0.05,
height: 0.18 + Math.random() * 0.3,
phase: Math.random() * TAU,
blades: 3 + Math.floor(Math.random() * 3)
});
}
this.bubbles = [];
for (var b = 0; b < 26; b++) {
this.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
};
/* ------------------------------------------------------------------ *
* Simulation
* ------------------------------------------------------------------ */
Tank.prototype.step = function (dt) {
if (this.paused) return;
this.time += dt;
var species = findById(SPECIES, this.species);
var light = findById(LIGHTING, this.lighting);
/* Food decays on its own, so a feed is a real, observable event rather
* than a flag that stays set forever. */
if (this.foodLevel > 0) {
this.foodLevel = Math.max(0, this.foodLevel - FEED_DECAY_PER_SECOND * dt);
}
var currentForce = (this.current - 0.5) * 0.09;
var tempFactor = 0.6 + this.temperature * 0.9;
var hunger = this.foodLevel;
for (var i = 0; i < this.fish.length; i++) {
var f = this.fish[i];
f.phase += dt * f.wobble * 2.2;
/* Schooling: drift toward the shoal's average position. */
var avgX = 0, avgY = 0;
for (var j = 0; j < this.fish.length; j++) {
avgX += this.fish[j].x;
avgY += this.fish[j].y;
}
avgX /= this.fish.length;
avgY /= this.fish.length;
var pull = species.schooling * 0.35;
f.vx += (avgX - f.x) * pull * dt;
f.vy += (avgY - f.y) * pull * dt;
/* Feeding: food sinks from the surface, so fish rise toward it. */
if (hunger > 0.01) {
f.vy -= hunger * 0.5 * dt;
f.energy = clamp(f.energy + hunger * dt * 0.6, 0, 1);
}
/* Water current pushes horizontally. */
f.vx += currentForce * dt;
/* Temperature sets overall activity. */
var speed = species.speed * tempFactor * (0.7 + f.energy * 0.6);
f.vx = clamp(f.vx, -0.09 * speed, 0.09 * speed);
f.vy = clamp(f.vy, -0.05 * speed, 0.05 * speed);
f.x += f.vx * dt * 6;
f.y += f.vy * dt * 6;
/* Wander and keep off the glass. */
f.vy += Math.sin(f.phase) * 0.004 * dt;
if (f.x < 0.04) { f.x = 0.04; f.vx = Math.abs(f.vx); }
if (f.x > 0.96) { f.x = 0.96; f.vx = -Math.abs(f.vx); }
if (f.y < 0.1) { f.y = 0.1; f.vy = Math.abs(f.vy); }
if (f.y > 0.9) { f.y = 0.9; f.vy = -Math.abs(f.vy); }
f.energy = clamp(f.energy - dt * 0.02, 0.15, 1);
}
/* Bubbles rise faster in warmer, more turbulent water. */
var bubbleSpeed = 0.5 + this.temperature * 0.8 + Math.abs(this.current - 0.5) * 0.6;
for (var b = 0; b < this.bubbles.length; b++) {
var bubble = this.bubbles[b];
bubble.y -= bubble.speed * bubbleSpeed * dt;
bubble.drift += dt * 1.4;
bubble.x += Math.sin(bubble.drift) * 0.004 * dt * 6;
if (bubble.y < -0.02) {
bubble.y = 1.02;
bubble.x = Math.random();
}
}
/* Light level feeds back into the scene's own ambient value. */
this.ambient = light.ambient;
};
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
function Renderer(canvas, tank) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.tank = tank;
this.width = 0;
this.height = 0;
this.resize();
}
Renderer.prototype.resize = function () {
var rect = this.canvas.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
this.width = Math.max(1, Math.round(rect.width));
this.height = Math.max(1, Math.round(rect.height));
this.canvas.width = Math.round(this.width * dpr);
this.canvas.height = Math.round(this.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
Renderer.prototype.draw = function () {
var ctx = this.ctx;
var w = this.width;
var h = this.height;
var tank = this.tank;
var light = findById(LIGHTING, tank.lighting);
var species = findById(SPECIES, tank.species);
ctx.clearRect(0, 0, w, h);
/* Water column. */
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, shade(light.tint, 0.55 * light.ambient + 0.12));
grad.addColorStop(0.55, shade(light.tint, 0.3 * light.ambient + 0.06));
grad.addColorStop(1, shade(light.tint, 0.12 * light.ambient + 0.03));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
/* Caustic light shafts from the surface. */
ctx.save();
ctx.globalAlpha = 0.1 * light.surface;
ctx.fillStyle = '#ffffff';
for (var s = 0; s < 6; s++) {
var sx = ((s / 6) + Math.sin(tank.time * 0.25 + s) * 0.03) * w;
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx + w * 0.05, 0);
ctx.lineTo(sx + w * 0.12, h);
ctx.lineTo(sx - w * 0.02, h);
ctx.closePath();
ctx.fill();
}
ctx.restore();
/* Substrate. */
ctx.fillStyle = shade(light.tint, 0.1);
ctx.beginPath();
ctx.moveTo(0, h);
for (var x = 0; x <= w; x += 12) {
var y = h - 26 - Math.sin(x * 0.02) * 6 - Math.sin(x * 0.007) * 9;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
/* Plants. */
for (var p = 0; p < tank.plants.length; p++) {
var plant = tank.plants[p];
var baseX = plant.x * w;
var baseY = h - 24;
var sway = Math.sin(tank.time * 0.9 + plant.phase) * (0.02 + tank.current * 0.05);
ctx.strokeStyle = shade('#2f7d4f', 0.35 + light.ambient * 0.5);
ctx.lineWidth = 2.5;
for (var bl = 0; bl < plant.blades; bl++) {
var offset = (bl - (plant.blades - 1) / 2) * 7;
ctx.beginPath();
ctx.moveTo(baseX + offset, baseY);
ctx.quadraticCurveTo(
baseX + offset + sway * w * 0.5,
baseY - plant.height * h * 0.6,
baseX + offset + sway * w,
baseY - plant.height * h
);
ctx.stroke();
}
}
/* Fish. */
for (var i = 0; i < tank.fish.length; i++) {
var f = tank.fish[i];
var fx = f.x * w;
var fy = f.y * h;
var facing = f.vx >= 0 ? 1 : -1;
var size = f.size * (0.85 + light.ambient * 0.3);
ctx.save();
ctx.translate(fx, fy);
ctx.scale(facing, 1);
/* Tail. */
ctx.fillStyle = shade(species.color, 0.55 + light.ambient * 0.4);
ctx.beginPath();
ctx.moveTo(-size * 0.9, 0);
ctx.lineTo(-size * 1.7, -size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.lineTo(-size * 1.7, size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.closePath();
ctx.fill();
/* Body. */
ctx.fillStyle = shade(species.color, 0.6 + light.ambient * 0.45);
ctx.beginPath();
ctx.ellipse(0, 0, size, size * 0.52, 0, 0, TAU);
ctx.fill();
/* Eye. */
ctx.fillStyle = 'rgba(10,12,18,0.85)';
ctx.beginPath();
ctx.arc(size * 0.5, -size * 0.1, Math.max(1, size * 0.13), 0, TAU);
ctx.fill();
ctx.restore();
}
/* Bubbles. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.12 + light.ambient * 0.22) + ')';
ctx.lineWidth = 1;
for (var b = 0; b < tank.bubbles.length; b++) {
var bubble = tank.bubbles[b];
ctx.beginPath();
ctx.arc(bubble.x * w, bubble.y * h, bubble.r * w, 0, TAU);
ctx.stroke();
}
/* Food particles sinking from the surface. */
if (tank.foodLevel > 0.01) {
ctx.fillStyle = 'rgba(226,196,120,' + (0.35 + tank.foodLevel * 0.5) + ')';
for (var k = 0; k < 40; k++) {
var seed = k * 12.9898;
var px = (Math.sin(seed) * 0.5 + 0.5) * w;
var py = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * 0.5 + 0.5) * h;
var sink = (tank.time * 0.06 + (k / 40)) % 1;
ctx.beginPath();
ctx.arc(px, (py * 0.3 + sink * 0.7) * h, 1.6, 0, TAU);
ctx.fill();
}
}
/* Surface line. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.1 + light.surface * 0.25) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
for (var sx2 = 0; sx2 <= w; sx2 += 8) {
var sy = 8 + Math.sin(sx2 * 0.03 + tank.time * 1.6) * 3;
if (sx2 === 0) ctx.moveTo(sx2, sy); else ctx.lineTo(sx2, sy);
}
ctx.stroke();
if (tank.paused) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.font = '600 13px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('PAUSED', w / 2, h / 2);
ctx.textAlign = 'start';
}
};
/** Lighten/darken a hex colour by a 0..1 factor. */
function shade(hex, factor) {
var n = parseInt(hex.slice(1), 16);
var r = (n >> 16) & 255;
var g = (n >> 8) & 255;
var b = n & 255;
var f = clamp(factor, 0, 1.6);
r = Math.round(clamp(r * f, 0, 255));
g = Math.round(clamp(g * f, 0, 255));
b = Math.round(clamp(b * f, 0, 255));
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
/* ------------------------------------------------------------------ *
* Exhibit
* ------------------------------------------------------------------ */
function AquariumExhibit(options) {
this.canvas = options.canvas;
this.tank = new Tank();
this.renderer = new Renderer(this.canvas, this.tank);
this.announcer = options.announcer;
this.onChange = options.onChange || function () {};
this._lastFrame = 0;
this._running = false;
this._boundFrame = this._frame.bind(this);
}
AquariumExhibit.prototype.start = function () {
if (this._running) return;
this._running = true;
this._lastFrame = performance.now();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype._frame = function (now) {
if (!this._running) return;
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
this._lastFrame = now;
this.tank.step(dt);
this.renderer.draw();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype.resize = function () {
this.renderer.resize();
};
/* ------------------------------------------------------------------ *
* Absolute, idempotent setters.
*
* Each returns { changed: boolean }. Returning changed:false for a no-op
* is what lets the contract layer leave stateRevision untouched, and it is
* also what stops a host-set value from being silently reverted the next
* time a local control is touched (Authoring Guide §W).
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.setSpecies = function (id) {
if (this.tank.species === id) return { changed: false };
this.tank.species = id;
/* Restocking is a real consequence of changing species, so the fish are
* respawned rather than recoloured in place. */
this.tank._spawnAll();
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPopulation = function (count) {
count = Math.round(count);
if (this.tank.population === count) return { changed: false };
var previous = this.tank.population;
this.tank.population = count;
if (count > previous) {
for (var i = previous; i < count; i++) this.tank.fish.push(this.tank._makeFish(i));
} else {
this.tank.fish.length = count;
}
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setLighting = function (id) {
if (this.tank.lighting === id) return { changed: false };
this.tank.lighting = id;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setCurrent = function (value) {
if (this.tank.current === value) return { changed: false };
this.tank.current = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setTemperature = function (value) {
if (this.tank.temperature === value) return { changed: false };
this.tank.temperature = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPaused = function (on) {
if (this.tank.paused === on) return { changed: false };
this.tank.paused = on;
this.onChange();
return { changed: true };
};
/* ------------------------------------------------------------------ *
* Real actions.
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.feed = function () {
this.tank.foodLevel = clamp(this.tank.foodLevel + FEED_AMOUNT, 0, 1);
for (var i = 0; i < this.tank.fish.length; i++) {
this.tank.fish[i].energy = clamp(this.tank.fish[i].energy + 0.25, 0, 1);
}
this.announcer.flash('Feeding — food dispersing through the water column.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.startle = function () {
for (var i = 0; i < this.tank.fish.length; i++) {
var f = this.tank.fish[i];
f.vx += (Math.random() - 0.5) * 0.12;
f.vy += (Math.random() - 0.5) * 0.08;
f.energy = 1;
}
this.announcer.flash('Startle — the shoal scatters.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.cleanGlass = function () {
this.tank.bubbles.length = 0;
for (var b = 0; b < 26; b++) {
this.tank.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
this.announcer.flash('Glass cleaned — clarity restored.');
this.onChange();
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.read = function (targetId) {
var t = this.tank;
switch (targetId) {
case 'tank.species': return t.species;
case 'tank.population': return t.population;
case 'tank.lighting': return t.lighting;
case 'tank.current': return t.current;
case 'tank.temperature': return t.temperature;
case 'tank.paused': return t.paused;
case 'telemetry.fish-count': return t.fish.length;
case 'telemetry.food-level': return Math.round(t.foodLevel * 1000) / 1000;
case 'telemetry.water-temp': return Math.round((18 + t.temperature * 10) * 10) / 10;
default: return null;
}
};
/* ------------------------------------------------------------------ *
* Native UI
*
* Every handler below calls the exhibit's own service method. None of them
* dispatches a synthetic DOM event, and none of them writes state directly
* (Authoring Guide §W).
* ------------------------------------------------------------------ */
function AquariumUI(exhibit, announcer) {
this.exhibit = exhibit;
this.announcer = announcer;
this.nodes = {
species: document.getElementById('species-select'),
population: document.getElementById('population-range'),
populationReadout: document.getElementById('population-readout'),
lighting: document.getElementById('lighting-select'),
current: document.getElementById('current-range'),
currentReadout: document.getElementById('current-readout'),
temperature: document.getElementById('temperature-range'),
temperatureReadout: document.getElementById('temperature-readout'),
pause: document.getElementById('pause-button'),
feed: document.getElementById('feed-button'),
startle: document.getElementById('startle-button'),
clean: document.getElementById('clean-button')
};
this._buildOptions();
this._bind();
this.sync();
}
AquariumUI.prototype._buildOptions = function () {
var speciesSelect = this.nodes.species;
for (var i = 0; i < SPECIES.length; i++) {
var opt = document.createElement('option');
opt.value = SPECIES[i].id;
opt.textContent = SPECIES[i].label;
speciesSelect.appendChild(opt);
}
var lightSelect = this.nodes.lighting;
for (var j = 0; j < LIGHTING.length; j++) {
var lopt = document.createElement('option');
lopt.value = LIGHTING[j].id;
lopt.textContent = LIGHTING[j].label;
lightSelect.appendChild(lopt);
}
};
AquariumUI.prototype._bind = function () {
var self = this;
var ex = this.exhibit;
this.nodes.species.addEventListener('change', function (e) {
self.dispatch('tank.species', e.target.value);
});
this.nodes.population.addEventListener('input', function (e) {
self.dispatch('tank.population', Number(e.target.value));
});
this.nodes.lighting.addEventListener('change', function (e) {
self.dispatch('tank.lighting', e.target.value);
});
this.nodes.current.addEventListener('input', function (e) {
self.dispatch('tank.current', Number(e.target.value));
});
this.nodes.temperature.addEventListener('input', function (e) {
self.dispatch('tank.temperature', Number(e.target.value));
});
this.nodes.pause.addEventListener('click', function () {
self.dispatch('tank.paused', !ex.tank.paused);
});
this.nodes.feed.addEventListener('click', function () {
self.invoke('action.feed');
});
this.nodes.startle.addEventListener('click', function () {
self.invoke('action.startle');
});
this.nodes.clean.addEventListener('click', function () {
self.invoke('action.clean-glass');
});
};
/**
* The UI's route into the canonical path. `dispatch` is installed by the
* contract adapter; until then the UI still works, because the fallback
* calls the exhibit service directly. Either way the exhibit's own setter
* is the only thing that mutates state.
*/
AquariumUI.prototype.dispatch = function (targetId, value) {
if (typeof this.dispatchOverride === 'function') {
this.dispatchOverride(targetId, value);
return;
}
this._fallbackSet(targetId, value);
};
AquariumUI.prototype.invoke = function (targetId) {
if (typeof this.invokeOverride === 'function') {
this.invokeOverride(targetId);
return;
}
this._fallbackInvoke(targetId);
};
AquariumUI.prototype._fallbackSet = function (targetId, value) {
var ex = this.exhibit;
switch (targetId) {
case 'tank.species': ex.setSpecies(value); break;
case 'tank.population': ex.setPopulation(value); break;
case 'tank.lighting': ex.setLighting(value); break;
case 'tank.current': ex.setCurrent(value); break;
case 'tank.temperature': ex.setTemperature(value); break;
case 'tank.paused': ex.setPaused(value); break;
default: break;
}
this.sync();
};
AquariumUI.prototype._fallbackInvoke = function (targetId) {
var ex = this.exhibit;
if (targetId === 'action.feed') ex.feed();
else if (targetId === 'action.startle') ex.startle();
else if (targetId === 'action.clean-glass') ex.cleanGlass();
this.sync();
};
/** Reflect current exhibit state into the controls. */
AquariumUI.prototype.sync = function () {
var t = this.exhibit.tank;
var n = this.nodes;
if (n.species.value !== t.species) n.species.value = t.species;
if (Number(n.population.value) !== t.population) n.population.value = String(t.population);
if (n.lighting.value !== t.lighting) n.lighting.value = t.lighting;
if (Number(n.current.value) !== t.current) n.current.value = String(t.current);
if (Number(n.temperature.value) !== t.temperature) n.temperature.value = String(t.temperature);
n.populationReadout.textContent = t.population + ' fish';
n.currentReadout.textContent = window.XZBTShell.formatPercent(t.current);
n.temperatureReadout.textContent = window.XZBTShell.formatNumber(18 + t.temperature * 10, 1) + ' \u00b0C';
n.pause.setAttribute('aria-pressed', t.paused ? 'true' : 'false');
n.pause.textContent = t.paused ? 'Resume' : 'Pause';
};
/* ------------------------------------------------------------------ *
* Exports
* ------------------------------------------------------------------ */
window.AquariumExhibit = AquariumExhibit;
window.AquariumUI = AquariumUI;
window.AquariumData = { SPECIES: SPECIES, LIGHTING: LIGHTING, MAX_FISH: MAX_FISH };
})();
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aquarium — XZBT Reference Exhibit</title>
<link rel="stylesheet" href="../shared/exhibit-shell.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="exhibit">
<header class="exhibit-header">
<h1>Aquarium</h1>
<span class="subtitle">Reef tank &mdash; live simulation</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<canvas id="tank-canvas" aria-label="Aquarium view"></canvas>
</main>
<aside class="panel">
<section>
<h2>Stocking</h2>
<div class="control">
<label for="species-select">Species</label>
<select id="species-select"></select>
</div>
<div class="control">
<label for="population-range">
<span>Population</span>
<span class="readout" id="population-readout">12 fish</span>
</label>
<input type="range" id="population-range" min="1" max="40" step="1" value="12">
</div>
</section>
<section>
<h2>Environment</h2>
<div class="control">
<label for="lighting-select">Lighting</label>
<select id="lighting-select"></select>
</div>
<div class="control">
<label for="current-range">
<span>Water current</span>
<span class="readout" id="current-readout">35%</span>
</label>
<input type="range" id="current-range" min="0" max="1" step="0.01" value="0.35">
</div>
<div class="control">
<label for="temperature-range">
<span>Temperature</span>
<span class="readout" id="temperature-readout">23.0 &deg;C</span>
</label>
<input type="range" id="temperature-range" min="0" max="1" step="0.01" value="0.5">
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="feed-button" type="button">Feed</button>
<button id="startle-button" type="button">Startle</button>
<button id="clean-button" type="button">Clean glass</button>
</div>
<div class="toggle-row" style="margin-top:12px">
<span>Simulation</span>
<button id="pause-button" type="button" aria-pressed="false">Pause</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">Tank</span>
<span class="announcement-text is-idle" id="announcement">Tank settled &mdash; awaiting instruction.</span>
</footer>
</div>
<script src="../shared/contract-core.js"></script>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="boot.js"></script>
</body>
</html>
@@ -0,0 +1,54 @@
/* Aquarium — domain styling only. Layout and controls come from the shell. */
body {
background: #071018;
color: #dff1ff;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(30, 90, 130, 0.35), rgba(255, 255, 255, 0.03));
border-color: rgba(120, 200, 255, 0.22);
}
.exhibit-header h1 { color: #bfe9ff; }
.stage {
border-color: rgba(120, 200, 255, 0.25);
box-shadow: inset 0 0 60px rgba(0, 40, 70, 0.8);
}
.panel {
background: rgba(20, 60, 90, 0.22);
border-color: rgba(120, 200, 255, 0.18);
}
.panel h2 { color: #8fd4ff; }
input[type="range"] { accent-color: #4fc3ff; }
button {
background: rgba(79, 195, 255, 0.12);
border-color: rgba(120, 200, 255, 0.35);
color: #dff1ff;
}
button:hover { background: rgba(79, 195, 255, 0.24); }
button[aria-pressed="true"] {
background: rgba(79, 195, 255, 0.4);
border-color: rgba(160, 225, 255, 0.7);
}
select {
background: rgba(4, 24, 38, 0.85);
border-color: rgba(120, 200, 255, 0.3);
}
.exhibit-footer {
background: rgba(20, 60, 90, 0.22);
border-color: rgba(120, 200, 255, 0.18);
}
.exhibit-footer .announcement-label { color: #8fd4ff; opacity: 0.7; }
.exhibit-header .status { color: #8fd4ff; }