@crowdedkingdoms/crowdyjs 12.0.0 → 12.2.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 (58) hide show
  1. package/MIGRATION.md +45 -0
  2. package/README.md +70 -1
  3. package/dist/crowdy-studio/agent-dom-shell.d.ts +29 -1
  4. package/dist/crowdy-studio/agent-dom-shell.d.ts.map +1 -1
  5. package/dist/crowdy-studio/agent-dom-shell.js +108 -44
  6. package/dist/crowdy-studio/dom-shell.d.ts +46 -3
  7. package/dist/crowdy-studio/dom-shell.d.ts.map +1 -1
  8. package/dist/crowdy-studio/dom-shell.js +551 -125
  9. package/dist/crowdy-studio/embed/dock.d.ts +56 -0
  10. package/dist/crowdy-studio/embed/dock.d.ts.map +1 -0
  11. package/dist/crowdy-studio/embed/dock.js +231 -0
  12. package/dist/crowdy-studio/embed/embed-styles.d.ts +12 -0
  13. package/dist/crowdy-studio/embed/embed-styles.d.ts.map +1 -0
  14. package/dist/crowdy-studio/embed/embed-styles.js +549 -0
  15. package/dist/crowdy-studio/embed/hud-layer.d.ts +40 -0
  16. package/dist/crowdy-studio/embed/hud-layer.d.ts.map +1 -0
  17. package/dist/crowdy-studio/embed/hud-layer.js +100 -0
  18. package/dist/crowdy-studio/embed/index.d.ts +5 -0
  19. package/dist/crowdy-studio/embed/index.d.ts.map +1 -0
  20. package/dist/crowdy-studio/embed/index.js +4 -0
  21. package/dist/crowdy-studio/embed/panel.d.ts +162 -0
  22. package/dist/crowdy-studio/embed/panel.d.ts.map +1 -0
  23. package/dist/crowdy-studio/embed/panel.js +642 -0
  24. package/dist/crowdy-studio/index.d.ts +3 -0
  25. package/dist/crowdy-studio/index.d.ts.map +1 -1
  26. package/dist/crowdy-studio/index.js +3 -0
  27. package/dist/crowdy-studio/layout.d.ts +50 -0
  28. package/dist/crowdy-studio/layout.d.ts.map +1 -0
  29. package/dist/crowdy-studio/layout.js +155 -0
  30. package/dist/crowdy-studio/mount.d.ts.map +1 -1
  31. package/dist/crowdy-studio/mount.js +3 -1
  32. package/dist/crowdy-studio/splitter.d.ts +33 -0
  33. package/dist/crowdy-studio/splitter.d.ts.map +1 -0
  34. package/dist/crowdy-studio/splitter.js +133 -0
  35. package/dist/crowdy-studio/styles.d.ts +1 -1
  36. package/dist/crowdy-studio/styles.d.ts.map +1 -1
  37. package/dist/crowdy-studio/styles.js +128 -13
  38. package/dist/domains/gameModel.d.ts +68 -1
  39. package/dist/domains/gameModel.d.ts.map +1 -1
  40. package/dist/domains/gameModel.js +88 -1
  41. package/dist/generated/graphql.d.ts +132 -15
  42. package/dist/generated/graphql.d.ts.map +1 -1
  43. package/dist/generated/graphql.js +12 -0
  44. package/dist/index.d.ts +4 -4
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -3
  47. package/dist/kit/npcs.d.ts.map +1 -1
  48. package/dist/kit/social.d.ts.map +1 -1
  49. package/dist/player-host/control-banner.d.ts +40 -0
  50. package/dist/player-host/control-banner.d.ts.map +1 -0
  51. package/dist/player-host/control-banner.js +252 -0
  52. package/dist/player-host/control-gate.d.ts +77 -0
  53. package/dist/player-host/control-gate.d.ts.map +1 -0
  54. package/dist/player-host/control-gate.js +197 -0
  55. package/dist/player-host/index.d.ts +2 -0
  56. package/dist/player-host/index.d.ts.map +1 -1
  57. package/dist/player-host/index.js +2 -0
  58. package/package.json +6 -1
@@ -1,37 +1,69 @@
1
1
  import { CrowdyStudioAgentDomShell, } from './agent-dom-shell.js';
2
+ import { StudioLayoutController, studioPaneSizeRange, } from './layout.js';
3
+ import { createPaneSplitter } from './splitter.js';
2
4
  import { projectTargets, } from './models.js';
3
5
  import { CROWDY_STUDIO_STYLES } from './styles.js';
