thomwolf HF Staff Codex Claude Opus Claude Fable 5 commited on
Commit
3f3e761
·
1 Parent(s): d3283b8

Left carets, home star, project subtitles, empty-group rename fix

Browse files

Fold carets move to the left of the label (aligned spacer for leaf
rows); the hover trash overlays the row end so long titles run to the
edge. A muted star marks the home page. Renaming a group with no
children no longer loses its input — every group-label single-click
consequence now defers behind the same 280ms timer the double-click
cancels. The home page's first paragraph becomes the project's
subtitle (derived on store, persisted on the registry) and previews
under each title in the project dropdown.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

.e2e-collapsed.png CHANGED
.e2e-dnd-data/docs/Mu8PCMYK.md DELETED
@@ -1,2 +0,0 @@
1
- # DnD e2e
2
-
 
 
 
.e2e-dnd-data/docs/Mu8PCMYK.yjs DELETED
Binary file (827 Bytes)
 
.e2e-dnd-data/docs/hUfaQZjM.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # DnD e2e
2
+
3
+ A tiny project used by the browser test.
.e2e-dnd-data/docs/hUfaQZjM.yjs ADDED
Binary file (942 Bytes). View file
 
.e2e-dnd-data/registry.json CHANGED
@@ -1,29 +1,30 @@
1
  {
2
- "Mu8PCMYK": {
3
  "title": "DnD e2e",
4
- "createdAt": 1785799046623,
5
- "updatedAt": 1785799061921,
6
  "createdBy": "alice",
7
  "pages": {
8
  "_structure": {
9
  "title": "Structure",
10
- "createdAt": 1785799046633
11
  },
12
  "intro": {
13
  "title": "Intro Renamed",
14
- "createdAt": 1785799046640,
15
  "createdBy": "alice"
16
  },
17
  "notes": {
18
  "title": "Notes",
19
- "createdAt": 1785799046652,
20
  "createdBy": "alice"
21
  },
22
  "untitled": {
23
  "title": "Fresh Page",
24
- "createdAt": 1785799059504,
25
  "createdBy": "alice"
26
  }
27
- }
 
28
  }
29
  }
 
1
  {
2
+ "hUfaQZjM": {
3
  "title": "DnD e2e",
4
+ "createdAt": 1785800941745,
5
+ "updatedAt": 1785800977161,
6
  "createdBy": "alice",
7
  "pages": {
8
  "_structure": {
9
  "title": "Structure",
10
+ "createdAt": 1785800941755
11
  },
12
  "intro": {
13
  "title": "Intro Renamed",
14
+ "createdAt": 1785800941762,
15
  "createdBy": "alice"
16
  },
17
  "notes": {
18
  "title": "Notes",
19
+ "createdAt": 1785800941771,
20
  "createdBy": "alice"
21
  },
22
  "untitled": {
23
  "title": "Fresh Page",
24
+ "createdAt": 1785800954962,
25
  "createdBy": "alice"
26
  }
27
+ },
28
+ "subtitle": "A tiny project used by the browser test."
29
  }
30
  }
.e2e-dnd-data/session-secret CHANGED
@@ -1 +1 @@
1
- bce198fbd9965593ec73dc9d4be025e270943af1b54503e30d64efbb3252362e
 
1
+ cc366cbee91eb082c31e73e023746c356916662d233098a2d4a5533b684cc451
.e2e-dnd-data/snapshots/Mu8PCMYK/1785799048662.yjs DELETED
Binary file (533 Bytes)
 
.e2e-dnd-data/snapshots/hUfaQZjM/1785800943780.yjs ADDED
Binary file (533 Bytes). View file
 
.e2e-final.png CHANGED
client/app.css CHANGED
@@ -254,6 +254,8 @@ textarea { resize: vertical; }
254
  .doc-row.current { background: var(--accent-soft); }
255
  .doc-row-main { display: flex; flex-direction: column; gap: 1px; padding: 7px 0; flex: 1; min-width: 0; text-decoration: none; color: var(--text); }
256
  .doc-row .title { font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
 
 
257
  .doc-row .meta { font-size: 11.5px; color: var(--text-3); }
258
  .doc-row .iconbtn { opacity: 0; flex-shrink: 0; }
259
  .doc-row:hover .iconbtn, .doc-row .iconbtn:focus-visible { opacity: 1; }
@@ -759,28 +761,32 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
759
  #page-tree .nav-cat { margin-bottom: 10px; }
760
  #page-tree .nav-cat .nav-cat { margin-bottom: 2px; }
761
  #page-tree .tree-row {
762
- display: flex; align-items: center; min-width: 0; border-left: 2px solid transparent; border-radius: 5px;
 
763
  }
