@bongos/core 1.24.30 → 1.24.31

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 (101) hide show
  1. package/.bongos-core.json +153 -103
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +139 -1
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +192 -187
  10. package/docs/copy-registry.json +530 -458
  11. package/docs/module-api-changelog.md +2 -2
  12. package/docs/page-inventory.json +15 -35
  13. package/docs/page-readings.json +226 -263
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/hall-render.js +62 -86
  35. package/modules/hall-ui/public/home-city-draw.js +360 -52
  36. package/modules/hall-ui/public/home-city.css +47 -11
  37. package/modules/hall-ui/public/home-city.js +156 -40
  38. package/modules/hall-ui/public/home-gallery.css +129 -0
  39. package/modules/hall-ui/public/home-gallery.js +349 -0
  40. package/modules/hall-ui/public/home-studio.css +72 -0
  41. package/modules/hall-ui/public/home-studio.js +203 -0
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +4 -1
  45. package/modules/hall-ui/public/market-watch-item.html +1 -1
  46. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  47. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  48. package/modules/hall-ui/public/market-watch.html +141 -54
  49. package/modules/hall-ui/public/market-watch.js +54 -0
  50. package/modules/hall-ui/public/market-watch.states.json +2 -1
  51. package/modules/hall-ui/public/modules.html +1 -1
  52. package/modules/hall-ui/public/my-agents.html +1 -1
  53. package/modules/hall-ui/public/primer.html +1 -1
  54. package/modules/hall-ui/public/profile.html +1 -1
  55. package/modules/hall-ui/public/project-settings.html +1 -1
  56. package/modules/hall-ui/public/ranks.html +1 -1
  57. package/modules/hall-ui/public/roadmap.html +1 -1
  58. package/modules/hall-ui/public/roster.html +1 -1
  59. package/modules/hall-ui/public/sessions.html +1 -1
  60. package/modules/hall-ui/public/settings.html +1 -1
  61. package/modules/hall-ui/public/shell.js +2 -3
  62. package/modules/hall-ui/public/street.html +1 -1
  63. package/modules/hall-ui/public/studio.html +9 -1
  64. package/modules/hall-ui/public/style.css +74 -27
  65. package/modules/hall-ui/public/task.html +1 -1
  66. package/modules/hall-ui/public/thinking.html +1 -1
  67. package/modules/hall-ui/public/tweak-editor.html +1 -1
  68. package/modules/hall-ui/public/watch.html +1 -1
  69. package/modules/hall-ui/public/work.html +1 -1
  70. package/modules/hall-ui/records/home-city.md +123 -0
  71. package/modules/hall-ui/records/loader.md +1 -1
  72. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  73. package/modules/hall-ui/records/market-watch.md +3 -1
  74. package/modules/lifecycle/home-city.js +58 -10
  75. package/modules/lifecycle/home-gallery.js +320 -0
  76. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  77. package/modules/lifecycle/module.json +1 -0
  78. package/modules/lifecycle/routes/home-gallery.js +37 -0
  79. package/modules/market-watch/routes/settings.js +1 -1
  80. package/package-lock.json +2 -2
  81. package/package.json +1 -1
  82. package/release-notes.json +29 -1
  83. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  84. package/scripts/hall-preview/README.md +1 -0
  85. package/scripts/hall-preview/server.js +10 -0
  86. package/src/bongos/serve-internal.js +17 -3
  87. package/src/module-api.js +1 -1
  88. package/tests/hall_builders_page_pass.mjs +18 -27
  89. package/tests/hall_loading.mjs +1 -1
  90. package/tests/hall_nav.mjs +11 -4
  91. package/tests/hall_page_gate_map.mjs +7 -4
  92. package/tests/home_city.mjs +200 -5
  93. package/tests/home_city_widget.mjs +167 -27
  94. package/tests/home_gallery.mjs +255 -0
  95. package/tests/home_gallery_widget.mjs +220 -0
  96. package/tests/home_studio.mjs +169 -0
  97. package/tests/home_studio_widget.mjs +157 -0
  98. package/tests/market_watch_pages.mjs +162 -0
  99. package/tests/nav_permission_atoms.mjs +2 -2
  100. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  101. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -1,4 +1,4 @@