4
- /** Modular DOM shell; editor implementations mount into `editorHost`. */
6
+ const FAILURE_PHASES = new Set([
7
+ 'COMPILE_FAILED',
8
+ 'ERROR',
9
+ 'PARTIAL_FAILURE',
10
+ ]);
11
+ /**
12
+ * Modular DOM shell; editor implementations mount into `editorHost`.
13
+ *
14
+ * Layout is user directed: the editor is always visible while the explorer,
15
+ * project settings, bottom panel, and agent dock are toggled from the
16
+ * activity rail, resized with accessible splitters, and persisted through
17
+ * {@link StudioLayoutController}.
18
+ */
5
19
  export class CrowdyStudioDomShell {
6
20
  constructor(host, controller, agentController, agentOptions = {}) {
7
21
  this.controller = controller;
8
22
  this.panelButtons = new Map();
9
23
  this.panels = new Map();
24
+ this.railButtons = new Map();
25
+ this.splitters = new Map();
10
26
  this.activePanel = 'problems';
27
+ this.explorerForm = null;
28
+ this.lastState = null;
29
+ this.lastPhase = 'IDLE';
11
30
  this.disposed = false;
12
31
  const style = document.createElement('style');
13
32
  style.textContent = CROWDY_STUDIO_STYLES;
14
33
  this.root = element('div', 'ck-crowdy-studio');
15
34
  this.root.append(style);
35
+ this.layout = new StudioLayoutController();
36
+ // ----- Top bar -----------------------------------------------------
16
37
  const toolbar = element('div', 'ck-crowdy-studio-toolbar');
17
38
  const projectTools = element('div', 'ck-crowdy-studio-project-tools');
18
- this.projectSelect = document.createElement('select');
19
- this.projectSelect.setAttribute('aria-label', 'Current project');
20
- const newButton = button('New project');
21
- this.saveBadge = element('span', 'ck-crowdy-studio-save');
39
+ this.projectMenu = this.createMenu('No projects yet', {
40
+ buttonClass: 'ck-crowdy-studio-project-button',
41
+ ariaLabel: 'Current project',
42
+ });
43
+ this.projectMenuList = this.projectMenu.menu;
44
+ this.saveMenu = this.createMenu('Saved', {
45
+ buttonClass: 'ck-crowdy-studio-save',
46
+ ariaLabel: 'Save status',
47
+ });
48
+ this.saveMessageText = element('p', 'ck-crowdy-studio-save-message');
22
49
  this.saveAction = button('Retry save');
23
50
  this.remoteAction = button('Use cloud version');
24
- this.saveAction.hidden = true;
25
- this.remoteAction.hidden = true;
26
- projectTools.append(this.projectSelect, newButton, this.saveBadge, this.saveAction, this.remoteAction);
51
+ this.saveActions = element('div', 'ck-crowdy-studio-save-actions');
52
+ this.saveActions.append(this.saveAction, this.remoteAction);
53
+ this.saveMenu.menu.append(this.saveMessageText, this.saveActions);
54
+ projectTools.append(this.projectMenu.wrap, this.saveMenu.wrap);
27
55
  const runtimeTools = element('div', 'ck-crowdy-studio-runtime-tools');
28
56
  this.testAction = button('Test draft');
29
- this.deployAction = button('Deploy live');
30
- const stop = button('Stop project');
31
- this.runtimeStatus = element('span', 'ck-crowdy-studio-status');
32
- this.budgetStatus = element('span', 'ck-crowdy-studio-status');
33
- runtimeTools.append(this.testAction, this.deployAction, stop, this.runtimeStatus, this.budgetStatus);
57
+ this.testAction.className = 'ck-crowdy-studio-primary';
58
+ this.runMenu = this.createMenu('Run ', {
59
+ ariaLabel: 'More run actions',
60
+ });
61
+ this.deployAction = menuItem('Deploy live');
62
+ this.stopAction = menuItem('Stop project');
63
+ this.runMenu.menu.append(this.deployAction, this.stopAction);
64
+ runtimeTools.append(this.testAction, this.runMenu.wrap);
34
65
  toolbar.append(projectTools, runtimeTools);
66
+ // ----- New-project popover -----------------------------------------
35
67
  this.newForm = document.createElement('form');
36
68
  this.newForm.className = 'ck-crowdy-studio-new';
37
69
  this.newForm.dataset.open = 'false';
@@ -57,13 +89,34 @@ export class CrowdyStudioDomShell {
57
89
  const create = button('Create project', 'submit');
58
90
  const cancel = button('Cancel');
59
91
  this.newForm.append(this.newName, this.newKind, create, cancel);
60
- const main = element('div', 'ck-crowdy-studio-main');
92
+ // ----- Workspace: rail + panes + editor -----------------------------
93
+ this.workspace = element('div', 'ck-crowdy-studio-workspace');
94
+ const rail = element('nav', 'ck-crowdy-studio-rail');
95
+ rail.setAttribute('aria-label', 'Studio panes');
96
+ rail.append(this.railButton('explorer', 'Files', 'Show or hide project files'), this.railButton('settings', 'Settings', 'Show or hide project settings'), this.railButton('bottom', 'Console', 'Show or hide the console panel'));
61
97
  this.explorer = element('aside', 'ck-crowdy-studio-explorer');
62
- const editorColumn = element('section', 'ck-crowdy-studio-editor-column');
98
+ this.explorer.setAttribute('aria-label', 'Project files');
99
+ this.editorColumn = element('section', 'ck-crowdy-studio-editor-column');
63
100
  this.tabs = element('div', 'ck-crowdy-studio-tabs');
64
101
  this.editorHost = element('div', 'ck-crowdy-studio-editor');
65
- editorColumn.append(this.tabs, this.editorHost);
66
- const settings = element('aside', 'ck-crowdy-studio-settings');
102
+ this.bottom = element('section', 'ck-crowdy-studio-bottom');
103
+ const panelTabs = element('div', 'ck-crowdy-studio-panel-tabs');
104
+ this.problemsPanel = this.createPanel('problems', 'Problems', panelTabs);
105
+ this.buildPanel = this.createPanel('build', 'Build', panelTabs);
106
+ this.logsPanel = this.createPanel('logs', 'Logs', panelTabs);
107
+ this.runsPanel = this.createPanel('runs', 'Runs', panelTabs);
108
+ this.invokePanel = this.createPanel('invoke', 'Invoke', panelTabs);
109
+ const hidePanel = button('×');
110
+ hidePanel.className = 'ck-crowdy-studio-panel-hide';
111
+ hidePanel.setAttribute('aria-label', 'Hide the console panel');
112
+ hidePanel.addEventListener('click', () => this.layout.setVisible('bottom', false));
113
+ panelTabs.append(hidePanel);
114
+ const panelBody = element('div', 'ck-crowdy-studio-panel-body');
115
+ for (const panel of this.panels.values())
116
+ panelBody.append(panel);
117
+ this.bottom.append(panelTabs, panelBody);
118
+ this.editorColumn.append(this.tabs, this.editorHost, this.paneSplitter('bottom', 'horizontal', 'after', 'Resize the console panel'), this.bottom);
119
+ this.settings = element('aside', 'ck-crowdy-studio-settings');
67
120
  const settingsTitle = element('h3');
68
121
  settingsTitle.textContent = 'Project settings';
69
122
  this.settingsName = input('Project name');
@@ -75,43 +128,38 @@ export class CrowdyStudioDomShell {
75
128
  ['OPTIONAL', 'Optional companion'],
76
129
  ['REQUIRED', 'Require client companion'],
77
130
  ]);
78
- settings.append(settingsTitle, labeled('Name', this.settingsName), labeled('Description', this.settingsDescription), labeled('Server module', this.serverModuleName), labeled('Client module', this.clientModuleName), labeled('Pairing', this.pairing));
79
- main.append(this.explorer, editorColumn, settings);
80
- const bottom = element('section', 'ck-crowdy-studio-bottom');
81
- const panelTabs = element('div', 'ck-crowdy-studio-panel-tabs');
82
- this.problemsPanel = this.createPanel('problems', 'Problems', panelTabs);
83
- this.buildPanel = this.createPanel('build', 'Build', panelTabs);
84
- this.logsPanel = this.createPanel('logs', 'Logs', panelTabs);
85
- this.runsPanel = this.createPanel('runs', 'Runs', panelTabs);
86
- this.invokePanel = this.createPanel('invoke', 'Invoke', panelTabs);
87
- const panelBody = element('div');
88
- for (const panel of this.panels.values())
89
- panelBody.append(panel);
90
- bottom.append(panelTabs, panelBody);
91
- this.invokeExport = input('Export name');
92
- this.invokeExport.value = 'invoke';
93
- this.invokeParams = document.createElement('textarea');
94
- this.invokeParams.placeholder = '{"example": true}';
95
- this.invokeParams.setAttribute('aria-label', 'Invoke JSON parameters');
96
- const invokeButton = button('Invoke server export');
97
- this.invokeResult = document.createElement('pre');
98
- const invokeControls = element('div', 'ck-crowdy-studio-invoke');
99
- invokeControls.append(this.invokeExport, this.invokeParams, invokeButton);
100
- this.invokePanel.append(invokeControls, this.invokeResult);
101
- this.root.append(toolbar, this.newForm, main, bottom);
102
- host.appendChild(this.root);
103
- this.agentShell = agentController
104
- ? new CrowdyStudioAgentDomShell(this.root, agentController, agentOptions)
105
- : null;
106
- newButton.addEventListener('click', () => {
107
- this.newForm.dataset.open =
108
- this.newForm.dataset.open === 'true' ? 'false' : 'true';
109
- if (this.newForm.dataset.open === 'true')
110
- this.newName.focus();
111
- });
112
- cancel.addEventListener('click', () => {
113
- this.newForm.dataset.open = 'false';
131
+ this.settings.append(settingsTitle, labeled('Name', this.settingsName), labeled('Description', this.settingsDescription), labeled('Server module', this.serverModuleName), labeled('Client module', this.clientModuleName), labeled('Pairing', this.pairing));
132
+ this.workspace.append(rail, this.explorer, this.paneSplitter('explorer', 'vertical', 'before', 'Resize the file explorer'), this.editorColumn, this.paneSplitter('settings', 'vertical', 'after', 'Resize project settings'), this.settings);
133
+ // ----- Status bar ----------------------------------------------------
134
+ const statusbar = element('footer', 'ck-crowdy-studio-statusbar');
135
+ this.saveStatus = element('span', 'ck-crowdy-studio-status');
136
+ this.runtimeStatus = document.createElement('button');
137
+ this.runtimeStatus.type = 'button';
138
+ this.runtimeStatus.className =
139
+ 'ck-crowdy-studio-status ck-crowdy-studio-statusbar-runtime';
140
+ this.runtimeStatus.setAttribute('aria-label', 'Runtime status; opens the console panel');
141
+ this.runtimeStatus.addEventListener('click', () => {
142
+ this.layout.setVisible('bottom', true);
143
+ this.activatePanel(this.activePanel);
114
144
  });
145
+ this.budgetStatus = element('span', 'ck-crowdy-studio-status');
146
+ statusbar.append(this.saveStatus, this.runtimeStatus, this.budgetStatus);
147
+ // The popover is absolutely positioned against the toolbar so it opens
148
+ // right under the project menu button.
149
+ toolbar.append(this.newForm);
150
+ this.root.append(toolbar, this.workspace, statusbar);
151
+ host.appendChild(this.root);
152
+ // ----- Agent dock ----------------------------------------------------
153
+ if (agentController) {
154
+ rail.append(this.railButton('agent', 'Agent', 'Show or hide the Crowdy Agent dock'));
155
+ this.workspace.append(this.paneSplitter('agent', 'vertical', 'after', 'Resize the agent dock'));
156
+ this.agentShell = new CrowdyStudioAgentDomShell(this.workspace, agentController, { ...agentOptions, layout: agentOptions.layout ?? this.layout });
157
+ this.root.dataset.agent = 'true';
158
+ }
159
+ else {
160
+ this.agentShell = null;
161
+ }
162
+ // ----- Wiring ---------------------------------------------------------
115
163
  this.newForm.addEventListener('submit', (event) => {
116
164
  event.preventDefault();
117
165
  void this.run(async () => {
@@ -123,18 +171,32 @@ export class CrowdyStudioDomShell {
123
171
  this.newForm.dataset.open = 'false';
124
172
  });
125
173
  });
126
- this.projectSelect.addEventListener('change', () => {
127
- void this.run(() => this.controller.switchProject(this.projectSelect.value));
174
+ cancel.addEventListener('click', () => {
175
+ this.newForm.dataset.open = 'false';
176
+ });
177
+ this.testAction.addEventListener('click', () => {
178
+ this.revealPanel('build');
179
+ void this.run(() => this.controller.testDraft());
180
+ });
181
+ this.deployAction.addEventListener('click', () => {
182
+ this.runMenu.close();
183
+ this.revealPanel('build');
184
+ void this.run(() => this.controller.deployLive());
185
+ });
186
+ this.stopAction.addEventListener('click', () => {
187
+ this.runMenu.close();
188
+ void this.run(() => this.controller.stopProject());
128
189
  });
129
- this.testAction.addEventListener('click', () => void this.run(() => this.controller.testDraft()));
130
- this.deployAction.addEventListener('click', () => void this.run(() => this.controller.deployLive()));
131
- stop.addEventListener('click', () => void this.run(() => this.controller.stopProject()));
132
190
  this.saveAction.addEventListener('click', () => {
191
+ this.saveMenu.close();
133
192
  void this.run(() => this.controller.getState().saveState === 'CONFLICT'
134
193
  ? this.controller.overwriteConflict()
135
194
  : this.controller.retrySave());
136
195
  });
137
- this.remoteAction.addEventListener('click', () => void this.run(() => this.controller.acceptRemoteConflict()));
196
+ this.remoteAction.addEventListener('click', () => {
197
+ this.saveMenu.close();
198
+ void this.run(() => this.controller.acceptRemoteConflict());
199
+ });
138
200
  this.settingsName.addEventListener('change', () => this.controller.updateSettings({ name: this.settingsName.value }));
139
201
  this.settingsDescription.addEventListener('change', () => this.controller.updateSettings({
140
202
  description: this.settingsDescription.value,
@@ -146,15 +208,28 @@ export class CrowdyStudioDomShell {
146
208
  clientModuleName: this.clientModuleName.value,
147
209
  }));
148
210
  this.pairing.addEventListener('change', () => this.controller.setPairingPreference(this.pairing.value));
211
+ this.invokeExport = input('Export name');
212
+ this.invokeExport.value = 'invoke';
213
+ this.invokeParams = document.createElement('textarea');
214
+ this.invokeParams.placeholder = '{"example": true}';
215
+ this.invokeParams.setAttribute('aria-label', 'Invoke JSON parameters');
216
+ const invokeButton = button('Invoke server export');
217
+ this.invokeResult = document.createElement('pre');
218
+ const invokeControls = element('div', 'ck-crowdy-studio-invoke');
219
+ invokeControls.append(this.invokeExport, this.invokeParams, invokeButton);
220
+ this.invokePanel.append(invokeControls, this.invokeResult);
149
221
  invokeButton.addEventListener('click', () => {
150
222
  void this.run(() => this.controller.invoke(this.invokeExport.value, this.invokeParams.value));
151
223
  });
152
224
  this.controller.setSurfaceVisible('usage', true);
225
+ this.unsubscribeLayout = this.layout.subscribe((state) => this.applyLayout(state));
226
+ this.applyLayout(this.layout.getState());
153
227
  this.activatePanel('problems');
154
228
  }
155
229
  render(state) {
156
230
  if (this.disposed)
157
231
  return;
232
+ this.lastState = state;
158
233
  this.renderProjects(state);
159
234
  this.renderSaveState(state);
160
235
  this.renderExplorer(state);
@@ -172,14 +247,13 @@ export class CrowdyStudioDomShell {
172
247
  this.invokeResult.textContent = state.invokeResult
173
248
  ? formatInvokeResult(state.invokeResult)
174
249
  : '';
175
- this.runtimeStatus.textContent = [
176
- state.runtime.phase,
177
- state.runtime.target,
178
- state.runtime.message,
179
- ]
180
- .filter(Boolean)
181
- .join(' · ');
250
+ this.runtimeStatus.textContent =
251
+ [state.runtime.phase, state.runtime.target, state.runtime.message]
252
+ .filter(Boolean)
253
+ .join(' · ') || 'IDLE';
254
+ this.runtimeStatus.dataset.phase = state.runtime.phase;
182
255
  this.budgetStatus.textContent = budgetText(state);
256
+ this.autoRevealFailures(state);
183
257
  }
184
258
  dispose() {
185
259
  if (this.disposed)
@@ -188,9 +262,174 @@ export class CrowdyStudioDomShell {
188
262
  this.controller.setSurfaceVisible('usage', false);
189
263
  this.controller.setSurfaceVisible('logs', false);
190
264
  this.controller.setSurfaceVisible('runs', false);
265
+ this.unsubscribeLayout();
266
+ for (const splitter of this.splitters.values())
267
+ splitter.dispose();
268
+ this.projectMenu.dispose();
269
+ this.saveMenu.dispose();
270
+ this.runMenu.dispose();
191
271
  this.agentShell?.dispose();
192
272
  this.root.remove();
193
273
  }
274
+ // ----- Layout ----------------------------------------------------------
275
+ railButton(pane, label, description) {
276
+ const control = button(label);
277
+ control.className = 'ck-crowdy-studio-rail-button';
278
+ control.dataset.pane = pane;
279
+ control.title = description;
280
+ control.setAttribute('aria-pressed', 'false');
281
+ control.addEventListener('click', () => {
282
+ const wasVisible = this.layout.isVisible(pane);
283
+ this.layout.toggle(pane);
284
+ if (!wasVisible)
285
+ this.collapseOverlappingPanes(pane);
286
+ });
287
+ this.railButtons.set(pane, control);
288
+ return control;
289
+ }
290
+ /**
291
+ * Narrow widths turn side panes into overlays (styles.ts container
292
+ * queries), where several open panes would stack invisibly on top of each
293
+ * other. Enforce the "single pane" rule there: opening one side pane
294
+ * closes the others that overlay at the current width.
295
+ */
296
+ collapseOverlappingPanes(opened) {
297
+ if (opened === 'bottom')
298
+ return;
299
+ const width = this.root.clientWidth;
300
+ if (!width)
301
+ return;
302
+ const overlaying = [];
303
+ if (width <= 900)
304
+ overlaying.push('settings');
305
+ if (width <= 760)
306
+ overlaying.push('agent');
307
+ if (width <= 620)
308
+ overlaying.push('explorer');
309
+ if (!overlaying.includes(opened))
310
+ return;
311
+ for (const pane of overlaying) {
312
+ if (pane !== opened && this.layout.isVisible(pane)) {
313
+ this.layout.setVisible(pane, false);
314
+ }
315
+ }
316
+ }
317
+ paneSplitter(pane, orientation, position, label) {
318
+ const splitter = createPaneSplitter({
319
+ orientation,
320
+ pane: position,
321
+ label,
322
+ range: () => studioPaneSizeRange(pane),
323
+ getSize: () => this.layout.paneSize(pane),
324
+ setSize: (size, commit) => this.layout.setSize(pane, size, commit),
325
+ });
326
+ this.splitters.set(pane, splitter);
327
+ return splitter.element;
328
+ }
329
+ applyLayout(state) {
330
+ const paneElements = {
331
+ explorer: this.explorer,
332
+ settings: this.settings,
333
+ bottom: this.bottom,
334
+ agent: this.agentShell?.root ?? null,
335
+ };
336
+ for (const pane of ['explorer', 'settings', 'bottom', 'agent']) {
337
+ const paneElement = paneElements[pane];
338
+ const visible = state.visible[pane] && paneElement !== null;
339
+ if (paneElement) {
340
+ paneElement.hidden = !visible;
341
+ if (pane === 'bottom') {
342
+ paneElement.style.height = `${state.sizes[pane]}px`;
343
+ }
344
+ else {
345
+ paneElement.style.width = `${state.sizes[pane]}px`;
346
+ }
347
+ }
348
+ const splitter = this.splitters.get(pane);
349
+ if (splitter) {
350
+ splitter.element.hidden = !visible;
351
+ splitter.refresh();
352
+ }
353
+ const railControl = this.railButtons.get(pane);
354
+ if (railControl) {
355
+ railControl.setAttribute('aria-pressed', String(visible));
356
+ railControl.dataset.active = String(visible);
357
+ }
358
+ }
359
+ this.syncPanelPolling();
360
+ }
361
+ revealPanel(name) {
362
+ this.layout.setVisible('bottom', true);
363
+ this.activatePanel(name);
364
+ }
365
+ autoRevealFailures(state) {
366
+ const phase = state.runtime.phase;
367
+ if (phase !== this.lastPhase && FAILURE_PHASES.has(phase)) {
368
+ const diagnostics = state.authoritativeDiagnostics.length + state.localDiagnostics.length;
369
+ this.revealPanel(diagnostics > 0 ? 'problems' : 'build');
370
+ }
371
+ this.lastPhase = phase;
372
+ }
373
+ // ----- Menus -----------------------------------------------------------
374
+ createMenu(label, options = {}) {
375
+ const wrap = element('div', 'ck-crowdy-studio-menu-wrap');
376
+ const control = button(label);
377
+ control.className = options.buttonClass
378
+ ? `ck-crowdy-studio-menu-button ${options.buttonClass}`
379
+ : 'ck-crowdy-studio-menu-button';
380
+ if (options.ariaLabel)
381
+ control.setAttribute('aria-label', options.ariaLabel);
382
+ control.setAttribute('aria-haspopup', 'true');
383
+ control.setAttribute('aria-expanded', 'false');
384
+ const menu = element('div', 'ck-crowdy-studio-menu');
385
+ menu.hidden = true;
386
+ menu.setAttribute('role', 'menu');
387
+ wrap.append(control, menu);
388
+ const outsideClick = (event) => {
389
+ if (!wrap.contains(event.target))
390
+ close();
391
+ };
392
+ const escape = (event) => {
393
+ if (event.key !== 'Escape')
394
+ return;
395
+ event.stopPropagation();
396
+ close();
397
+ control.focus();
398
+ };
399
+ const close = () => {
400
+ if (menu.hidden)
401
+ return;
402
+ menu.hidden = true;
403
+ control.setAttribute('aria-expanded', 'false');
404
+ document.removeEventListener('pointerdown', outsideClick, true);
405
+ wrap.removeEventListener('keydown', escape);
406
+ };
407
+ const open = () => {
408
+ if (!menu.hidden)
409
+ return;
410
+ menu.hidden = false;
411
+ control.setAttribute('aria-expanded', 'true');
412
+ document.addEventListener('pointerdown', outsideClick, true);
413
+ wrap.addEventListener('keydown', escape);
414
+ };
415
+ control.addEventListener('click', () => {
416
+ if (menu.hidden)
417
+ open();
418
+ else
419
+ close();
420
+ });
421
+ return {
422
+ button: control,
423
+ menu,
424
+ wrap,
425
+ close,
426
+ dispose() {
427
+ close();
428
+ wrap.remove();
429
+ },
430
+ };
431
+ }
432
+ // ----- Panels ------------------------------------------------------------
194
433
  createPanel(name, label, tabs) {
195
434
  const tab = button(label);
196
435
  tab.className = 'ck-crowdy-studio-panel-tab';
@@ -203,7 +442,6 @@ export class CrowdyStudioDomShell {
203
442
  return panel;
204
443
  }
205
444
  activatePanel(name) {
206
- const previous = this.activePanel;
207
445
  this.activePanel = name;
208
446
  for (const [key, buttonElement] of this.panelButtons) {
209
447
  buttonElement.dataset.active = String(key === name);
@@ -211,32 +449,43 @@ export class CrowdyStudioDomShell {
211
449
  for (const [key, panel] of this.panels) {
212
450
  panel.dataset.active = String(key === name);
213
451
  }
214
- const previousPoll = polledSurface(previous);
215
- const nextPoll = polledSurface(name);
216
- if (previousPoll && previousPoll !== nextPoll) {
217
- this.controller.setSurfaceVisible(previousPoll, false);
452
+ this.syncPanelPolling();
453
+ }
454
+ /** Poll logs/runs only while their panel is both selected and shown. */
455
+ syncPanelPolling() {
456
+ const bottomVisible = this.layout.isVisible('bottom');
457
+ for (const surface of ['logs', 'runs']) {
458
+ this.controller.setSurfaceVisible(surface, bottomVisible && this.activePanel === surface);
218
459
  }
219
- if (nextPoll)
220
- this.controller.setSurfaceVisible(nextPoll, true);
221
460
  }
461
+ // ----- Rendering -----------------------------------------------------------
222
462
  renderProjects(state) {
223
- const selected = state.project?.projectId ?? '';
224
- const options = state.projects.map((project) => {
225
- const option = document.createElement('option');
226
- option.value = project.projectId;
227
- option.textContent = `${project.name} · ${project.kind
228
- .toLowerCase()
229
- .replace('_', ' ')}`;
230
- return option;
463
+ const selected = state.project;
464
+ this.projectMenu.button.textContent = selected
465
+ ? `${selected.metadata.name} ▾`
466
+ : state.projects.length === 0
467
+ ? 'No projects yet ▾'
468
+ : 'Select project ▾';
469
+ const items = state.projects.map((project) => {
470
+ const item = menuItem(`${project.name} · ${project.kind.toLowerCase().replace('_', ' ')}`);
471
+ item.dataset.active = String(project.projectId === selected?.projectId);
472
+ item.addEventListener('click', () => {
473
+ this.projectMenu.close();
474
+ void this.run(() => this.controller.switchProject(project.projectId));
475
+ });
476
+ return item;
231
477
  });
232
- if (options.length === 0) {
233
- const empty = document.createElement('option');
234
- empty.value = '';
235
- empty.textContent = 'No projects yet';
236
- options.push(empty);
478
+ if (items.length === 0) {
479
+ items.push(empty('No projects yet.'));
237
480
  }
238
- this.projectSelect.replaceChildren(...options);
239
- this.projectSelect.value = selected;
481
+ const divider = element('div', 'ck-crowdy-studio-menu-divider');
482
+ const newProject = menuItem('New project…');
483
+ newProject.addEventListener('click', () => {
484
+ this.projectMenu.close();
485
+ this.newForm.dataset.open = 'true';
486
+ this.newName.focus();
487
+ });
488
+ this.projectMenuList.replaceChildren(...items, divider, newProject);
240
489
  }
241
490
  renderSaveState(state) {
242
491
  const label = {
@@ -245,16 +494,25 @@ export class CrowdyStudioDomShell {
245
494
  CONFLICT: 'Conflict',
246
495
  OFFLINE: 'Offline',
247
496
  }[state.saveState];
248
- this.saveBadge.dataset.state = state.saveState;
249
- this.saveBadge.textContent = state.saveMessage
497
+ this.saveMenu.button.dataset.state = state.saveState;
498
+ this.saveMenu.button.textContent = label;
499
+ this.saveStatus.textContent = state.saveMessage
250
500
  ? `${label}: ${state.saveMessage}`
251
501
  : label;
252
- this.saveAction.hidden = !['CONFLICT', 'OFFLINE'].includes(state.saveState);
502
+ this.saveMessageText.textContent = state.saveMessage
503
+ ? state.saveMessage
504
+ : state.saveState === 'SAVED'
505
+ ? 'Edits autosave to your private project.'
506
+ : label;
507
+ const attention = ['CONFLICT', 'OFFLINE'].includes(state.saveState);
508
+ this.saveAction.hidden = !attention;
253
509
  this.saveAction.textContent =
254
510
  state.saveState === 'CONFLICT' ? 'Keep my version' : 'Retry save';
255
511
  this.remoteAction.hidden = state.saveState !== 'CONFLICT';
512
+ this.saveMenu.button.dataset.attention = String(attention);
256
513
  }
257
514
  renderExplorer(state) {
515
+ const focused = focusedExplorerField(this.explorer);
258
516
  this.explorer.replaceChildren();
259
517
  if (!state.project) {
260
518
  this.explorer.append(empty('Create a project to start authoring.'));
@@ -269,6 +527,7 @@ export class CrowdyStudioDomShell {
269
527
  }))));
270
528
  }
271
529
  this.explorer.append(this.referenceSection('Personal library', state.personalLibraryFiles), this.referenceSection('Common files', state.commonFiles));
530
+ restoreExplorerFocus(this.explorer, focused);
272
531
  }
273
532
  projectSection(target, refs) {
274
533
  const section = element('section', 'ck-crowdy-studio-section');
@@ -279,13 +538,29 @@ export class CrowdyStudioDomShell {
279
538
  add.setAttribute('aria-label', `Add ${target} file`);
280
539
  add.disabled = !this.controller.canTarget(target, 'write');
281
540
  add.addEventListener('click', () => {
282
- const path = window.prompt(`New ${target} file path`, 'src/new.rs');
283
- if (!path)
284
- return;
285
- void this.run(() => Promise.resolve(this.controller.addFile(target, path)));
541
+ this.explorerForm =
542
+ this.explorerForm?.kind === 'add' && this.explorerForm.target === target
543
+ ? null
544
+ : { kind: 'add', target, value: 'src/new.rs' };
545
+ this.rerenderExplorer();
286
546
  });
287
547
  header.append(title, add);
288
548
  section.append(header);
549
+ if (this.explorerForm?.kind === 'add' && this.explorerForm.target === target) {
550
+ section.append(this.inlineForm({
551
+ value: this.explorerForm.value,
552
+ placeholder: `New ${target} file path`,
553
+ submitLabel: 'Add file',
554
+ onInput: (value) => {
555
+ if (this.explorerForm?.kind === 'add')
556
+ this.explorerForm.value = value;
557
+ },
558
+ onSubmit: (value) => {
559
+ this.explorerForm = null;
560
+ void this.run(() => Promise.resolve(this.controller.addFile(target, value)));
561
+ },
562
+ }));
563
+ }
289
564
  for (const ref of refs) {
290
565
  section.append(this.projectFileRow(ref));
291
566
  }
@@ -294,38 +569,97 @@ export class CrowdyStudioDomShell {
294
569
  return section;
295
570
  }
296
571
  projectFileRow(ref) {
572
+ const form = this.explorerForm;
573
+ if (form &&
574
+ form.kind !== 'add' &&
575
+ form.kind !== 'import' &&
576
+ form.target === ref.target &&
577
+ form.path === ref.path) {
578
+ return this.projectFileFormRow(ref, form);
579
+ }
297
580
  const row = element('div', 'ck-crowdy-studio-file');
298
581
  const open = button(ref.path);
299
582
  open.title = `${ref.target}:${ref.path}`;
300
583
  open.addEventListener('click', () => this.controller.openFile(ref));
301
- const rename = button('');
302
- rename.setAttribute('aria-label', `Rename ${ref.path}`);
303
- rename.addEventListener('click', () => {
304
- const next = window.prompt('Rename file', ref.path);
305
- if (!next || !ref.target)
306
- return;
307
- void this.run(() => Promise.resolve(this.controller.renameFile(ref.target, ref.path, next)));
584
+ const actions = this.createMenu('', {
585
+ ariaLabel: `Actions for ${ref.path}`,
586
+ buttonClass: 'ck-crowdy-studio-file-action',
308
587
  });
309
- const remove = button('×');
310
- remove.setAttribute('aria-label', `Delete ${ref.path}`);
311
- remove.addEventListener('click', () => {
312
- if (!ref.target ||
313
- !window.confirm(`Delete ${ref.target}:${ref.path}?`)) {
588
+ const library = menuItem('Save to library');
589
+ library.addEventListener('click', () => {
590
+ actions.close();
591
+ if (!ref.target)
314
592
  return;
315
- }
316
- void this.run(() => Promise.resolve(this.controller.deleteFile(ref.target, ref.path)));
593
+ this.explorerForm = {
594
+ kind: 'library',
595
+ target: ref.target,
596
+ path: ref.path,
597
+ value: ref.path.split('/').at(-1) ?? ref.path,
598
+ };
599
+ this.rerenderExplorer();
317
600
  });
318
- const library = button('Library');
319
- library.setAttribute('aria-label', `Save ${ref.path} to My Library`);
320
- library.addEventListener('click', () => {
601
+ const rename = menuItem('Rename');
602
+ rename.addEventListener('click', () => {
603
+ actions.close();
321
604
  if (!ref.target)
322
605
  return;
323
- const title = window.prompt('Library file title', ref.path.split('/').at(-1) ?? ref.path);
324
- if (title == null)
606
+ this.explorerForm = {
607
+ kind: 'rename',
608
+ target: ref.target,
609
+ path: ref.path,
610
+ value: ref.path,
611
+ };
612
+ this.rerenderExplorer();
613
+ });
614
+ const remove = menuItem('Delete');
615
+ remove.addEventListener('click', () => {
616
+ actions.close();
617
+ if (!ref.target)
325
618
  return;
326
- void this.run(() => this.controller.saveProjectFileToLibrary(ref.target, ref.path, title));
619
+ this.explorerForm = { kind: 'delete', target: ref.target, path: ref.path };
620
+ this.rerenderExplorer();
327
621
  });
328
- row.append(open, library, rename, remove);
622
+ actions.menu.append(library, rename, remove);
623
+ row.append(open, actions.wrap);
624
+ return row;
625
+ }
626
+ projectFileFormRow(ref, form) {
627
+ const row = element('div', 'ck-crowdy-studio-file-form-row');
628
+ if (form.kind === 'delete') {
629
+ const text = document.createElement('span');
630
+ text.textContent = `Delete ${ref.target}:${ref.path}?`;
631
+ const confirm = button('Delete');
632
+ confirm.addEventListener('click', () => {
633
+ this.explorerForm = null;
634
+ void this.run(() => Promise.resolve(this.controller.deleteFile(form.target, form.path)));
635
+ });
636
+ const cancel = button('Cancel');
637
+ cancel.addEventListener('click', () => {
638
+ this.explorerForm = null;
639
+ this.rerenderExplorer();
640
+ });
641
+ row.append(text, confirm, cancel);
642
+ return row;
643
+ }
644
+ row.append(this.inlineForm({
645
+ value: form.value,
646
+ placeholder: form.kind === 'rename' ? 'New file path' : 'Library file title',
647
+ submitLabel: form.kind === 'rename' ? 'Rename' : 'Save to library',
648
+ onInput: (value) => {
649
+ if (this.explorerForm && this.explorerForm.kind === form.kind) {
650
+ this.explorerForm.value = value;
651
+ }
652
+ },
653
+ onSubmit: (value) => {
654
+ this.explorerForm = null;
655
+ if (form.kind === 'rename') {
656
+ void this.run(() => Promise.resolve(this.controller.renameFile(form.target, form.path, value)));
657
+ }
658
+ else {
659
+ void this.run(() => this.controller.saveProjectFileToLibrary(form.target, form.path, value));
660
+ }
661
+ },
662
+ }));
329
663
  return row;
330
664
  }
331
665
  referenceSection(titleText, files) {
@@ -346,18 +680,78 @@ export class CrowdyStudioDomShell {
346
680
  const add = button('Add');
347
681
  add.setAttribute('aria-label', `Add ${file.title} to project`);
348
682
  add.addEventListener('click', () => {
349
- const destination = window.prompt('Destination project path', file.path);
350
- if (!destination)
351
- return;
352
- void this.run(() => this.controller.importReferenceFile(file, destination));
683
+ this.explorerForm =
684
+ this.explorerForm?.kind === 'import' &&
685
+ this.explorerForm.referenceId === file.id
686
+ ? null
687
+ : { kind: 'import', referenceId: file.id, value: file.path };
688
+ this.rerenderExplorer();
353
689
  });
354
690
  row.append(open, add);
355
691
  section.append(row);
692
+ if (this.explorerForm?.kind === 'import' &&
693
+ this.explorerForm.referenceId === file.id) {
694
+ section.append(this.inlineForm({
695
+ value: this.explorerForm.value,
696
+ placeholder: 'Destination project path',
697
+ submitLabel: 'Add to project',
698
+ onInput: (value) => {
699
+ if (this.explorerForm?.kind === 'import') {
700
+ this.explorerForm.value = value;
701
+ }
702
+ },
703
+ onSubmit: (value) => {
704
+ this.explorerForm = null;
705
+ void this.run(() => this.controller.importReferenceFile(file, value));
706
+ },
707
+ }));
708
+ }
356
709
  }
357
710
  if (files.length === 0)
358
711
  section.append(empty('No files'));
359
712
  return section;
360
713
  }
714
+ inlineForm(options) {
715
+ const form = document.createElement('form');
716
+ form.className = 'ck-crowdy-studio-inline-form';
717
+ const field = document.createElement('input');
718
+ field.value = options.value;
719
+ field.placeholder = options.placeholder;
720
+ field.setAttribute('aria-label', options.placeholder);
721
+ field.dataset.explorerField = 'true';
722
+ field.addEventListener('input', () => options.onInput(field.value));
723
+ const submit = button(options.submitLabel, 'submit');
724
+ const cancel = button('Cancel');
725
+ cancel.addEventListener('click', () => {
726
+ this.explorerForm = null;
727
+ this.rerenderExplorer();
728
+ });
729
+ form.append(field, submit, cancel);
730
+ form.addEventListener('submit', (event) => {
731
+ event.preventDefault();
732
+ const value = field.value.trim();
733
+ if (!value)
734
+ return;
735
+ options.onSubmit(value);
736
+ });
737
+ queueMicrotask(() => {
738
+ try {
739
+ if (field.isConnected &&
740
+ !this.explorer.contains(document.activeElement)) {
741
+ field.focus();
742
+ field.select();
743
+ }
744
+ }
745
+ catch {
746
+ // Focus management is an enhancement only.
747
+ }
748
+ });
749
+ return form;
750
+ }
751
+ rerenderExplorer() {
752
+ if (this.lastState)
753
+ this.renderExplorer(this.lastState);
754
+ }
361
755
  renderTabs(state) {
362
756
  this.tabs.replaceChildren();
363
757
  for (const ref of state.openFiles) {
@@ -406,6 +800,11 @@ export class CrowdyStudioDomShell {
406
800
  ...state.authoritativeDiagnostics,
407
801
  ...state.localDiagnostics,
408
802
  ];
803
+ const problemsTab = this.panelButtons.get('problems');
804
+ if (problemsTab) {
805
+ problemsTab.textContent =
806
+ diagnostics.length > 0 ? `Problems (${diagnostics.length})` : 'Problems';
807
+ }
409
808
  if (diagnostics.length === 0) {
410
809
  this.problemsPanel.append(empty('No problems.'));
411
810
  return;
@@ -457,11 +856,6 @@ export class CrowdyStudioDomShell {
457
856
  }
458
857
  }
459
858
  }
460
- function polledSurface(panel) {
461
- if (panel === 'logs' || panel === 'runs')
462
- return panel;
463
- return null;
464
- }
465
859
  function budgetText(state) {
466
860
  const usage = state.usage
467
861
  ? `units ${state.usage.hourUnitsUsed}/${state.usage.unitsPerHour ?? '∞'} · compiles ${state.usage.compilesThisHour}/${state.usage.maxCompilesPerHour}`
@@ -486,6 +880,32 @@ function sameRef(a, b) {
486
880
  a.path === b.path &&
487
881
  a.referenceId === b.referenceId);
488
882
  }
883
+ function focusedExplorerField(explorer) {
884
+ if (typeof HTMLInputElement === 'undefined')
885
+ return null;
886
+ const active = document.activeElement;
887
+ if (active instanceof HTMLInputElement &&
888
+ explorer.contains(active) &&
889
+ active.dataset.explorerField === 'true') {
890
+ return active.value;
891
+ }
892
+ return null;
893
+ }
894
+ function restoreExplorerFocus(explorer, value) {
895
+ if (value === null)
896
+ return;
897
+ const field = explorer.querySelector('input[data-explorer-field="true"]');
898
+ if (!field)
899
+ return;
900
+ field.focus();
901
+ const end = field.value.length;
902
+ try {
903
+ field.setSelectionRange(end, end);
904
+ }
905
+ catch {
906
+ // Selection APIs are an enhancement only.
907
+ }
908
+ }
489
909
  function element(tag, className) {
490
910
  const value = document.createElement(tag);
491
911
  if (className)
@@ -498,6 +918,12 @@ function button(label, type = 'button') {
498
918
  value.textContent = label;
499
919
  return value;
500
920
  }
921
+ function menuItem(label) {
922
+ const value = button(label);
923
+ value.className = 'ck-crowdy-studio-menu-item';
924
+ value.setAttribute('role', 'menuitem');
925
+ return value;
926
+ }
501
927
  function input(placeholder) {
502
928
  const value = document.createElement('input');
503
929
  value.placeholder = placeholder;