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