diff --git a/AGENTS.md b/AGENTS.md
index 1752367..2692a63 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,3 +35,7 @@
## Audio Asset Rules
- Keep all runtime sounds in `client/public/sounds/`.
- Reference sounds as absolute web paths (example: `/sounds/roll.ogg`).
+
+## Changelog Policy
+- Footer changelog content is sourced from `client/public/changelog.json`.
+- Do not add or edit changelog lines unless the user explicitly instructs to do so.
diff --git a/client/index.html b/client/index.html
index e85e3e9..711a796 100644
--- a/client/index.html
+++ b/client/index.html
@@ -67,7 +67,16 @@
diff --git a/client/public/changelog.json b/client/public/changelog.json
new file mode 100644
index 0000000..26f03d7
--- /dev/null
+++ b/client/public/changelog.json
@@ -0,0 +1,17 @@
+{
+ "sections": [
+ {
+ "date": "February 20, 2026",
+ "items": [
+ "Fixed reconnect failure paths so bad saved position or signaling errors do not leave connect stuck, and hardened websocket message parsing.",
+ "Unified voice effects under a shared effect model for users and radio stations, including loopback monitoring.",
+ "Added global item use cooldown support on the server with per-item-type override capability.",
+ "Added Shift+O inspector mode to read all item properties, including global and non-editable values.",
+ "Added paste support for canvas text-entry modes (chat and field editors).",
+ "Expanded character/arrow review announcements to include common punctuation names.",
+ "Persisted player position across refresh/unload so reconnect restores the previous grid location.",
+ "Added the deploy helper script for quick client publish plus service restart workflow."
+ ]
+ }
+ ]
+}
diff --git a/client/public/version.js b/client/public/version.js
index bee3547..4878805 100644
--- a/client/public/version.js
+++ b/client/public/version.js
@@ -1,3 +1,3 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
-window.CHGRID_WEB_VERSION = "2026.02.21 R82";
+window.CHGRID_WEB_VERSION = "2026.02.21 R83";
diff --git a/client/src/main.ts b/client/src/main.ts
index a204b7a..43e1a13 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -43,6 +43,9 @@ declare global {
type Dom = {
appVersion: HTMLElement;
+ updatesSection: HTMLElement;
+ updatesToggle: HTMLButtonElement;
+ updatesPanel: HTMLDivElement;
nicknameContainer: HTMLDivElement;
preconnectNickname: HTMLInputElement;
connectButton: HTMLButtonElement;
@@ -62,6 +65,9 @@ type Dom = {
const dom: Dom = {
appVersion: requiredById('appVersion'),
+ updatesSection: requiredById('updatesSection'),
+ updatesToggle: requiredById('updatesToggle'),
+ updatesPanel: requiredById('updatesPanel'),
nicknameContainer: requiredById('nicknameContainer'),
preconnectNickname: requiredById('preconnectNickname'),
connectButton: requiredById('connectButton'),
@@ -79,6 +85,15 @@ const dom: Dom = {
instructions: requiredById('instructions'),
};
+type ChangelogSection = {
+ date: string;
+ items: string[];
+};
+
+type ChangelogData = {
+ sections: ChangelogSection[];
+};
+
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
dom.appVersion.textContent = APP_VERSION
? `Another AI experiment with Jage. Version ${APP_VERSION}`
@@ -181,6 +196,7 @@ const peerManager = new PeerManager(
audio.setOutputMode(outputMode);
loadEffectLevels();
+void loadChangelog();
function requiredById(id: string): T {
const found = document.getElementById(id);
@@ -190,6 +206,53 @@ function requiredById(id: string): T {
return found as T;
}
+function setUpdatesExpanded(expanded: boolean): void {
+ dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates';
+ dom.updatesPanel.hidden = !expanded;
+ dom.updatesPanel.classList.toggle('hidden', !expanded);
+}
+
+function renderChangelog(changelog: ChangelogData): void {
+ dom.updatesPanel.innerHTML = '';
+ for (const section of changelog.sections) {
+ const heading = document.createElement('h3');
+ heading.textContent = section.date;
+ dom.updatesPanel.appendChild(heading);
+
+ const list = document.createElement('ul');
+ for (const item of section.items) {
+ const li = document.createElement('li');
+ li.textContent = item;
+ list.appendChild(li);
+ }
+ dom.updatesPanel.appendChild(list);
+ }
+}
+
+async function loadChangelog(): Promise {
+ try {
+ const response = await fetch(withBase('changelog.json'), { cache: 'no-store' });
+ if (!response.ok) {
+ dom.updatesSection.classList.add('hidden');
+ return;
+ }
+ const changelog = (await response.json()) as ChangelogData;
+ if (!Array.isArray(changelog.sections) || changelog.sections.length === 0) {
+ dom.updatesSection.classList.add('hidden');
+ return;
+ }
+ renderChangelog(changelog);
+ setUpdatesExpanded(false);
+ dom.updatesToggle.addEventListener('click', () => {
+ const expanded = dom.updatesToggle.getAttribute('aria-expanded') === 'true';
+ setUpdatesExpanded(!expanded);
+ });
+ } catch {
+ dom.updatesSection.classList.add('hidden');
+ }
+}
+
function updateStatus(message: string): void {
const normalized = String(message)
.replace(/\s*\n+\s*/g, ' ')
diff --git a/client/src/styles.css b/client/src/styles.css
index 8956936..73ef1ae 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -24,6 +24,38 @@ body {
margin-bottom: 0.5rem;
}
+#appFooter {
+ margin-top: 0.5rem;
+}
+
+.updates-section {
+ color: #94a3b8;
+ text-align: left;
+ width: fit-content;
+ margin: 0 auto;
+}
+
+.updates-section h2 {
+ color: #cbd5e1;
+ font-size: 1rem;
+ margin: 0.35rem 0;
+}
+
+#updatesToggle {
+ margin-bottom: 0.35rem;
+}
+
+#updatesPanel h3 {
+ color: #cbd5e1;
+ font-size: 0.95rem;
+ margin: 0.35rem 0 0.2rem;
+}
+
+#updatesPanel ul {
+ margin: 0 0 0.5rem 1.1rem;
+ padding: 0;
+}
+
#deviceSummary {
color: #94a3b8;
margin: 0 auto 0.75rem;