agents-city 0.3.0-beta.21 → 0.3.0

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. package/plugin/scripts/workspace.py +197 -0
@@ -11,7 +11,17 @@
11
11
  * ever serves this file.
12
12
  */
13
13
 
14
- import { renderRepoRoleEditor, RepoRoleOption, selectedRepoRoles } from './repo-roles';
14
+ import './es'; // the Spanish dictionary registers itself on import
15
+ import { idioma, plural, ponIdioma, t as _ } from './idioma';
16
+ import {
17
+ ESFUERZOS,
18
+ MODELOS,
19
+ NIVEL_ESFUERZO,
20
+ RUNTIMES,
21
+ nivelDeMotor,
22
+ opciones as opcionesDe,
23
+ } from './motores';
24
+ import { Bienvenida } from './bienvenida';
15
25
  import {
16
26
  ActivityEvent,
17
27
  MAP_ACTIVITY_PROTOCOL,
@@ -64,6 +74,8 @@ interface Ciudad {
64
74
  slug?: string;
65
75
  id?: string;
66
76
  actual: boolean;
77
+ /** How many agents live in it. An agent belongs to exactly one city. */
78
+ agentes?: number;
67
79
  }
68
80
  interface Road {
69
81
  id: string;
@@ -105,7 +117,9 @@ interface FichaAgente {
105
117
  avatar: string;
106
118
  cli: { binary: string; installed: boolean; connected: boolean };
107
119
  legacy: boolean;
108
- mounts: number;
120
+ /** What this agent actually works on — repos, worktrees, document folders.
121
+ * `fixed` is a legacy repo agent's own repo, which is not a mount it chose. */
122
+ mounts: { label: string; target: string; fixed?: boolean }[];
109
123
  growth: { floors: number; bricks: number; activity30: number; signal: string };
110
124
  }
111
125
  interface Deliberation {
@@ -172,6 +186,8 @@ interface Estado {
172
186
  agents?: FichaAgente[];
173
187
  /** True only for the packaged Aurora demo: unlocks the replay controls. */
174
188
  demo?: boolean;
189
+ /** One line when a newer release is published, '' otherwise. */
190
+ update?: string;
175
191
  }
176
192
  interface RolInfo {
177
193
  id: string;
@@ -184,6 +200,16 @@ interface DomainInfo {
184
200
  name: string;
185
201
  summary: string;
186
202
  }
203
+ /** What a reset would do, as the server computes it — never guessed here. */
204
+ interface Efectos {
205
+ city: string;
206
+ backup: string;
207
+ agents: number;
208
+ roads: number;
209
+ deliberations: number;
210
+ keeps: string[];
211
+ loses: string[];
212
+ }
187
213
  interface RepoLocal {
188
214
  nombre: string;
189
215
  ruta: string;
@@ -260,15 +286,35 @@ const SECCIONES: Array<[string, string]> = [
260
286
  ['resumen', 'Overview'],
261
287
  ['mapa', 'The map'],
262
288
  ['puesto', 'My seat'],
263
- ['barrios', 'Districts & houses'],
289
+ ['barrios', 'Districts'],
264
290
  ['red', 'Roads'],
265
291
  ['committee', 'Committee'],
266
- ['gente', 'Agents & skills'],
292
+ ['gente', 'Houses'],
293
+ ['ciudades', 'Cities'],
267
294
  ];
268
295
 
296
+ /** True until the person navigates: the landing section is decided once, from
297
+ * the state, and never yanked out from under them afterwards. */
298
+ let primeraCarga = true;
299
+
269
300
  async function refresca(): Promise<void> {
270
301
  E = await api<Estado>('/api/estado');
302
+ if (primeraCarga) {
303
+ primeraCarga = false;
304
+ // A city with no seat or no agents has nothing to draw, and "Not drawn
305
+ // yet" is a dead end as a first impression. Start where the next step is.
306
+ const mia = E.tarjetas.find((t) => t.user === E.yo);
307
+ // Never land on a dead end. Three cases, in order of what the person needs:
308
+ // nothing set up -> the guide, which asks the questions
309
+ // set up, no map yet -> the overview, which says what is left and how
310
+ // set up, map running -> the map, which is the point of the product
311
+ // Landing on the map view without a map was how "Not drawn yet" became the
312
+ // first thing an owner saw every single morning.
313
+ if (!mia || (E.agents ?? []).length === 0) SECCION = 'bienvenida';
314
+ else if (!E.mapa) SECCION = 'resumen';
315
+ }
271
316
  pinta();
317
+ pintaAvisoDeVersion();
272
318
  pintaDemoControles();
273
319
  sincronizaActividad(E.live_bus);
274
320
  }
@@ -279,6 +325,16 @@ async function refresca(): Promise<void> {
279
325
  * whose map was still baking missed it — these buttons are the way back. Real
280
326
  * cities never see them, and the server refuses them anyway.
281
327
  */
328
+ /** A newer release, said once and quietly: the server already decided whether
329
+ * there is one (cached for a day), so this only renders what it was told. */
330
+ function pintaAvisoDeVersion(): void {
331
+ const caja = document.getElementById('avisoVersion');
332
+ if (!caja) return;
333
+ const texto = (E.update ?? '').trim();
334
+ caja.textContent = texto;
335
+ caja.hidden = !texto;
336
+ }
337
+
282
338
  function pintaDemoControles(): void {
283
339
  const header = document.querySelector<HTMLElement>('#livePanel header');
284
340
  const previa = document.getElementById('demoControles');
@@ -692,22 +748,36 @@ function icono(kind: string): string {
692
748
  }
693
749
 
694
750
  function rail(): void {
695
- q('#rail').innerHTML = SECCIONES.map(([id, et]) => {
696
- const n =
697
- id === 'gente'
698
- ? String(Object.keys(E.skills).length)
699
- : id === 'committee'
700
- ? String(E.deliberations.length)
701
- : id === 'red'
702
- ? String(E.roads.length)
703
- : id === 'barrios'
704
- ? String(E.parcelas.length)
705
- : '';
706
- return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
707
- <span>${et}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
708
- }).join('');
709
- todos<HTMLElement>('#rail li').forEach((li) => {
751
+ // Everything above the divider belongs to ONE city — its seat, its agents,
752
+ // its map. Saying so here is the difference between a menu and a place: an
753
+ // agent's workspace and mounts live inside this city's folder, and a flat
754
+ // list of "Cities" and "Agents" as siblings hid that completely.
755
+ const cuantos = (E.agents ?? []).length;
756
+ q('#railCiudad').innerHTML = `<span class="railEtiqueta">${_('you are in')}</span>
757
+ <b>${esc(E.city_name)}</b>
758
+ <span class="railDe">${esc(E.domain)} · ${plural(cuantos, '{n} house', '{n} houses')}</span>`;
759
+ q('#rail').innerHTML = SECCIONES.filter(([id]) => id !== 'ciudades')
760
+ .map(([id, et]) => {
761
+ const n =
762
+ id === 'gente'
763
+ ? String(Object.keys(E.skills).length)
764
+ : id === 'committee'
765
+ ? String(E.deliberations.length)
766
+ : id === 'red'
767
+ ? String(E.roads.length)
768
+ : id === 'barrios'
769
+ ? String(E.parcelas.length)
770
+ : '';
771
+ return `<li class="${id === SECCION ? 'aqui' : ''}" data-s="${id}">
772
+ <span>${esc(_(et))}</span>${n ? `<span class="n">${n}</span>` : ''}</li>`;
773
+ })
774
+ .join('');
775
+ q('#railOtras').innerHTML =
776
+ `<li class="${SECCION === 'ciudades' ? 'aqui' : ''}" data-s="ciudades">
777
+ <span>${_('All cities')}</span><span class="n">${E.ciudades.length}</span></li>`;
778
+ todos<HTMLElement>('#rail li,#railOtras li').forEach((li) => {
710
779
  li.onclick = () => {
780
+ document.body.classList.remove('enGuia');
711
781
  SECCION = li.dataset.s ?? 'mapa';
712
782
  pinta();
713
783
  };
@@ -754,17 +824,185 @@ function pinta(): void {
754
824
  const VISTAS: Record<string, () => void> = {};
755
825
 
756
826
  // ── overview ─────────────────────────────────────────────────────────────────
827
+ /** The guided first run. Built once and kept, so a half-finished answer
828
+ * survives a repaint. */
829
+ let guia: Bienvenida | null = null;
830
+
831
+ VISTAS.bienvenida = () => {
832
+ document.body.classList.add('enGuia');
833
+ guia ??= new Bienvenida({
834
+ api,
835
+ esc,
836
+ aviso: toast,
837
+ refresca,
838
+ vete: (seccion: string) => {
839
+ document.body.classList.remove('enGuia');
840
+ SECCION = seccion;
841
+ pinta();
842
+ },
843
+ ciudad: E.city_name,
844
+ yo: E.yo,
845
+ datos: corto(E.datos),
846
+ });
847
+ void guia.pinta(q<HTMLElement>('#lienzo'));
848
+ };
849
+
850
+ VISTAS.ciudades = () => {
851
+ const filas = E.ciudades
852
+ .map(
853
+ (c) => `
854
+ <div class="fila ${c.actual ? 'on' : ''}">
855
+ <span class="et">${esc(c.nombre)}${c.actual ? ' <b class="de">· open now</b>' : ''}</span>
856
+ <span class="de">${c.agentes ?? 0} house${(c.agentes ?? 0) === 1 ? '' : 's'} of its own</span>
857
+ <span class="de mono">${esc(corto(c.ruta))}</span>
858
+ <span style="margin-left:auto;display:flex;gap:6px">
859
+ ${
860
+ c.actual
861
+ ? ''
862
+ : `<a class="bt mini" href="?city=${encodeURIComponent(c.ruta)}">open</a>
863
+ <button class="bt mini archiva" data-ciudad="${esc(c.ruta)}"
864
+ data-nombre="${esc(c.nombre)}">archive</button>`
865
+ }
866
+ </span>
867
+ </div>`,
868
+ )
869
+ .join('');
870
+ q('#lienzo').innerHTML = `<div><span class="sub">cities</span>
871
+ <h1 style="margin-top:6px">${_('Your cities')}</h1>
872
+ <p class="prosa" style="margin-top:8px">One person, several autonomous cities: each has
873
+ its own identity, domain, chair, <b>its own houses</b> and its own roads. A house is not
874
+ shared — it stands inside the city that owns it, with its workspace and mounts under that
875
+ city's folder — so two cities can each have a <code class="mono">docs</code> house and
876
+ they are two different workers. They share nothing unless you build a road between
877
+ them.</p></div>
878
+ <div class="lista"><div class="filas">${filas}</div></div>
879
+ <div class="campos" style="max-width:420px">
880
+ <div class="campo"><label>${_('Start another city')}</label>
881
+ <input type="text" id="nuevaCiudad" placeholder="client-a, research, the book…"></div>
882
+ <button class="bt ppal" id="creaCiudad">Create it</button>
883
+ </div>
884
+ <div class="peligro">
885
+ <h3>${_('Start this city over')}</h3>
886
+ <p class="prosa">Takes <b>${esc(E.city_name)}</b> back to its first day: no seat, no
887
+ agents, no committee history, no map. Your repositories and document folders are
888
+ <b>never touched</b> — only the city that points at them.</p>
889
+ <button class="bt malo" id="reiniciaCiudad">${_('Start over…')}</button>
890
+ </div>
891
+ <p class="pista">Archiving <b>moves</b> a city into
892
+ <code class="mono">.backups/</code> — nothing is deleted, and you can bring it back with
893
+ <code class="mono">mv</code>. The city you are standing in, and the last one you own,
894
+ cannot be archived.</p>`;
895
+ enlaza();
896
+ const campo = q<HTMLInputElement>('#nuevaCiudad');
897
+ q<HTMLButtonElement>('#creaCiudad').onclick = async () => {
898
+ const nombre = campo.value.trim();
899
+ if (!nombre) {
900
+ toast('Give it a name', true);
901
+ return;
902
+ }
903
+ const r = await api<Respuesta>('/api/ciudades', {
904
+ method: 'POST',
905
+ body: JSON.stringify({ name: nombre }),
906
+ });
907
+ if (!r.ok) {
908
+ toast(r.error ?? 'Could not create it', true);
909
+ return;
910
+ }
911
+ toast(`${nombre} created`);
912
+ await refresca();
913
+ };
914
+ const reiniciar = document.getElementById('reiniciaCiudad');
915
+ if (reiniciar)
916
+ reiniciar.onclick = async () => {
917
+ // Two round-trips on purpose: the first asks the server what this would
918
+ // actually do, and the person reads it before typing anything. "Are you
919
+ // sure?" is not information; a list of what disappears is.
920
+ const previa = await api<{ preview?: Efectos; error?: string }>('/api/ciudad-reinicia', {
921
+ method: 'POST',
922
+ body: '{}',
923
+ });
924
+ if (!previa.preview) {
925
+ toast(previa.error ?? 'Could not read what a reset would do', true);
926
+ return;
927
+ }
928
+ const p = previa.preview;
929
+ const texto =
930
+ `Start over ${p.city}?\n\nYou lose:\n` +
931
+ p.loses.map((l) => ` · ${l}`).join('\n') +
932
+ `\n\nRight now that is ${p.agents} agent(s), ${p.roads} road(s) and ` +
933
+ `${p.deliberations} committee act(s).\n\nYou keep:\n` +
934
+ p.keeps.map((l) => ` · ${l}`).join('\n') +
935
+ `\n\nType the city's name to confirm:`;
936
+ const escrito = window.prompt(texto, '');
937
+ if (escrito === null) return;
938
+ if (escrito.trim() !== p.city) {
939
+ toast('That is not the name — nothing was changed', true);
940
+ return;
941
+ }
942
+ const r = await api<{ ok?: boolean; backup?: string; error?: string }>(
943
+ '/api/ciudad-reinicia',
944
+ { method: 'POST', body: JSON.stringify({ confirm: p.city }) },
945
+ );
946
+ if (!r.ok) {
947
+ toast(r.error ?? 'Could not reset it', true);
948
+ return;
949
+ }
950
+ toast(`${p.city} starts over — the old one is at ${r.backup ?? 'its backup'}`);
951
+ primeraCarga = true; // land wherever a fresh city should land: the guide
952
+ await refresca();
953
+ };
954
+ todos<HTMLButtonElement>('.archiva').forEach((boton) => {
955
+ boton.onclick = async () => {
956
+ const nombre = boton.dataset.nombre ?? '';
957
+ if (!window.confirm(`Archive ${nombre}? It moves into .backups — nothing is deleted.`))
958
+ return;
959
+ const r = await api<{ ok?: boolean; backup?: string; error?: string }>(
960
+ '/api/ciudad-archiva',
961
+ { method: 'POST', body: JSON.stringify({ city: boton.dataset.ciudad ?? '' }) },
962
+ );
963
+ if (!r.ok) {
964
+ toast(r.error ?? 'Could not archive it', true);
965
+ return;
966
+ }
967
+ toast(`${nombre} archived — it is in ${r.backup ?? '.backups'}`);
968
+ await refresca();
969
+ };
970
+ });
971
+ };
972
+
757
973
  VISTAS.resumen = () => {
758
974
  const mia = E.tarjetas.find((t) => t.user === E.yo);
759
975
  const sinUnidad = E.parcelas.filter((p) => p.unidad === 'none' || p.unidad === 'mine').length;
976
+ const agentes = E.agents ?? [];
977
+ // The order somebody actually does it in: take the chair, say who works
978
+ // here, say what for. The old list asked for "folders" — the vocabulary of
979
+ // the model before agents existed — and never mentioned the roster at all,
980
+ // so a new city could look finished with nobody in it.
760
981
  const tareas = [
761
982
  {
762
983
  ok: !!mia,
763
984
  txt: mia
764
- ? `Your card exists — you are <b>${esc(mia.agent)}</b>`
765
- : 'Take your seat: role, folders, one goal',
985
+ ? `Your seat is taken — you are <b>${esc(mia.agent)}</b> in <b>${esc(E.domain)}</b>`
986
+ : 'Take your seat: the work domain and your role in it',
766
987
  ir: 'puesto',
767
988
  },
989
+ {
990
+ ok: agentes.length > 0,
991
+ txt:
992
+ agentes.length === 0
993
+ ? 'Build the <b>houses</b> of this city — one per worker, each whole: its kind, its role, and everything it works on'
994
+ : `<b>${agentes.length}</b> house${agentes.length === 1 ? '' : 's'} in this city: ${esc(
995
+ agentes.map((a) => a.name).join(', '),
996
+ )}`,
997
+ ir: 'gente',
998
+ },
999
+ {
1000
+ ok: agentes.length === 0 || agentes.some((a) => (a.mounts ?? []).length),
1001
+ txt: agentes.some((a) => (a.mounts ?? []).length)
1002
+ ? 'Every house works on something real'
1003
+ : 'Give a house something to work on — a repository, a worktree, a folder of documents',
1004
+ ir: 'gente',
1005
+ },
768
1006
  {
769
1007
  ok: !!(mia && mia.goals_defined),
770
1008
  txt:
@@ -774,23 +1012,33 @@ VISTAS.resumen = () => {
774
1012
  ir: 'puesto',
775
1013
  },
776
1014
  {
777
- ok: E.parcelas.length > 0 && sinUnidad === 0,
1015
+ ok: E.parcelas.length === 0 || sinUnidad === 0,
778
1016
  txt:
779
1017
  E.parcelas.length === 0
780
- ? 'No houses yet — pick your folders and they appear'
1018
+ ? 'Districts appear on the map once agents have houses'
781
1019
  : sinUnidad
782
1020
  ? `<b>${sinUnidad}</b> of ${E.parcelas.length} houses sit in no real district — assign them`
783
1021
  : 'Every house has its district',
784
1022
  ir: 'barrios',
785
1023
  },
786
1024
  ];
1025
+ const empezando = !mia || agentes.length === 0;
787
1026
  const sesionArriba = E.tmux.includes(E.sesion);
788
1027
  q('#lienzo').innerHTML = `
789
- <div><span class="sub">your city</span>
790
- <h1 style="margin-top:6px">${esc(E.city_name)}</h1>
1028
+ <div><span class="sub">${empezando ? 'welcome' : 'your city'}</span>
1029
+ <h1 style="margin-top:6px">${empezando ? 'Let’s build ' + esc(E.city_name) : esc(E.city_name)}</h1>
791
1030
  <p class="de mono" style="margin-top:5px">${esc(E.address)}</p>
792
- <p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
793
- counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p></div>
1031
+ ${
1032
+ empezando
1033
+ ? `<p class="prosa" style="margin-top:8px">A city is a chair and the agents behind
1034
+ it. Two steps and it is alive: take your seat — the work domain and your role in
1035
+ it — then add the agents, each one asked for whole: what kind of work it does,
1036
+ its role, everything it works on, the engine that runs it and the skills it
1037
+ starts with. Everything below is a plain file in
1038
+ <code class="mono">${esc(corto(E.datos))}</code> you can also edit by hand.</p>`
1039
+ : `<p class="prosa" style="margin-top:8px">Domain: <b>${esc(E.domain)}</b>. Growth is
1040
+ counted with <code class="mono">${esc(E.grow || 'nothing yet')}</code>.</p>`
1041
+ }</div>
794
1042
  <div class="cifras">
795
1043
  <div class="cifra"><b>${Object.keys(E.skills).length}</b><span>repo agents</span></div>
796
1044
  <div class="cifra"><b>${E.parcelas.length}</b><span>houses</span></div>
@@ -882,6 +1130,12 @@ VISTAS.mapa = () => {
882
1130
  },
883
1131
  origin,
884
1132
  );
1133
+ // The map loads after the theme was decided, so tell it now as well —
1134
+ // otherwise a light Hall frames a night map until the next toggle.
1135
+ frame.contentWindow?.postMessage(
1136
+ { type: 'agents-city-map-theme/1', theme: temaActual() },
1137
+ origin,
1138
+ );
885
1139
  if (pendingMapActivity) {
886
1140
  const event = pendingMapActivity;
887
1141
  pendingMapActivity = null;
@@ -890,12 +1144,23 @@ VISTAS.mapa = () => {
890
1144
  };
891
1145
  return;
892
1146
  }
1147
+ const cuantos = (E.agents ?? []).length;
893
1148
  q('#lienzo').innerHTML = `<div><span class="sub">the map</span>
894
- <h1 style="margin-top:6px">Not drawn yet</h1>
895
- <p class="prosa" style="margin-top:8px">The map can be served independently.
896
- Locally the hall runs it for you: first time bakes the front end
897
- and seeds the local database, which takes about a minute.</p></div>
898
- <div><button class="bt ppal" id="arrancaMapa">Draw my city</button></div>
1149
+ <h1 style="margin-top:6px">Your city, drawn</h1>
1150
+ <p class="prosa" style="margin-top:8px">${
1151
+ cuantos
1152
+ ? `<b>${cuantos}</b> agent${cuantos === 1 ? '' : 's'} live here, and the map shows
1153
+ them as houses that grow with the work they do — with the town hall in the middle
1154
+ when a committee is sitting, and a gate for every road out.`
1155
+ : `The map draws one house per agent. There are none yet, so it would be an empty
1156
+ plot — build one from <b>Houses</b> first.`
1157
+ }</p>
1158
+ <p class="prosa">The map is its own little server, so it is not running until you say so.
1159
+ The first time takes about a minute: it builds the front end and seeds a local database.
1160
+ After that it opens instantly, and it is yours — nothing is uploaded anywhere.</p></div>
1161
+ <div><button class="bt ppal" id="arrancaMapa">${
1162
+ cuantos ? 'Draw my city' : 'Draw it anyway'
1163
+ }</button></div>
899
1164
  <p class="cargando" id="mapaEspera" style="display:none">baking the map — first time takes a minute</p>`;
900
1165
  q<HTMLButtonElement>('#arrancaMapa').onclick = async () => {
901
1166
  q<HTMLButtonElement>('#arrancaMapa').disabled = true;
@@ -923,22 +1188,21 @@ async function puesto(): Promise<void> {
923
1188
  const mia = E.tarjetas.find((t) => t.user === E.yo);
924
1189
  q('#lienzo').innerHTML = `<div><span class="sub">my seat</span>
925
1190
  <h1 style="margin-top:6px">${esc(E.yo)}</h1>
926
- <p class="prosa" style="margin-top:8px">First choose the work domain, then your
927
- chair role inside it, the folders you answer for, and one goal. The seat stays
928
- the boss even when its professional role is blank. Saving writes your card —
1191
+ <p class="prosa" style="margin-top:8px">This is your chair: the work domain, your
1192
+ role inside it, and one goal. The seat stays the boss even when its
1193
+ professional role is blank. Saving writes your card —
929
1194
  <code class="mono">${esc(corto(E.datos))}/${esc(E.yo)}.md</code> — the same file
930
- every other door writes.</p></div>
1195
+ every other door writes, and it never touches your roster.</p></div>
931
1196
  <div><span class="sub">work domain</span><div class="rolejilla" id="domains" style="margin-top:9px">
932
1197
  <p class="cargando">reading the domain packs</p></div></div>
933
1198
  <div><span class="sub">role</span><div class="rolejilla" id="roles" style="margin-top:9px">
934
1199
  <p class="cargando">reading the role files</p></div></div>
935
- <div><span class="sub">folders</span><div id="carpetas" style="margin-top:9px">
936
- <p class="cargando">indexing this disk — first run can take a minute</p></div></div>
937
- <div><span class="sub">role of each support agent</span>
938
- <p class="prosa" style="margin-top:8px">The seat above is the chair. Each
939
- selected repo below adopts its own specialist role; it never gains chair or
940
- road authority. Blank means no preset role knowledge.</p>
941
- <div id="agentRoles" style="margin-top:9px"></div></div>
1200
+ <div><span class="sub">the agents</span>
1201
+ <p class="prosa" style="margin-top:8px">Who works in this city — and what each
1202
+ one works on — lives in <b>Agents &amp; skills</b>, where an agent is asked for
1203
+ whole: its kind, its role, its repositories and document folders, its engine
1204
+ and its skills. One place, so the terminal and this page cannot disagree.
1205
+ <button class="bt" type="button" data-ir="gente" style="margin-left:8px">Open the roster</button></p></div>
942
1206
  <div><span class="sub">one goal — optional</span><div class="campos" id="meta" style="margin-top:9px"></div></div>
943
1207
  <div style="display:flex;gap:10px;align-items:center">
944
1208
  <button class="bt ppal" id="guardaPuesto">Save my seat</button>
@@ -992,84 +1256,9 @@ async function puesto(): Promise<void> {
992
1256
  pintaDominios();
993
1257
  await cargaRoles();
994
1258
 
995
- // folders
996
- const [reposResponse, agentRolesResponse] = await Promise.all([
997
- api<{ repos: RepoLocal[] }>('/api/misrepos?user=' + encodeURIComponent(E.yo)),
998
- api<{ roles: RepoRoleOption[] }>('/api/roles?scope=agent'),
999
- ]);
1000
- let { repos } = reposResponse;
1001
- const agentRoleOptions = agentRolesResponse.roles;
1002
- const sel = new Set<string>(
1003
- mia && mia.repos.length ? mia.repos : repos.filter((r) => r.mio).map((r) => r.nombre),
1004
- );
1005
- const assignedRoles: Record<string, string> = { ...(mia?.repo_roles || {}) };
1006
- const pintaRolesAgentes = (): void => {
1007
- q('#agentRoles').innerHTML = renderRepoRoleEditor(sel, assignedRoles, agentRoleOptions, esc);
1008
- todos<HTMLSelectElement>('#agentRoles [data-repo-role]').forEach((select) => {
1009
- select.onchange = () => {
1010
- assignedRoles[select.dataset.repoRole ?? ''] = select.value || 'blank';
1011
- };
1012
- });
1013
- };
1014
- let filtro = '';
1015
- const pintaCarpetas = (): void => {
1016
- const vis = repos.filter((r) => !filtro || r.nombre.toLowerCase().includes(filtro));
1017
- q('#carpetas').innerHTML = `<div class="lista">
1018
- <div class="barra">
1019
- <input class="mono" id="fl" placeholder="filter ${repos.length} repos…" value="${esc(filtro)}">
1020
- <button class="mini2" id="rescan" title="the index caches for a day — this rebuilds it now">rescan disk</button>
1021
- <button class="mini2" id="nada">none</button>
1022
- <span class="de" style="font-family:var(--mono);font-size:11px;color:var(--tinta3)">${sel.size} picked</span>
1023
- </div>
1024
- <div class="filas">${
1025
- vis
1026
- .map(
1027
- (r) => `
1028
- <div class="fila ${sel.has(r.nombre) ? 'on' : ''}" data-n="${esc(r.nombre)}">
1029
- <span class="caja">${sel.has(r.nombre) ? '[x]' : '[ ]'}</span>
1030
- <span class="et">${esc(r.nombre)}</span>
1031
- <span class="de">${esc(r.cuando || '—')} ${esc(corto(r.ruta))}</span></div>`,
1032
- )
1033
- .join('') ||
1034
- '<div class="fila"><span class="de" style="margin:0">nothing matches</span></div>'
1035
- }
1036
- </div></div>
1037
- <p class="pista" style="margin-top:6px;font-size:11.5px;color:var(--tinta3);font-style:italic">
1038
- Ticked by your commits this year. Owning <b>no</b> folders is a real answer for a
1039
- cross-cutting role — your city is simply empty, and your work is the roads.</p>`;
1040
- const fl = q<HTMLInputElement>('#fl');
1041
- fl.oninput = () => {
1042
- filtro = fl.value.toLowerCase();
1043
- pintaCarpetas();
1044
- const f2 = q<HTMLInputElement>('#fl');
1045
- f2.focus();
1046
- f2.setSelectionRange(f2.value.length, f2.value.length);
1047
- };
1048
- q<HTMLButtonElement>('#rescan').onclick = async () => {
1049
- const b = q<HTMLButtonElement>('#rescan');
1050
- b.textContent = 'scanning…';
1051
- b.disabled = true;
1052
- ({ repos } = await api<{ repos: RepoLocal[] }>(
1053
- '/api/misrepos?refresh=1&user=' + encodeURIComponent(E.yo),
1054
- ));
1055
- pintaCarpetas();
1056
- toast(`${repos.length} repos on this disk`);
1057
- };
1058
- q<HTMLButtonElement>('#nada').onclick = () => {
1059
- vis.forEach((r) => sel.delete(r.nombre));
1060
- pintaCarpetas();
1061
- };
1062
- todos<HTMLElement>('#carpetas .fila[data-n]').forEach((f) => {
1063
- f.onclick = () => {
1064
- const n = f.dataset.n ?? '';
1065
- if (sel.has(n)) sel.delete(n);
1066
- else sel.add(n);
1067
- pintaCarpetas();
1068
- };
1069
- });
1070
- pintaRolesAgentes();
1071
- };
1072
- pintaCarpetas();
1259
+ // The roster is not asked for here any more: it lives in one place,
1260
+ // the agents view, so this page and the terminal cannot write different
1261
+ // cities. What this page owns is the chair — domain, role, goal.
1073
1262
 
1074
1263
  // goal
1075
1264
  const o: Partial<Objetivo> = (mia && mia.objetivo) || {};
@@ -1111,8 +1300,6 @@ async function puesto(): Promise<void> {
1111
1300
  user: E.yo,
1112
1301
  domain,
1113
1302
  role: rol,
1114
- repos: [...sel],
1115
- repo_roles: selectedRepoRoles(sel, assignedRoles),
1116
1303
  objetivo: {
1117
1304
  title: val('#g_title'),
1118
1305
  signal: val('#g_signal'),
@@ -1417,36 +1604,9 @@ VISTAS.committee = () => {
1417
1604
  };
1418
1605
 
1419
1606
  // ── agents and skills ────────────────────────────────────────────────────────
1420
- /** The engine's honest power reading. An alias maps to its tier; empty means
1421
- * the owner's Claude default — unknown here, so the bar says "default" rather
1422
- * than inventing a number. */
1423
- function nivelDeMotor(model: string): { ancho: number; texto: string; defecto: boolean } {
1424
- const alias = model.toLowerCase();
1425
- if (!alias) return { ancho: 0.5, texto: 'default', defecto: true };
1426
- for (const [clave, ancho] of [
1427
- ['fable', 1],
1428
- ['opus', 0.8],
1429
- ['sonnet', 0.55],
1430
- ['haiku', 0.35],
1431
- ] as const) {
1432
- if (alias.includes(clave)) return { ancho, texto: alias, defecto: false };
1433
- }
1434
- return { ancho: 0.6, texto: alias, defecto: false };
1435
- }
1436
-
1437
- const NIVEL_ESFUERZO: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 };
1438
- const MODELOS = ['haiku', 'sonnet', 'opus', 'fable'];
1439
-
1440
- /** `<option>`s from known values plus the current one when it is off the list:
1441
- * a card key must never be held hostage by a curated dropdown. */
1607
+ /** The shared `<option>` builder, with this page's escaper bound in. */
1442
1608
  function opciones(valores: string[], actual: string): string {
1443
- return valores
1444
- .concat(actual && !valores.includes(actual) ? [actual] : [])
1445
- .map(
1446
- (v) =>
1447
- `<option value="${esc(v)}"${v === actual ? ' selected' : ''}>${esc(v || 'default')}</option>`,
1448
- )
1449
- .join('');
1609
+ return opcionesDe(valores, actual, esc);
1450
1610
  }
1451
1611
 
1452
1612
  /** The one sheet-avatar guard: a data URI or nothing. */
@@ -1476,15 +1636,16 @@ VISTAS.gente = () => {
1476
1636
  // the alias is resolved by the CLI at launch, not by this list.
1477
1637
  const opcionesModelo =
1478
1638
  opciones([''].concat(MODELOS), a.model) + '<option value="__otro__">custom…</option>';
1479
- const opcionesEsfuerzo = opciones([''].concat(Object.keys(NIVEL_ESFUERZO)), a.effort);
1480
- const opcionesRuntime = opciones(['claude', 'codex', 'opencode', 'kimi'], a.runtime);
1639
+ const opcionesEsfuerzo = opciones([''].concat(ESFUERZOS), a.effort);
1640
+ const opcionesRuntime = opciones(RUNTIMES, a.runtime);
1641
+ const montajes = a.mounts ?? [];
1481
1642
  return `
1482
1643
  <div class="fichaRPG">
1483
1644
  <div class="rpgCab">${caraRPG(a.avatar)}
1484
1645
  <div><h3>${esc(a.name)}</h3>
1485
1646
  <span class="rpgTags"><span class="kindChip ${esc(a.kind)}">${esc(a.kind)}</span>
1486
1647
  <span class="rpgRol">${esc(a.role)}</span>
1487
- <span class="rpgHogar">${a.legacy ? 'repo' : `workspace · ${a.mounts | 0} ${a.mounts === 1 ? 'mount' : 'mounts'}`}</span></span></div>
1648
+ <span class="rpgHogar">${a.legacy ? 'repo' : `workspace · ${montajes.length} ${montajes.length === 1 ? 'mount' : 'mounts'}`}</span></span></div>
1488
1649
  <span class="rpgBotones">
1489
1650
  <button class="rpgMini rpgDado" type="button" data-agente="${esc(a.slug)}"
1490
1651
  title="Reroll this agent's face — deterministic, persisted on the card">🎲</button>
@@ -1494,17 +1655,17 @@ VISTAS.gente = () => {
1494
1655
  data-file="AGENTS.md" title="Instructions Codex, OpenCode and Kimi read">AGENTS.md</button>
1495
1656
  </span>
1496
1657
  </div>
1497
- <div class="rpgFila"><label>engine</label>
1658
+ <div class="rpgFila"><label>${_('engine')}</label>
1498
1659
  ${barra(motor.ancho, motor.defecto ? 'defecto' : '')}
1499
1660
  <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="model"
1500
1661
  ${esClaude ? '' : 'disabled title="This window runs its own CLI; its model lives in its own flags"'}>
1501
1662
  ${opcionesModelo}</select></div>
1502
- <div class="rpgFila"><label>effort</label>
1663
+ <div class="rpgFila"><label>${_('effort')}</label>
1503
1664
  ${barra(esfuerzo / 5, esfuerzo ? '' : 'defecto')}
1504
1665
  <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="effort"
1505
1666
  ${esClaude ? '' : 'disabled title="This window runs its own CLI; its effort lives in its own flags"'}>
1506
1667
  ${opcionesEsfuerzo}</select></div>
1507
- <div class="rpgFila"><label>provider</label>
1668
+ <div class="rpgFila"><label>${_('provider')}</label>
1508
1669
  <span class="rpgDato"><span class="cliDot ${
1509
1670
  a.cli.connected ? 'on' : a.cli.installed ? 'idle' : 'off'
1510
1671
  }" title="${esc(
@@ -1514,16 +1675,39 @@ VISTAS.gente = () => {
1514
1675
  ? 'installed on this machine, not in use by this agent'
1515
1676
  : `${a.cli.binary} is not installed`,
1516
1677
  )}"></span>${esc(
1517
- a.cli.connected ? 'connected' : a.cli.installed ? 'idle' : 'missing',
1678
+ _(a.cli.connected ? 'connected' : a.cli.installed ? 'idle' : 'missing'),
1518
1679
  )} · ${esc(a.cli.binary)}
1519
1680
  <button class="rpgMini rpgTest" type="button" data-agente="${esc(a.slug)}"
1520
1681
  title="Run the engine for real: --version, and the login state on Claude">test</button></span>
1521
1682
  <select class="rpgSel" data-agente="${esc(a.slug)}" data-campo="runtime">
1522
1683
  ${opcionesRuntime}</select></div>
1523
- <div class="rpgFila"><label>growth</label>
1684
+ <div class="rpgSkills"><label>${_('works on')} · ${montajes.length}</label>
1685
+ ${
1686
+ montajes.length
1687
+ ? montajes
1688
+ .map(
1689
+ (m) =>
1690
+ `<code class="mono" title="${esc(m.target)}">${esc(m.label)}${
1691
+ m.fixed
1692
+ ? ''
1693
+ : `<button class="rpgQuitar rpgDesmonta" type="button"
1694
+ data-agente="${esc(a.slug)}" data-mount="${esc(m.label)}"
1695
+ title="Stop this agent working on ${esc(m.target)}">×</button>`
1696
+ }</code>`,
1697
+ )
1698
+ .join(' ')
1699
+ : '<span class="rpgDato">nothing mounted yet</span>'
1700
+ }
1701
+ ${
1702
+ a.legacy
1703
+ ? '<span class="rpgDato">a legacy repo agent works on its own repo</span>'
1704
+ : `<button class="rpgMini rpgMonta" type="button" data-agente="${esc(a.slug)}"
1705
+ title="A repo, a worktree or a folder of documents — this agent works on all of them">+ folder</button>`
1706
+ }</div>
1707
+ <div class="rpgFila"><label>${_('growth')}</label>
1524
1708
  ${barra(crecido)}
1525
1709
  <span class="rpgDato">${g.floors | 0} floors · ${g.bricks | 0} bricks · ${g.activity30 | 0}/30d</span></div>
1526
- <div class="rpgSkills"><label>skills · ${skills.length}</label>
1710
+ <div class="rpgSkills"><label>${_('skills')} · ${skills.length}</label>
1527
1711
  ${
1528
1712
  skills.length
1529
1713
  ? skills
@@ -1545,13 +1729,22 @@ VISTAS.gente = () => {
1545
1729
  </div>`;
1546
1730
  };
1547
1731
 
1548
- q('#lienzo').innerHTML = `<div><span class="sub">agents & skills</span>
1549
- <h1 style="margin-top:6px">One seat, support agents behind it</h1>
1550
- <p class="prosa" style="margin-top:8px">Every number on a card is real: the
1551
- engine comes from the card keys the launcher resolves, growth from what the
1552
- agent actually produced, skills from live read-only recognition — Agents
1553
- City copies and installs nothing. Engine changes persist to the card and
1554
- apply the next time the session opens.</p></div>
1732
+ q('#lienzo').innerHTML = `<div><span class="sub">the houses of ${esc(E.city_name)}</span>
1733
+ <h1 style="margin-top:6px">${_('Who lives in {city}', { city: esc(E.city_name) })}</h1>
1734
+ <p class="prosa" style="margin-top:8px"><b>A house is where an agent lives and works</b>,
1735
+ and many houses are a city — it is the same thing the map draws, growing with what that
1736
+ agent actually does. The card and the CLI call them <code class="mono">agents</code>;
1737
+ here you see their houses.</p>
1738
+ <p class="prosa">They belong to <b>this</b> city and only
1739
+ to it: each one's workspace and its mounts live inside
1740
+ <code class="mono">${esc(corto(E.datos))}/agents/</code>, so another city has its own
1741
+ people even if you give them the same names. Every agent is whole here: the kind of work
1742
+ it does, its role, everything it works on — any number of repositories and document
1743
+ folders at once — the engine and effort that run it, and the skills in its own home.</p>
1744
+ <p class="prosa">Every number is real: growth from what the agent actually produced,
1745
+ skills from live read-only recognition. Changes persist to the card and apply the next
1746
+ time the session opens.</p>
1747
+ <button class="rpgAlta" type="button" id="altaAgente">${_('+ Build a house')}</button></div>
1555
1748
  <div class="fichasRPG">
1556
1749
  <div class="fichaRPG rpgSeat">
1557
1750
  <div class="rpgCab">${caraRPG(E.avatars?.['seat'])}
@@ -1672,6 +1865,71 @@ VISTAS.gente = () => {
1672
1865
  };
1673
1866
  });
1674
1867
 
1868
+ // Adding an agent from the web: the roster the wizard builds question by
1869
+ // question, reachable by somebody who never opens a terminal.
1870
+ const alta = document.getElementById('altaAgente');
1871
+ if (alta)
1872
+ alta.onclick = async () => {
1873
+ const nombre = window.prompt("The agent's name:", '');
1874
+ if (nombre === null || !nombre.trim()) return;
1875
+ const clase =
1876
+ window.prompt('What kind of work does it do? code, knowledge or coordinator', 'code') ?? '';
1877
+ if (!clase.trim()) return;
1878
+ const rol = window.prompt('Its role (its specialty, not authority):', 'blank') ?? '';
1879
+ if (!rol.trim()) return;
1880
+ try {
1881
+ const r = await api<{ ok?: boolean; error?: string }>('/api/agentes', {
1882
+ method: 'POST',
1883
+ body: JSON.stringify({ name: nombre.trim(), kind: clase.trim(), role: rol.trim() }),
1884
+ });
1885
+ if (!r.ok) {
1886
+ toast(r.error || 'Could not add the agent', true);
1887
+ return;
1888
+ }
1889
+ toast(`${nombre.trim()} has a house now — give it something to work on`);
1890
+ void refresca();
1891
+ } catch (e) {
1892
+ toast(String(e), true);
1893
+ }
1894
+ };
1895
+
1896
+ // Mounts: what an agent works on, added and removed where it is read.
1897
+ const montaje = async (carga: Record<string, string>, hecho: string) => {
1898
+ try {
1899
+ const r = await api<{ ok?: boolean; error?: string }>('/api/montaje', {
1900
+ method: 'POST',
1901
+ body: JSON.stringify(carga),
1902
+ });
1903
+ if (!r.ok) {
1904
+ toast(r.error || 'Could not change the mounts', true);
1905
+ return;
1906
+ }
1907
+ toast(hecho);
1908
+ void refresca();
1909
+ } catch (e) {
1910
+ toast(String(e), true);
1911
+ }
1912
+ };
1913
+ todos<HTMLButtonElement>('.rpgMonta').forEach((boton) => {
1914
+ boton.onclick = () => {
1915
+ const ruta = window.prompt('A repo, a worktree or a folder of documents:', '~/');
1916
+ if (ruta === null || !ruta.trim()) return;
1917
+ void montaje({ agent: boton.dataset.agente ?? '', add: ruta.trim() }, 'Mounted');
1918
+ };
1919
+ });
1920
+ todos<HTMLButtonElement>('.rpgDesmonta').forEach((boton) => {
1921
+ boton.onclick = () => {
1922
+ const etiqueta = boton.dataset.mount ?? '';
1923
+ if (!window.confirm(`Stop this agent working on ${etiqueta}?`)) return;
1924
+ // Unmounting removes a symlink and a card key. What it points at is not
1925
+ // touched — say so, because "remove" reads as "delete my folder".
1926
+ void montaje(
1927
+ { agent: boton.dataset.agente ?? '', remove: etiqueta },
1928
+ `${etiqueta} unmounted — the folder itself is untouched`,
1929
+ );
1930
+ };
1931
+ });
1932
+
1675
1933
  // The instruction editors: one click, the modal opens on that agent's file.
1676
1934
  todos<HTMLButtonElement>('.rpgIns').forEach((boton) => {
1677
1935
  boton.onclick = () =>
@@ -1861,7 +2119,101 @@ window.addEventListener('message', (message) => {
1861
2119
  pinta();
1862
2120
  });
1863
2121
 
2122
+ /**
2123
+ * Night or day. The city's own light is night — an isometric map at 3am — so
2124
+ * that stays the default; day is the palette of agentscity.net, so the tool on
2125
+ * your machine and the site are visibly one product.
2126
+ *
2127
+ * Three states, not two: no stored choice means "follow this machine", and only
2128
+ * an explicit click stamps the root element and outranks the system setting.
2129
+ * The map lives in an iframe of its own, so it is told as well — a light Hall
2130
+ * around a night map would read as two products in one window.
2131
+ */
2132
+ /** Which skin is showing right now: the stamped choice, or this machine's. */
2133
+ function temaActual(): 'light' | 'dark' {
2134
+ const elegido = document.documentElement.getAttribute('data-tema');
2135
+ if (elegido) return elegido === 'claro' ? 'light' : 'dark';
2136
+ // Day is THE default, full stop: a desktop set to dark does not decide this
2137
+ // for the owner. Only the switch does, and it is remembered.
2138
+ return 'light';
2139
+ }
2140
+
2141
+ function tema(): void {
2142
+ const boton = document.getElementById('temaBoton');
2143
+ const guardado = ((): string => {
2144
+ try {
2145
+ return localStorage.getItem('hall-tema') ?? '';
2146
+ } catch {
2147
+ return ''; // a browser with site data blocked still gets a working page
2148
+ }
2149
+ })();
2150
+ const oscuroDelSistema = false; // daylight is the default; only the switch changes it
2151
+
2152
+ const pon = (elegido: string): void => {
2153
+ const raiz = document.documentElement;
2154
+ if (elegido) raiz.setAttribute('data-tema', elegido);
2155
+ else raiz.removeAttribute('data-tema');
2156
+ const claro = elegido ? elegido === 'claro' : !oscuroDelSistema;
2157
+ // The button says where a click TAKES you, not where you are: a control
2158
+ // labelled with the state you can already see is a riddle.
2159
+ if (boton) boton.textContent = claro ? '☾ Night' : '☀ Day';
2160
+ const frame = document.querySelector<HTMLIFrameElement>('#cityMapFrame');
2161
+ frame?.contentWindow?.postMessage(
2162
+ { type: 'agents-city-map-theme/1', theme: claro ? 'light' : 'dark' },
2163
+ new URL(frame.src, location.href).origin,
2164
+ );
2165
+ };
2166
+
2167
+ pon(guardado);
2168
+ if (boton)
2169
+ boton.onclick = () => {
2170
+ const claroAhora = document.documentElement.getAttribute('data-tema')
2171
+ ? document.documentElement.getAttribute('data-tema') === 'claro'
2172
+ : !oscuroDelSistema;
2173
+ const elegido = claroAhora ? 'oscuro' : 'claro';
2174
+ try {
2175
+ localStorage.setItem('hall-tema', elegido);
2176
+ } catch {
2177
+ /* not being able to remember it is not a reason to refuse the switch */
2178
+ }
2179
+ pon(elegido);
2180
+ };
2181
+ }
2182
+
2183
+ /**
2184
+ * Español or English, on a switch next to the theme.
2185
+ *
2186
+ * The two READMEs shipped bilingual from the first commit and the product did
2187
+ * not, which is backwards: documentation is read once and the interface every
2188
+ * day. The page starts in this browser's language and the button says which one
2189
+ * a click takes you to, the same way the theme button does.
2190
+ */
2191
+ function interruptorDeIdioma(): void {
2192
+ const boton = document.getElementById('idiomaBoton');
2193
+ if (!boton) return;
2194
+ const pon = (): void => {
2195
+ document.documentElement.lang = idioma();
2196
+ // The label is the language you would GET, not the one you are reading.
2197
+ boton.textContent = idioma() === 'es' ? 'EN' : 'ES';
2198
+ // The chrome that lives in the HTML file rather than in a view: its English
2199
+ // is kept on the element the first time through, so switching back and
2200
+ // forth translates from the source every time instead of from itself.
2201
+ todos<HTMLElement>('[data-i18n]').forEach((el) => {
2202
+ if (!el.dataset.en) el.dataset.en = (el.textContent ?? '').trim();
2203
+ el.textContent = _(el.dataset.en);
2204
+ });
2205
+ };
2206
+ pon();
2207
+ boton.onclick = () => {
2208
+ ponIdioma(idioma() === 'es' ? 'en' : 'es');
2209
+ pon();
2210
+ void refresca();
2211
+ };
2212
+ }
2213
+
1864
2214
  arrastreDelRail();
2215
+ interruptorDeIdioma();
2216
+ tema();
1865
2217
  void refresca();
1866
2218
 
1867
2219
  export {};