1
- /* modules/hall-ui/public/home-city.css — Home's project city (task 1005054).
1
+ /* modules/hall-ui/public/home-city.css — Home's project city (task 1005054; v16, task 1005058).
2
2
  The scene card, the ladder of builders beside it and the time slider under it
3
3
  (home-city.js; record modules/hall-ui/records/home-city.md). Every colour is a
4
4
  hall token or a mix of one; the tower faces are drawing data in
@@ -12,7 +12,32 @@
12
12
  .hc-svg .hc-wait__n { font: 700 11px var(--font-display); fill: var(--accent-on); }
13
13
  .hc-svg .hc-av__i { font: 600 13px var(--font-display); }
14
14
  .hc-svg [data-tip] { cursor: default; }
15
+ .hc-svg a[href] { cursor: pointer; }
16
+ .hc-svg a:focus { outline: none; }
17
+ .hc-svg a:focus-visible .hc-face { filter: brightness(1.35); }
15
18
  .hc-svg .hc-tower:hover .hc-face { filter: brightness(1.3); }
19
+ /* the core and the museum stay quiet on the builder's Home so the new buildings lead;
20
+ each comes back to full colour under the pointer or the keyboard */
21
+ .hc-svg .hc-quiet { opacity: .3; filter: saturate(0) brightness(.85); transition: opacity .3s, filter .3s; }
22
+ .hc-svg .hc-quiet:hover, .hc-svg .hc-quiet:focus-visible { opacity: .95; filter: none; }
23
+ .hc-svg .hc-core:hover .hc-face, .hc-svg .hc-museum:hover .hc-face { filter: brightness(1.15); }
24
+ .hc-svg .hc-sky:hover circle, .hc-svg .hc-sky:focus-visible circle { opacity: 1; }
25
+
26
+ /* the ideator's sky (task 1005059): night falls over the same city, seen from the street.
27
+ The sky is the page's own ground tinted toward the sea, deeper at the horizon; the
28
+ skyline's outlines are hidden until one of the three groups is pointed at or focused,
29
+ then the whole group lights at once. */
30
+ .home-city.hc-is-sky { background: linear-gradient(color-mix(in srgb, var(--bg) 86%, var(--sea)), color-mix(in srgb, var(--bg-card) 86%, var(--accent))); }
31
+ html[data-theme="dark"] .home-city.hc-is-sky { background: linear-gradient(color-mix(in srgb, var(--bg) 92%, var(--sea)), var(--bg) 40%, color-mix(in srgb, var(--bg) 58%, var(--sea))); }
32
+ @media (prefers-color-scheme: dark) {
33
+ html:not([data-theme]) .home-city.hc-is-sky { background: linear-gradient(color-mix(in srgb, var(--bg) 92%, var(--sea)), var(--bg) 40%, color-mix(in srgb, var(--bg) 58%, var(--sea))); }
34
+ }
35
+ .hc-is-sky .hc-time { background: color-mix(in srgb, var(--bg-card) 70%, transparent); }
36
+ .hc-svg .hc-sky__label { font: 500 11px var(--font-display); letter-spacing: .06em; }
37
+ .hc-svg .hc-ol { stroke-opacity: 0; transition: stroke-opacity .25s; }
38
+ .hc-svg:has(.hc-hit[data-group="area"]:hover, .hc-hit[data-group="area"]:focus-visible) .hc-ol-area,
39
+ .hc-svg:has(.hc-hit[data-group="civic"]:hover, .hc-hit[data-group="civic"]:focus-visible) .hc-ol-civic,
40
+ .hc-svg:has(.hc-hit[data-group="museum"]:hover, .hc-hit[data-group="museum"]:focus-visible) .hc-ol-museum { stroke-opacity: .9; }
16
41
 
17
42
  /* the ladder: a small face and one thin line split by craft */
18
43
  .hc-ladder { position: relative; flex: none; width: 230px; margin: 24px 28px 24px 0; }
@@ -32,20 +57,30 @@
32
57
  .hc-seg:hover { color: var(--ink); }
33
58
  .hc-seg:hover i { background: var(--ink); }
34
59
 
35
- /* the time slider */
36
- .hc-time { display: flex; align-items: center; gap: 16px; padding: 6px 24px 18px; background: var(--bg-card); }
37
- .hc-play { flex: none; width: 46px; height: 46px; border-radius: 50%; border: 1px solid var(--rule); background: transparent; color: var(--ink); cursor: pointer; display: grid; place-items: center; }
38
- .hc-play svg { width: 16px; height: 16px; }
39
- .hc-play .hc-play__off, .hc-play.is-playing .hc-play__on { display: none; }
40
- .hc-play.is-playing .hc-play__off { display: block; }
41
- .hc-play:focus-visible, .hc-slider:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
42
- .hc-track { flex: 1; min-width: 0; }
60
+ /* the time slider: live at its right end, a quiet player beside it */
61
+ .hc-time { display: flex; align-items: center; gap: 12px; padding: 18px 24px 16px; background: var(--bg-card); }
62
+ .hc-player { display: flex; gap: 2px; flex: none; }
63
+ .hc-p { width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ink-faint); cursor: pointer; display: grid; place-items: center; }
64
+ .hc-p svg { width: 13px; height: 13px; }
65
+ .hc-p:hover { color: var(--ink); background: var(--rule-soft); }
66
+ .hc-p[aria-pressed="true"] { color: var(--ink); }
67
+ .hc-p:disabled { opacity: .3; cursor: default; background: transparent; }
68
+ .hc-p-play .hc-play__off, .hc-p-play.is-playing .hc-play__on { display: none; }
69
+ .hc-p-play.is-playing .hc-play__off { display: block; }
70
+ .hc-p:focus-visible, .hc-slider:focus-visible, .hc-live:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
71
+ .hc-track { position: relative; flex: 1; min-width: 0; }
72
+ .hc-when { position: absolute; top: -18px; transform: translateX(-50%); font: 600 11px/1 var(--font-display); padding: 3px 8px; border-radius: var(--radius-pill); background: var(--ink); color: var(--bg-card); opacity: 0; transition: opacity .25s; white-space: nowrap; pointer-events: none; }
73
+ .hc-when.is-on { opacity: 1; }
74
+ .hc-live { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 0; border: 0; background: transparent; cursor: pointer; font: 600 11px var(--font-display); color: var(--ink-soft); }
75
+ .hc-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-faint); }
76
+ .hc-live.is-live { color: var(--ink); }
77
+ .hc-live.is-live i { background: var(--ok); animation: hc-live 2.4s ease-out infinite; }
43
78
  .hc-slider { width: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; height: 24px; cursor: pointer; }
44
79
  .hc-slider::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--rule-soft); }
45
80
  .hc-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--rule-soft); }
46
81
  .hc-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--ink); margin-top: -8px; border: 3px solid var(--bg-card); }
