agents-city 0.4.0 → 0.5.2

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.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { plural, t as _ } from './idioma';
19
- import { CLASES, FormularioDeCasa } from './casa';
19
+ import { FormularioDeCasa } from './casa';
20
20
  import type { DatosDeCasa, Rol } from './casa';
21
21
 
22
22
  export type { Rol } from './casa';
@@ -46,7 +46,7 @@ const PASOS = ['Welcome', 'The work', 'Your chair', 'The houses', 'Ready'];
46
46
  /** A house, drawn rather than shipped: the same isometric shape the map and the
47
47
  * desktop icon use, so the first thing somebody sees is already the product. */
48
48
  function casita(color: string): string {
49
- return `<svg viewBox="0 0 120 108" role="img" aria-label="an isometric house" class="bvCasa">
49
+ return `<svg viewBox="0 0 120 108" role="img" aria-label="${_('an isometric house')}" class="bvCasa">
50
50
  <polygon points="60,10 112,40 60,70 8,40" fill="${color}" opacity=".95"/>
51
51
  <polygon points="8,40 60,70 60,100 8,70" fill="${color}" opacity=".62"/>
52
52
  <polygon points="112,40 60,70 60,100 112,70" fill="${color}" opacity=".40"/>
@@ -67,7 +67,18 @@ export class Bienvenida {
67
67
 
68
68
  constructor(private readonly p: Puerta) {}
69
69
 
70
- /** Draw the current step into `lienzo`. The only entry point. */
70
+ /**
71
+ * The view contract, so the Hall's dispatcher treats every section the same.
72
+ *
73
+ * The painting itself is async — the first one asks the server for the domain
74
+ * packs — and a router cannot wait on a view. It does not need to: the guide
75
+ * draws a loading line first and fills itself in.
76
+ */
77
+ monta(host: HTMLElement): void {
78
+ void this.pinta(host);
79
+ }
80
+
81
+ /** Draw the current step into `lienzo`. */
71
82
  async pinta(lienzo: HTMLElement): Promise<void> {
72
83
  this.lienzo = lienzo;
73
84
  if (!this.dominios.length) {
@@ -243,16 +254,6 @@ export class Bienvenida {
243
254
  </div>`;
244
255
  }
245
256
 
246
- /**
247
- * The places on this disk worth offering for this house, ranked.
248
- *
249
- * With nothing typed it is what you touched recently, because on a machine
250
- * with three hundred clones a list is not an answer. What is typed matches
251
- * the name and the path both — people look for `rankia` and for `Documents`
252
- * with equal frequency, and only one of those is a repository name. A house
253
- * that keeps knowledge sees document folders first, because that is the one
254
- * kind of agent the old picker could not help at all.
255
- */
256
257
  // ── 4. done ───────────────────────────────────────────────────────────────
257
258
  private pantallaFinal(): string {
258
259
  return `
@@ -13,7 +13,8 @@
13
13
 
14
14
  import { plural, t as _ } from './idioma';
15
15
  import { Explorador } from './explorador';
16
- import { ESFUERZOS, MODELOS, RUNTIMES, opciones } from './motores';
16
+ import { ESFUERZOS, RUNTIMES, motorDe, opciones } from './motores';
17
+ import { Montada } from './vista';
17
18
 
18
19
  export interface Rol {
19
20
  id: string;
@@ -57,7 +58,7 @@ export const CLASES: Array<[string, string, string]> = [
57
58
  ['coordinator', 'It coordinates', 'Its house grows with the decisions it records.'],
58
59
  ];
59
60
 
60
- export class FormularioDeCasa {
61
+ export class FormularioDeCasa extends Montada {
61
62
  readonly datos: DatosDeCasa = {
62
63
  nombre: '',
63
64
  clase: 'code',
@@ -68,40 +69,70 @@ export class FormularioDeCasa {
68
69
  esfuerzo: '',
69
70
  };
70
71
  private roles: Rol[] = [];
71
- private host: HTMLElement | null = null;
72
-
72
+ private rolesPedidos = false;
73
73
  constructor(
74
74
  private readonly p: Puerta,
75
75
  roles: Rol[] = [],
76
76
  ) {
77
+ super();
77
78
  this.roles = roles;
78
79
  }
79
80
 
80
- /** Render into `host` and wire it. Safe to call again: that is the repaint. */
81
- monta(host: HTMLElement): void {
82
- this.host = host;
81
+ override monta(host: HTMLElement): void {
83
82
  void this.aseguraRoles();
84
- host.innerHTML = this.html();
85
- this.enlaza(host);
83
+ super.monta(host);
84
+ // The picker is a view of its own, living in a hole this one leaves for it.
86
85
  const hueco = host.querySelector<HTMLElement>('.casaExp');
87
86
  if (hueco) this.explora().monta(hueco);
88
87
  }
89
88
 
90
- private repinta(): void {
89
+ protected override repinta(): void {
90
+ // A repaint here rebuilds the hole too, so the picker has to be remounted
91
+ // into the new one — which is what `monta` does.
91
92
  if (this.host) this.monta(this.host);
92
93
  }
93
94
 
95
+ /** Asked once, ever. `monta` runs on every repaint, so a guard on "did we get
96
+ * any roles back" would refetch — and repaint, and refetch — forever the day
97
+ * the catalogue comes back empty. */
94
98
  private async aseguraRoles(): Promise<void> {
95
- if (this.roles.length) return;
99
+ if (this.rolesPedidos) return;
100
+ this.rolesPedidos = true;
96
101
  try {
97
102
  const r = await this.p.api<{ roles: Rol[] }>('/api/roles?scope=agent');
98
103
  this.roles = r.roles ?? [];
99
- this.repinta();
104
+ // Only the one control that changed. Rebuilding the whole form because a
105
+ // dropdown filled in is how the name got lost in the first place, and it
106
+ // also took the folder picker down mid-walk.
107
+ this.pintaRoles();
100
108
  } catch {
101
109
  this.roles = []; // the role list is a convenience; blank is always valid
102
110
  }
103
111
  }
104
112
 
113
+ /** The role list, once it arrives, without touching anything else. */
114
+ private pintaRoles(): void {
115
+ const sel = this.host?.querySelector<HTMLSelectElement>('#bvRol');
116
+ if (!sel || !this.roles.length) return;
117
+ sel.innerHTML = this.opcionesDeRol();
118
+ sel.value = this.datos.rol;
119
+ // A select that answers to nothing is a select that silently keeps the
120
+ // first option no matter what the person picks.
121
+ sel.onchange = () => this.recoge();
122
+ this.recoge();
123
+ }
124
+
125
+ private opcionesDeRol(): string {
126
+ return this.roles
127
+ .map(
128
+ (r) =>
129
+ `<option value="${this.p.esc(r.id)}"${
130
+ r.id === this.datos.rol ? ' selected' : ''
131
+ }>${this.p.esc(r.name)}</option>`,
132
+ )
133
+ .join('');
134
+ }
135
+
105
136
  /** The folder picker, built once so walking survives a repaint. */
106
137
  private explorador: Explorador | null = null;
107
138
 
@@ -113,14 +144,17 @@ export class FormularioDeCasa {
113
144
  const ya = this.datos.montajes.indexOf(ruta);
114
145
  if (ya >= 0) this.datos.montajes.splice(ya, 1);
115
146
  else this.datos.montajes.push(ruta);
116
- this.repinta();
147
+ // Only the strip of chosen chips changes here. Repainting the whole
148
+ // form tore the picker down and rebuilt it mid-click — one tick
149
+ // rendered two thousand rows twice, and lost the person's place.
150
+ this.pintaElegidas();
117
151
  },
118
152
  () => this.datos.montajes,
119
153
  );
120
154
  return this.explorador;
121
155
  }
122
156
 
123
- html(): string {
157
+ protected html(): string {
124
158
  const a = this.datos;
125
159
  const esc = this.p.esc;
126
160
  const clases = CLASES.map(
@@ -135,7 +169,7 @@ export class FormularioDeCasa {
135
169
  `<option value="${esc(r.id)}"${r.id === a.rol ? ' selected' : ''}>${esc(r.name)}</option>`,
136
170
  )
137
171
  .join('');
138
- const esClaude = (a.runtime || 'claude') === 'claude';
172
+ const motor = motorDe(a.runtime);
139
173
  return `
140
174
  <div class="campo"><label>${_('What do you call it?')}</label>
141
175
  <input type="text" id="bvNombre" value="${esc(a.nombre)}"
@@ -146,52 +180,91 @@ export class FormularioDeCasa {
146
180
  <select id="bvRol">${roles}</select></div>
147
181
  <label class="bvEtiqueta">${_('What runs it?')}</label>
148
182
  <p class="pista">${_(
149
- 'Leave all three on default and it runs the way you do. Model and effort are Claude’s to read; another CLI carries its own flags.',
183
+ 'Leave all three on default and it runs the way you do. Whatever you set here is written once on the card, and the launcher hands it to whichever CLI runs this house.',
150
184
  )}</p>
151
185
  <div class="bvMotor">
152
186
  <div class="campo"><label>${_('provider')}</label>
153
187
  <select id="bvRuntime">${opciones(RUNTIMES, a.runtime, esc, 'claude')}</select></div>
154
188
  <div class="campo"><label>${_('engine')}</label>
155
- <select id="bvModelo" ${esClaude ? '' : 'disabled'}>${opciones(
156
- [''].concat(MODELOS),
157
- a.modelo,
158
- esc,
159
- _('default'),
160
- )}</select></div>
189
+ ${
190
+ motor.modelos.length
191
+ ? `<select id="bvModelo">${opciones(
192
+ [''].concat(motor.modelos),
193
+ a.modelo,
194
+ esc,
195
+ _('default'),
196
+ )}</select>`
197
+ : `<input type="text" id="bvModelo" value="${esc(a.modelo)}"
198
+ placeholder="${_('default')}" autocomplete="off" spellcheck="false">`
199
+ }</div>
161
200
  <div class="campo"><label>${_('effort')}</label>
162
- <select id="bvEsfuerzo" ${esClaude ? '' : 'disabled'}>${opciones(
163
- [''].concat(ESFUERZOS),
164
- a.esfuerzo,
165
- esc,
166
- _('default'),
167
- )}</select></div>
201
+ <select id="bvEsfuerzo" ${motor.esfuerzo ? '' : 'disabled'}
202
+ title="${motor.esfuerzo ? '' : _('This CLI has no effort setting.')}">${opciones(
203
+ [''].concat(ESFUERZOS),
204
+ motor.esfuerzo ? a.esfuerzo : '',
205
+ esc,
206
+ _('default'),
207
+ )}</select></div>
168
208
  </div>
209
+ <p class="pista">${esc(_(motor.pista))}</p>
169
210
  <label class="bvEtiqueta">${_('What does it work on?')}</label>
170
211
  <p class="pista">${_(`Walk your disk and pick whatever you like: a repository, a
171
212
  worktree, a folder of documents, one exact file. As many as you want. Nothing is
172
213
  copied — each one is linked into this agent's own home.`)}</p>
173
214
  <div class="casaExp"></div>
174
- ${
175
- a.montajes.length
176
- ? `<div class="expElegidas"><label class="bvEtiqueta">${_('Working on')}</label>
177
- ${a.montajes
178
- .map(
179
- (m) => `<button type="button" class="bvChip elegida" data-bv="quita"
180
- data-ruta="${esc(m)}" title="${esc(m)}">${esc(
181
- m.split('/').pop() || m,
182
- )}<i class="expQuita">✕</i></button>`,
183
- )
184
- .join('')}</div>`
185
- : `<p class="pista">${_('Nothing chosen yet — an agent with no mounts is fine too.')}</p>`
186
- }`;
215
+ <div id="casaElegidas">${this.elegidas()}</div>`;
216
+ }
217
+
218
+ /** The mounts chosen so far, as chips you can take back off. Rendered on its
219
+ * own so ticking a row does not rebuild the picker underneath the cursor. */
220
+ private elegidas(): string {
221
+ const esc = this.p.esc;
222
+ const montajes = this.datos.montajes;
223
+ if (!montajes.length)
224
+ return `<p class="pista">${_('Nothing chosen yet — an agent with no mounts is fine too.')}</p>`;
225
+ return `<div class="expElegidas"><label class="bvEtiqueta">${_('Working on')}</label>
226
+ ${montajes
227
+ .map(
228
+ (m) => `<button type="button" class="bvChip elegida" data-bv="quita"
229
+ data-ruta="${esc(m)}" title="${esc(m)}">${esc(
230
+ m.split('/').pop() || m,
231
+ )}<i class="expQuita">✕</i></button>`,
232
+ )
233
+ .join('')}</div>`;
234
+ }
235
+
236
+ private pintaElegidas(): void {
237
+ const hueco = this.host?.querySelector<HTMLElement>('#casaElegidas');
238
+ if (!hueco) return;
239
+ hueco.innerHTML = this.elegidas();
240
+ this.enlaza(hueco);
187
241
  }
188
242
 
189
- private enlaza(raiz: HTMLElement): void {
243
+ protected enlaza(raiz: HTMLElement): void {
244
+ // Every field writes through as it is typed.
245
+ //
246
+ // `recoge()` used to be the only sync, called at the moments somebody
247
+ // remembered to call it — and a repaint from anywhere else redrew the
248
+ // inputs from a state that had never heard of what was in them. The roles
249
+ // list arrives a few hundred milliseconds after this form opens, which is
250
+ // exactly while a person is typing the name: the answer landed, the form
251
+ // repainted, the name was gone, and then "Build it" said to give it one.
252
+ //
253
+ // Now nothing can lose it, because there is no window in which the field
254
+ // and the state disagree.
255
+ raiz
256
+ .querySelectorAll<HTMLInputElement | HTMLSelectElement>(
257
+ '#bvNombre,#bvRol,#bvModelo,#bvEsfuerzo',
258
+ )
259
+ .forEach((el) => {
260
+ el.oninput = () => this.recoge();
261
+ el.onchange = () => this.recoge();
262
+ });
190
263
  raiz.querySelectorAll<HTMLElement>('[data-bv]').forEach((el) => {
191
264
  el.onclick = (evento) => {
192
265
  evento.preventDefault();
193
266
  const que = el.dataset.bv;
194
- this.recoge(raiz);
267
+ this.recoge();
195
268
  if (que === 'clase') this.datos.clase = el.dataset.id ?? 'code';
196
269
  else if (que === 'quita') {
197
270
  const ya = this.datos.montajes.indexOf(el.dataset.ruta ?? '');
@@ -201,13 +274,20 @@ export class FormularioDeCasa {
201
274
  };
202
275
  });
203
276
  raiz.querySelector<HTMLSelectElement>('#bvRuntime')?.addEventListener('change', () => {
204
- this.recoge(raiz);
277
+ this.recoge();
205
278
  this.repinta();
206
279
  });
207
280
  }
208
281
 
209
- /** Keep what is typed when a click forces a repaint. */
210
- private recoge(raiz: HTMLElement): void {
282
+ /** Keep what is typed when a click forces a repaint.
283
+ *
284
+ * Always from the form's own root, never from whatever element the click
285
+ * came through: the chosen-chips strip re-uses this wiring, and reading the
286
+ * fields relative to that strip would find none of them and quietly throw
287
+ * away everything the person had typed. */
288
+ private recoge(): void {
289
+ const raiz = this.host;
290
+ if (!raiz) return;
211
291
  const nombre = raiz.querySelector<HTMLInputElement>('#bvNombre');
212
292
  const rol = raiz.querySelector<HTMLSelectElement>('#bvRol');
213
293
  if (nombre) this.datos.nombre = nombre.value;
@@ -217,8 +297,8 @@ export class FormularioDeCasa {
217
297
  ['#bvModelo', 'modelo'],
218
298
  ['#bvEsfuerzo', 'esfuerzo'],
219
299
  ] as const) {
220
- const sel = raiz.querySelector<HTMLSelectElement>(id);
221
- if (sel) this.datos[campo] = sel.value;
300
+ const sel = raiz.querySelector<HTMLSelectElement | HTMLInputElement>(id);
301
+ if (sel) this.datos[campo] = sel.value.trim();
222
302
  }
223
303
  }
224
304
 
@@ -231,7 +311,7 @@ export class FormularioDeCasa {
231
311
  * another's job just to save a round trip.
232
312
  */
233
313
  async guarda(): Promise<{ slug: string; nombre: string } | null> {
234
- if (this.host) this.recoge(this.host);
314
+ this.recoge();
235
315
  const a = this.datos;
236
316
  const nombre = a.nombre.trim();
237
317
  if (!nombre) {
@@ -247,13 +327,20 @@ export class FormularioDeCasa {
247
327
  this.p.aviso(r.error || _('Could not add that agent'), true);
248
328
  return null;
249
329
  }
250
- for (const ruta of a.montajes) {
251
- const m = await this.p.api<{ ok?: boolean; error?: string }>('/api/montaje', {
252
- method: 'POST',
253
- body: JSON.stringify({ agent: r.agent, add: ruta }),
254
- });
255
- if (!m.ok) this.p.aviso(`${ruta}: ${m.error ?? _('could not mount')}`, true);
256
- }
330
+ // The mounts are independent of each other; awaiting them one at a time
331
+ // put six serialised round trips on the critical path of "Build it".
332
+ const montados = await Promise.all(
333
+ a.montajes.map((ruta) =>
334
+ this.p
335
+ .api<{ ok?: boolean; error?: string }>('/api/montaje', {
336
+ method: 'POST',
337
+ body: JSON.stringify({ agent: r.agent, add: ruta }),
338
+ })
339
+ .then((m) => (m.ok ? '' : `${ruta}: ${m.error ?? _('could not mount')}`))
340
+ .catch((e) => `${ruta}: ${String(e)}`),
341
+ ),
342
+ );
343
+ for (const fallo of montados.filter(Boolean)) this.p.aviso(fallo, true);
257
344
  const motor: Record<string, string> = {};
258
345
  if (a.runtime) motor.runtime = a.runtime;
259
346
  if (a.modelo) motor.model = a.modelo;
@@ -19,6 +19,7 @@
19
19
 
20
20
  import type { ActivityEvent } from './activity';
21
21
  import { t as _ } from './idioma';
22
+ import { Montada } from './vista';
22
23
 
23
24
  export interface Ficha {
24
25
  id: string;
@@ -50,7 +51,7 @@ const VELOCIDADES: Array<[string, number]> = [
50
51
  ['4×', 350],
51
52
  ];
52
53
 
53
- export class Demos {
54
+ export class Demos extends Montada {
54
55
  private catalogo: Ficha[] = [];
55
56
  private cargando = false;
56
57
  private elegida: Grabacion | null = null;
@@ -58,19 +59,19 @@ export class Demos {
58
59
  private corriendo = false;
59
60
  private velocidad = 1450;
60
61
  private reloj: number | null = null;
61
- private host: HTMLElement | null = null;
62
62
 
63
- constructor(private readonly p: PuertaDemo) {}
63
+ constructor(private readonly p: PuertaDemo) {
64
+ super();
65
+ }
64
66
 
65
- monta(host: HTMLElement): void {
66
- this.host = host;
67
+ override monta(host: HTMLElement): void {
67
68
  if (!this.catalogo.length && !this.cargando) void this.lee();
68
- this.repinta();
69
+ super.monta(host);
69
70
  }
70
71
 
71
- /** Stop the clock. Called when the section is left, or the page repaints
72
- * elsewhere: a timer still firing into a detached node is a leak with a view. */
73
- para(): void {
72
+ /** Stop the clock. The router calls this when the section is left: a timer
73
+ * still firing into a detached node is a leak with a soundtrack. */
74
+ desmonta(): void {
74
75
  this.corriendo = false;
75
76
  if (this.reloj !== null) {
76
77
  clearTimeout(this.reloj);
@@ -78,14 +79,10 @@ export class Demos {
78
79
  }
79
80
  }
80
81
 
81
- private repinta(): void {
82
- if (!this.host) return;
83
- this.host.innerHTML = this.html();
84
- this.enlaza(this.host);
85
- if (this.elegida) {
86
- const lista = this.host.querySelector<HTMLElement>('#demoLista');
87
- if (lista) lista.scrollTop = lista.scrollHeight;
88
- }
82
+ protected override repinta(): void {
83
+ super.repinta();
84
+ const lista = this.elegida ? this.host?.querySelector<HTMLElement>('#demoLista') : null;
85
+ if (lista) lista.scrollTop = lista.scrollHeight;
89
86
  }
90
87
 
91
88
  private async lee(): Promise<void> {
@@ -102,7 +99,7 @@ export class Demos {
102
99
  }
103
100
 
104
101
  private async abre(id: string): Promise<void> {
105
- this.para();
102
+ this.desmonta();
106
103
  this.hasta = 0;
107
104
  this.elegida = null;
108
105
  this.repinta();
@@ -128,19 +125,47 @@ export class Demos {
128
125
  if (this.reloj !== null) clearTimeout(this.reloj);
129
126
  this.reloj = window.setTimeout(() => {
130
127
  if (!this.corriendo || !this.elegida) return;
128
+ const evento = this.elegida.eventos[this.hasta];
131
129
  this.hasta += 1;
132
- if (this.hasta >= this.elegida.eventos.length) {
130
+ const final = this.hasta >= this.elegida.eventos.length;
131
+ if (final) {
133
132
  this.hasta = this.elegida.eventos.length;
134
133
  this.corriendo = false;
135
- this.repinta();
136
- return;
137
134
  }
138
- this.repinta();
139
- this.siguiente();
135
+ // A turn arriving is one more turn on the list. Repainting the player
136
+ // rebuilt every turn so far from zero, rebound the four speed buttons and
137
+ // forced a layout — twenty-two times, to append twenty-two lines. The
138
+ // full repaint is kept for the transitions that actually change the
139
+ // controls: play, pause, replay, and the end.
140
+ if (evento && !final) this.anade(evento);
141
+ else this.repinta();
142
+ if (!final) this.siguiente();
140
143
  }, this.velocidad);
141
144
  }
142
145
 
143
- private html(): string {
146
+ /** One more turn on screen, without taking the screen down. */
147
+ private anade(evento: ActivityEvent): void {
148
+ const lista = this.host?.querySelector<HTMLElement>('#demoLista');
149
+ if (!lista || !this.elegida) return this.repinta();
150
+ const vacia = lista.querySelector('.liveEmpty');
151
+ if (vacia) lista.innerHTML = '';
152
+ lista.insertAdjacentHTML('beforeend', this.p.pinta(evento));
153
+ lista.scrollTop = lista.scrollHeight;
154
+ this.marcador();
155
+ }
156
+
157
+ /** The counter and the bar, which are the only other things a turn moves. */
158
+ private marcador(): void {
159
+ if (!this.host || !this.elegida) return;
160
+ const total = this.elegida.eventos.length;
161
+ const hecho = Math.min(this.hasta, total);
162
+ const cuenta = this.host.querySelector<HTMLElement>('.demoCuenta');
163
+ if (cuenta) cuenta.textContent = _('{done} of {total}', { done: hecho, total });
164
+ const barra = this.host.querySelector<HTMLElement>('.demoBarra i');
165
+ if (barra) barra.style.width = `${total ? (hecho / total) * 100 : 0}%`;
166
+ }
167
+
168
+ protected html(): string {
144
169
  if (this.elegida) return this.reproductor(this.elegida);
145
170
  if (this.cargando) return `<p class="cargando">${_('reading the demo shelf')}</p>`;
146
171
  if (!this.catalogo.length)
@@ -185,9 +210,14 @@ export class Demos {
185
210
  <button type="button" class="bt ppal" data-demo="${this.corriendo ? 'pausa' : 'play'}">${
186
211
  this.corriendo ? `❚❚ ${_('Pause')}` : acabado ? `↺ ${_('Replay')}` : `▶ ${_('Play')}`
187
212
  }</button>
188
- <button type="button" class="bt" data-demo="replay" ${hecho ? '' : 'disabled'}>↺ ${_(
189
- 'Replay',
190
- )}</button>
213
+ ${
214
+ // Once it has finished, the primary button already says Replay and
215
+ // does exactly this. Two identical controls side by side is a person
216
+ // wondering which one is the real one.
217
+ hecho && !acabado
218
+ ? `<button type="button" class="bt" data-demo="replay">↺ ${_('Replay')}</button>`
219
+ : ''
220
+ }
191
221
  <span class="demoVel">${VELOCIDADES.map(
192
222
  ([etiqueta, ms]) =>
193
223
  `<button type="button" class="demoVelBoton ${
@@ -205,7 +235,7 @@ export class Demos {
205
235
  </div>`;
206
236
  }
207
237
 
208
- private enlaza(raiz: HTMLElement): void {
238
+ protected enlaza(raiz: HTMLElement): void {
209
239
  raiz.querySelectorAll<HTMLElement>('[data-demo]').forEach((el) => {
210
240
  el.onclick = (evento) => {
211
241
  evento.preventDefault();
@@ -214,7 +244,7 @@ export class Demos {
214
244
  void this.abre(el.dataset.id ?? '');
215
245
  break;
216
246
  case 'atras':
217
- this.para();
247
+ this.desmonta();
218
248
  this.elegida = null;
219
249
  this.hasta = 0;
220
250
  this.repinta();
@@ -223,11 +253,11 @@ export class Demos {
223
253
  this.arranca();
224
254
  break;
225
255
  case 'pausa':
226
- this.para();
256
+ this.desmonta();
227
257
  this.repinta();
228
258
  break;
229
259
  case 'replay':
230
- this.para();
260
+ this.desmonta();
231
261
  this.hasta = 0;
232
262
  this.arranca();
233
263
  break;