764
  #page-tree a {
765
  display: block; flex: 1; min-width: 0; padding: 5px 9px; color: var(--text-2); font-size: 12.5px; line-height: 1.35; text-decoration: none;
766
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
767
  transition: background .12s, color .12s;
768
  }
769
- #page-tree .page-row:hover { background: var(--hl-weak); }
770
  #page-tree .page-row:hover > a { color: var(--text); }
771
- #page-tree .page-row:has(a.current) { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
 
 
 
772
  #page-tree a.current { color: var(--accent); font-weight: 500; }
773
- #page-tree .tree-row[data-selected='1']:not(:has(a.current)) { background: var(--hl); }
774
  #page-tree .tree-row[data-selected='1']:not(:has(a.current)) a,
775
  #page-tree .group-row[data-selected='1'] .group-label { color: var(--text); }
776
  #page-tree a.ghost-page { color: var(--text-3); font-style: italic; }
777
  #page-tree .tree-caret,
778
  #page-tree .tree-caret-spacer {
779
- order: 3; margin-left: auto; width: 16px; height: 26px; flex: 0 0 16px; padding: 0; border: 0; background: transparent;
780
  }
781
  #page-tree .tree-caret {
782
  color: var(--text-3); cursor: pointer; font-size: 18px; line-height: 26px; transform: rotate(0deg);
783
- text-align: center; transition: transform .15s, color .12s;
784
  }
785
  #page-tree .tree-caret.collapsed { transform: rotate(-90deg); }
786
  #page-tree .tree-caret:hover { color: var(--text); }
@@ -794,7 +800,8 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
794
  #page-tree .group-row.lvl-2 .group-label { font-size: 12.5px; }
795
  #page-tree .group-row.lvl-3 .group-label,
796
  #page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
797
- #page-tree .group-row:hover { background: var(--hl-weak); }
 
798
  #page-tree .sidebar-rename {
799
  flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
800
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
@@ -907,12 +914,11 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
907
  #margin-toggle:hover { color: var(--text); }
908
 
909
 
910
- /* The delete slot is always reserved: hover only changes visibility, so labels
911
- and carets never jump. It is the final control at the far right. */
912
- #page-tree .page-del, #page-tree .group-del, #page-tree .tree-delete-spacer {
913
- order: 4; width: 22px; height: 26px; flex: 0 0 22px; margin: 0; padding: 0 2px;
914
- }
915
  #page-tree .page-del, #page-tree .group-del {
 
 
916
  visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s, color .12s, background .12s;
917
  }
918
  #page-tree .tree-row:hover > .page-del, #page-tree .tree-row:hover > .group-del,
@@ -921,7 +927,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
921
  }
922
  #page-tree .page-del svg, #page-tree .group-del svg { width: 13px; height: 13px; }
923
  #page-tree .page-del:hover, #page-tree .group-del:hover { color: var(--danger); }
924
- #page-tree .page-del.arming, #page-tree .group-del.arming { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
925
 
926
 
927
  /* --- slug legend on the structure page --- */
@@ -1262,7 +1268,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
1262
  #page-tree a { padding: 10px 10px; border-radius: 8px; }
1263
  #page-tree .tree-caret, #page-tree .tree-caret-spacer { width: 24px; height: 40px; flex-basis: 24px; line-height: 40px; }
1264
  #page-tree .group-label { padding: 10px 6px; font-size: 13.5px; }
1265
- #page-tree .page-del, #page-tree .group-del, #page-tree .tree-delete-spacer { width: 30px; height: 40px; flex-basis: 30px; }
1266
  #page-tree .page-del, #page-tree .group-del { visibility: visible; opacity: 1; pointer-events: auto; padding: 6px; }
1267
  #page-tree .page-del svg, #page-tree .group-del svg { width: 16px; height: 16px; }
1268
  /* the desktop edge tabs are replaced by real header buttons */
 
254
  .doc-row.current { background: var(--accent-soft); }
255
  .doc-row-main { display: flex; flex-direction: column; gap: 1px; padding: 7px 0; flex: 1; min-width: 0; text-decoration: none; color: var(--text); }