47
82
  .hc-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--ink); border: 3px solid var(--bg-card); }
48
- .hc-ticks { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
83
+ .hc-ticks { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--ink-faint); margin-top: 2px; }
49
84
 
50
85
  .hc-tip { position: fixed; pointer-events: none; z-index: var(--z-modal); background: var(--ink); color: var(--bg-card); font-size: 12px; line-height: 1.35; padding: 8px 11px; border-radius: 9px; max-width: 260px; opacity: 0; transform: translate(-50%, calc(-100% - 12px)); }
51
86
  .hc-tip.is-on { opacity: 1; }
@@ -54,8 +89,9 @@
54
89
  /* live, and nothing more: a crane's slab climbs, this week's ideas glow (20s+) */
55
90
  @keyframes hc-lift { 0%, 12% { transform: translateY(0); } 55%, 70% { transform: translateY(-24px); } 100% { transform: translateY(0); } }
56
91
  @keyframes hc-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
92
+ @keyframes hc-live { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); } 100% { box-shadow: 0 0 0 8px transparent; } }
57
93
  .hc-slab { animation: hc-lift 20s ease-in-out infinite; }
58
- .hc-orb { animation: hc-glow 24s ease-in-out infinite; }
94
+ .hc-orb, .hc-twinkle { animation: hc-glow 24s ease-in-out infinite; }
59
95
 
60
96
  /* a phone: the ladder runs full width under the city */
