fraim-hub 2.0.340 → 2.0.342

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.
@@ -0,0 +1,415 @@
1
+ /*
2
+ * FRAIM Hub - Coach intake, rendered in the main pane (issue #1911, Treatment B).
3
+ *
4
+ * Coach used to open #teach-job-modal: a 520px overlay with its own three-row
5
+ * textarea sitting on top of the composer the manager uses for every other
6
+ * instruction in the product. This module moves that intake into the pane, as
7
+ * one surface registered with PaneSurfaces, and hands the detailed instruction
8
+ * to #coach-text.
9
+ *
10
+ * Nothing about the intake itself changes. The same four facts are collected
11
+ * (mode, job, owner, details), the same validation messages fire, and the same
12
+ * buildTeachIntakeInstructions() payload starts the same evolve-employee run
13
+ * under Mandy. That is a hard constraint, not a preference: PR #1660 collapsed
14
+ * this intake into free text and was reverted in f8857ae41.
15
+ *
16
+ * This file is the first consumer of pane-surfaces.js and gets no special
17
+ * treatment from it - everything below goes through the public API.
18
+ */
19
+ (function (global) {
20
+ 'use strict';
21
+
22
+ var SURFACE_ID = 'coach-intake';
23
+
24
+ // Per-open intake state. Lives here rather than on the app's `state` so the
25
+ // surface owns its own lifecycle; PaneSurfaces clears it via onClose.
26
+ var intake = {
27
+ mode: 'new',
28
+ jobName: '',
29
+ ownerKey: '',
30
+ search: '',
31
+ selectedJob: null,
32
+ errors: {},
33
+ };
34
+
35
+ function scopeNote(area) {
36
+ if (area === 'company') return 'Company jobs';
37
+ if (area === 'manager') return 'Your jobs';
38
+ return "This project's jobs";
39
+ }
40
+
41
+ function resetIntake() {
42
+ intake = { mode: 'new', jobName: '', ownerKey: '', search: '', selectedJob: null, errors: {} };
43
+ }
44
+
45
+ function correcting() { return intake.mode === 'customize'; }
46
+
47
+ function matchingJobs() {
48
+ var q = (intake.search || '').trim().toLowerCase();
49
+ return teachJobCatalog().filter(function (job) {
50
+ if (!q) return true;
51
+ return [job.id, job.title, job.intent, job.requiredPersonaKey, teachJobOwnerLabel(job)]
52
+ .filter(Boolean)
53
+ .some(function (value) { return String(value).toLowerCase().indexOf(q) !== -1; });
54
+ });
55
+ }
56
+
57
+ function fieldError(id, message) {
58
+ var el = document.createElement('div');
59
+ el.className = 'tjd-field-error';
60
+ el.id = id;
61
+ el.setAttribute('role', 'alert');
62
+ el.textContent = message || '';
63
+ return el;
64
+ }
65
+
66
+ function buildModeControl() {
67
+ var modes = document.createElement('div');
68
+ modes.className = 'tjd-modes';
69
+ modes.setAttribute('role', 'group');
70
+ modes.setAttribute('aria-label', 'Coaching mode');
71
+ [
72
+ { mode: 'new', id: 'ci-mode-new', title: 'Teach a new job', sub: "Describe a capability that doesn't exist yet." },
73
+ { mode: 'customize', id: 'ci-mode-customize', title: 'Correct an existing job', sub: 'Pick a job and tell Mandy what to change.' },
74
+ ].forEach(function (option) {
75
+ var button = document.createElement('button');
76
+ button.type = 'button';
77
+ button.className = 'tjd-mode';
78
+ button.id = option.id;
79
+ button.dataset.mode = option.mode;
80
+ button.setAttribute('aria-pressed', String(intake.mode === option.mode));
81
+ var strong = document.createElement('strong');
82
+ strong.textContent = option.title;
83
+ button.appendChild(strong);
84
+ var span = document.createElement('span');
85
+ span.textContent = option.sub;
86
+ button.appendChild(span);
87
+ button.addEventListener('click', function () { setMode(option.mode); });
88
+ modes.appendChild(button);
89
+ });
90
+ return modes;
91
+ }
92
+
93
+ function buildNewFields() {
94
+ var row = document.createElement('div');
95
+ row.className = 'hm-fieldrow ci-new-fields';
96
+ row.id = 'ci-new-fields';
97
+
98
+ var nameField = document.createElement('div');
99
+ nameField.className = 'hm-field';
100
+ var nameLabel = document.createElement('label');
101
+ nameLabel.setAttribute('for', 'ci-job-name');
102
+ nameLabel.textContent = 'Job name';
103
+ nameField.appendChild(nameLabel);
104
+ var nameInput = document.createElement('input');
105
+ nameInput.id = 'ci-job-name';
106
+ nameInput.type = 'text';
107
+ nameInput.autocomplete = 'off';
108
+ nameInput.value = intake.jobName;
109
+ nameInput.addEventListener('input', function () { intake.jobName = nameInput.value; });
110
+ // Open question 1, resolved: Enter here must not start a run from a
111
+ // half-filled intake. Submission has exactly one trigger, the composer.
112
+ nameInput.addEventListener('keydown', function (event) {
113
+ if (event.key !== 'Enter') return;
114
+ event.preventDefault();
115
+ var composer = document.getElementById('coach-text');
116
+ if (composer) composer.focus();
117
+ });
118
+ nameField.appendChild(nameInput);
119
+ nameField.appendChild(fieldError('ci-job-name-error', intake.errors.jobName));
120
+ row.appendChild(nameField);
121
+
122
+ var ownerField = document.createElement('div');
123
+ ownerField.className = 'hm-field';
124
+ var ownerLabel = document.createElement('label');
125
+ ownerLabel.setAttribute('for', 'ci-owner');
126
+ ownerLabel.textContent = 'Owner';
127
+ ownerField.appendChild(ownerLabel);
128
+ var owner = document.createElement('select');
129
+ owner.id = 'ci-owner';
130
+ var placeholder = document.createElement('option');
131
+ placeholder.value = '';
132
+ placeholder.textContent = 'Choose an employee...';
133
+ owner.appendChild(placeholder);
134
+ teachOwnerOptions().forEach(function (option) {
135
+ var el = document.createElement('option');
136
+ el.value = option.key;
137
+ el.textContent = option.label + ' - ' + option.role;
138
+ owner.appendChild(el);
139
+ });
140
+ owner.value = intake.ownerKey;
141
+ owner.addEventListener('change', function () { intake.ownerKey = owner.value; });
142
+ ownerField.appendChild(owner);
143
+ ownerField.appendChild(fieldError('ci-owner-error', intake.errors.owner));
144
+ row.appendChild(ownerField);
145
+ return row;
146
+ }
147
+
148
+ function buildPickerRow(job) {
149
+ var row = document.createElement('button');
150
+ row.type = 'button';
151
+ row.className = 'cp-row ci-picker-row';
152
+ row.dataset.jobId = job.id;
153
+ var selected = !!intake.selectedJob && intake.selectedJob.id === job.id;
154
+ row.classList.toggle('selected', selected);
155
+ row.setAttribute('aria-pressed', String(selected));
156
+
157
+ var icon = document.createElement('span');
158
+ icon.className = 'cp-row-icon';
159
+ icon.textContent = 'J';
160
+ row.appendChild(icon);
161
+
162
+ var body = document.createElement('span');
163
+ body.className = 'cp-row-body';
164
+ var title = document.createElement('span');
165
+ title.className = 'cp-row-title';
166
+ title.textContent = job.title || job.id;
167
+ body.appendChild(title);
168
+ var sub = document.createElement('span');
169
+ sub.className = 'cp-row-sub';
170
+ sub.textContent = job.intent || job.id;
171
+ body.appendChild(sub);
172
+ row.appendChild(body);
173
+
174
+ var tag = document.createElement('span');
175
+ tag.className = 'cp-row-tag';
176
+ tag.textContent = job.requiredPersonaKey || 'general';
177
+ row.appendChild(tag);
178
+
179
+ row.addEventListener('click', function () { selectJob(selected ? null : job); });
180
+ return row;
181
+ }
182
+
183
+ function buildPicker() {
184
+ var picker = document.createElement('div');
185
+ picker.className = 'ci-picker';
186
+ picker.id = 'ci-picker';
187
+
188
+ var head = document.createElement('div');
189
+ head.className = 'ci-picker-head';
190
+ var label = document.createElement('label');
191
+ label.className = 'tjd-label';
192
+ label.id = 'ci-picker-label';
193
+ label.setAttribute('for', 'ci-picker-search');
194
+ label.textContent = correcting() ? 'Which job needs correcting?' : 'Start from an existing job (optional)';
195
+ head.appendChild(label);
196
+ var search = document.createElement('input');
197
+ search.id = 'ci-picker-search';
198
+ search.className = 'tjd-search';
199
+ search.type = 'text';
200
+ search.autocomplete = 'off';
201
+ search.spellcheck = false;
202
+ search.placeholder = 'Search jobs...';
203
+ search.value = intake.search;
204
+ search.addEventListener('input', function () {
205
+ intake.search = search.value;
206
+ global.PaneSurfaces.refresh();
207
+ });
208
+ head.appendChild(search);
209
+ picker.appendChild(head);
210
+
211
+ var list = document.createElement('div');
212
+ list.className = 'ci-picker-list';
213
+ list.id = 'ci-picker-list';
214
+ var jobs = matchingJobs();
215
+ jobs.forEach(function (job) { list.appendChild(buildPickerRow(job)); });
216
+ picker.appendChild(list);
217
+
218
+ var empty = document.createElement('div');
219
+ empty.className = 'ci-picker-empty';
220
+ empty.id = 'ci-picker-empty';
221
+ empty.textContent = correcting()
222
+ ? 'No matching jobs. Try another search.'
223
+ : 'No matching jobs. Mandy will still check for duplicates.';
224
+ empty.hidden = jobs.length !== 0;
225
+ picker.appendChild(empty);
226
+ return picker;
227
+ }
228
+
229
+ function render(context) {
230
+ var card = document.createElement('div');
231
+ card.className = 'ci-card';
232
+ card.id = 'coach-intake-panel';
233
+
234
+ var header = document.createElement('div');
235
+ header.className = 'ci-card-hdr';
236
+ var heading = document.createElement('h3');
237
+ heading.textContent = 'Coach';
238
+ header.appendChild(heading);
239
+ var note = document.createElement('p');
240
+ note.id = 'ci-scope-note';
241
+ note.textContent = scopeNote(context && context.area);
242
+ header.appendChild(note);
243
+ card.appendChild(header);
244
+
245
+ card.appendChild(buildModeControl());
246
+ if (!correcting()) card.appendChild(buildNewFields());
247
+ card.appendChild(buildPicker());
248
+
249
+ if (correcting()) {
250
+ var hint = document.createElement('p');
251
+ hint.className = 'tjd-hint';
252
+ hint.id = 'ci-correction-hint';
253
+ hint.textContent = 'Mandy keeps the same owning employee and existing phases. This only sharpens the instructions.';
254
+ card.appendChild(hint);
255
+ }
256
+
257
+ var error = document.createElement('div');
258
+ error.className = 'tjd-submit-error';
259
+ error.id = 'ci-submit-error';
260
+ error.setAttribute('role', 'alert');
261
+ error.textContent = intake.errors.picker || '';
262
+ card.appendChild(error);
263
+ return card;
264
+ }
265
+
266
+ // Switching mode drops the selection and clears errors, matching what
267
+ // setTeachMode() did in the dialog.
268
+ function setMode(mode) {
269
+ if (intake.mode === mode) return;
270
+ intake.mode = mode === 'customize' ? 'customize' : 'new';
271
+ intake.selectedJob = null;
272
+ intake.search = '';
273
+ intake.errors = {};
274
+ // refresh() restores focus to the mode button that was just pressed, so
275
+ // Tab from there lands on the first field of the mode now showing.
276
+ global.PaneSurfaces.refresh();
277
+ global.PaneSurfaces.setComposerError('');
278
+ }
279
+
280
+ function selectJob(job) {
281
+ intake.selectedJob = job || null;
282
+ if (job) intake.errors.picker = '';
283
+ global.PaneSurfaces.refresh();
284
+ }
285
+
286
+ function ownerOptionFor(key) {
287
+ return teachOwnerOptions().find(function (option) { return option.key === key; })
288
+ || { key: key, label: key, role: 'AI Employee' };
289
+ }
290
+
291
+ // The dialog's validateTeachJobDialog(), with each message rendered next to
292
+ // the control it refers to: the panel for job/name/owner, the composer for
293
+ // the details (which now live there).
294
+ function validate(input) {
295
+ intake.errors = {};
296
+ var text = (input.text || '').trim();
297
+ if (correcting() && !intake.selectedJob) {
298
+ intake.errors.picker = 'Choose the existing job to improve.';
299
+ global.PaneSurfaces.refresh();
300
+ return { ok: false, message: '', focus: '#ci-picker-search' };
301
+ }
302
+ if (!correcting() && !intake.jobName.trim()) {
303
+ intake.errors.jobName = 'Job name is required.';
304
+ global.PaneSurfaces.refresh();
305
+ return { ok: false, message: '', focus: '#ci-job-name' };
306
+ }
307
+ if (!correcting() && !intake.ownerKey) {
308
+ intake.errors.owner = 'Choose the employee who should own this job.';
309
+ global.PaneSurfaces.refresh();
310
+ return { ok: false, message: '', focus: '#ci-owner' };
311
+ }
312
+ if (!text) {
313
+ global.PaneSurfaces.refresh();
314
+ return { ok: false, message: 'Job details are required.', focus: '#coach-text' };
315
+ }
316
+ global.PaneSurfaces.refresh();
317
+ return { ok: true };
318
+ }
319
+
320
+ function buildIntakePayload(text) {
321
+ var ownerKey = correcting()
322
+ ? (intake.selectedJob.requiredPersonaKey || GENERIC_WORKER_PERSONA.key)
323
+ : intake.ownerKey;
324
+ return {
325
+ mode: intake.mode,
326
+ jobName: correcting()
327
+ ? (intake.selectedJob.title || intake.selectedJob.id)
328
+ : intake.jobName.trim(),
329
+ owner: ownerOptionFor(ownerKey),
330
+ details: text.trim(),
331
+ mimic: intake.selectedJob || null,
332
+ };
333
+ }
334
+
335
+ async function submit(input) {
336
+ var context = input.context || managementActionContext();
337
+ // The composer's own gate: no configured agent means no run, and the
338
+ // manager is told where to set one up rather than failing at startRun.
339
+ if (!hubHasAvailableEmployee()) {
340
+ renderCpAgentInstallPanel();
341
+ return { ok: false, error: 'Set up a Hub CLI agent before starting work in FRAIM Hub.', keepOpen: true };
342
+ }
343
+ var payload = buildIntakePayload(input.text);
344
+ var employeeId = normalizeConfiguredAgentChoice(
345
+ (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude',
346
+ );
347
+ // buildTeachIntakeInstructions() reads the invoking scope off
348
+ // state.teachActionContext, exactly as the dialog set it.
349
+ state.teachActionContext = context;
350
+ var pendingConvId = newConversationId();
351
+ var attachmentIds = hasValidStagedAttachment()
352
+ ? await uploadStagedAttachments({
353
+ id: pendingConvId,
354
+ projectPath: context.projectPath,
355
+ scope: context.area === 'projects' ? 'project' : context.area,
356
+ })
357
+ : [];
358
+ var result = await startRun(
359
+ { id: 'evolve-employee', title: correcting() ? 'Improve a job' : 'Teach a new job', requiredPersonaKey: 'mandy' },
360
+ buildTeachIntakeInstructions(payload),
361
+ employeeId,
362
+ pendingConvId,
363
+ context.area,
364
+ attachmentIds,
365
+ context,
366
+ );
367
+ if (result && result.ok === false) {
368
+ return { ok: false, error: result.error || 'Could not start the teaching run.', keepOpen: true };
369
+ }
370
+ clearStagedAttachments();
371
+ return { ok: true };
372
+ }
373
+
374
+ function chipLabel() {
375
+ if (correcting() && intake.selectedJob) {
376
+ return 'Coaching: ' + (intake.selectedJob.title || intake.selectedJob.id)
377
+ + ' · ' + teachJobOwnerLabel(intake.selectedJob);
378
+ }
379
+ return correcting() ? 'Coaching: Correct an existing job' : 'Coaching: Teach a new job';
380
+ }
381
+
382
+ function placeholder() {
383
+ return correcting() ? 'Tell Mandy what to change...' : 'Describe what this job should do...';
384
+ }
385
+
386
+ function registerCoachIntakeSurface() {
387
+ if (!global.PaneSurfaces || typeof global.PaneSurfaces.register !== 'function') return;
388
+ global.PaneSurfaces.register({
389
+ id: SURFACE_ID,
390
+ focusSelector: '#ci-job-name',
391
+ render: render,
392
+ onOpen: function () {
393
+ // Every open is a fresh intake, the same reset openTeachJobDialog() did.
394
+ resetIntake();
395
+ },
396
+ onClose: function () { resetIntake(); },
397
+ composer: {
398
+ chipLabel: chipLabel,
399
+ chipClearLabel: function () { return 'Cancel coaching'; },
400
+ placeholder: placeholder,
401
+ validate: validate,
402
+ submit: submit,
403
+ },
404
+ });
405
+ }
406
+
407
+ registerCoachIntakeSurface();
408
+
409
+ // Test/diagnostic bridge, mirroring script.js's own window export block.
410
+ global.COACH_INTAKE_SURFACE_ID = SURFACE_ID;
411
+ global.openCoachIntake = function (actionContext) {
412
+ if (!global.PaneSurfaces || !global.PaneSurfaces.isRegistered(SURFACE_ID)) registerCoachIntakeSurface();
413
+ return global.PaneSurfaces.open(SURFACE_ID, actionContext || managementActionContext());
414
+ };
415
+ }(typeof window !== 'undefined' ? window : globalThis));
@@ -427,6 +427,12 @@
427
427
  </div>
428
428
  </div>
429
429
 
430
+ <!-- Issue #1911 R7/R16: the reusable main-pane surface slot. A fourth
431
+ sibling of #empty/#hire-strip/#active-conv, filled at runtime by
432
+ whichever surface is registered with PaneSurfaces (pane-surfaces.js).
433
+ Coach's intake is its first tenant; the slot itself is generic. -->
434
+ <div id="pane-surface-host" class="pane-surface-host" hidden></div>
435
+
430
436
  <div id="active-conv" hidden>
431
437
  <div class="conv-topline">
432
438
  <div class="employee-identity" id="active-identity"></div>
@@ -575,10 +581,18 @@
575
581
  the search text (which found the job, not the instructions) instead of
576
582
  sending immediately - this chip shows which job is armed and lets the
577
583
  manager back out before typing real instructions. -->
584
+ <!-- Issue #1911: the whole chip sentence now lives in
585
+ .cp-armed-job-label. Delegate writes "Delegating to: X"; a pane
586
+ surface that claims the composer writes its own label
587
+ (e.g. "Coaching: Teach a new job"). One element, two owners,
588
+ no mode-specific markup. -->
578
589
  <div class="cp-armed-job-chip" id="cp-armed-job-chip" hidden>
579
- <span class="cp-job-chip">Delegating to: <span class="cp-armed-job-label"></span></span>
590
+ <span class="cp-job-chip"><span class="cp-armed-job-label"></span></span>
580
591
  <button type="button" id="cp-armed-job-clear" aria-label="Change job">&times;</button>
581
592
  </div>
593
+ <!-- Issue #1911 R10: where a pane surface reports a validation
594
+ failure that belongs to the composer, not to its own fields. -->
595
+ <div class="pane-surface-composer-error" id="pane-surface-composer-error" role="alert"></div>
582
596
  <div class="staged-attachments" id="staged-attachments"></div>
583
597
 
584
598
  <div class="coach-input">
@@ -897,68 +911,6 @@
897
911
  </div>
898
912
 
899
913
  <!-- Issue #976: Teach-new-job structured intake before launching Mandy/evolve-employee -->
900
- <div id="teach-job-modal" class="modal-overlay" hidden>
901
- <div class="modal-card tjd-card" role="dialog" aria-modal="true" aria-labelledby="tjd-title">
902
- <div class="modal-hdr tjd-hdr">
903
- <div>
904
- <h2 id="tjd-title">Coach</h2>
905
- <p id="tjd-scope-note"></p>
906
- </div>
907
- <button class="modal-close" id="tjd-close" type="button" aria-label="Close">x</button>
908
- </div>
909
- <div class="modal-body tjd-body">
910
- <form class="tjd-form" id="tjd-form" novalidate>
911
- <input id="tjd-mode" type="hidden" value="new">
912
- <div class="tjd-modes" role="group" aria-label="Coaching mode">
913
- <button class="tjd-mode" id="tjd-mode-new" type="button" data-mode="new" aria-pressed="true">
914
- <strong>Teach a new job</strong><span>Describe a capability that doesn't exist yet.</span>
915
- </button>
916
- <button class="tjd-mode" id="tjd-mode-customize" type="button" data-mode="customize" aria-pressed="false">
917
- <strong>Correct an existing job</strong><span>Pick a job and tell Mandy what to change.</span>
918
- </button>
919
- </div>
920
- <div id="tjd-new-fields" class="tjd-new-fields">
921
- <div class="hm-field">
922
- <label for="tjd-job-name">Job name</label>
923
- <input id="tjd-job-name" type="text" autocomplete="off">
924
- <div class="tjd-field-error" id="tjd-job-name-error" role="alert"></div>
925
- </div>
926
- <div class="hm-field">
927
- <label for="tjd-owner">Owner</label>
928
- <select id="tjd-owner"></select>
929
- <div class="tjd-field-error" id="tjd-owner-error" role="alert"></div>
930
- </div>
931
- </div>
932
- <div class="hm-field tjd-details-field">
933
- <label for="tjd-details">Details</label>
934
- <textarea id="tjd-details" rows="3" placeholder="What should this job do?"></textarea>
935
- <div class="tjd-field-error" id="tjd-details-error" role="alert"></div>
936
- </div>
937
- <details class="tjd-mimic" id="tjd-mimic">
938
- <summary id="tjd-mimic-toggle">Start from an existing job (optional)</summary>
939
- <div class="tjd-mimic-head">
940
- <label class="tjd-label" for="tjd-mimic-search">Mimic an existing job (optional)</label>
941
- <input id="tjd-mimic-search" class="tjd-search" type="text" autocomplete="off" spellcheck="false">
942
- </div>
943
- <div class="tjd-selected-mimic" id="tjd-selected-mimic" hidden>
944
- <span id="tjd-selected-mimic-label"></span>
945
- <button type="button" id="tjd-clear-mimic">Clear</button>
946
- </div>
947
- <div class="tjd-mimic-results" id="tjd-mimic-results"></div>
948
- <div class="tjd-mimic-empty" id="tjd-mimic-empty" hidden>No matching jobs. Mandy will still check for duplicates.</div>
949
- </details>
950
- <p id="tjd-correction-hint" class="tjd-hint" hidden>Mandy keeps the same owning employee and existing phases. This only sharpens the instructions.</p>
951
- </form>
952
-
953
- <div class="tjd-submit-error" id="tjd-submit-error" role="alert"></div>
954
- <div class="tjd-actions">
955
- <button class="tjd-secondary" id="tjd-cancel" type="button">Cancel</button>
956
- <button class="send-button" id="tjd-submit" type="button">Delegate to Mandy</button>
957
- </div>
958
- </div>
959
- </div>
960
- </div>
961
-
962
914
  <!-- Issue #512: Pricing modal -->
963
915
  <div id="pricing-modal" class="modal-overlay pricing-overlay" role="dialog" aria-modal="true" aria-labelledby="pr-title" hidden>
964
916
  <div class="modal-card pricing-card">
@@ -1250,7 +1202,13 @@
1250
1202
  <span class="rb-handoff" id="review-handoff">Comment on the doc to leave inline notes, or type below to coach.</span>
1251
1203
  </div>
1252
1204
  </div>
1205
+ <!-- Issue #1911 R16: the pane-surface host is feature-agnostic and loads
1206
+ before the app so any surface can register at any time. Surfaces load
1207
+ after script.js because they call into it (startRun, the teach intake
1208
+ builders, state) - the dependency runs one way only. -->
1209
+ <script src="./pane-surfaces.js?v=pane-surfaces-20260930"></script>
1253
1210
  <script src="./script.js?v=dark-status-20260721"></script>
1211
+ <script src="./coach-intake-surface.js?v=coach-intake-20260930"></script>
1254
1212
  <script src="./remote-ui-loader.js?v=server-driven-ui-20260724"></script>
1255
1213
 
1256
1214
  </body>