caracms 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +23 -2
  2. package/package.json +1 -1
  3. package/skills/caracms-hosting/SKILL.md +1 -1
  4. package/src/body.ts +55 -0
  5. package/src/cli/validate.ts +1 -1
  6. package/src/cloud-middleware.ts +39 -3
  7. package/src/content.ts +15 -6
  8. package/src/document-problems.ts +48 -0
  9. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  10. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  11. package/src/editor/client/documents/DocMenu.tsx +56 -1
  12. package/src/editor/client/documents/actions.ts +30 -1
  13. package/src/editor/client/documents/dialogs.tsx +61 -3
  14. package/src/editor/client/editor.ts +98 -2
  15. package/src/editor/client/fields/LockupField.tsx +4 -4
  16. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  17. package/src/editor/client/live/notices.tsx +19 -0
  18. package/src/editor/client/live/publish.tsx +58 -2
  19. package/src/editor/client/nav/DocRow.tsx +4 -4
  20. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  21. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  22. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  23. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  24. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  25. package/src/editor/client/pane/parts.tsx +67 -4
  26. package/src/editor/client/pane/switcher.tsx +4 -4
  27. package/src/editor/client/shell/Bar.tsx +11 -0
  28. package/src/editor/client/state/keeper.ts +28 -4
  29. package/src/editor/client/state/live.ts +18 -1
  30. package/src/editor/routes/_live.ts +1 -1
  31. package/src/editor/routes/admin.astro +9 -0
  32. package/src/editor/routes/discard.ts +11 -0
  33. package/src/editor/server/admin-props.ts +18 -2
  34. package/src/editor/server/arrange.ts +4 -4
  35. package/src/editor/server/create.ts +4 -4
  36. package/src/editor/server/drafts-store.ts +2 -2
  37. package/src/editor/server/empty-site.ts +23 -0
  38. package/src/editor/server/media/cloud-mux.ts +4 -1
  39. package/src/editor/server/media/upload.ts +2 -41
  40. package/src/editor/server/move.ts +32 -4
  41. package/src/editor/server/write.ts +4 -4
  42. package/src/editor/styles/levels/live.css +2 -0
  43. package/src/editor/types.ts +92 -4
  44. package/src/forms/cloud-store.ts +4 -1
  45. package/src/forms/inbox.ts +10 -5
  46. package/src/forms/store.ts +14 -2
  47. package/src/index.ts +23 -8
  48. package/src/live/auth/login.ts +4 -3
  49. package/src/live/auth/views.ts +8 -1
  50. package/src/live/autosaves.ts +94 -6
  51. package/src/live/cloud.ts +64 -20
  52. package/src/live/drafts-store.ts +17 -6
  53. package/src/live/editor.ts +232 -16
  54. package/src/live/git.ts +492 -22
  55. package/src/live/github.ts +84 -17
  56. package/src/live/locks.ts +22 -0
  57. package/src/live/messages.ts +49 -2
  58. package/src/live/notices.ts +5 -1
  59. package/src/live/runtime.ts +2 -1
  60. package/src/live/status.ts +5 -0
  61. package/src/live/sweep.ts +44 -1
  62. package/src/live/webhook.ts +8 -3
@@ -30,6 +30,7 @@
30
30
  * editor.editHere() / useMine() / discardMine() / publish() / tryAgain() /
31
31
  * dismissNotice(id) / signOut() / signInAgain()
32
32
  */