61
97
  @media (max-width: 700px) {
@@ -7,9 +7,21 @@
7
7
  // rule tests/home_bounded_reads.mjs holds Home to. The drawing is
8
8
  // home-city-draw.js (pure); this file owns the read, the DOM, the slider and hover.
9
9
  //
10
- // Motion stays CSS: play steps the slider a week at a time on a timer (state, not
11
- // animation); the ladder's rows slide and a crane's slab lifts in CSS, which the
12
- // hall's reduced-motion rule stops.
10
+ // v16 (task 1005058): the city is the one city of every craft (home-city-draw.js), so
11
+ // this file also hands the drawing the Governor City (window.GovCity, city-draw.js)
12
+ // and turns its district doors into craft switches. The time slider is LIVE at its
13
+ // right end; moving it, or the quiet player under it (rewind, play, fast forward),
14
+ // glides the city through every week in between — the owner asked for a rewind, not
15
+ // a jump. That glide is the one motion here driven from script (a requestAnimationFrame
16
+ // loop that redraws the city at a fractional week); under prefers-reduced-motion it
17
+ // does not run at all and every control jumps straight to where it was going. The
18
+ // ladder's rows and a crane's slab still move in CSS, which the hall's rule stops.
19
+ //
20
+ // The ideator's Home (task 1005059): while the craft is Ideating (OTBCraftMode, its
21
+ // otb:craft event) the same widget draws the sky over the city's skyline
22
+ // (home-city-draw.js skySvg) from the same read, and the ladder ranks the week's
23
+ // ideators — ideas filed, votes cast, ideas that became work. Every other craft keeps
24
+ // the builder's city exactly as it was.
13
25
  (function () {
14
26
  'use strict';
15
27
  if (!window.OTB || typeof window.OTB.contributeHallWidget !== 'function') return;
@@ -17,16 +29,31 @@
17
29
  if (!Draw) return;
18
30
  const API = '/api/bongos';
19
31
  const WEEKS = 10;
20
- const STEP_MS = 800;
21
32
  const RUNG = 40;
33
+ // the player's speeds, in weeks per frame at 60 fps: rewind and fast forward cross the
34
+ // whole slider in about five and four seconds; play is about a week every 1.6 s
35
+ const SPEED = { rw: -1 / 34, play: 1 / 96, ff: 1 / 26 };
36
+ const STILL = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
22
37
  const CRAFTS = [
23
38
  ['art', 'artistry', 'Art', 'pages designed'],
24
39
  ['build', 'building', 'Build', 'shipped'],
25
40
  ['idea', 'ideating', 'Ideas', 'ideas'],
26
41
  ['gov', 'governing', 'Govern', 'votes and decisions'],
27
42
  ];
43
+ // the ideator's line: a lightbulb, a star, and an arrow into the work
44
+ const IDEATOR = [
45
+ ['idea', 'ideating', 'Ideas', 'ideas filed'],
46
+ ['vote', '#vote', 'Votes', 'votes cast'],
47
+ ['work', '#work', 'Became work', 'ideas became work'],
48
+ ];
49
+ const GLYPH = {
50
+ '#vote': '<path d="M8 2.2l1.7 3.6 3.9.5-2.9 2.7.8 3.9L8 11l-3.5 1.9.8-3.9L2.4 6.3l3.9-.5z"/>',
51
+ '#work': '<path d="M2.5 2.5l7 7M9.5 9.5l-1 4 5-5-4 1z"/>',
52
+ };
53
+ const SKY_LABEL = 'The project\'s ideas as a night sky over the city\'s real skyline, with this week\'s ideators ranked beside it';
54
+ const CITY_LABEL = 'The project as a city: one tower per working area, as tall as what has shipped there, with the builders of the week beside it';
28
55
 
29
- let host = null, model = null, viewerId = null, week = 0, timer = null;
56
+ let host = null, model = null, viewerId = null, t = 0, target = 0, raf = null, run = null, lastWeek = -1, shown = false, gallery = null;
30
57
  const rungs = new Map();
31
58
 
32
59
  function mode() {
@@ -35,6 +62,7 @@
35
62
  return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
36
63
  }
37
64
 
65
+ function atLive(v) { return !model || v >= model.W - 1 - 1e-3; }
38
66
  function weekName(i) {
39
67
  if (!model || i === model.W - 1) return 'this week';
40
68
  const w = model.weeks[i];
@@ -42,7 +70,15 @@
42
70
  return d && !isNaN(d) ? 'week of ' + d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : 'that week';
43
71
  }
44
72
 
73
+ // Ideating draws the sky; every other craft (or none) the builder's city.
74
+ function craft() {
75
+ const CM = window.OTBCraftMode;
76
+ const m = CM && typeof CM.current === 'function' ? CM.current() : document.documentElement.dataset.craft;
77
+ return m === 'ideating' ? 'ideating' : null;
78
+ }
79
+
45
80
  function craftIcon(key) {
81
+ if (GLYPH[key]) return '<svg class="hc-ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + GLYPH[key] + '</svg>';
46
82
  const CM = window.OTBCraftMode;
47
83
  return CM && typeof CM.iconHtml === 'function' ? CM.iconHtml(key, 'hc-ico') : '';
48
84
  }
@@ -59,7 +95,12 @@
59
95
  }
60
96
  function drawScene() {
61
97
  const svg = host.querySelector('.hc-svg');
62
- const out = Draw.citySvg(model, week, { mode: mode() });
98
+ const sky = craft() === 'ideating';
99
+ const draw = sky ? Draw.skySvg : Draw.citySvg;
100
+ const out = draw(model, atLive(t) ? model.W - 1 : t, { mode: mode(), gov: window.GovCity || null });
101
+ host.classList.toggle('hc-is-sky', sky);
102
+ svg.setAttribute('aria-label', sky ? SKY_LABEL : CITY_LABEL);
103
+ host.querySelector('#home-city-h').textContent = sky ? 'The project\'s ideas this week' : 'The project this week';
63
104
  svg.setAttribute('viewBox', out.viewBox);
64
105
  svg.replaceChildren(...out.nodes.map(build));
65
106
  }
@@ -91,8 +132,12 @@
91
132
  }
92
133
 
93
134
  function drawLadder() {
94
- const rows = Draw.ladderFor(model, week, viewerId);
135
+ const week = Math.floor(t + 1e-6);
136
+ const who = craft();
137
+ const rows = Draw.ladderFor(model, week, viewerId, who);
138
+ const kinds = who === 'ideating' ? IDEATOR : CRAFTS;
95
139
  const ladder = host.querySelector('.hc-ladder');
140
+ ladder.setAttribute('aria-label', who === 'ideating' ? 'This week\'s ideators, ranked by what they did' : 'This week\'s builders, ranked by what they did');
96
141
  ladder.style.height = (Math.max(1, rows.length) * RUNG) + 'px';
97
142
  const top = Math.max(1, ...rows.map((r) => r.total));
98
143
  const room = Math.max(60, ladder.clientWidth - 40);
@@ -107,7 +152,7 @@
107
152
  r.dataset.tipBody = weekName(week);
108
153
  const line = r.querySelector('.hc-line');
109
154
  line.textContent = '';
110
- const crafts = CRAFTS.filter((c) => row.v[c[0]] > 0);
155
+ const crafts = kinds.filter((c) => row.v[c[0]] > 0);
111
156
  line.style.width = Math.max(crafts.length * 29, room * row.total / top).toFixed(1) + 'px';
112
157
  crafts.forEach((c) => {
113
158
  const seg = document.createElement('span');
@@ -125,29 +170,73 @@
125
170
  rungs.forEach((r, id) => { if (!shown.has(id)) r.hidden = true; });
126
171
  }
127
172
 
128
- function setWeek(i) {
129
- week = Math.max(0, Math.min(model.W - 1, i));
173
+ // ---- time: live at the right end; the city glides to wherever the slider is sent ----
174
+ function when(v) {
175
+ if (atLive(v)) return 'Live';
176
+ const w0 = model.weeks[0] && model.weeks[0].start ? Date.parse(model.weeks[0].start + 'T00:00:00Z') : NaN;
177
+ return isNaN(w0) ? weekName(Math.floor(v)) : new Date(w0 + v * 7 * 864e5).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
178
+ }
179
+ function setT(v) {
180
+ t = Math.max(0, Math.min(model.W - 1, v));
181
+ const live = atLive(t);
130
182
  const s = host.querySelector('.hc-slider');
131
- s.value = String(week);
132
- s.setAttribute('aria-valuetext', weekName(week));
183
+ s.value = String(t);
184
+ s.setAttribute('aria-valuetext', live ? 'Live' : when(t));
185
+ const pill = host.querySelector('.hc-when');
186
+ pill.textContent = when(t);
187
+ pill.style.left = (t / Math.max(1, model.W - 1) * 100) + '%';
188
+ pill.classList.toggle('is-on', !live);
189
+ const l = host.querySelector('.hc-live');
190
+ l.classList.toggle('is-live', live);
191
+ l.querySelector('span').textContent = live ? 'Live' : 'Back to live';
133
192
  drawScene();
134
- drawLadder();
193
+ const w = Math.floor(t + 1e-6);
194
+ if (w !== lastWeek) { lastWeek = w; drawLadder(); }
195
+ buttons();
135
196
  }
136
-
137
- // ---- play: a week at a time, then stop on now ----
138
- function stop() {
139
- if (timer) clearTimeout(timer);
140
- timer = null;
141
- const b = host && host.querySelector('.hc-play');
142
- if (b) { b.classList.remove('is-playing'); b.setAttribute('aria-label', 'Play the weeks from the start'); }
197
+ function buttons() {
198
+ const b = (k) => host.querySelector(`.hc-p-${k}`);
199
+ b('play').classList.toggle('is-playing', run === 'play');
200
+ b('play').setAttribute('aria-label', run === 'play' ? 'Pause' : atLive(t) ? 'Play from the start' : 'Play');
201
+ ['rw', 'play', 'ff'].forEach((k) => b(k).setAttribute('aria-pressed', String(run === k)));
202
+ b('rw').disabled = t <= 1e-3 && run !== 'rw';
203
+ b('ff').disabled = atLive(t) && run !== 'ff';
143
204
  }
144
- function play() {
145
- if (timer) { stop(); return; }
146
- const b = host.querySelector('.hc-play');
147
- b.classList.add('is-playing'); b.setAttribute('aria-label', 'Pause');
148
- let i = 0;
149
- const step = () => { setWeek(i); if (i++ < model.W - 1) timer = setTimeout(step, STEP_MS); else stop(); };
150
- step();
205
+ function halt() { if (raf) cancelAnimationFrame(raf); raf = null; run = null; target = t; if (host) buttons(); }
206
+ function tick() {
207
+ raf = requestAnimationFrame(() => {
208
+ let v;
209
+ if (run) { v = t + SPEED[run]; if (v <= 0 || v >= model.W - 1) { v = Math.max(0, Math.min(model.W - 1, v)); run = null; } target = v; }
210
+ else { v = t + (target - t) * 0.16; if (Math.abs(target - v) < 0.004) v = target; }
211
+ setT(v);
212
+ if (run || v !== target) tick(); else { raf = null; buttons(); }
213
+ });
214
+ }
215
+ function glideTo(v) {
216
+ run = null; target = Math.max(0, Math.min(model.W - 1, v));
217
+ if (STILL()) { const v = target; halt(); setT(v); return; }
218
+ if (!raf) tick();
219
+ }
220
+ function press(k) {
221
+ if (run === k) { halt(); return; }
222
+ if (k === 'play' && atLive(t)) setT(0);
223
+ if (STILL()) { halt(); setT(k === 'rw' ? 0 : model.W - 1); return; }
224
+ run = k; buttons();
225
+ if (!raf) tick();
226
+ }
227
+
228
+ // ---- the doors: the museum and the stars switch the craft (the craft's own Home); the
229
+ // core is a plain link to the street and a building a plain link to its goal ----
230
+ function wireDoors() {
231
+ host.addEventListener('click', (e) => {
232
+ const d = e.target.closest('[data-craft-door]');
233
+ if (!d || !host.contains(d)) return;
234
+ const CM = window.OTBCraftMode;
235
+ if (!CM || typeof CM.choose !== 'function') return;
236
+ e.preventDefault();
237
+ CM.choose(d.getAttribute('data-craft-door'));
238
+ window.scrollTo(0, 0);
239
+ });
151
240
  }
152
241
 
153
242
  // ---- one tooltip for the whole widget; labels are ledger data, so text only ----
@@ -169,12 +258,14 @@
169
258
  }
170
259
 
171
260
  function ticks() {
172
- const t = host.querySelector('.hc-ticks');
261
+ const tk = host.querySelector('.hc-ticks');
173
262
  const first = model.weeks[0] && model.weeks[0].start ? new Date(model.weeks[0].start + 'T00:00:00Z') : null;
174
263
  const mid = model.weeks[Math.floor(model.W / 2)] && model.weeks[Math.floor(model.W / 2)].start ? new Date(model.weeks[Math.floor(model.W / 2)].start + 'T00:00:00Z') : null;
175
264
  const mon = (d) => (d && !isNaN(d) ? d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' }) : '');
176
- t.innerHTML = '';
177
- [mon(first), mon(mid), 'Now'].forEach((s) => { const sp = document.createElement('span'); sp.textContent = s; t.appendChild(sp); });
265
+ const live = host.querySelector('.hc-live');
266
+ tk.textContent = '';
267
+ [mon(first), mon(mid)].forEach((s) => { const sp = document.createElement('span'); sp.textContent = s; tk.appendChild(sp); });
268
+ tk.appendChild(live);
178
269
  }
179
270
 
180
271
  async function load() {
@@ -189,8 +280,19 @@
189
280
  const s = host.querySelector('.hc-slider');
190
281
  s.max = String(model.W - 1);
191
282
  ticks();
192
- host.hidden = false;
193
- setWeek(model.W - 1);
283
+ shown = true;
284
+ craftHome();
285
+ target = model.W - 1;
286
+ setT(model.W - 1);
287
+ }
288
+
289
+ // ---- the artist's Home (task 1005060): in Artistry the same section is the gallery,
290
+ // home-gallery.js, the bench in the museum the city's art district opens onto ----
291
+ function craftHome() {
292
+ const art = !!gallery && document.documentElement.dataset.craft === 'artistry';
293
+ host.classList.toggle('hc--gallery', art);
294
+ if (gallery) gallery.show(art);
295
+ host.hidden = !(art || shown);
194
296
  }
195
297
 
196
298
  function mount(hostEl) {
@@ -198,20 +300,34 @@
198
300
  host.innerHTML =
199
301
  '<h2 id="home-city-h" class="sr-only">The project this week</h2>' +
200
302
  '<div class="hc-stage">' +
201
- '<svg class="hc-svg" role="img" aria-label="The project as a city: one tower per working area, as tall as what has shipped there, with the builders of the week beside it"></svg>' +
303
+ '<svg class="hc-svg" role="img"></svg>' +
202
304
  '<div class="hc-ladder" aria-label="This week\'s builders, ranked by what they did"></div>' +
203
305
  '</div>' +
204
306
  '<div class="hc-time">' +
205
- '<button type="button" class="hc-play" aria-label="Play the weeks from the start">' +
206
- '<svg class="hc-play__on" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.5v11l9-5.5z" fill="currentColor"/></svg>' +
207
- '<svg class="hc-play__off" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor"/></svg>' +
208
- '</button>' +
209
- '<div class="hc-track"><input type="range" class="hc-slider" min="0" max="0" step="1" value="0" aria-label="Week"><div class="hc-ticks"></div></div>' +
307
+ '<div class="hc-player" role="group" aria-label="Time">' +
308
+ '<button type="button" class="hc-p hc-p-rw" aria-label="Rewind"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5v9L2.5 8zM14 3.5v9L8.5 8z" fill="currentColor"/></svg></button>' +
309
+ '<button type="button" class="hc-p hc-p-play" aria-label="Play">' +
310
+ '<svg class="hc-play__on" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 3v10l8-5z" fill="currentColor"/></svg>' +
311
+ '<svg class="hc-play__off" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 3h2.4v10H4.5zM9.1 3h2.4v10H9.1z" fill="currentColor"/></svg>' +
312
+ '</button>' +
313
+ '<button type="button" class="hc-p hc-p-ff" aria-label="Fast forward"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5v9L7.5 8zM8 3.5v9L13.5 8z" fill="currentColor"/></svg></button>' +
314
+ '</div>' +
315
+ '<div class="hc-track"><div class="hc-when" aria-hidden="true"></div>' +
316
+ '<input type="range" class="hc-slider" min="0" max="0" step="any" value="0" aria-label="Time, from the start of the version to live">' +
317
+ '<div class="hc-ticks"></div>' +
318
+ '<button type="button" class="hc-live is-live"><i aria-hidden="true"></i><span>Live</span></button>' +
319
+ '</div>' +
210
320
  '</div>';
211
- host.querySelector('.hc-slider').addEventListener('input', (e) => { stop(); setWeek(Number(e.target.value)); });
212
- host.querySelector('.hc-play').addEventListener('click', play);
321
+ host.querySelector('.hc-slider').addEventListener('input', (e) => { if (model) glideTo(Number(e.target.value)); });
322
+ ['rw', 'play', 'ff'].forEach((k) => host.querySelector(`.hc-p-${k}`).addEventListener('click', () => { if (model) press(k); }));
323
+ host.querySelector('.hc-live').addEventListener('click', () => { if (model) glideTo(model.W - 1); });
213
324
  wireTip();
325
+ wireDoors();
326
+ if (window.OTBHomeGallery) gallery = window.OTBHomeGallery.attach(host);
327
+ document.addEventListener('otb:craft', craftHome);
328
+ craftHome();
214
329
  new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
330
+ document.addEventListener('otb:craft', () => { if (model) { drawScene(); drawLadder(); } });
215
331
  window.addEventListener('resize', () => { if (model) drawLadder(); });
216
332
  load();
217
333
  }
@@ -0,0 +1,129 @@
1
+ /* modules/hall-ui/public/home-gallery.css — the artist's Home: a bench in the museum
2
+ (task 1005060; mock docs/design/mocks/hall/home/v16-artistry.html, record
3
+ modules/hall-ui/records/home-city.md § "The gallery"). A quiet wall, a floor and a
4
+ bench barely there, and the art the largest thing in the room in a thin frame.
5
+ Every colour is a hall token or a mix of one; the only literal is black with alpha,
6
+ as a shadow. The tokens carry both modes, so no rule here is written per mode.
7
+ No :root; motion is CSS and the hall's reduced-motion rule stops it. */
8
+
9
+ /* home-city.js shows the gallery in Artistry and the city otherwise */
10
+ .home-city.hc--gallery > :not(.hg) { display: none; }
11
+ .home-city:not(.hc--gallery) > .hg { display: none; }
12
+
13
+ .hg-room {
14
+ --hg-wall: color-mix(in srgb, var(--bg-card) 94%, var(--accent));
15
+ --hg-wall-low: color-mix(in srgb, var(--bg) 90%, var(--accent-deep));
16
+ --hg-floor: color-mix(in srgb, var(--bg-deep) 86%, var(--accent-deep));
17
+ --hg-spot: color-mix(in srgb, var(--accent) 14%, transparent);
18
+ --hg-frame: rgba(0, 0, 0, .82);
19
+ --hg-shadow: rgba(0, 0, 0, .45);
20
+ position: relative; height: 760px; overflow: hidden; user-select: none; touch-action: pan-y; outline: none;
21
+ color: var(--ink);
22
+ background: linear-gradient(var(--hg-wall), var(--hg-wall-low) 88%, transparent 88%), var(--hg-floor);
23
+ }
24
+ .hg-room:focus-visible { box-shadow: inset 0 0 0 2px var(--focus, var(--accent)); }
25
+ .hg-room::before { content: ""; position: absolute; inset: 0 0 12% 0; background: radial-gradient(ellipse 40% 64% at 46% 44%, var(--hg-spot), transparent 70%); pointer-events: none; }
26
+ /* the skirting board and the floorboards */
27
+ .hg-room::after { content: ""; position: absolute; left: 0; right: 0; top: 88%; bottom: 0; border-top: 2px solid color-mix(in srgb, var(--hg-wall-low) 82%, var(--ink)); background: repeating-linear-gradient(90deg, transparent 0 160px, color-mix(in srgb, var(--ink) 5%, transparent) 160px 161px); pointer-events: none; }
28
+ .hg-bench { position: absolute; left: 50%; bottom: -30px; width: 46%; height: 44px; transform: translateX(-50%); border-radius: 14px 14px 0 0; background: linear-gradient(color-mix(in srgb, var(--ink) 70%, var(--accent-deep)), var(--ink)); opacity: .22; filter: blur(1.2px); z-index: 3; pointer-events: none; }
29
+
30
+ /* the wall title, in vinyl lettering */
31
+ .hg-vinyl { position: absolute; left: 44px; top: 34px; z-index: 4; }
32
+ .hg-h { margin: 0; font: 300 30px/1.2 var(--font-display); letter-spacing: .01em; color: var(--ink); }
33
+ .hg-sub { margin: 6px 0 0; font-size: 11px; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-soft); }
34
+ .hg-sort { display: flex; gap: 18px; margin-top: 16px; }
35
+ .hg-sort button, .hg-way { all: unset; cursor: pointer; box-sizing: border-box; min-height: 24px; min-width: 24px; display: inline-flex; align-items: center; color: var(--ink-soft); }
36
+ .hg-sort button { font-size: 12.5px; letter-spacing: .04em; border-bottom: 1px solid transparent; }
37
+ .hg-sort button[aria-pressed="true"] { color: var(--ink); border-color: var(--ink); }
38
+ .hg-ways { display: flex; flex-wrap: wrap; gap: 4px 22px; margin-top: 22px; }
39
+ .hg-way { font-size: 13px; letter-spacing: .01em; text-decoration: none; gap: 6px; }
40
+ .hg-way:hover, .hg-way[aria-pressed="true"] { color: var(--ink); }
41
+ .hg-sort button:focus-visible, .hg-way:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 3px; }
42
+
43
+ /* the wall, one work at a time along it */
44
+ .hg-wall { position: absolute; inset: 0 0 12% 0; overflow: hidden; }
45
+ .hg-track { position: absolute; inset: 0; display: flex; transition: transform .7s cubic-bezier(.22, .7, .2, 1); }
46
+ .hg-track.is-drag { transition: none; }
47
+ .hg-hang { flex: 0 0 100%; position: relative; }
48
+ .hg-work { position: absolute; left: 49%; top: 188px; transform: translateX(-50%); width: min(700px, 56%); transition: opacity .3s; }
49
+ .hg-canvas { position: relative; aspect-ratio: 16 / 10; overflow: hidden; background: var(--bg-deep); border: 3px solid var(--hg-frame); box-shadow: 0 18px 34px -20px var(--hg-shadow); }
50
+ .hg-canvas img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; pointer-events: none; -webkit-user-drag: none; }
51
+
52
+ /* the wall label: small, at the work's foot to its right; click to read it closer */
53
+ .hg-label { position: absolute; left: calc(100% + 28px); bottom: 0; width: 158px; font-size: 10px; line-height: 1.4; color: var(--ink); }
54
+ .hg-text { display: block; cursor: zoom-in; transform-origin: 100% 100%; transition: transform .35s cubic-bezier(.2, .7, .2, 1); border-radius: 4px; outline-offset: 6px; }
55
+ .hg-text:focus-visible { outline: 2px solid var(--focus, var(--accent)); }
56
+ .hg-who { display: block; font-weight: 700; font-size: 10.5px; }
57
+ .hg-bio { display: block; margin-bottom: 6px; color: var(--ink-soft); }
58
+ .hg-title { display: block; }
59
+ .hg-medium, .hg-place { display: block; color: var(--ink-soft); }
60
+ .hg-words, .hg-credit { display: none; margin: 8px 0 0; }
61
+ .hg-credit { color: var(--ink-soft); }
62
+ .hg-hang.is-reading .hg-text { transform: scale(1.85); cursor: zoom-out; }
63
+ .hg-hang.is-reading .hg-words, .hg-hang.is-reading .hg-credit { display: block; }
64
+ .hg-hang.is-reading .hg-work > .hg-canvas { opacity: .4; }
65
+ /* the signature, in the artist's hand, on the wall below the label; the heart never changes size */
66
+ .hg-sigline { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
67
+ .hg-sig { font: 500 17px/1 "Caveat", "Segoe Script", "Bradley Hand", "Snell Roundhand", cursive; color: var(--ink); opacity: .82; transform: rotate(-2deg); white-space: nowrap; }
68
+ .hg-heart { all: unset; cursor: pointer; flex: none; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; color: var(--ink-soft); }
69
+ .hg-heart svg { width: 15px; height: 15px; }
70
+ .hg-heart:hover { color: var(--ink); }
71
+ .hg-heart[aria-pressed="true"] { color: var(--accent-deep); }
72
+ .hg-heart[aria-pressed="true"] path { fill: currentColor; }
73
+ .hg-heart:focus-visible { outline: 2px solid var(--focus, var(--accent)); }
74
+
75
+ /* moving along the wall */
76
+ .hg-edge { all: unset; position: absolute; top: 0; bottom: 12%; width: 9%; min-width: 44px; z-index: 4; cursor: pointer; display: grid; place-items: center; color: var(--ink-soft); opacity: .55; transition: opacity .2s; }
77
+ .hg-edge:hover, .hg-edge:focus-visible { opacity: 1; }
78
+ .hg-edge:focus-visible svg { outline: 2px solid var(--focus, var(--accent)); outline-offset: 6px; border-radius: 50%; }
79
+ .hg-edge svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }
80
+ .hg-prev { left: 0; } .hg-next { right: 0; }
81
+ .hg-edge[disabled] { opacity: 0; pointer-events: none; }
82
+ .hg-count { position: absolute; left: 49%; top: calc(88% - 30px); transform: translateX(-50%); z-index: 4; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-soft); }
83
+ .hg-empty { position: absolute; left: 50%; top: 46%; transform: translate(-50%, -50%); width: min(440px, 80%); margin: 0; text-align: center; font-size: 14px; color: var(--ink-soft); }
84
+ .hg-room.is-empty .hg-edge, .hg-room.is-empty .hg-fsbtn { display: none; }
85
+
86
+ /* full screen: only the art, its caption, the signature and the heart */
87
+ .hg-fsbtn { all: unset; position: absolute; right: 16px; top: 16px; z-index: 5; width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; color: var(--ink-soft); cursor: pointer; opacity: .6; }
88
+ .hg-fsbtn:hover, .hg-fsbtn:focus-visible { opacity: 1; }
89
+ .hg-fsbtn:focus-visible { outline: 2px solid var(--focus, var(--accent)); }
90
+ .hg-fsbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
91
+ .hg-cap { display: none; position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); font-size: 12px; color: var(--ink-soft); white-space: nowrap; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; }
92
+ .hg-room:fullscreen, .hg-room.is-fs { --hg-spot: transparent; background: var(--hg-wall-low); }
93
+ .hg-room.is-fs { position: fixed; inset: 0; height: auto; z-index: 1000; }
94
+ .hg-room:fullscreen::after, .hg-room.is-fs::after, .hg-room:fullscreen .hg-bench, .hg-room.is-fs .hg-bench,
95
+ .hg-room:fullscreen .hg-vinyl, .hg-room.is-fs .hg-vinyl, .hg-room:fullscreen .hg-count, .hg-room.is-fs .hg-count { display: none; }
96
+ .hg-room:fullscreen .hg-wall, .hg-room.is-fs .hg-wall, .hg-room:fullscreen .hg-edge, .hg-room.is-fs .hg-edge { bottom: 0; }
97
+ .hg-room:fullscreen .hg-work, .hg-room.is-fs .hg-work { left: 50%; top: 50%; transform: translate(-50%, calc(-50% - 22px)); width: min(86vw, calc((100vh - 130px) * 1.6)); }
98
+ .hg-room:fullscreen .hg-canvas, .hg-room.is-fs .hg-canvas { border: 0; box-shadow: none; opacity: 1; }
99
+ .hg-room:fullscreen .hg-label, .hg-room.is-fs .hg-label { position: absolute; left: auto; right: 0; top: calc(100% + 10px); bottom: auto; width: auto; margin: 0; }
100
+ .hg-room:fullscreen .hg-text, .hg-room.is-fs .hg-text { display: none; }
101
+ .hg-room:fullscreen .hg-cap, .hg-room.is-fs .hg-cap { display: block; }
102
+
103
+ /* the full gallery: every work on the wall at once, hung salon-style */
104
+ .hg-salon { position: absolute; left: 44px; right: 44px; top: 210px; bottom: calc(12% + 20px); overflow: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 34px 30px; align-content: start; padding: 4px; z-index: 4; }
105
+ .hg-salon[hidden] { display: none; }
106
+ .hg-room.is-all .hg-wall, .hg-room.is-all .hg-edge, .hg-room.is-all .hg-count, .hg-room.is-all .hg-fsbtn { display: none; }
107
+ .hg-piece { all: unset; cursor: pointer; display: block; width: 100%; }
108
+ .hg-pc { position: relative; display: block; aspect-ratio: 16 / 10; overflow: hidden; background: var(--bg-deep); border: 2px solid var(--hg-frame); box-shadow: 0 12px 22px -16px var(--hg-shadow); transition: transform .25s; }
109
+ .hg-pc img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; }
110
+ .hg-piece:hover .hg-pc, .hg-piece:focus-visible .hg-pc { transform: translateY(-3px); }
111
+ .hg-piece:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 6px; }
112
+ .hg-pt { display: block; margin-top: 9px; font-size: 10.5px; color: var(--ink); }
113
+ .hg-pl { display: flex; align-items: center; gap: 5px; margin-top: 2px; font-size: 10px; color: var(--ink-soft); }
114
+ .hg-pl svg { width: 11px; height: 11px; }
115
+
116
+ @media (max-width: 860px) {
117
+ .hg-room { height: 660px; }
118
+ .hg-vinyl { left: 20px; right: 20px; top: 20px; }
119
+ .hg-h { font-size: 24px; }
120
+ .hg-work { top: 196px; left: 50%; width: 86%; }
121
+ .hg-label { position: static; width: auto; margin-top: 14px; }
122
+ /* a phone has no room to scale the label: read closer, it opens over the dimmed picture */
123
+ .hg-hang.is-reading .hg-text { transform: none; }
124
+ .hg-hang.is-reading .hg-label { position: absolute; left: 0; right: 0; top: 0; margin: 0; font-size: 12px; }
125
+ .hg-hang.is-reading .hg-who { font-size: 12.5px; }
126
+ .hg-hang.is-reading .hg-work > .hg-canvas { opacity: 0; }
127
+ .hg-count { left: 50%; }
128
+ .hg-salon { left: 20px; right: 20px; top: 196px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 22px 16px; }
129
+ }