256
  .doc-row .title { font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
257
+ /* the project's subtitle (home page's first paragraph) — a description line */
258
+ .doc-row .doc-row-sub { font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
259
  .doc-row .meta { font-size: 11.5px; color: var(--text-3); }
260
  .doc-row .iconbtn { opacity: 0; flex-shrink: 0; }
261
  .doc-row:hover .iconbtn, .doc-row .iconbtn:focus-visible { opacity: 1; }
 
761
  #page-tree .nav-cat { margin-bottom: 10px; }
762
  #page-tree .nav-cat .nav-cat { margin-bottom: 2px; }
763
  #page-tree .tree-row {
764
+ --sidebar-row-bg: var(--bg);
765
+ position: relative; display: flex; align-items: center; min-width: 0; border-left: 2px solid transparent; border-radius: 5px;
766
  }
767
  #page-tree a {
768
  display: block; flex: 1; min-width: 0; padding: 5px 9px; color: var(--text-2); font-size: 12.5px; line-height: 1.35; text-decoration: none;
769
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
770
  transition: background .12s, color .12s;
771
  }
772
+ #page-tree .page-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
773
  #page-tree .page-row:hover > a { color: var(--text); }
774
+ #page-tree .page-row:has(a.current) {
775
+ --sidebar-row-bg: color-mix(in srgb, var(--accent) 12%, transparent);
776
+ border-left-color: var(--accent); background: var(--sidebar-row-bg);
777
+ }
778
  #page-tree a.current { color: var(--accent); font-weight: 500; }
779
+ #page-tree .tree-row[data-selected='1']:not(:has(a.current)) { --sidebar-row-bg: var(--hl); background: var(--sidebar-row-bg); }
780
  #page-tree .tree-row[data-selected='1']:not(:has(a.current)) a,
781
  #page-tree .group-row[data-selected='1'] .group-label { color: var(--text); }
782
  #page-tree a.ghost-page { color: var(--text-3); font-style: italic; }
783
  #page-tree .tree-caret,
784
  #page-tree .tree-caret-spacer {
785
+ width: 16px; height: 26px; flex: 0 0 16px; margin: 0; padding: 0; border: 0; background: transparent;
786
  }
787
  #page-tree .tree-caret {
788
  color: var(--text-3); cursor: pointer; font-size: 18px; line-height: 26px; transform: rotate(0deg);
789
+ align-self: center; text-align: center; transition: transform .15s, color .12s;
790
  }
791
  #page-tree .tree-caret.collapsed { transform: rotate(-90deg); }
792
  #page-tree .tree-caret:hover { color: var(--text); }
 
800
  #page-tree .group-row.lvl-2 .group-label { font-size: 12.5px; }
801
  #page-tree .group-row.lvl-3 .group-label,
802
  #page-tree .group-row.lvl-4 .group-label { color: var(--text-2); font-size: 12px; }
803
+ #page-tree .group-row:hover { --sidebar-row-bg: var(--hl-weak); background: var(--sidebar-row-bg); }
804
+ #page-tree .home-star { width: 12px; height: 12px; flex: 0 0 12px; color: var(--text-3); }
805
  #page-tree .sidebar-rename {
806
  flex: 1; min-width: 0; margin: 1px 3px; padding: 3px 5px; border: 1px solid var(--accent); border-radius: 5px;
807
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
 
914
  #margin-toggle:hover { color: var(--text); }
915
 
916
 
917
+ /* Trash overlays the row's trailing edge instead of shortening every title.
918
+ Its opaque hover-matched fill lets text disappear cleanly underneath. */
 
 
 
919
  #page-tree .page-del, #page-tree .group-del {
920
+ position: absolute; right: 0; top: 50%; z-index: 2; width: 22px; height: 26px; margin: 0; padding: 0 2px;
921
+ transform: translateY(-50%); background: var(--sidebar-row-bg);
922
  visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s, color .12s, background .12s;
923
  }
924
  #page-tree .tree-row:hover > .page-del, #page-tree .tree-row:hover > .group-del,
 
927
  }
928
  #page-tree .page-del svg, #page-tree .group-del svg { width: 13px; height: 13px; }
929
  #page-tree .page-del:hover, #page-tree .group-del:hover { color: var(--danger); }
930
+ #page-tree .page-del.arming, #page-tree .group-del.arming { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, var(--sidebar-row-bg)); }
931
 
932
 
933
  /* --- slug legend on the structure page --- */
 
