canopycms 0.1.0 → 0.2.0-int.121

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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
package/dist/cli/cli.js CHANGED
@@ -13321,6 +13321,21 @@ var init_generate_ai_content = __esm({
13321
13321
  }
13322
13322
  });
13323
13323
 
13324
+ // dist/utils/logger.js
13325
+ function canopyLogWarn3(...args) {
13326
+ active3.warn(...args);
13327
+ }
13328
+ function canopyLogError2(...args) {
13329
+ active3.error(...args);
13330
+ }
13331
+ var active3;
13332
+ var init_logger3 = __esm({
13333
+ "dist/utils/logger.js"() {
13334
+ "use strict";
13335
+ active3 = console;
13336
+ }
13337
+ });
13338
+
13324
13339
  // dist/assets/asset-prefixes.js
13325
13340
  var ASSET_PREFIXES;
13326
13341
  var init_asset_prefixes = __esm({
@@ -13330,6 +13345,8 @@ var init_asset_prefixes = __esm({
13330
13345
  originals: "asset-originals",
13331
13346
  staging: "asset-staging",
13332
13347
  meta: "asset-meta",
13348
+ /** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
13349
+ hidden: "asset-hidden",
13333
13350
  public: "assets",
13334
13351
  /** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
13335
13352
  transform: "assets/t"
@@ -13337,21 +13354,6 @@ var init_asset_prefixes = __esm({
13337
13354
  }
13338
13355
  });
13339
13356
 
13340
- // dist/utils/logger.js
13341
- function canopyLogWarn3(...args) {
13342
- active3.warn(...args);
13343
- }
13344
- function canopyLogError2(...args) {
13345
- active3.error(...args);
13346
- }
13347
- var active3;
13348
- var init_logger3 = __esm({
13349
- "dist/utils/logger.js"() {
13350
- "use strict";
13351
- active3 = console;
13352
- }
13353
- });
13354
-
13355
13357
  // dist/assets/sharp-loader.js
13356
13358
  function loadSharp() {
13357
13359
  if (!loading) {
@@ -13721,7 +13723,11 @@ var init_transform = __esm({
13721
13723
 
13722
13724
  // dist/assets/materialize.js
13723
13725
  async function storeTransform(store, parsed, key, options = {}) {
13724
- const meta = await store.getMeta(parsed.hash32);
13726
+ const { source } = options;
13727
+ if (source && source.meta.hash32 !== parsed.hash32) {
13728
+ throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
13729
+ }
13730
+ const meta = source?.meta ?? await store.getMeta(parsed.hash32);
13725
13731
  if (!meta) {
13726
13732
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
13727
13733
  }
@@ -13739,7 +13745,7 @@ async function storeTransform(store, parsed, key, options = {}) {
13739
13745
  if (requestedFormat === void 0 && parsed.ext !== meta.ext) {
13740
13746
  return { ok: false, status: 400, error: "Extension does not match the source format" };
13741
13747
  }
13742
- const original = await store.readOriginal(parsed.hash32, meta.ext);
13748
+ const original = source ? { data: source.data, ext: meta.ext } : await store.readOriginal(parsed.hash32, meta.ext);
13743
13749
  if (!original) {
13744
13750
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
13745
13751
  }
@@ -14115,16 +14121,18 @@ function createKeyBuilders(prefixes = ASSET_PREFIXES) {
14115
14121
  stagingKey: (uuid) => `${prefixes.staging}/${uuid}`,
14116
14122
  metaKey: (hash32) => `${prefixes.meta}/${hash32}.json`,
14117
14123
  metaPrefix: () => `${prefixes.meta}/`,
14124
+ hiddenKey: (hash32) => `${prefixes.hidden}/${hash32}.json`,
14125
+ hiddenPrefix: () => `${prefixes.hidden}/`,
14118
14126
  publicKey: (hash32, slug, ext) => `${prefixes.public}/${hash32}/${slug}.${ext}`
14119
14127
  };
14120
14128
  }
14121
- var keyBuilders, originalKey, stagingKey, metaKey, publicKey, originalPrefix, metaPrefix;
14129
+ var keyBuilders, originalKey, stagingKey, metaKey, hiddenKey, publicKey, originalPrefix, metaPrefix;
14122
14130
  var init_keys = __esm({
14123
14131
  "dist/assets/keys.js"() {
14124
14132
  "use strict";
14125
14133
  init_asset_prefixes();
14126
14134
  keyBuilders = createKeyBuilders();
14127
- ({ originalKey, stagingKey, metaKey, publicKey } = keyBuilders);
14135
+ ({ originalKey, stagingKey, metaKey, hiddenKey, publicKey } = keyBuilders);
14128
14136
  ({ originalPrefix, metaPrefix } = keyBuilders);
14129
14137
  }
14130
14138
  });
@@ -14140,15 +14148,18 @@ function matchesAwsError(err2, name, httpStatusCode) {
14140
14148
  const shaped = err2;
14141
14149
  return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
14142
14150
  }
14143
- var DEFAULT_MAX_UPLOAD_BYTES, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14151
+ var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_WINDOW_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, CREDENTIAL_EXPIRY_MARGIN_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14144
14152
  var init_store_s3 = __esm({
14145
14153
  "dist/assets/store-s3.js"() {
14146
14154
  "use strict";
14147
14155
  init_sanitize_href3();
14148
14156
  init_keys();
14149
14157
  DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14158
+ JSON_SUFFIX = ".json";
14150
14159
  PRESIGN_EXPIRY_SECONDS = 15 * 60;
14151
- PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
14160
+ PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
14161
+ PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
14162
+ CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
14152
14163
  isPreconditionFailed = (err2) => matchesAwsError(err2, "PreconditionFailed", 412);
14153
14164
  isConditionalRequestConflict = (err2) => err2 instanceof Error && err2.name === "ConditionalRequestConflict";
14154
14165
  CONFLICT_ATTEMPTS = 4;
@@ -14341,7 +14352,10 @@ var init_store_s3 = __esm({
14341
14352
  throw err2;
14342
14353
  }
14343
14354
  }
14344
- async *listPublicObjectKeys(prefix) {
14355
+ listPublicObjectKeys(prefix) {
14356
+ return this.listKeys(prefix);
14357
+ }
14358
+ async *listKeys(prefix) {
14345
14359
  const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
14346
14360
  for await (const page of pages) {
14347
14361
  for (const object of page.Contents ?? []) {
@@ -14350,13 +14364,24 @@ var init_store_s3 = __esm({
14350
14364
  }
14351
14365
  }
14352
14366
  }
14353
- /** HEADs first: signing is local and succeeds for a missing key too. */
14367
+ /**
14368
+ * HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
14369
+ * credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
14370
+ * of the credentials' `expiration` when the provider reports one.
14371
+ */
14354
14372
  async presignPublicObjectRead(key) {
14355
14373
  if (!await this.hasPublicObject(key))
14356
14374
  return null;
14357
- return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
14358
- expiresIn: PRESIGNED_READ_EXPIRY_SECONDS
14359
- });
14375
+ const { expiration } = await this.client.config.credentials();
14376
+ const nowSeconds = Math.floor(Date.now() / 1e3);
14377
+ const windowStart = nowSeconds - nowSeconds % PRESIGNED_READ_WINDOW_SECONDS;
14378
+ const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1e3) });
14379
+ let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
14380
+ if (expiration) {
14381
+ const credentialSeconds = Math.floor(expiration.getTime() / 1e3) - nowSeconds;
14382
+ maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
14383
+ }
14384
+ return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
14360
14385
  }
