@obelism/content 0.1.0 → 0.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.
package/dist/bridge.js CHANGED
@@ -64,6 +64,7 @@ const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual
64
64
  // What every bridge without a feature does for it.
65
65
  const INERT_METHODS = {
66
66
  prompt: inertPrompt,
67
+ createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
67
68
  undo: ()=>{},
68
69
  save: onlyOnSite,
69
70
  publish: onlyOnSite,
@@ -306,48 +307,182 @@ const DEFAULT_PROMPT_LABELS = {
306
307
  unsaved: 'Unsaved changes',
307
308
  signOut: 'Exit editing',
308
309
  viewingDrafts: 'Viewing drafts',
309
- selectHint: 'Click a block to change only that block'
310
+ selectHint: 'Click a block to change only that block',
311
+ newPage: 'New page',
312
+ newPageCancel: 'Edit this page instead',
313
+ newPagePlaceholder: 'Describe the new page, e.g. “a pricing page for agencies”',
314
+ path: 'Path',
315
+ pathPlaceholder: '/pricing',
316
+ creating: 'Writing the page…',
317
+ created: 'Opening the new page…'
310
318
  };
311
- const ACCENT = '#d58a1f';
319
+ // Icons (Lucide), drawn in the text colour.
320
+ const ICONS = {
321
+ page: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
322
+ newPage: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/>',
323
+ block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
324
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
325
+ close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
326
+ send: '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>'
327
+ };
328
+ // Obelism's design tokens (obelism.studio/design.md), under their own names:
329
+ // beige and near-black, one orange accent, Inter, pill buttons.
312
330
  const CSS = `
313
331
  :host { all: initial; }
314
- * { box-sizing: border-box; font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
332
+ * { box-sizing: border-box; font: inherit; color: inherit; margin: 0; }
315
333
  .root {
316
- --bg: #fff; --fg: #1d1b19; --muted: #6b655e; --line: #e4ded6; --error: #b3261e;
317
- position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
318
- z-index: 2147483647; width: min(640px, calc(100vw - 32px)); color: var(--fg);
334
+ --color-white-rgb: 248 248 248; --color-beige-rgb: 238 236 233; --color-beige-off-rgb: 228 225 217;
335
+ --color-grey-light-rgb: 132 132 132; --color-grey-rgb: 108 108 108; --color-grey-mid-rgb: 100 100 100; --color-grey-dark-rgb: 40 40 40;
336
+ --color-black-off-rgb: 20 20 20; --color-black-rgb: 12 12 12;
337
+ --color-orange-rgb: 212 130 30; --color-red-rgb: 240 93 93;
338
+ --theme-foreground-rgb: var(--color-black-rgb);
339
+ --theme-background-alt-rgb: var(--color-white-rgb);
340
+ --theme-background-actions-rgb: var(--color-beige-off-rgb);
341
+ --theme-border-rgb: var(--color-grey-light-rgb);
342
+ --theme-accent-rgb: var(--color-orange-rgb);
343
+ --theme-foreground-muted-rgb: var(--color-grey-mid-rgb);
344
+ --theme-error: #b3261e;
345
+ --border-radius-medium: 20px; --border-radius-pill: 5em;
346
+ --ease-roll: cubic-bezier(0.65, 0, 0.35, 1);
347
+ --shadow: 0 18px 40px -16px rgb(var(--color-black-rgb) / .3), 0 2px 6px rgb(var(--color-black-rgb) / .05);
348
+ --font-inter: 'InterVariable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
349
+ Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
350
+ position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
351
+ z-index: 2147483647; width: min(640px, calc(100vw - 32px)); color-scheme: light dark;
352
+ font: 400 14px/1.4 var(--font-inter); color: rgb(var(--theme-foreground-rgb));
353
+ -webkit-font-smoothing: antialiased;
319
354
  }
320
355
  @media (prefers-color-scheme: dark) {
321
- .root { --bg: #23201d; --fg: #f3efe9; --muted: #a9a198; --line: #3b3631; --error: #ff8a80; }
356
+ .root {
357
+ --theme-foreground-rgb: var(--color-white-rgb);
358
+ --theme-background-alt-rgb: var(--color-black-off-rgb);
359
+ --theme-background-actions-rgb: var(--color-grey-dark-rgb);
360
+ --theme-border-rgb: var(--color-grey-rgb);
361
+ --theme-foreground-muted-rgb: var(--color-grey-light-rgb);
362
+ --theme-error: rgb(var(--color-red-rgb));
363
+ --shadow: 0 18px 40px -14px rgb(0 0 0 / .7), 0 2px 6px rgb(0 0 0 / .3);
364
+ }
365
+ }
366
+ .icon { display: inline-flex; flex: none; }
367
+ .icon svg {
368
+ width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.75;
369
+ stroke-linecap: round; stroke-linejoin: round;
322
370
  }
323
- .pill, .panel { background: var(--bg); border: 1px solid var(--line); box-shadow: 0 8px 30px rgba(0,0,0,.18); }
371
+ .visually-hidden {
372
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
373
+ }
374
+ button { cursor: pointer; background: none; border: 0; padding: 0; }
375
+ button:disabled { cursor: not-allowed; }
376
+ button:focus-visible { outline: 2px solid rgb(var(--theme-accent-rgb)); outline-offset: 2px; }
377
+
378
+ /* Closed: one floating pill that reads like an empty field, with the mark. */
379
+ .bar { display: flex; justify-content: center; }
324
380
  .pill {
325
- display: block; margin: 0 auto; border-radius: 999px; padding: 8px 16px; cursor: pointer;
326
- color: var(--fg);
381
+ display: flex; align-items: center; gap: 12px; width: min(560px, 100%); height: 56px;
382
+ padding: 0 22px 0 26px; border-radius: var(--border-radius-pill);
383
+ background: rgb(var(--theme-background-alt-rgb));
384
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); box-shadow: var(--shadow);
385
+ color: rgb(var(--theme-foreground-muted-rgb)); font-size: 16px; text-align: left;
386
+ transition: transform .25s var(--ease-roll), color .25s var(--ease-roll);
387
+ }
388
+ button.pill:hover { color: rgb(var(--theme-foreground-rgb)); transform: translateY(-2px); }
389
+ .pill > span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
390
+ .mark { color: rgb(var(--theme-accent-rgb)); font-size: 22px; line-height: 1; }
391
+ .pill.compact { width: auto; height: 40px; padding: 0 18px; font-size: 14px; gap: 4px; }
392
+
393
+ /* Open: a floating card, the text on top and a row of pill buttons below. */
394
+ .panel {
395
+ display: grid; background: rgb(var(--theme-background-alt-rgb));
396
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-medium);
397
+ box-shadow: var(--shadow); overflow: hidden;
398
+ transition: border-color .25s var(--ease-roll), box-shadow .25s var(--ease-roll);
399
+ animation: rise .25s var(--ease-roll);
400
+ }
401
+ .panel:focus-within {
402
+ border-color: rgb(var(--theme-accent-rgb) / .6);
403
+ box-shadow: 0 0 0 3px rgb(var(--theme-accent-rgb) / .18), var(--shadow);
327
404
  }
328
- .pill:hover { border-color: ${ACCENT}; }
329
- .panel { border-radius: 14px; padding: 10px; display: grid; gap: 8px; }
330
- .row { display: flex; align-items: center; gap: 8px; }
331
- .scope { color: var(--muted); font-size: 12px; flex: 1; min-width: 0; }
332
- .scope button { color: inherit; font-size: 12px; text-decoration: underline; background: none; border: 0; padding: 0 0 0 6px; cursor: pointer; }
405
+ @keyframes rise { from { opacity: 0; transform: translateY(8px) scale(.98); } }
406
+ @keyframes spin { to { transform: rotate(1turn); } }
407
+ @media (prefers-reduced-motion: reduce) {
408
+ .panel { animation: none; }
409
+ .pill { transition: none; }
410
+ }
411
+ .status {
412
+ padding: 16px 22px 0; font-size: 14px; color: rgb(var(--theme-foreground-muted-rgb));
413
+ display: flex; align-items: center; gap: 8px;
414
+ }
415
+ .status.working::before {
416
+ content: ''; width: 12px; height: 12px; flex: none; border-radius: 50%;
417
+ border: 2px solid rgb(var(--theme-border-rgb) / .3); border-top-color: rgb(var(--theme-accent-rgb));
418
+ animation: spin .7s linear infinite;
419
+ }
420
+ .status.error { color: var(--theme-error); }
333
421
  textarea {
334
- width: 100%; resize: none; min-height: 44px; max-height: 160px; padding: 8px 10px;
335
- border-radius: 10px; border: 1px solid var(--line); background: transparent; color: var(--fg);
422
+ display: block; width: 100%; resize: none; min-height: 76px; max-height: 200px;
423
+ padding: 16px 22px 6px; border: 0; outline: 0; background: transparent;
424
+ font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
425
+ }
426
+ textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
427
+ /* New page: where it goes, above what it is for. */
428
+ .path {
429
+ display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
430
+ color: rgb(var(--theme-foreground-muted-rgb));
431
+ }
432
+ .path input {
433
+ flex: 1; min-width: 0; height: 34px; padding: 0 14px; outline: 0;
434
+ border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
435
+ background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-rgb));
436
+ transition: border-color .25s var(--ease-roll);
437
+ }
438
+ .path input:focus-visible { border-color: rgb(var(--theme-accent-rgb)); }
439
+ .path input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
440
+ .path input:disabled { opacity: .5; }
441
+ textarea:disabled { opacity: .5; }
442
+ .toolbar { display: flex; align-items: center; gap: 6px; padding: 8px 12px 12px; }
443
+ .spacer { flex: 1; }
444
+ .chip {
445
+ display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: .4em 1.1em .4em .9em;
446
+ border: 1px solid rgb(var(--theme-border-rgb) / .2); border-radius: var(--border-radius-pill);
447
+ background: rgb(var(--theme-background-actions-rgb) / .6); white-space: nowrap;
448
+ transition: background-color .25s var(--ease-roll);
336
449
  }
337
- textarea:focus { outline: 2px solid ${ACCENT}; outline-offset: -1px; }
338
- button.action {
339
- border-radius: 8px; border: 1px solid var(--line); background: transparent; color: var(--fg);
340
- padding: 6px 12px; cursor: pointer;
450
+ button.chip:hover:not(:disabled) { background: rgb(var(--theme-background-actions-rgb)); }
451
+ button.chip:disabled { opacity: .5; }
452
+ .scope { min-width: 0; background: transparent; }
453
+ .scope > span:not(.icon) { overflow: hidden; text-overflow: ellipsis; }
454
+ .scope button {
455
+ display: inline-flex; margin-right: -6px; padding: 3px; border-radius: 50%;
456
+ color: rgb(var(--theme-foreground-muted-rgb));
457
+ }
458
+ .scope button:hover { background: rgb(var(--theme-background-actions-rgb)); color: rgb(var(--theme-foreground-rgb)); }
459
+ .scope button svg { width: 14px; height: 14px; }
460
+ .icon-button {
461
+ display: inline-flex; align-items: center; justify-content: center;
462
+ width: 34px; height: 34px; border-radius: 50%; color: rgb(var(--theme-foreground-muted-rgb));
463
+ transition: background-color .25s var(--ease-roll), color .25s var(--ease-roll), opacity .25s var(--ease-roll);
464
+ }
465
+ .icon-button:hover:not(:disabled) { background: rgb(var(--theme-background-actions-rgb)); color: rgb(var(--theme-foreground-rgb)); }
466
+ /* The one call to action: white on orange. */
467
+ .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
468
+ .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
469
+ .icon-button.primary:disabled { opacity: .35; }
470
+ .divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
471
+
472
+ /* On the site: storing the draft, and the way out. */
473
+ .footer {
474
+ display: flex; align-items: center; gap: 8px; padding: 10px 12px 10px 22px;
475
+ border-top: 1px solid rgb(var(--theme-border-rgb) / .2);
476
+ background: rgb(var(--theme-background-actions-rgb) / .45);
477
+ }
478
+ .footer .chip { height: 30px; padding: .4em 1.2em; background: rgb(var(--theme-background-alt-rgb)); }
479
+ .unsaved { color: rgb(var(--theme-foreground-muted-rgb)); display: inline-flex; align-items: center; }
480
+ .link { color: rgb(var(--theme-foreground-muted-rgb)); text-decoration: underline; text-underline-offset: 3px; padding: 0 6px; }
481
+ .link:hover { color: rgb(var(--theme-foreground-rgb)); }
482
+ .dot {
483
+ display: inline-block; width: 7px; height: 7px; border-radius: 50%;
484
+ background: rgb(var(--theme-accent-rgb)); margin-right: 8px;
341
485
  }
342
- button.primary { background: ${ACCENT}; border-color: ${ACCENT}; color: #fff; }
343
- button:disabled { opacity: .5; cursor: default; }
344
- .close { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px; }
345
- .status { font-size: 13px; color: var(--muted); flex: 1; min-width: 0; }
346
- .status.error { color: var(--error); }
347
- .bar { display: flex; justify-content: center; gap: 8px; }
348
- .row > .link { margin-right: auto; }
349
- .link { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 12px; text-decoration: underline; padding: 0; }
350
- .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: ${ACCENT}; margin-right: 6px; vertical-align: 1px; }
351
486
  [hidden] { display: none !important; }
352
487
  `;
353
488
  const element = (doc, tag, props = {}, ...children)=>{
@@ -360,6 +495,14 @@ const element = (doc, tag, props = {}, ...children)=>{
360
495
  }
361
496
  return node;
362
497
  };
498
+ const icon = (doc, name)=>{
499
+ const span = element(doc, 'span', {
500
+ className: 'icon'
501
+ });
502
+ span.setAttribute('aria-hidden', 'true');
503
+ span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
504
+ return span;
505
+ };
363
506
  // Mounts the box on the page; returns a function that removes it.
364
507
  const mountPromptBox = (doc, bridge, options = {})=>{
365
508
  const labels = {
@@ -372,15 +515,21 @@ const mountPromptBox = (doc, bridge, options = {})=>{
372
515
  mode: 'open'
373
516
  });
374
517
  const site = bridge.mode === 'site';
518
+ const pillText = element(doc, 'span', {
519
+ textContent: labels.open
520
+ });
375
521
  const pill = element(doc, 'button', {
376
522
  className: 'pill',
377
- type: 'button',
378
- textContent: `✨ ${labels.open}`
379
- });
523
+ type: 'button'
524
+ }, pillText, // The Obelism mark.
525
+ element(doc, 'span', {
526
+ className: 'mark',
527
+ textContent: '⁜'
528
+ }));
380
529
  // Someone signed in who may only read: a note that drafts show, and a