1268
  #page-tree a { padding: 10px 10px; border-radius: 8px; }
1269
  #page-tree .tree-caret, #page-tree .tree-caret-spacer { width: 24px; height: 40px; flex-basis: 24px; line-height: 40px; }
1270
  #page-tree .group-label { padding: 10px 6px; font-size: 13.5px; }
1271
+ #page-tree .page-del, #page-tree .group-del { width: 30px; height: 40px; }
1272
  #page-tree .page-del, #page-tree .group-del { visibility: visible; opacity: 1; pointer-events: auto; padding: 6px; }
1273
  #page-tree .page-del svg, #page-tree .group-del svg { width: 16px; height: 16px; }
1274
  /* the desktop edge tabs are replaced by real header buttons */
client/src/common.js CHANGED
@@ -3,6 +3,7 @@
3
  const ICONS = {
4
  file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>',
5
  folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>',
 
6
  tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>',
7
  key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>',
8
  todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>',
 
3
  const ICONS = {
4
  file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>',
5
  folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>',
6
+ star: '<path d="M11 3.5l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z"/>',
7
  tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>',
8
  key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>',
9
  todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>',
client/src/doc.js CHANGED
@@ -1608,10 +1608,13 @@ async function loadStructure() {
1608
  let clickTimer = null
1609
  label.addEventListener('click', e => {
1610
  clearTimeout(clickTimer)
1611
- const plain = updateSelection(e, key)
1612
- if (!plain) return loadStructure()
1613
- if (hasChildren) clickTimer = setTimeout(() => setSidebarCollapsed(key, !isCollapsed), 280)
1614
- else loadStructure()
 
 
 
1615
  })
1616
  label.addEventListener('dblclick', e => {
1617
  e.preventDefault()
@@ -1639,9 +1642,10 @@ async function loadStructure() {
1639
  if (e.button !== 0) return
1640
  e.preventDefault()
1641
  clearTimeout(clickTimer)
1642
- const plain = updateSelection(e, key)
1643
- if (!plain) return loadStructure()
1644
  clickTimer = setTimeout(() => {
 
 
1645
  if (node.slug === pageSlug) loadStructure()
1646
  else openPage(node.slug)
1647
  }, 280)
@@ -1653,9 +1657,13 @@ async function loadStructure() {
1653
  if (!known.has(node.slug)) return
1654
  beginPageRename(a, node.slug, pageTitle)
1655
  })
 
 
 
 
 
1656
  row.appendChild(a)
1657
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
1658
- else row.appendChild(el('span', { class: 'tree-delete-spacer', 'aria-hidden': 'true' }))
1659
  }
1660
  li.appendChild(row)
1661
  if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
@@ -1686,9 +1694,10 @@ async function loadStructure() {
1686
  if (e.button !== 0) return
1687
  e.preventDefault()
1688
  clearTimeout(clickTimer)
1689
- const plain = updateSelection(e, slug)
1690
- if (!plain) return loadStructure()
1691
  clickTimer = setTimeout(() => {
 
 
1692
  if (slug === pageSlug) loadStructure()
1693
  else openPage(slug)
1694
  }, 280)
@@ -1701,7 +1710,6 @@ async function loadStructure() {
1701
  })
1702
  row.appendChild(a)
1703
  if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
1704
- else row.appendChild(el('span', { class: 'tree-delete-spacer', 'aria-hidden': 'true' }))
1705
  li.appendChild(row)
1706
  ul.appendChild(li)
1707
  }
@@ -2951,6 +2959,7 @@ function openComposer(editor, opts = {}) {
2951
  // There is no separate index page: the document you are in is the whole app, and
2952
  // this is how you reach the others. Switching is a normal navigation — the shell
2953
  // is cheap and it keeps one document per URL, which is what sharing a link means.
 
2954
  function wireDocsMenu() {
2955
  const btn = document.getElementById('docs-btn')
2956
  const pop = document.getElementById('docs-pop')
@@ -2970,6 +2979,9 @@ function wireDocsMenu() {
2970
  const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') })
2971
  const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' })
2972
  link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
 
 
 
2973
  const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : ''
2974
  // timeAgo says 'now' for anything inside a minute, which does not take 'ago'
2975
  const when = timeAgo(d.updatedAt)
 
1608
  let clickTimer = null
1609
  label.addEventListener('click', e => {
1610
  clearTimeout(clickTimer)
1611
+ const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
1612
+ clickTimer = setTimeout(() => {
1613
+ const plain = updateSelection(modifiers, key)
1614
+ if (!plain) return loadStructure()
1615
+ if (hasChildren) setSidebarCollapsed(key, !isCollapsed)
1616
+ else loadStructure()
1617
+ }, 280)
1618
  })
1619
  label.addEventListener('dblclick', e => {
1620
  e.preventDefault()
 
1642
  if (e.button !== 0) return
1643
  e.preventDefault()
1644
  clearTimeout(clickTimer)
1645
+ const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
 
1646
  clickTimer = setTimeout(() => {
1647
+ const plain = updateSelection(modifiers, key)
1648
+ if (!plain) return loadStructure()
1649
  if (node.slug === pageSlug) loadStructure()
1650
  else openPage(node.slug)
1651
  }, 280)
 
1657
  if (!known.has(node.slug)) return
1658
  beginPageRename(a, node.slug, pageTitle)
1659
  })
1660
+ if (node.slug === 'home') {
1661
+ const star = icon('star')
1662
+ star.classList.add('home-star')
1663
+ row.appendChild(star)
1664
+ }
1665
  row.appendChild(a)
1666
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
 
1667
  }
1668
  li.appendChild(row)
1669
  if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
 
1694
  if (e.button !== 0) return
1695
  e.preventDefault()
1696
  clearTimeout(clickTimer)
1697
+ const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey }
 
1698
  clickTimer = setTimeout(() => {
1699
+ const plain = updateSelection(modifiers, slug)
1700
+ if (!plain) return loadStructure()
1701
  if (slug === pageSlug) loadStructure()
1702
  else openPage(slug)
1703
  }, 280)
 
1710
  })
1711
  row.appendChild(a)
1712
  if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
 
1713
  li.appendChild(row)
1714
  ul.appendChild(li)
1715
  }
 
2959
  // There is no separate index page: the document you are in is the whole app, and
2960
  // this is how you reach the others. Switching is a normal navigation — the shell
2961
  // is cheap and it keeps one document per URL, which is what sharing a link means.
2962
+
2963
  function wireDocsMenu() {
2964
  const btn = document.getElementById('docs-btn')
2965
  const pop = document.getElementById('docs-pop')
 
2979
  const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') })
2980
  const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' })
2981
  link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled'))
2982
+ // how the project itself opens, so the list says what each one is about.
2983
+ // Nothing written under home's title yet = no second line at all.
2984
+ if (d.subtitle) link.appendChild(el('span', { class: 'doc-row-sub' }, d.subtitle))
2985
  const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : ''
2986
  // timeAgo says 'now' for anything inside a minute, which does not take 'ago'
2987
  const when = timeAgo(d.updatedAt)
server/api.js CHANGED
@@ -40,7 +40,9 @@ export function apiRouter() {
40
  const reg = store.getRegistry()
41
  const docs = Object.entries(reg)
42
  .filter(([id]) => store.canAccessDoc(id, req.user.username))
43
- .map(([id, meta]) => ({ id, ...meta }))
 
 
44
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
45
  res.json({ docs })
46
  })
@@ -90,6 +92,7 @@ export function apiRouter() {
90
  res.json({
91
  id: req.params.id,
92
  title: meta.title,
 
93
  page: docName.includes('::') ? docName.split('::')[1] : 'home',
94
  pages: Object.entries(pages).map(([slug, p]) => ({ slug, title: p.title || slug })),
95
  created_by: meta.createdBy || null,
 
40
  const reg = store.getRegistry()
41
  const docs = Object.entries(reg)
42
  .filter(([id]) => store.canAccessDoc(id, req.user.username))
43
+ // subtitle spelled out: the switcher shows it under each title, and a
44
+ // project that has none must still say so rather than leave the key off
45
+ .map(([id, meta]) => ({ id, ...meta, subtitle: meta.subtitle || null }))
46
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
47
  res.json({ docs })
48
  })
 
92
  res.json({
93
  id: req.params.id,
94
  title: meta.title,
95
+ subtitle: meta.subtitle || null,
96
  page: docName.includes('::') ? docName.split('::')[1] : 'home',
97
  pages: Object.entries(pages).map(([slug, p]) => ({ slug, title: p.title || slug })),
98
  created_by: meta.createdBy || null,
server/collab.js CHANGED
@@ -195,27 +195,60 @@ export const hocuspocus = new Hocuspocus({
195
  let homeMarkdown = null
196
  for (const name of projectPageNames(projectId)) {
197
  try {
 
198
  const pm = yDocToProsemirrorJSON(document, pageField(name))
199
- if (pageSlugOf(name) === 'home') homeMarkdown = pmToMarkdown(pm)
200
- store.touchProjectOnStore(projectId, pageSlugOf(name), titleFromPm(pm))
 
201
  } catch {}
202
  }
203
  store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown)
204
  },
205
  })
206
 
207
- function titleFromPm(pm) {
208
- for (const node of pm.content || []) {
209
- const text = blockToMarkdown(node)
 
 
 
210
  ?.replace(/^#+\s*/, '')
 
211
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text
212
  .replace(/[*_`]/g, '')
213
- .trim()
 
 
 
 
 
 
214
  if (text) return text.slice(0, 80)
215
  }
216
  return null
217
  }
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  // --- document watcher: detect new @mentions in comment threads ---
220
 
221
  // Watch the LIVE document instance, not just the name. A document can be
 
195
  let homeMarkdown = null
196
  for (const name of projectPageNames(projectId)) {
197
  try {
198
+ const slug = pageSlugOf(name)
199
  const pm = yDocToProsemirrorJSON(document, pageField(name))
200
+ if (slug === 'home') homeMarkdown = pmToMarkdown(pm)
201
+ // the subtitle only describes the project, so only home has one
202
+ store.touchProjectOnStore(projectId, slug, titleFromPm(pm), slug === 'home' ? subtitleFromPm(pm) : null)
203
  } catch {}
204
  }
205
  store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown)
206
  },
207
  })
208
 
209
+ // The plain text of a ProseMirror block, with the markup that markdown adds
210
+ // around it taken back off — what a title or a subtitle reads as, not how it is
211
+ // written. (blockPlainText below is the same idea for *parsed markdown* blocks.)
212
+ function pmBlockText(node) {
213
+ return (
214
+ blockToMarkdown(node)
215
  ?.replace(/^#+\s*/, '')
216
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> their alt text, if any
217
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text
218
  .replace(/[*_`]/g, '')
219
+ .trim() || ''
220
+ )
221
+ }
222
+
223
+ function titleFromPm(pm) {
224
+ for (const node of pm.content || []) {
225
+ const text = pmBlockText(node)
226
  if (text) return text.slice(0, 80)
227
  }
228
  return null
229
  }
230
 
231
+ // The project's one-line description: the first ordinary paragraph under the
232
+ // title, the way the page itself opens. Derived like the title — read-only, on
233
+ // store, never written back into the document. Section headings, code, lists and
234
+ // tables are not prose, so they are passed over rather than ending the search.
235
+ const SUBTITLE_MAX = 160
236
+
237
+ function subtitleFromPm(pm) {
238
+ let pastTitle = false
239
+ for (const node of pm.content || []) {
240
+ const text = pmBlockText(node)
241
+ if (!text) continue
242
+ if (!pastTitle) {
243
+ pastTitle = true // whatever it was, that block is the title
244
+ continue
245
+ }
246
+ if (node.type !== 'paragraph') continue
247
+ return text.slice(0, SUBTITLE_MAX).trim()
248
+ }
249
+ return null
250
+ }
251
+
252
  // --- document watcher: detect new @mentions in comment threads ---
253
 
254
  // Watch the LIVE document instance, not just the name. A document can be
server/store.js CHANGED
@@ -145,7 +145,7 @@ export function canAccessDoc(docName, username) {
145
  // One combined registry touch per doc store, and only when something actually
146
  // changed — registry.json lives on bucket-mounted storage where every sync
147
  // write is expensive (and a hung write blocks the event loop).
148
- export function touchProjectOnStore(projectId, slug, title) {
149
  const reg = getRegistry()
150
  const meta = reg[projectId]
151
  if (!meta) return
@@ -157,6 +157,15 @@ export function touchProjectOnStore(projectId, slug, title) {
157
  next.title = title || 'Untitled'
158
  dirty = true
159
  }
 
 
 
 
 
 
 
 
 
160
  } else {
161
  const pageTitle = slug === '_structure' ? 'Structure' : title || slug
162
  if (meta.pages?.[slug]?.title !== pageTitle) {
 
145
  // One combined registry touch per doc store, and only when something actually
146
  // changed — registry.json lives on bucket-mounted storage where every sync
147
  // write is expensive (and a hung write blocks the event loop).
148
+ export function touchProjectOnStore(projectId, slug, title, subtitle) {
149
  const reg = getRegistry()
150
  const meta = reg[projectId]
151
  if (!meta) return
 
157
  next.title = title || 'Untitled'
158
  dirty = true
159
  }
160
+ // the project's one-line description, from home's opening paragraph. Absent
161
+ // (the paragraph was emptied or never written) drops the key rather than
162
+ // storing a null — a write either way, but only when it really changed.
163
+ const sub = subtitle || null
164
+ if (sub !== (meta.subtitle || null)) {
165
+ if (sub) next.subtitle = sub
166
+ else delete next.subtitle
167
+ dirty = true
168
+ }
169
  } else {
170
  const pageTitle = slug === '_structure' ? 'Structure' : title || slug
171
  if (meta.pages?.[slug]?.title !== pageTitle) {
test/e2e-dnd.mjs CHANGED
@@ -236,8 +236,48 @@ try {
236
  assert.ok(!st.tree.some(n => n.group === 'Extras'), 'armed second click dissolves')
237
  }
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  await page.screenshot({ path: path.join(root, '.e2e-final.png') })
240
- console.log('E2E DND TESTS PASSED (rounds 1+2+3)')
241
  console.log(st.raw)
242
  } finally {
243
  await browser?.close()
 
236
  assert.ok(!st.tree.some(n => n.group === 'Extras'), 'armed second click dissolves')
237
  }
238
 
239
+ // --- round 4: caret left, home star, empty-group rename, subtitle ---
240
+
241
+ // 19. caret sits LEFT of the label; home row carries the star
242
+ {
243
+ const order = await page.evaluate(() => {
244
+ const row = document.querySelector('#page-tree [data-key="home"]')
245
+ return [...row.children].map(c => c.className && String(c.className.baseVal ?? c.className).split(' ')[0])
246
+ })
247
+ const caretAt = order.findIndex(c => c && c.startsWith('tree-caret'))
248
+ const starAt = order.findIndex(c => c === 'icon' || c === 'home-star')
249
+ const labelAt = order.findIndex(c => !c || c === 'current' || c === '')
250
+ assert.ok(caretAt === 0, 'caret/spacer first in the row: ' + JSON.stringify(order))
251
+ assert.ok(await page.locator('#page-tree [data-key="home"] .home-star').count(), 'home star present')
252
+ assert.ok(starAt < labelAt || labelAt === -1, 'star before label: ' + JSON.stringify(order))
253
+ }
254
+
255
+ // 20. renaming a group with NO children: the input must survive the double-click
256
+ await api(`/api/docs/${doc.id}/structure/groups`, { label: 'Lonely' })
257
+ await page.waitForTimeout(1200)
258
+ await page.dblclick(`#page-tree [data-key='"Lonely"'] .group-label`)
259
+ await page.waitForTimeout(600) // long enough for any stray re-render to kill it
260
+ const lonelyInput = page.locator('#page-tree input.sidebar-rename')
261
+ assert.ok(await lonelyInput.count(), 'rename input still open on an empty group')
262
+ await lonelyInput.fill('Kept')
263
+ await lonelyInput.press('Enter')
264
+ await page.waitForTimeout(700)
265
+ st = await api(`/api/docs/${doc.id}/structure`)
266
+ assert.ok(st.tree.some(n => n.group === 'Kept'), 'empty group renamed: ' + JSON.stringify(st.tree))
267
+
268
+ // 21. home subtitle: first paragraph under the H1 previews in the project dropdown
269
+ await page.click('#page-tree [data-key="home"] a')
270
+ await page.waitForTimeout(800)
271
+ await page.evaluate(() => window.__editor.commands.focus('end'))
272
+ await page.keyboard.type('A tiny project used by the browser test.')
273
+ await page.waitForTimeout(4000) // store debounce persists the registry subtitle
274
+ await page.click('#docs-btn')
275
+ await page.waitForSelector('.doc-row', { timeout: 5000 })
276
+ const sub = await page.locator('.doc-row .doc-row-sub').first().textContent()
277
+ assert.ok(sub.includes('tiny project'), 'subtitle previews in the dropdown: ' + sub)
278
+
279
  await page.screenshot({ path: path.join(root, '.e2e-final.png') })
280
+ console.log('E2E DND TESTS PASSED (rounds 1-4)')
281
  console.log(st.raw)
282
  } finally {
283
  await browser?.close()