33
+ import { heldBackSentence } from '../../document-problems.ts';
33
34
  import { ARRANGE_ADDRESS, addressOf, inboxAddress, inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
34
35
  import { heldBases } from '../../held.ts';
35
36
  import {
@@ -52,6 +53,7 @@ import {
52
53
  type SubmissionsSetup,
53
54
  type UiState,
54
55
  type ViewMode,
56
+ type WaitingView,
55
57
  type Width,
56
58
  } from '../types.ts';
57
59
  import type { MediaOnly } from '../../media-value.ts';
@@ -300,6 +302,13 @@ export interface Editor {
300
302
  useMine(): Promise<void>;
301
303
  /** Discard mine: drop them and show the document as the server has it. */
302
304
  discardMine(): Promise<void>;
305
+ /**
306
+ * Discard changes (live mode): put one waiting document back to what's live. This tab's own
307
+ * unsent edits in it go first, and its held copy goes from this browser; an open document is
308
+ * read again (a new one closes, a moved one opens at its old address). The server's
309
+ * sentence when it refuses.
310
+ */
311
+ discardChanges(item: Pick<WaitingView, 'file' | 'collection' | 'path' | 'title' | 'change'>): Promise<{ ok: true } | { ok: false; error: string }>;
303
312
  /** Publish. `ok: false` with the reason when it didn't publish (the Publish popover says so; press again to retry). */
304
313
  publish(): Promise<{ ok: boolean; error?: string }>;
305
314
  tryAgain(): Promise<{ ok: boolean; error?: string }>;
@@ -409,6 +418,7 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
409
418
  ...(liveProps?.keepDelayMs !== undefined ? { keepDelay: liveProps.keepDelayMs } : {}),
410
419
  ...(liveProps?.keepCapMs !== undefined ? { keepCap: liveProps.keepCapMs } : {}),
411
420
  onLocked: (file, refusal) => lockOverride.set({ file, state: refusal.state, lock: refusal.lock, after: liveSession.started() }),
421
+ onDiscarded: (file, body) => void letGo(file, body.by && body.at ? { by: body.by, at: body.at } : null),
412
422
  onSignedOut: (body) => liveState.update({ signedOut: body }),
413
423
  }
414
424
  : {}),
@@ -587,6 +597,48 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
587
597
  }
588
598
  }
589
599
 
600
+ /** Wait until nothing for this document is in flight (at most a few seconds). */
601
+ async function settled(id: string): Promise<void> {
602
+ for (let i = 0; i < 100 && keeper?.dirty(id); i++) await new Promise((resolve) => setTimeout(resolve, 50));
603
+ }
604
+
605
+ /**
606
+ * Discard changes, from another tab's side: someone put this document back to what's live
607
+ * while this tab had it (the poll's `open.discarded`, or a write refused as `discarded`). This
608
+ * tab's copy is stale: its edits in it are dropped, the server is told it let go, and the
609
+ * document is read again (Home opens if it's gone: a new page discarded, or one moved back).
610
+ */
611
+ const lettingGo = new Set<string>();
612
+ async function letGo(file: string, discarded: { by: Person; at: string } | null): Promise<void> {
613
+ if (!liveSession || !keeper) return;
614
+ const key = `${file}:${discarded?.at ?? ''}`;
615
+ if (lettingGo.has(key)) return;
616
+ lettingGo.add(key);
617
+ const open = doc.get().entry.file === file;
618
+ const id = open ? docId(doc.get().ref) : null;
619
+ const title = open ? doc.get().document.title || doc.get().entry.label : null;
620
+ if (id) {
621
+ keeper.discard(id);
622
+ await settled(id);
623
+ }
624
+ await liveSession.discardSeen(file);
625
+ if (open && doc.get().entry.file === file) {
626
+ let gone = false;
627
+ try {
628
+ await readDocument(doc.get().ref);
629
+ } catch {
630
+ gone = true;
631
+ }
632
+ if (gone) await editor.openDocument(site.home, { history: 'replace' });
633
+ else await refreshOpen();
634
+ }
635
+ if (title) {
636
+ const who = discarded ? displayName(discarded.by) : 'Someone';
637
+ editor.toast(`${who} discarded the unpublished changes to ${title}. It's back to what's live.`, 'check');
638
+ }
639
+ void liveSession.poll();
640
+ }
641
+
590
642
  /** Held edits looked at again against the document as it is now (Decision 11). */
591
643
  async function recheck(id: string): Promise<void> {
592
644
  if (!keeper) return;
@@ -866,6 +918,38 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
866
918
  editor.announce('Your held changes were discarded.');
867
919
  },
868
920
 