381
530
  // way out.
382
531
  const viewing = element(doc, 'span', {
383
- className: 'pill'
532
+ className: 'pill compact'
384
533
  }, `${labels.viewingDrafts} · `);
385
534
  const pillSignOut = element(doc, 'button', {
386
535
  className: 'link',
@@ -388,71 +537,111 @@ const mountPromptBox = (doc, bridge, options = {})=>{
388
537
  textContent: labels.signOut
389
538
  });
390
539
  viewing.appendChild(pillSignOut);
391
- const scopeText = element(doc, 'span');
392
- const clearBlock = element(doc, 'button', {
393
- type: 'button',
394
- textContent: labels.clearBlock
395
- });
396
- const scope = element(doc, 'div', {
397
- className: 'scope'
398
- }, '✨ ', scopeText, clearBlock);
399
- const close = element(doc, 'button', {
400
- className: 'close',
401
- type: 'button',
402
- textContent: '×',
403
- title: labels.close
540
+ const status = element(doc, 'p', {
541
+ className: 'status'
404
542
  });
405
- close.setAttribute('aria-label', labels.close);
543
+ status.setAttribute('role', 'status');
406
544
  const input = element(doc, 'textarea', {
407
545
  placeholder: labels.placeholder,
408
546
  rows: 1
409
547
  });
410
548
  input.setAttribute('aria-label', labels.open);
411
- const status = element(doc, 'div', {
412
- className: 'status'
549
+ // What the prompt acts on: the whole page, or the selected block with a
550
+ // way back to the page.
551
+ const pageIcon = icon(doc, 'page');
552
+ const blockIcon = icon(doc, 'block');
553
+ const scopeText = element(doc, 'span');
554
+ const clearBlock = element(doc, 'button', {
555
+ type: 'button',
556
+ title: labels.clearBlock
413
557
  });
414
- status.setAttribute('role', 'status');
558
+ clearBlock.setAttribute('aria-label', labels.clearBlock);
559
+ clearBlock.appendChild(icon(doc, 'close'));
560
+ const scope = element(doc, 'span', {
561
+ className: 'chip scope'
562
+ }, pageIcon, blockIcon, scopeText, clearBlock);
415
563
  const undo = element(doc, 'button', {
416
- className: 'action',
564
+ className: 'action chip',
565
+ type: 'button'
566
+ }, icon(doc, 'undo'), labels.undo);
567
+ // New page (on the site, for people who may add pages): the scope becomes
568
+ // the new page, with a way back, and a path field shows above the prompt.
569
+ const newPage = element(doc, 'button', {
570
+ className: 'action chip',
571
+ type: 'button'
572
+ }, icon(doc, 'newPage'), labels.newPage);
573
+ const cancelNew = element(doc, 'button', {
417
574
  type: 'button',
418
- textContent: labels.undo
575
+ title: labels.newPageCancel
419
576
  });
577
+ cancelNew.setAttribute('aria-label', labels.newPageCancel);
578
+ cancelNew.appendChild(icon(doc, 'close'));
579
+ const newScope = element(doc, 'span', {
580
+ className: 'chip scope'
581
+ }, icon(doc, 'newPage'), element(doc, 'span', {
582
+ textContent: labels.newPage
583
+ }), cancelNew);
584
+ const pathInput = element(doc, 'input', {
585
+ type: 'text',
586
+ placeholder: labels.pathPlaceholder,
587
+ autocomplete: 'off',
588
+ spellcheck: false
589
+ });
590
+ const pathRow = element(doc, 'label', {
591
+ className: 'path'
592
+ }, element(doc, 'span', {
593
+ textContent: labels.path
594
+ }), pathInput);
595
+ const close = element(doc, 'button', {
596
+ className: 'close icon-button',
597
+ type: 'button',
598
+ title: labels.close
599
+ }, icon(doc, 'close'));
600
+ close.setAttribute('aria-label', labels.close);
420
601
  const send = element(doc, 'button', {
421
- className: 'action primary',
422
- type: 'submit',
602
+ className: 'action primary icon-button',
603
+ type: 'submit'
604
+ }, element(doc, 'span', {
605
+ className: 'visually-hidden',
423
606
  textContent: labels.send
607
+ }), icon(doc, 'send'));
608
+ send.title = labels.send;
609
+ const unsaved = element(doc, 'span', {
610
+ className: 'unsaved'
611
+ });
612
+ unsaved.appendChild(element(doc, 'span', {
613
+ className: 'dot'
614
+ }));
615
+ unsaved.appendChild(doc.createTextNode(labels.unsaved));
616
+ const signOut = element(doc, 'button', {
617
+ className: 'link',
618
+ type: 'button',
619
+ textContent: labels.signOut
424
620
  });
425
621
  const save = element(doc, 'button', {
426
- className: 'action',
622
+ className: 'action chip',
427
623
  type: 'button',
428
624
  textContent: labels.save
429
625
  });
430
626
  const publish = element(doc, 'button', {
431
- className: 'action',
627
+ className: 'action chip',
432
628
  type: 'button',
433
629
  textContent: labels.publish
434
630
  });
435
- const signOut = element(doc, 'button', {
436
- className: 'link',
437
- type: 'button',
438
- textContent: labels.signOut
439
- });
440
- const unsaved = element(doc, 'span', {
441
- className: 'scope'
442
- });
443
- unsaved.appendChild(element(doc, 'span', {
444
- className: 'dot'
445
- }));
446
- unsaved.appendChild(doc.createTextNode(labels.unsaved));
631
+ const footer = element(doc, 'div', {
632
+ className: 'footer'
633
+ }, unsaved, element(doc, 'span', {
634
+ className: 'spacer'
635
+ }), signOut, save, publish);
447
636
  const panel = element(doc, 'form', {
448
637
  className: 'panel'
449
- }, element(doc, 'div', {
450
- className: 'row'
451
- }, scope, close), input, element(doc, 'div', {
452
- className: 'row'
453
- }, status, undo, send), element(doc, 'div', {
454
- className: 'row'
455
- }, unsaved, signOut, save, publish));
638
+ }, status, pathRow, input, element(doc, 'div', {
639
+ className: 'toolbar'
640
+ }, scope, newScope, undo, newPage, element(doc, 'span', {
641
+ className: 'spacer'
642
+ }), close, element(doc, 'span', {
643
+ className: 'divider'
644
+ }), send), footer);
456
645
  const root = element(doc, 'div', {
457
646
  className: 'root'
458
647
  }, element(doc, 'div', {
@@ -462,6 +651,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
462
651
  textContent: CSS
463
652
  }), root);
464
653
  let open = false;
654
+ let creating = false;
465
655
  let message = null;
466
656
  const render = (state)=>{
467
657
  const available = state.features.includes('prompt');
@@ -471,23 +661,43 @@ const mountPromptBox = (doc, bridge, options = {})=>{
471
661
  pill.hidden = open || !available;
472
662
  viewing.hidden = available || !site;
473
663
  panel.hidden = !open || !available;
664
+ const canCreate = site && state.features.includes('create');
665
+ // A page of the site that isn't an entry (no `editable(entry)`, like a
666
+ // hand-written /about) has nothing to edit: only New page.
667
+ const pageless = site && bridge.entryId === null;
668
+ const onlyNew = pageless && canCreate;
669
+ if (!canCreate) creating = false;
670
+ if (onlyNew) creating = true;
671
+ pillText.textContent = onlyNew ? labels.newPage : labels.open;
672
+ cancelNew.hidden = onlyNew;
673
+ scope.hidden = creating;
674
+ newScope.hidden = !creating;
675
+ newPage.hidden = creating || !canCreate;
676
+ newPage.disabled = busy;
677
+ pathRow.hidden = !creating;
678
+ pathInput.disabled = busy;
679
+ input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
474
680
  scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
475
681
  clearBlock.hidden = !state.selectedUid;
476
- undo.hidden = !state.canUndo;
682
+ pageIcon.hidden = !!state.selectedUid;
683
+ blockIcon.hidden = !state.selectedUid;
684
+ undo.hidden = creating || !state.canUndo;
477
685
  undo.disabled = busy;
478
- send.disabled = busy || input.value.trim() === '';
686
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
479
687
  input.disabled = busy;
480
- save.hidden = !state.features.includes('save');
688
+ save.hidden = !state.features.includes('save') || pageless;
481
689
  save.disabled = busy || !state.unsaved;
482
690
  save.textContent = state.saving ? labels.saving : labels.save;
483
- publish.hidden = !state.features.includes('publish');
691
+ publish.hidden = !state.features.includes('publish') || pageless;
484
692
  publish.disabled = busy;
485
693
  publish.textContent = state.publishing ? labels.publishing : labels.publish;
486
694
  signOut.hidden = !site;
695
+ footer.hidden = !site;
487
696
  unsaved.hidden = !state.unsaved;
488
- const text = state.prompting ? labels.working : message?.text ?? (site && !state.selectedUid ? labels.selectHint : '');
697
+ const text = state.prompting ? creating ? labels.creating : labels.working : message?.text ?? (site && !creating && !state.selectedUid ? labels.selectHint : '');
489
698
  status.textContent = text;
490
- status.className = `status${!busy && message?.error ? ' error' : ''}`;
699
+ status.hidden = !text;
700
+ status.className = `status${state.prompting ? ' working' : ''}${!busy && message?.error ? ' error' : ''}`;
491
701
  };
492
702
  const refresh = ()=>render(bridge.getState());
493
703
  const grow = ()=>{
@@ -499,11 +709,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
499
709
  if (!prompt || bridge.getState().prompting) return;
500
710
  message = null;
501
711
  try {
502
- const { summary } = await bridge.prompt(prompt);
503
- message = {
504
- text: summary,
505
- error: false
506
- };
712
+ if (creating) {
713
+ const path = pathInput.value.trim();
714
+ if (!path) return pathInput.focus();
715
+ // The bridge opens the new page; this shows until it does.
716
+ await bridge.createPage(path, prompt);
717
+ message = {
718
+ text: labels.created,
719
+ error: false
720
+ };
721
+ } else {
722
+ const { summary } = await bridge.prompt(prompt);
723
+ message = {
724
+ text: summary,
725
+ error: false
726
+ };
727
+ }
507
728
  input.value = '';
508
729
  grow();
509
730
  } catch (error) {
@@ -517,8 +738,16 @@ const mountPromptBox = (doc, bridge, options = {})=>{
517
738
  };
518
739
  const setOpen = (next)=>{
519
740
  open = next;
520
- if (site) bridge.setSelecting(open);
521
741
  refresh();
742
+ if (site) bridge.setSelecting(open && !creating);
743
+ };
744
+ const setCreating = (next)=>{
745
+ creating = next;
746
+ message = null;
747
+ bridge.setSelecting(open && !creating);
748
+ if (creating) bridge.select(null);
749
+ refresh();
750
+ (creating ? pathInput : input).focus();
522
751
  };
523
752
  const store = async (action)=>{
524
753
  message = null;
@@ -538,7 +767,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
538
767
  };
539
768
  pill.addEventListener('click', ()=>{
540
769
  setOpen(true);
541
- input.focus();
770
+ (creating ? pathInput : input).focus();
542
771
  });
543
772
  close.addEventListener('click', ()=>setOpen(false));
544
773
  save.addEventListener('click', ()=>void store('save'));
@@ -546,6 +775,17 @@ const mountPromptBox = (doc, bridge, options = {})=>{
546
775
  signOut.addEventListener('click', ()=>bridge.signOut());
547
776
  pillSignOut.addEventListener('click', ()=>bridge.signOut());
548
777
  clearBlock.addEventListener('click', ()=>bridge.select(null));
778
+ newPage.addEventListener('click', ()=>setCreating(true));
779
+ cancelNew.addEventListener('click', ()=>setCreating(false));
780
+ pathInput.addEventListener('input', refresh);
781
+ pathInput.addEventListener('keydown', (event)=>{
782
+ if (event.key === 'Enter' && !event.isComposing) {
783
+ event.preventDefault();
784
+ input.focus();
785
+ } else if (event.key === 'Escape') {
786
+ setOpen(false);
787
+ }
788
+ });
549
789
  undo.addEventListener('click', ()=>{
550
790
  message = null;
551
791
  bridge.undo();
@@ -599,7 +839,7 @@ const readSiteEditing = (doc)=>{
599
839
  return null;
600
840
  };
601
841
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
602
- const createSiteBridge = (win, basePath)=>{
842
+ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
603
843
  const doc = win.document;
604
844
  const emitter = createEmitter();
605
845
  const cleanup = [];
@@ -762,6 +1002,39 @@ const createSiteBridge = (win, basePath)=>{
762
1002
  });
763
1003
  }
764
1004
  },
1005
+ async createPage (path, prompt) {
1006
+ const text = prompt.trim();
1007
+ if (!text) throw new Error('Say what the page is for');
1008
+ if (!path.trim()) throw new Error('Say where the page goes, like /pricing');
1009
+ if (!has('create')) throw new Error('You may not add pages here');
1010
+ // Opening the new page would lose them.
1011
+ if (state.unsaved) {
1012
+ throw new Error('Save or undo your changes before adding a page');
1013
+ }
1014
+ setState({
1015
+ prompting: true
1016
+ });
1017
+ try {
1018
+ const created = await api('entries', {
1019
+ path,
1020
+ prompt: text,
1021
+ locale: locale(),
1022
+ from: pageEntryId()
1023
+ });
1024
+ const url = pagePath(created.path);
1025
+ win.location.assign(url);
1026
+ return {
1027
+ summary: created.summary,
1028
+ entryId: created.entryId,
1029
+ path: created.path,
1030
+ url
1031
+ };
1032
+ } finally{
1033
+ setState({
1034
+ prompting: false
1035
+ });
1036
+ }
1037
+ },
765
1038
  undo () {
766
1039
  const last = history.pop();
767
1040
  const entry = last && working.get(last.entryId);
@@ -888,7 +1161,7 @@ const peekContentBridge = ()=>typeof window === 'undefined' ? null : window[KEY]
888
1161
  // if (bridge.isEditing) bridge.on('update', ({ entry }) => render(entry))
889
1162
  //
890
1163
  // The first call's options win: there is one bridge per page.
891
- const getContentBridge = ({ url, prompt = true } = {})=>{
1164
+ const getContentBridge = ({ url, prompt = true, pagePath } = {})=>{
892
1165
  if (typeof window === 'undefined') return SERVER_BRIDGE;
893
1166
  const win = window;
894
1167
  if (win[KEY]) return win[KEY];
@@ -900,7 +1173,9 @@ const getContentBridge = ({ url, prompt = true } = {})=>{
900
1173
  // Not in the visual editor, but signed in to edit through the site's
901
1174
  // own `/cms/login` (@obelism/content/site).
902
1175
  const sitePath = bridge.isEditing || win.parent !== win ? null : readSiteEditing(win.document);
903
- if (sitePath) bridge = createSiteBridge(win, sitePath);
1176
+ if (sitePath) bridge = createSiteBridge(win, sitePath, {
1177
+ pagePath
1178
+ });
904
1179
  if (bridge.isEditing && prompt) {
905
1180
  const unmount = mountPromptBox(win.document, bridge, prompt === true ? {} : prompt);
906
1181
  const destroy = bridge.destroy;