14361
14386
  async putMetaIfAbsent(hash32, meta) {
14362
14387
  return this.putIfAbsent({
@@ -14371,27 +14396,67 @@ var init_store_s3 = __esm({
14371
14396
  return null;
14372
14397
  return JSON.parse(Buffer.from(bytes).toString("utf-8"));
14373
14398
  }
14399
+ /**
14400
+ * One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
14401
+ * and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
14402
+ * `includeHidden`.
14403
+ */
14374
14404
  async listMeta(input) {
14375
- const listed = await this.client.send(new ListObjectsV2Command({
14376
- Bucket: this.bucket,
14377
- Prefix: this.keys.metaPrefix(),
14378
- MaxKeys: input?.limit,
14379
- ContinuationToken: input?.cursor
14380
- }));
14405
+ const metaPrefix2 = this.keys.metaPrefix();
14406
+ const [listed, hidden] = await Promise.all([
14407
+ this.client.send(new ListObjectsV2Command({
14408
+ Bucket: this.bucket,
14409
+ Prefix: metaPrefix2,
14410
+ MaxKeys: input?.limit,
14411
+ ContinuationToken: input?.cursor
14412
+ })),
14413
+ this.listHiddenHashes()
14414
+ ]);
14381
14415
  const objectKeys = (listed.Contents ?? []).map((obj) => obj.Key).filter((key) => Boolean(key));
14382
14416
  const fetched = await Promise.all(objectKeys.map(async (key) => {
14417
+ const isHidden = hidden.has(key.slice(metaPrefix2.length, -JSON_SUFFIX.length));
14418
+ if (isHidden && !input?.includeHidden)
14419
+ return null;
14383
14420
  const bytes = await this.getObjectBytes(key);
14384
14421
  if (!bytes)
14385
14422
  return null;
14386
- return JSON.parse(Buffer.from(bytes).toString("utf-8"));
14423
+ const meta = JSON.parse(Buffer.from(bytes).toString("utf-8"));
14424
+ return isHidden ? { ...meta, hidden: true } : meta;
14387
14425
  }));
14388
14426
  return {
14389
14427
  items: fetched.filter((meta) => meta !== null),
14390
14428
  nextCursor: listed.IsTruncated ? listed.NextContinuationToken : void 0
14391
14429
  };
14392
14430
  }
14393
- async deleteMeta(hash32) {
14394
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: this.keys.metaKey(hash32) }));
14431
+ async listHiddenHashes() {
14432
+ const prefix = this.keys.hiddenPrefix();
14433
+ const hashes = /* @__PURE__ */ new Set();
14434
+ for await (const key of this.listKeys(prefix)) {
14435
+ if (key.endsWith(JSON_SUFFIX))
14436
+ hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
14437
+ }
14438
+ return hashes;
14439
+ }
14440
+ async hideAsset(hash32, marker) {
14441
+ return this.putIfAbsent({
14442
+ Key: this.keys.hiddenKey(hash32),
14443
+ Body: JSON.stringify(marker),
14444
+ ContentType: "application/json"
14445
+ });
14446
+ }
14447
+ /**
14448
+ * Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
14449
+ * The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
14450
+ * policy scoping it by prefix must name this one), no object permission, so finalize can call
14451
+ * this on every re-upload under a role that has never hidden a file.
14452
+ */
14453
+ async unhideAsset(hash32) {
14454
+ const key = this.keys.hiddenKey(hash32);
14455
+ const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
14456
+ if (listed.Contents?.[0]?.Key !== key)
14457
+ return false;
14458
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
14459
+ return true;
14395
14460
  }
14396
14461
  async getObject(key) {
14397
14462
  try {
@@ -14766,7 +14831,14 @@ var init_asset_refs = __esm({
14766
14831
  import fs26 from "node:fs/promises";
14767
14832
  import path29 from "node:path";
14768
14833
  import { randomUUID as randomUUID4 } from "node:crypto";
14769
- var DEFAULT_MAX_UPLOAD_BYTES2, DEFAULT_LIST_LIMIT, HEADERS_SIDECAR_SUFFIX, TEMP_DIR, ORIGINAL_EXT_RE, LocalAssetStore;
14834
+ function compareNewestFirst(a, b) {
14835
+ if (a.uploadedAt !== b.uploadedAt)
14836
+ return a.uploadedAt < b.uploadedAt ? 1 : -1;
14837
+ if (a.hash32 === b.hash32)
14838
+ return 0;
14839
+ return a.hash32 < b.hash32 ? 1 : -1;
14840
+ }
14841
+ var DEFAULT_MAX_UPLOAD_BYTES2, DEFAULT_LIST_LIMIT, HEADERS_SIDECAR_SUFFIX, TEMP_DIR, ORIGINAL_EXT_RE, HIDDEN_MARKER_RE, LocalAssetStore;
14770
14842
  var init_store_local = __esm({
14771
14843
  "dist/assets/store-local.js"() {
14772
14844
  "use strict";
@@ -14778,6 +14850,7 @@ var init_store_local = __esm({
14778
14850
  HEADERS_SIDECAR_SUFFIX = ".headers.json";
14779
14851
  TEMP_DIR = ".asset-tmp";
14780
14852
  ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
14853
+ HIDDEN_MARKER_RE = /^([a-f0-9]{32})\.json$/;
14781
14854
  LocalAssetStore = class {
14782
14855
  constructor(options) {
14783
14856
  this.capabilities = { directUpload: false };
@@ -14849,11 +14922,12 @@ var init_store_local = __esm({
14849
14922
  }
14850
14923
  }
14851
14924
  /**
14852
- * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
14853
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
14854
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
14855
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
14856
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
14925
+ * Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
14926
+ * whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
14927
+ * (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
14928
+ * its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
14929
+ * as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
14930
+ * its uploaded file).
14857
14931
  */
14858
14932
  async createExclusive(key, data, headers) {
14859
14933
  const filePath = this.resolveKey(key);
@@ -14866,8 +14940,10 @@ var init_store_local = __esm({
14866
14940
  const tempHeaders = path29.join(tempDir, randomUUID4());
14867
14941
  try {
14868
14942
  await fs26.writeFile(tempBlob, data);
14869
- await fs26.writeFile(tempHeaders, JSON.stringify(headers), "utf-8");
14870
- await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
14943
+ if (headers) {
14944
+ await fs26.writeFile(tempHeaders, JSON.stringify(headers), "utf-8");
14945
+ await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
14946
+ }
14871
14947
  return await this.linkIfAbsent(tempBlob, filePath) ? "created" : "already-exists";
14872
14948
  } finally {
14873
14949
  await Promise.all([tempBlob, tempHeaders].map((temp) => fs26.unlink(temp).catch(() => {
@@ -14999,6 +15075,11 @@ var init_store_local = __esm({
14999
15075
  throw err2;
15000
15076
  }
15001
15077
  }
15078
+ /**
15079
+ * The cursor names the last item returned, and the next page starts after it in sort order, so a
15080
+ * page boundary holds when that item is hidden in between: its meta is never deleted, and the
15081
+ * comparison needs only its place, not its presence in the filtered list.
15082
+ */
15002
15083
  async listMeta(input) {
15003
15084
  const limit = input?.limit ?? DEFAULT_LIST_LIMIT;
15004
15085
  const dir = this.resolveKey(ASSET_PREFIXES.meta);
@@ -15022,29 +15103,43 @@ var init_store_local = __esm({
15022
15103
  }
15023
15104
  }
15024
15105
  }
15025
- all.sort((a, b) => {
15026
- if (a.uploadedAt !== b.uploadedAt)
15027
- return a.uploadedAt < b.uploadedAt ? 1 : -1;
15028
- return a.hash32 < b.hash32 ? 1 : -1;
15029
- });
15106
+ all.sort(compareNewestFirst);
15107
+ const hidden = await this.hiddenHashes();
15108
+ const listed = input?.includeHidden ? all.map((meta) => hidden.has(meta.hash32) ? { ...meta, hidden: true } : meta) : all.filter((meta) => !hidden.has(meta.hash32));
15030
15109
  let startIndex = 0;
15031
15110
  if (input?.cursor) {
15032
15111
  const afterHash32 = Buffer.from(input.cursor, "base64").toString("utf-8");
15033
- const idx = all.findIndex((m) => m.hash32 === afterHash32);
15034
- startIndex = idx === -1 ? 0 : idx + 1;
15112
+ const anchor = all.find((m) => m.hash32 === afterHash32);
15113
+ if (anchor) {
15114
+ const after = listed.findIndex((m) => compareNewestFirst(m, anchor) > 0);
15115
+ startIndex = after === -1 ? listed.length : after;
15116
+ }
15035
15117
  }
15036
- const items = all.slice(startIndex, startIndex + limit);
15037
- const hasMore = startIndex + limit < all.length;
15118
+ const items = listed.slice(startIndex, startIndex + limit);
15119
+ const hasMore = startIndex + limit < listed.length;
15038
15120
  const nextCursor = hasMore ? Buffer.from(items[items.length - 1].hash32, "utf-8").toString("base64") : void 0;
15039
15121
  return { items, nextCursor };
15040
15122
  }
15041
- async deleteMeta(hash32) {
15042
- const filePath = this.resolveKey(metaKey(hash32));
15123
+ async hiddenHashes() {
15043
15124
  try {
15044
- await fs26.unlink(filePath);
15125
+ const names = await fs26.readdir(this.resolveKey(ASSET_PREFIXES.hidden));
15126
+ return new Set(names.flatMap((name) => HIDDEN_MARKER_RE.exec(name)?.[1] ?? []));
15045
15127
  } catch (err2) {
15046
15128
  if (isNotFoundError(err2))
15047
- return;
15129
+ return /* @__PURE__ */ new Set();
15130
+ throw err2;
15131
+ }
15132
+ }
15133
+ async hideAsset(hash32, marker) {
15134
+ return this.createExclusive(hiddenKey(hash32), new TextEncoder().encode(JSON.stringify(marker)));
15135
+ }
15136
+ async unhideAsset(hash32) {
15137
+ try {
15138
+ await fs26.unlink(this.resolveKey(hiddenKey(hash32)));
15139
+ return true;
15140
+ } catch (err2) {
15141
+ if (isNotFoundError(err2))
15142
+ return false;
15048
15143
  throw err2;
15049
15144
  }
15050
15145
  }
@@ -144,8 +144,9 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
144
144
  export interface ImageFieldConfig extends BaseFieldConfig {
145
145
  type: 'image';
146
146
  /**
147
- * "W:H" aspect ratio (e.g. "16:9", "1:1") that triggers a crop step in the
148
- * editor. Validated at config-parse time: positive integers on both sides.
147
+ * "W:H" aspect ratio (e.g. "16:9", "1:1"). Picking an image opens a crop step locked to it,
148
+ * and Crop keeps that shape. Without it, a pick isn't cropped and Crop is free-form.
149
+ * Validated at config-parse time: positive integers on both sides.
149
150
  */
150
151
  aspect?: string;
151
152
  /** Allow an empty `alt`. Default: alt text is required (accessibility). */
@@ -208,5 +208,6 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
208
208
  ...branches.filter((b) => b.name !== currentBranchName),
209
209
  ]
210
210
  : branches;
211
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
211
+ const hasBranchThreads = comments.some((t) => t.type === 'branch');
212
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
212
213
  };
@@ -1,23 +1,17 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
- import { Badge, Button, Drawer, Group, Paper, ScrollArea, Stack, Text, Textarea, Title, Switch, Tooltip, VisuallyHidden, } from '@mantine/core';
4
+ import { Badge, Button, Group, Paper, ScrollArea, Stack, Text, Textarea, Switch, Tooltip, VisuallyHidden, } from '@mantine/core';
5
+ import { IconChevronRight } from '@tabler/icons-react';
5
6
  import { UserBadge } from './components/UserBadge.js';
6
7
  import { userIdLabel } from './user-display.js';
8
+ import { OverlayDrawer } from './shell/OverlayHeader.js';
7
9
  import { RelativeTime } from './components/RelativeTime.js';
8
10
  import { ThreadReplyControls } from './comments/ThreadReplyControls.js';
9
11
  import { useTrackedWrites } from './comments/use-tracked-writes.js';
10
12
  import { CloseRefusedNotice } from './comments/CloseRefusedNotice.js';
11
13
  import { ON_REMOVED_ITEM } from './comments/item-anchor-copy.js';
12
14
  import { CommentWriteAlert, toCommentWriteFailure, } from './comments/CommentWriteAlert.js';
13
- /**
14
- * A jump button that stays focusable while a write is in flight (`aria-disabled`, not `disabled`),
15
- * so its reason opens on keyboard focus and touch as well as hover.
16
- */
17
- const JumpButton = ({ blocked, onClick, children }) => (_jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: !blocked, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", onClick: () => {
18
- if (!blocked)
19
- onClick();
20
- }, "data-disabled": blocked ? true : undefined, "aria-disabled": blocked ? true : undefined, children: children }) }));
21
15
  const THREAD_KIND_LABELS = { field: 'Field', entry: 'Entry', branch: 'Branch' };
22
16
  export const CommentsPanel = ({ opened, branchName, comments, canResolve, currentUserId, onAddComment, onResolveThread, onClose, onJumpToField, onJumpToEntry, onJumpToBranch, onGetUserMetadata, entryLabel, fieldLabel, }) => {
23
17
  const [newCommentText, setNewCommentText] = useState('');
@@ -65,27 +59,71 @@ export const CommentsPanel = ({ opened, branchName, comments, canResolve, curren
65
59
  setIsSubmitting(false);
66
60
  }
67
61
  };
68
- const threadHeading = (thread) => {
69
- const kind = THREAD_KIND_LABELS[thread.type];
62
+ // "Entry › Field", or undefined for a thread on the branch itself.
63
+ const threadPath = (thread) => {
70
64
  if (!thread.entryPath)
71
- return kind;
65
+ return undefined;
72
66
  const entry = entryLabel?.(thread.entryPath) ?? thread.entryPath;
73
67
  const field = thread.canopyPath ? (fieldLabel?.(thread) ?? thread.canopyPath) : undefined;
74
- return `${kind} · ${field ? `${entry} › ${field}` : entry}`;
68
+ return field ? `${entry} › ${field}` : entry;
69
+ };
70
+ const threadHeading = (thread) => {
71
+ const kind = THREAD_KIND_LABELS[thread.type];
72
+ const path = threadPath(thread);
73
+ return path ? `${kind} · ${path}` : kind;
74
+ };
75
+ const jumpTarget = (thread) => {
76
+ const { entryPath, canopyPath } = thread;
77
+ if (thread.type === 'field' &&
78
+ entryPath &&
79
+ canopyPath &&
80
+ !thread.onRemovedItem &&
81
+ onJumpToField) {
82
+ return { label: 'Jump to field', go: () => onJumpToField(entryPath, canopyPath, thread.id) };
83
+ }
84
+ // A removed item's thread lives on its entry.
85
+ if ((thread.type === 'entry' || thread.onRemovedItem) && entryPath && onJumpToEntry) {
86
+ return { label: 'Jump to entry', go: () => onJumpToEntry(entryPath, thread.id) };
87
+ }
88
+ if (thread.type === 'branch' && onJumpToBranch) {
89
+ return { label: 'Open branch discussion', go: () => onJumpToBranch(thread.id) };
90
+ }
91
+ return null;
92
+ };
93
+ const setHeadingRef = (threadId, node) => {
94
+ if (node)
95
+ headingRefs.current.set(threadId, node);
96
+ else
97
+ headingRefs.current.delete(threadId);
75
98
  };
76
- return (_jsx(Drawer, { opened: opened, onClose: close, returnFocus: !jumping, position: "right", size: 420, title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Comments" }), _jsx(Text, { size: "xs", c: "dimmed", children: branchName })] }), children: _jsxs(Stack, { gap: "md", h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(CloseRefusedNotice, { refused: closeRefused }), _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Posts a branch-level comment on \"", branchName, "\". To comment on a field, use its comment icon."] }), _jsx(Textarea, { placeholder: "Add a comment\u2026", "aria-label": "New branch comment", value: newCommentText, onChange: (e) => setNewCommentText(e.target.value), minRows: 3, disabled: isSubmitting, "data-testid": "comment-textarea" }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), _jsx(Button, { onClick: handleAddComment, size: "sm", loading: isSubmitting, disabled: !newCommentText.trim(), "data-testid": "comment-submit", children: "Comment" })] }) }), _jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { ref: threadCountRef, size: "sm", fw: 500, tabIndex: -1, children: [filteredThreads.length, " ", filteredThreads.length === 1 ? 'thread' : 'threads'] }), _jsx(Switch, { label: "Show resolved", checked: showResolved, onChange: (e) => setShowResolved(e.currentTarget.checked), size: "sm" })] }), _jsx(ScrollArea, { style: { flex: 1 }, children: filteredThreads.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", ta: "center", py: "xl", children: showResolved ? 'No comments yet' : 'No unresolved comments' })) : (_jsx(Stack, { gap: "md", children: filteredThreads.map((thread) => (_jsx(Paper, { withBorder: true, p: "md", component: "article", "aria-labelledby": headingId(thread.id), bg: thread.resolved ? 'var(--mantine-color-default-hover)' : undefined, "data-testid": "comment-thread", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { id: headingId(thread.id), ref: (node) => {
77
- if (node)
78
- headingRefs.current.set(thread.id, node);
79
- else
80
- headingRefs.current.delete(thread.id);
81
- }, size: "xs", fw: 600, tabIndex: -1, "data-testid": "comment-thread-heading", children: [threadHeading(thread), thread.onRemovedItem && (_jsxs(VisuallyHidden, { children: [", ", ON_REMOVED_ITEM.toLowerCase()] }))] }), thread.resolved && (_jsx(Badge, { size: "xs", color: "green", variant: "light", children: "Resolved" })), thread.onRemovedItem && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", c: "var(--mantine-color-text)", children: ON_REMOVED_ITEM }))] }), _jsxs(Group, { gap: "xs", children: [thread.type === 'field' &&
82
- thread.entryPath &&
83
- thread.canopyPath &&
84
- !thread.onRemovedItem &&
85
- onJumpToField && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToField(thread.entryPath, thread.canopyPath, thread.id)), children: "Jump to field" })), (thread.type === 'entry' || thread.onRemovedItem) &&
86
- thread.entryPath &&
87
- onJumpToEntry && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToEntry(thread.entryPath, thread.id)), children: "Jump to entry" })), thread.type === 'branch' && onJumpToBranch && (_jsx(JumpButton, { blocked: inFlight > 0, onClick: () => jump(() => onJumpToBranch(thread.id)), children: "Open branch discussion" }))] }), thread.comments.map((comment, idx) => (_jsxs("div", { children: [idx > 0 && (_jsx("div", { style: {
88
- borderTop: '1px solid var(--mantine-color-default-border)',
89
- margin: '8px 0',
90
- } })), _jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [onGetUserMetadata ? (_jsx(UserBadge, { userId: comment.userId, getUserMetadata: onGetUserMetadata, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })) : (_jsx(Text, { size: "xs", fw: 500, children: userIdLabel(comment.userId) })), _jsx(RelativeTime, { iso: comment.timestamp, size: "xs", c: "dimmed" })] }), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-wrap' }, children: comment.text })] })] }, comment.id))), !thread.resolved && (_jsx(ThreadReplyControls, { thread: thread, onAddReply: (text) => addComment(text, thread.type, thread.entryPath, thread.canopyPath, thread.id), onResolve: () => resolveThread(thread.id), focusAfterResolve: () => showResolved ? headingRefs.current.get(thread.id) : threadCountRef.current, currentUserId: currentUserId, canResolve: canResolve, replyText: replyDrafts[thread.id] ?? '', onReplyTextChange: (text) => setReplyDrafts((prev) => ({ ...prev, [thread.id]: text })) }))] }) }, thread.id))) })) })] }) }));
99
+ // The heading's text names the article; the removed-item badge beside it is outside it.
100
+ const headingText = (thread) => (_jsxs("span", { id: headingId(thread.id), "data-testid": "comment-thread-heading", children: [threadHeading(thread), thread.onRemovedItem && _jsxs(VisuallyHidden, { children: [", ", ON_REMOVED_ITEM.toLowerCase()] })] }));
101
+ return (_jsx(OverlayDrawer, { opened: opened, onClose: close, returnFocus: !jumping, position: "right", size: 420, title: "Comments", children: _jsxs(Stack, { gap: "md", h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Select a comment's heading to see it where it was left." }), _jsx(CloseRefusedNotice, { refused: closeRefused }), _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Posts a branch-level comment on \"", branchName, "\". To comment on a field, use its comment icon."] }), _jsx(Textarea, { placeholder: "Add a comment\u2026", "aria-label": "New branch comment", value: newCommentText, onChange: (e) => setNewCommentText(e.target.value), minRows: 3, disabled: isSubmitting, "data-testid": "comment-textarea" }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), _jsx(Button, { onClick: handleAddComment, size: "sm", loading: isSubmitting, disabled: !newCommentText.trim(), "data-testid": "comment-submit", children: "Comment" })] }) }), _jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { ref: threadCountRef, size: "sm", fw: 500, tabIndex: -1, children: [filteredThreads.length, " ", filteredThreads.length === 1 ? 'thread' : 'threads'] }), _jsx(Switch, { label: "Show resolved", checked: showResolved, onChange: (e) => setShowResolved(e.currentTarget.checked), size: "sm" })] }), _jsx(ScrollArea, { style: { flex: 1 }, children: filteredThreads.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", ta: "center", py: "xl", children: showResolved ? 'No comments yet' : 'No unresolved comments' })) : (_jsx(Stack, { gap: "md", children: filteredThreads.map((thread) => {
102
+ const target = jumpTarget(thread);
103
+ return (_jsx(Paper, { withBorder: true, p: "md", component: "article", "aria-labelledby": headingId(thread.id), bg: thread.resolved ? 'var(--mantine-color-default-hover)' : undefined, "data-testid": "comment-thread", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", children: [target ? (
104
+ // The heading is the jump control, the one tab stop on the card, in an
105
+ // `h3` under the drawer's `h2` so it is reachable by heading. It stays
106
+ // focusable while a write is in flight (`aria-disabled`, not `disabled`),
107
+ // so its reason opens on keyboard focus and touch as well as hover, and
108
+ // keeps its text colour: only the cursor says it is waiting. The visible
109
+ // text is inside the button's name (WCAG 2.5.3), after a hidden prefix
110
+ // that says what it does.
111
+ _jsx(Text, { component: "h3", size: "xs", fw: 600, m: 0, children: _jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: inFlight === 0, multiline: true, maw: 280, children: _jsxs(Button, { ref: (node) => setHeadingRef(thread.id, node), variant: "subtle", color: "neutral", h: "auto", px: 6, py: 2, mx: -6, rightSection: _jsx(IconChevronRight, { size: 14, "aria-hidden": true }), "aria-disabled": inFlight > 0 ? true : undefined, styles: {
112
+ root: { cursor: inFlight > 0 ? 'default' : 'pointer' },
113
+ label: {
114
+ whiteSpace: 'normal',
115
+ textAlign: 'start',
116
+ lineHeight: 1.35,
117
+ overflowWrap: 'anywhere',
118
+ },
119
+ }, onClick: () => {
120
+ if (inFlight === 0)
121
+ jump(target.go);
122
+ }, children: [_jsx(VisuallyHidden, { component: "span", children: `${target.label}: ` }), headingText(thread)] }) }) })) : (_jsx(Text, { component: "h3", ref: (node) => setHeadingRef(thread.id, node), size: "xs", fw: 600, m: 0, tabIndex: -1, children: headingText(thread) })), thread.resolved && (_jsx(Badge, { size: "xs", color: "green", variant: "light", children: "Resolved" })), thread.onRemovedItem && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", c: "var(--mantine-color-text)", children: ON_REMOVED_ITEM }))] }), thread.comments.map((comment, idx) => (_jsxs("div", { children: [idx > 0 && (_jsx("div", { style: {
123
+ borderTop: '1px solid var(--mantine-color-default-border)',
124
+ margin: '8px 0',
125
+ } })), _jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [onGetUserMetadata ? (_jsx(UserBadge, { userId: comment.userId, getUserMetadata: onGetUserMetadata, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })) : (_jsx(Text, { size: "xs", fw: 500, children: userIdLabel(comment.userId) })), _jsx(RelativeTime, { iso: comment.timestamp, size: "xs", c: "dimmed" })] }), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-wrap' }, children: comment.text })] })] }, comment.id))), !thread.resolved && (_jsx(ThreadReplyControls, { thread: thread, onAddReply: (text) => addComment(text, thread.type, thread.entryPath, thread.canopyPath, thread.id), onResolve: () => resolveThread(thread.id), focusAfterResolve: () => showResolved
126
+ ? headingRefs.current.get(thread.id)
127
+ : threadCountRef.current, currentUserId: currentUserId, canResolve: canResolve, replyText: replyDrafts[thread.id] ?? '', onReplyTextChange: (text) => setReplyDrafts((prev) => ({ ...prev, [thread.id]: text })) }))] }) }, thread.id));
128
+ }) })) })] }) }));
91
129
  };
@@ -7,7 +7,10 @@ import type { LogicalPath, ContentId } from '../paths/types.js';
7
7
  export interface EditorEntry {
8
8
  path: LogicalPath;
9
9
  contentId: ContentId;
10
+ /** What the editor calls the entry: its title, or a labelled singleton's type label. */
10
11
  label: string;
12
+ /** The entry's title, kept where `label` is a singleton's type label: link text and search use it. */
13
+ title?: string;
11
14
  status?: string;
12
15
  schema: EntrySchema;
13
16
  previewSrc?: string;