921
+ async discardChanges(item) {
922
+ if (!liveSession) return { ok: false, error: 'Discarding changes needs live mode.' };
923
+ const id = item.collection && item.path ? docId({ collection: item.collection, path: item.path }) : null;
924
+ const openId = docId(doc.get().ref);
925
+ const isOpen = doc.get().entry.file === item.file || (id !== null && id === openId);
926
+ // This tab's own edits in it reach the server first, so nothing is in flight while it's discarded.
927
+ for (const one of new Set([id, isOpen ? openId : null])) {
928
+ if (!one) continue;
929
+ await keeper?.flush(one).catch(() => undefined);
930
+ await settled(one);
931
+ }
932
+ const answer = await liveSession.discard(item.file);
933
+ if (!answer.ok) return { ok: false, error: answer.error };
934
+ const back = answer.body.movedBack;
935
+ // This browser's held copies of it go too.
936
+ for (const one of new Set([id, isOpen ? openId : null, back ? docId(back) : null])) if (one) keeper?.discard(one);
937
+ await editor.refreshDocuments().catch(() => undefined);
938
+ if (isOpen) {
939
+ if (back) await editor.openDocument({ collection: back.collection, path: back.path }, { history: 'replace' });
940
+ else if (answer.body.change === 'new') {
941
+ await editor.openDocument(site.home, { history: 'push' });
942
+ const collection = site.collections.find((c) => c.name === item.collection);
943
+ if (collection && !collection.nested && collection.kind !== 'settings') editor.openCollection(collection.name);
944
+ else editor.goTo('documents');
945
+ } else await refreshOpen();
946
+ }
947
+ const said = answer.body.change === 'new' ? `Discarded ${item.title}.` : `Discarded the changes to ${item.title}. It's back to what's live.`;
948
+ editor.toast(said, 'check');
949
+ editor.announce(said);
950
+ return { ok: true };
951
+ },
952
+
869
953
  async publish() {
870
954
  if (!liveSession) return { ok: false };
871
955
  await keeper?.flush();
@@ -874,8 +958,18 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
874
958
  editor.announce("It didn't publish. Nothing went live.");
875
959
  return { ok: false, error: answer.error };
876
960
  }
877
- if (answer.body.result === 'nothing-waiting') editor.announce('Nothing to publish.');
878
- else editor.announce('Publishing.');
961
+ // caracms-cloud adversarial review, finding 15: what was left waiting, and why, in the toast.
962
+ const held = (answer.body.held ?? []).map((h) => heldBackSentence(h.title, h.reason)).join(' ');
963
+ if (answer.body.result === 'held-back') {
964
+ const said = `Nothing was published. ${held}`;
965
+ editor.toast(said, 'warn');
966
+ editor.announce(said);
967
+ } else if (answer.body.result === 'nothing-waiting') editor.announce('Nothing to publish.');
968
+ else if (held) {
969
+ const said = `Publishing the rest. ${held}`;
970
+ editor.toast(said, 'warn');
971
+ editor.announce(said);
972
+ } else editor.announce('Publishing.');
879
973
  return { ok: true };
880
974
  },
881
975
 
@@ -964,6 +1058,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
964
1058
  const seen = seenChange;
965
1059
  seenChange = change;
966
1060
  if (access.get().veil && change && change !== seen && change !== loadedVersion && !keeper.dirty(openId)) void refreshOpen();
1061
+ // Discard changes: someone put it back to what's live while this tab had it.
1062
+ if (open.discarded) void letGo(open.file, open.discarded);
967
1063
  }),
968
1064
  );
969
1065
  }
@@ -208,7 +208,8 @@ export function LockupField(props: { blockKey: string; index: number; name: stri
208
208
  </span>
209
209
  ) : null
210
210
  }
211
- meta={<span className="field-meta">Lockup · {styleName}</span>}
211
+ // Editor wording note (caracms-cloud adversarial review): "Lockup" is the package's word, not an editor's.
212
+ meta={<span className="field-meta">Heading style: {styleName}</span>}
212
213
  >
213
214
  {onPhone ? (
214
215
  <div className="mini-seg" role="radiogroup" aria-label="Which lines to edit">
@@ -291,9 +292,8 @@ export function LockupField(props: { blockKey: string; index: number; name: stri
291
292
  <Icon name="warn" />
292
293
  <div>
293
294
  <b>Too long for mobile: shorten a line or split it</b>
294
- <p>
295
- On a phone, “{longest}” sets the heading at {Math.round(warning.px)}px. {styleName}'s smallest size is {Math.round(warning.minPx)}px.
296
- </p>
295
+ {/* Editor wording note: what happens, not the sizes. A line never wraps; the heading shrinks to fit its longest line. */}
296
+ <p>On a phone, the heading shrinks so “{longest}” fits on one line, and comes out smaller than this heading style is meant to go.</p>
297
297
  {hasPhone ? null : (
298
298
  <div className="acts">
299
299
  {!onPhone ? (
@@ -7,7 +7,8 @@
7
7
  * **Reply by email** (a `mailto:` with the address percent-encoded and the subject
8
8
  * "Re: your enquiry to <brand>"), **Mark unread** (which closes it: staying open would be
9
9
  * reading it again), **Delete…** (after a confirm: documents/dialogs.tsx); then the email's
10
- * outcome (sent to whom, failed with Resend's message, skipped and why, or unknown), and
10
+ * outcome (sent to whom, failed with Resend's message, skipped and why, held behind a cloud
11
+ * site's daily limit with the cloud's sentence, or unknown), and
11
12
  * every field by its label, files as downloads with their sizes.
12
13
  *
13
14
  * **Visitor strings are only ever text** (React's escaping): no `dangerouslySetInnerHTML`
@@ -157,6 +158,16 @@ function Sheet({ answer }: { answer: SubmissionResponse }) {
157
158
  ))}
158
159
  </div>
159
160
  </dd>
161
+ ) : submission.filesNote ? (
162
+ // The cloud's attachment limit: the files weren't kept. Said plainly, not as an error; the sentence is the cloud's.
163
+ <dd key={`${f.name}:d`}>
164
+ <div className="note" data-files="not-kept">
165
+ <Icon name="attach" />
166
+ <div>
167
+ <p>{submission.filesNote}</p>
168
+ </div>
169
+ </div>
170
+ </dd>
160
171
  ) : (
161
172
  <dd key={`${f.name}:d`} className="none">
162
173
  No files
@@ -242,6 +253,19 @@ function MailOutcome({ email, formLabel, timeZone, editor }: { email: EmailOutco
242
253
  </div>
243
254
  );
244
255
  }
256
+ if (email.status === 'held') {
257
+ // Finding 20 (caracms-cloud adversarial review): the cloud holds it behind the site's daily
258
+ // limit; it goes out on its own, so it's calm, not a problem. The sentence is the cloud's.
259
+ return (
260
+ <div className="note" data-mail="held">
261
+ <Icon name="clock" />
262
+ <div>
263
+ <b>Email held</b>
264
+ <p>{email.error ?? 'The email about it is waiting to go out.'}</p>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
245
269
  // Skipped: no one to email (Site Settings sets who), or not the production server (which alone sends email).
246
270
  const notProduction = (email.error ?? '').startsWith('Not emailed');
247
271
  return (
@@ -11,6 +11,7 @@
11
11
  * lock) and **Discard mine**.
12
12
  * - **The merge notice** (`#merge`), until dismissed or published.
13
13
  * - **"Aoife published Home as it stood at 14:20"** (`#published-over`).
14
+ * - **"Aoife discarded the unpublished changes to About"** (Discard changes), with OK.
14
15
  * - **Someone deleted it** (M5a Decision 12: the poll's `open.deleted`): who and when,
15
16
  * read-only, with a way back to the documents.
16
17
  *
@@ -172,6 +173,24 @@ function Notice({ notice, timeZone }: { notice: NoticeView; timeZone: string })
172
173
  </div>
173
174
  );
174
175
  }
176
+ if (notice.kind === 'discarded') {
177
+ return (
178
+ <div className="note select" role="status" data-note="discarded">
179
+ <Icon name="undo" />
180
+ <div>
181
+ <b>
182
+ {displayName(notice.by)} discarded the unpublished changes to {notice.title} at {clock(notice.at, timeZone)}
183
+ </b>
184
+ <p>It's back to what's live.</p>
185
+ <div className="acts">
186
+ <button type="button" className="btn sm" data-act="dismiss" onClick={() => editor.dismissNotice(notice.id)}>
187
+ OK
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ );
193
+ }
175
194
  return (
176
195
  <div className="note select" role="status" data-note="published-over">
177
196
  <Icon name="check" />
@@ -25,6 +25,13 @@
25
25
  * document on that field (a list row expanded, the alt input focused; a document
26
26
  * someone else holds opens read-only with the field shown). The bar's count gets a
27
27
  * warning dot. **Publish stays on and one click**: nothing here holds it back.
28
+ * - **Held back** (caracms-cloud adversarial review, finding 15): a waiting document the
29
+ * reader would refuse (no title: `WaitingView.heldBack`) says so on its row ("Tinder won't
30
+ * be published: its title is empty."), with Add a title, which opens it. Publish sends the
31
+ * rest ("Publish 1 change"); with nothing else waiting it's off, and says why. What was
32
+ * held back is said again in the toast when Publish answers (editor.ts → publish).
33
+ * - **Discard…** on each waiting document (Discard changes): the confirmation
34
+ * (documents/dialogs.tsx → `discard`) puts it back to what's live.
28
35
  */
29
36
  import { useEffect, useRef, useState, type ReactNode } from 'react';
30
37
  import { useEditor, useStore } from '../context.tsx';
@@ -37,6 +44,8 @@ import { clock, displayName, joinNames, samePerson, when, zoneLine } from './for
37
44
  import { Avatar } from './marks.tsx';
38
45
  import { docIcon } from '../nav/DocRow.tsx';
39
46
  import { singular } from '../documents/actions.ts';
47
+ import { openDialog } from '../documents/dialogs.tsx';
48
+ import { NO_TITLE } from '../../../document-problems.ts';
40
49
 
41
50
  const histories = new WeakMap<Editor, { entries: HistoryEntryView[]; timeZone: string } | null>();
42
51
 
@@ -221,6 +230,8 @@ export function WaitingPop() {
221
230
  </div>
222
231
  );
223
232
  const n = status.waiting.length;
233
+ // Finding 15: what Publish will send; the rest stays waiting.
234
+ const going = status.waiting.filter((w) => !w.heldBack).length;
224
235
  const pub = status.publish;
225
236
  const record = pub.record;
226
237
  const names = joinNames(record?.titles ?? []);
@@ -399,6 +410,11 @@ export function WaitingPop() {
399
410
  <WaitItem key={w.file} item={w} status={status} />
400
411
  ))}
401
412
  </ul>
413
+ {going === 0 ? (
414
+ <p className="field-help" data-note="all-held">
415
+ Nothing can be published until {n === 1 ? 'it has' : 'they have'} what's missing.
416
+ </p>
417
+ ) : null}
402
418
  {publishError ? (
403
419
  <div className="note danger tight" role="alert" data-note="publish-failed">
404
420
  <Icon name="warn" />
@@ -416,7 +432,7 @@ export function WaitingPop() {
416
432
  type="button"
417
433
  className="btn lg accent"
418
434
  data-act="publish"
419
- disabled={busy}
435
+ disabled={busy || going === 0}
420
436
  onClick={async () => {
421
437
  setBusy(true);
422
438
  setPublishError(null);
@@ -425,7 +441,7 @@ export function WaitingPop() {
425
441
  if (!r.ok) setPublishError(r.error ?? 'the server said no.');
426
442
  }}
427
443
  >
428
- Publish {n} change{n === 1 ? '' : 's'}
444
+ {going ? `Publish ${going} change${going === 1 ? '' : 's'}` : 'Publish'}
429
445
  </button>
430
446
  </div>
431
447
  </>,
@@ -509,11 +525,51 @@ function WaitItem({ item, status }: { item: WaitingView; status: StatusResponse
509
525
  </div>
510
526
  </div>
511
527
  ) : null}
528
+ {item.heldBack ? <HeldBackNote item={item} /> : null}
512
529
  {item.change !== 'deleted' && item.missingAlt?.length ? <AltGaps item={item} held={others} /> : null}
530
+ <div className="wi-acts">
531
+ <button type="button" className="linkbtn subtle" data-act="discard" aria-label={`Discard the unpublished changes to ${item.title}`} onClick={() => openDialog(editor, { kind: 'discard', file: item.file })}>
532
+ Discard…
533
+ </button>
534
+ </div>
513
535
  </li>
514
536
  );
515
537
  }
516
538
 
539
+ /** Finding 15: a waiting document Publish will leave waiting, and why, with the way to fix it. */
540
+ function HeldBackNote({ item }: { item: WaitingView }) {
541
+ const editor = useEditor();
542
+ const untitled = item.heldBack === NO_TITLE.reason;
543
+ const open = async () => {
544
+ if (item.collection === null || item.path === null) return;
545
+ editor.setPop(null);
546
+ await editor.openDocument({ collection: item.collection, path: item.path });
547
+ // A form's title is its first field; a page or a shared block is renamed.
548
+ requestAnimationFrame(() => {
549
+ const input = document.querySelector<HTMLInputElement>('#side .side-level:not([hidden]) input[data-path="title"]');
550
+ if (input && !input.disabled) input.focus();
551
+ });
552
+ };
553
+ return (
554
+ <div className="note warn tight" data-note="held-back">
555
+ <Icon name="warn" />
556
+ <div>
557
+ <b>
558
+ {item.title} won't be published: {item.heldBack}.
559
+ </b>
560
+ <p>It stays waiting. Publish sends the rest.</p>
561
+ {untitled && item.collection !== null ? (
562
+ <div className="acts">
563
+ <button type="button" className="btn sm" data-act="add-title" onClick={() => void open()}>
564
+ Add a title
565
+ </button>
566
+ </div>
567
+ ) : null}
568
+ </div>
569
+ </div>
570
+ );
571
+ }
572
+
517
573
  /** One waiting document's images without alt text (#publish-alt), each with Add alt text. It doesn't hold the Publish back. */
518
574
  function AltGaps({ item, held }: { item: WaitingView; held: Person[] }) {
519
575
  const editor = useEditor();
@@ -14,7 +14,7 @@
14
14
  import { useState, type CSSProperties, type ReactNode } from 'react';
15
15
  import { useEditor, useStore } from '../context.tsx';
16
16
  import { Icon } from '../shell/Icons.tsx';
17
- import { docId, sameDoc, type ListedDocument } from '../../types.ts';
17
+ import { docId, opensInEditor, sameDoc, warnedWords, type ListedDocument } from '../../types.ts';
18
18
  import { DocMarksView } from '../live/marks.tsx';
19
19
 
20
20
  /** A document's icon by its collection: a page, a case study (any list-of-fields document), a shared block, Site Settings, else a post. */
@@ -56,7 +56,7 @@ export function DocRow(props: {
56
56
  const collection = editor.site.collections.find((c) => c.name === doc.collection);
57
57
  const classes = ['drow', props.className, current ? 'is-current' : '', doc.warning ? 'is-warned' : '', props.active ? 'is-active' : ''].filter(Boolean).join(' ');
58
58
  const open = () => {
59
- if (doc.warning) {
59
+ if (doc.warning && !opensInEditor(doc)) {
60
60
  setWhy((w) => !w);
61
61
  editor.announce(`${doc.label} can't be opened. ${doc.warning.message}`);
62
62
  return;
@@ -73,7 +73,7 @@ export function DocRow(props: {
73
73
  style={props.style}
74
74
  data-doc={id}
75
75
  aria-current={current ? 'page' : undefined}
76
- aria-expanded={doc.warning ? why : undefined}
76
+ aria-expanded={doc.warning && !opensInEditor(doc) ? why : undefined}
77
77
  title={doc.warning?.message}
78
78
  onClick={open}
79
79
  >
@@ -86,7 +86,7 @@ export function DocRow(props: {
86
86
  {doc.warning ? (
87
87
  <span className="wmark" data-warning={doc.warning.code}>
88
88
  <Icon name="warn" size="xs" />
89
- <span className="sr">, can't be opened</span>
89
+ <span className="sr">{warnedWords(doc)}</span>
90
90
  </span>
91
91
  ) : null}
92
92
  {/* M9 Decision 7: hidden from the site (or under a hidden page). */}
@@ -23,6 +23,7 @@ import { CurrentHead, DocumentsTab, GlobalNotes, RecentStrip, Trail } from '../p
23
23
  import { usedOnLine } from '../../block-list/summary.ts';
24
24
  import { PAGELESS_SUB, singular } from '../../documents/actions.ts';
25
25
  import { openDialog } from '../../documents/dialogs.tsx';
26
+ import { opensInEditor } from '../../../types.ts';
26
27
 
27
28
 
28
29
  export function CollectionLevel() {
@@ -88,7 +89,7 @@ export function CollectionLevel() {
88
89
  onChange={(e) => setQuery(e.target.value)}
89
90
  onKeyDown={(e) => {
90
91
  if (e.key === 'Enter') {
91
- const first = shown.find((d) => !d.warning);
92
+ const first = shown.find((d) => opensInEditor(d));
92
93
  if (first) void editor.openDocument({ collection: first.collection, path: first.path });
93
94
  } else if (e.key === 'Escape' && query) {
94
95
  e.preventDefault();
@@ -17,7 +17,7 @@ import { openPicker, pickerAt } from '../../block-list/link.ts';
17
17
  import { Picker } from '../../block-list/Picker.tsx';
18
18
  import { CurrentHead, DocumentsTab, GlobalNotes, parentLabel, RecentStrip, Trail } from '../parts.tsx';
19
19
  import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
20
- import { DocMenu } from '../../documents/DocMenu.tsx';
20
+ import { DocMenu, TitleNote } from '../../documents/DocMenu.tsx';
21
21
  import { PagesUnder } from '../../documents/PagesUnder.tsx';
22
22
  import { FieldsDocumentLevel, SettingsLevel, SharedDocumentLevel } from './FormLevel.tsx';
23
23
  import { SeoGroup } from '../../fields/SeoGroup.tsx';
@@ -71,6 +71,7 @@ function SectionsLevel() {
71
71
  <div className="side-body" data-document={`${ref.collection}/${ref.path}`}>
72
72
  <GlobalNotes />
73
73
  <DocNotices />
74
+ <TitleNote />
74
75
  {empty ? (
75
76
  <div className="empty" data-empty="page" style={{ padding: 'var(--space-4)' }}>
76
77
  <b>No blocks yet</b>
@@ -36,7 +36,7 @@ import { Bubble } from '../../live/marks.tsx';
36
36
  import { searchDocuments } from '../../nav/search.ts';
37
37
  import { treeRows, type TreeRow } from '../../nav/tree.ts';
38
38
  import { arrangeSite } from '../../arrange/store.ts';
39
- import type { EditorCollection, ListedDocument } from '../../../types.ts';
39
+ import { opensInEditor, type EditorCollection, type ListedDocument } from '../../../types.ts';
40
40
  import { CurrentHead, GlobalNotes, RecentStrip, Trail } from '../parts.tsx';
41
41
 
42
42
  export function DocumentsLevel() {
@@ -59,7 +59,7 @@ export function DocumentsLevel() {
59
59
  if (event.key === 'Enter') {
60
60
  const first = searchDocuments(query, documents, editor.site.collections)
61
61
  .flatMap((g) => g.hits)
62
- .find((h) => !h.doc.warning);
62
+ .find((h) => opensInEditor(h.doc));
63
63
  if (first) void editor.openDocument({ collection: first.doc.collection, path: first.doc.path });
64
64
  } else if (event.key === 'Escape' && query) {
65
65
  // Clear the box first; a second Esc leaves it (App.tsx).
@@ -28,9 +28,11 @@ import { parseMedia } from '../../../../media-value.ts';
28
28
  import { parseAllowEntry } from '../../../../live/auth/allow-rules.ts';
29
29
  import { useEditor, useStore } from '../../context.tsx';
30
30
  import type { Editor } from '../../editor.ts';
31
- import { DocMenu } from '../../documents/DocMenu.tsx';
31
+ import { DocMenu, TitleNote } from '../../documents/DocMenu.tsx';
32
32
  import { FieldControl } from '../../fields/FieldControl.tsx';
33
33
  import { FieldFrame } from '../../fields/FieldFrame.tsx';
34
+ import { Warn } from '../../fields/parts.tsx';
35
+ import { NO_TITLE } from '../../../../document-problems.ts';
34
36
  import { FieldHostProvider, parseBase, type FieldHost } from '../../fields/host.tsx';
35
37
  import { MediaSlot } from '../../fields/MediaField.tsx';
36
38
  import { mediaFor } from '../../media/store.ts';
@@ -102,14 +104,38 @@ export function CollectionTab({ name }: { name: string }) {
102
104
  return <LevelTab name={collection.label} kind="Collection" count={String(count)} onClick={() => editor.openCollection(name)} />;
103
105
  }
104
106
 
105
- /** The title field every fields document has first (Decision 7): one line, always there. */
106
- function TitleField({ scope }: { scope: string }) {
107
+ /**
108
+ * Editor wording note (caracms-cloud adversarial review): beside a heading (a lockup field),
109
+ * the title says where it shows, so the two aren't mistaken for each other. The document's
110
+ * title is its name in the site's lists and links, and the browser tab's and search
111
+ * results' title unless its SEO title says otherwise (head.ts).
112
+ */
113
+ export const TITLE_BESIDE_HEADING = 'Shown in lists, links, browser tabs and search results.';
114
+
115
+ /**
116
+ * The title field every fields document has first (Decision 7): one line, always there.
117
+ * Blank, it says so (caracms-cloud adversarial review, finding 15), as typed: the edit is
118
+ * kept all the same, and Publish leaves the document waiting until it has one.
119
+ */
120
+ function TitleField({ scope, help }: { scope: string; help?: string }) {
107
121
  const editor = useEditor();
108
122
  const title = useStore(editor.doc.state, (s) => s.document.title);
109
123
  const id = `f-${scope}-title`;
124
+ const blank = title.trim() === '';
110
125
  return (
111
- <FieldFrame blockKey={scope} name="title" label="Title" htmlFor={id}>
112
- <input className="input" id={id} data-path="title" value={title} autoComplete="off" spellCheck={false} onChange={(e) => editor.doc.setTitle(e.target.value.replace(/\n+/g, ' '))} />
126
+ <FieldFrame blockKey={scope} name="title" label="Title" htmlFor={id} help={help}>
127
+ <input
128
+ className="input"
129
+ id={id}
130
+ data-path="title"
131
+ value={title}
132
+ autoComplete="off"
133
+ spellCheck={false}
134
+ aria-invalid={blank || undefined}
135
+ aria-describedby={blank ? `${id}-problem` : undefined}
136
+ onChange={(e) => editor.doc.setTitle(e.target.value.replace(/\n+/g, ' '))}
137
+ />
138
+ {blank ? <Warn id={`${id}-problem`}>{NO_TITLE.note}</Warn> : null}
113
139
  </FieldFrame>
114
140
  );
115
141
  }
@@ -188,7 +214,7 @@ export function FieldsDocumentLevel() {
188
214
  <DocNotices />
189
215
  <FormFields readOnly={readOnly}>
190
216
  <FieldHostProvider host={host}>
191
- <TitleField scope={key} />
217
+ <TitleField scope={key} help={Object.values(fields).some((f) => f.kind === 'lockup') ? TITLE_BESIDE_HEADING : undefined} />
192
218
  {formRows(fields).map((row) => {
193
219
  const control = ([name, field]: [string, Field]) => (
194
220
  <FieldControl key={`${key}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} lockupIndex={0} />
@@ -257,6 +283,7 @@ export function SharedDocumentLevel() {
257
283
  <div className="side-body" data-document={docId(ref)}>
258
284
  <GlobalNotes />
259
285
  <DocNotices />
286
+ <TitleNote />
260
287
  {n ? (
261
288
  <div className="note select" data-note="used-on">
262
289
  <Icon name="shared" />
@@ -5,7 +5,8 @@
5
5
  * - **The list**, newest first, 50 at a time with Older (by cursor). A row: the unread
6
6
  * mark (a dot and a heavier name, never a side rail), who sent it, when (the site's
7
7
  * zone: "14:12" today, then "Yesterday" or "Tue 13 Oct"), a one-line snippet, the
8
- * form, the file count, and **Email failed** when its email failed. The open one has
8
+ * form, the file count, **Email held** (quiet, beside the file count: a cloud site's
9
+ * daily limit; finding 20) and **Email failed** when its email failed. The open one has
9
10
  * the selection's wash and ring.
10
11
  * - **A row opens the submission** on a sheet over the dimmed page (inbox/InboxStage.tsx),
11
12
  * with the list kept here; the address follows (`/admin/submissions/<id>`).
@@ -113,8 +114,12 @@ const settingsPath = (editor: ReturnType<typeof useEditor>) => editor.documents.
113
114
  function Row({ row, current, timeZone, onOpen }: { row: SubmissionRow; current: boolean; timeZone: string; onOpen: () => void }) {
114
115
  const unread = row.readAt === null;
115
116
  const failed = row.emailStatus === 'failed';
117
+ // Finding 20: a held email is said quietly, beside the files, never as a failure.
118
+ const held = row.emailStatus === 'held';
119
+ // The cloud's attachment limit: said as quietly as a held email.
120
+ const notKept = row.filesNotKept === true;
116
121
  const time = rowTime(row.receivedAt, timeZone);
117
- const label = [row.name, `${row.formLabel} form`, time, unread ? 'unread' : '', row.files ? filesLabel(row.files) : '', failed ? 'email failed' : ''].filter(Boolean).join(', ');
122
+ const label = [row.name, `${row.formLabel} form`, time, unread ? 'unread' : '', row.files ? filesLabel(row.files) : '', failed ? 'email failed' : '', held ? 'email held' : '', notKept ? 'attachments not kept' : ''].filter(Boolean).join(', ');
118
123
  return (
119
124
  <button
120
125
  type="button"
@@ -139,6 +144,18 @@ function Row({ row, current, timeZone, onOpen }: { row: SubmissionRow; current:
139
144
  {filesLabel(row.files)}
140
145
  </span>
141
146
  ) : null}
147
+ {notKept ? (
148
+ <span data-files="not-kept">
149
+ <Icon name="attach" />
150
+ Attachments not kept
151
+ </span>
152
+ ) : null}
153
+ {held ? (
154
+ <span data-mail="held">
155
+ <Icon name="clock" />
156
+ Email held
157
+ </span>
158
+ ) : null}
142
159
  {failed ? (
143
160
  <span className="chip danger">
144
161
  <Icon name="warn" size="xs" />