@jant/core 0.6.6 → 0.6.8

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 (112) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -72,6 +72,11 @@ export interface ManageManagedSiteDomainInput {
72
72
  makePrimary?: boolean;
73
73
  }
74
74
 
75
+ export interface RenameManagedSiteInput {
76
+ key: string;
77
+ primaryHost: string;
78
+ }
79
+
75
80
  export interface ExportManagedSiteDeps {
76
81
  env: Bindings;
77
82
  storage?: StorageDriver | null;
@@ -130,6 +135,20 @@ export interface SiteAdminService {
130
135
  ): Promise<ManagedSitePostCountResult[]>;
131
136
  suspendManagedSite(siteId: string): Promise<Site>;
132
137
  resumeManagedSite(siteId: string): Promise<Site>;
138
+ /**
139
+ * Atomically rename a managed site: change `sites.key` and rewrite the
140
+ * existing primary domain's host. The primary domain row keeps its id, so
141
+ * downstream projections that key off the domain id (e.g. the control
142
+ * plane's `cloud_site_domain` projection) stay stable across the rename.
143
+ *
144
+ * Throws ConflictError if the new key or primary host collide with another
145
+ * site (or with a non-primary domain on the same site). No-ops when the new
146
+ * key and host both match the current values.
147
+ */
148
+ renameManagedSite(
149
+ siteId: string,
150
+ input: RenameManagedSiteInput,
151
+ ): Promise<ManagedSiteResult>;
133
152
  deleteManagedSite(
134
153
  siteId: string,
135
154
  deps?: DeleteManagedSiteDeps,
@@ -1024,5 +1043,107 @@ export function createSiteAdminService(
1024
1043
  .where(eq(siteDomains.id, normalizedDomainId));
1025
1044
  });
1026
1045
  },
1046
+ async renameManagedSite(siteId, input) {
1047
+ assertManagedSiteOperationsEnabled();
1048
+ const normalizedSiteId = siteId.trim();
1049
+ if (!normalizedSiteId) {
1050
+ throw new NotFoundError("Site");
1051
+ }
1052
+
1053
+ const newKey = input.key.trim();
1054
+ const newPrimaryHost = input.primaryHost.trim().toLowerCase();
1055
+ if (!newKey) {
1056
+ throw new ConflictError("Site key is required.");
1057
+ }
1058
+ if (!newPrimaryHost) {
1059
+ throw new ConflictError("Primary host is required.");
1060
+ }
1061
+
1062
+ async function performRename(
1063
+ targetDb: Database,
1064
+ ): Promise<ManagedSiteResult> {
1065
+ const siteRow = await requireSiteRow(targetDb, normalizedSiteId);
1066
+
1067
+ const primaryRows = await targetDb
1068
+ .select()
1069
+ .from(siteDomains)
1070
+ .where(
1071
+ and(
1072
+ eq(siteDomains.siteId, normalizedSiteId),
1073
+ eq(siteDomains.kind, "primary"),
1074
+ ),
1075
+ )
1076
+ .limit(1);
1077
+ const primaryRow = primaryRows[0];
1078
+ if (!primaryRow) {
1079
+ throw new NotFoundError("Site primary domain");
1080
+ }
1081
+
1082
+ if (siteRow.key === newKey && primaryRow.host === newPrimaryHost) {
1083
+ return {
1084
+ domain: toSiteDomain(primaryRow),
1085
+ site: toSite(siteRow),
1086
+ };
1087
+ }
1088
+
1089
+ if (siteRow.key !== newKey) {
1090
+ const conflictingSite = await targetDb
1091
+ .select({ id: sites.id })
1092
+ .from(sites)
1093
+ .where(eq(sites.key, newKey))
1094
+ .limit(1);
1095
+ if (conflictingSite[0]) {
1096
+ throw new ConflictError("Site key is already in use.");
1097
+ }
1098
+ }
1099
+
1100
+ if (primaryRow.host !== newPrimaryHost) {
1101
+ const conflictingDomain = await targetDb
1102
+ .select({ id: siteDomains.id, siteId: siteDomains.siteId })
1103
+ .from(siteDomains)
1104
+ .where(eq(siteDomains.host, newPrimaryHost))
1105
+ .limit(1);
1106
+ if (conflictingDomain[0]) {
1107
+ throw new ConflictError("Primary host is already in use.");
1108
+ }
1109
+ }
1110
+
1111
+ const timestamp = now();
1112
+ const updatedSiteRow = (
1113
+ await targetDb
1114
+ .update(sites)
1115
+ .set({ key: newKey, updatedAt: timestamp })
1116
+ .where(eq(sites.id, normalizedSiteId))
1117
+ .returning()
1118
+ )[0];
1119
+ if (!updatedSiteRow) {
1120
+ throw new NotFoundError("Site");
1121
+ }
1122
+
1123
+ const updatedDomainRow = (
1124
+ await targetDb
1125
+ .update(siteDomains)
1126
+ .set({ host: newPrimaryHost, updatedAt: timestamp })
1127
+ .where(eq(siteDomains.id, primaryRow.id))
1128
+ .returning()
1129
+ )[0];
1130
+ if (!updatedDomainRow) {
1131
+ throw new NotFoundError("Site primary domain");
1132
+ }
1133
+
1134
+ return {
1135
+ domain: toSiteDomain(updatedDomainRow),
1136
+ site: toSite(updatedSiteRow),
1137
+ };
1138
+ }
1139
+
1140
+ if (supportsDrizzleTransaction(db, databaseDialect)) {
1141
+ return db.transaction(async (tx) =>
1142
+ performRename(tx as unknown as Database),
1143
+ );
1144
+ }
1145
+
1146
+ return performRename(db);
1147
+ },
1027
1148
  };
1028
1149
  }
@@ -52,6 +52,10 @@ const RELAY_MULTIPART_THRESHOLD = 95 * 1024 * 1024;
52
52
  const RELAY_MULTIPART_PART_SIZE = 50 * 1024 * 1024;
53
53
  const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
54
54
  const DEFAULT_EXPIRED_UPLOAD_CLEANUP_LIMIT = 20;
55
+ // Grace window before a finalized-but-unattached media row (uploaded during
56
+ // compose, `postId IS NULL`) is reaped. Generous enough that an in-progress
57
+ // compose/edit is never affected; based on `createdAt`, no heartbeat needed.
58
+ const ORPHAN_MEDIA_GRACE_SECONDS = 7 * 24 * 60 * 60;
55
59
  type CleanupableUploadSessionState = "pending" | "uploaded" | "failed";
56
60
  const CLEANUPABLE_UPLOAD_SESSION_STATES = [
57
61
  "pending",
@@ -143,6 +147,7 @@ export interface UploadSessionService {
143
147
  }): Promise<{
144
148
  abortedMultipartUploads: number;
145
149
  deletedSessions: number;
150
+ deletedOrphanMedia: number;
146
151
  }>;
147
152
  abort(id: string, deps: { storage: StorageDriver }): Promise<void>;
148
153
  }
@@ -760,9 +765,22 @@ export function createUploadSessionService(
760
765
  deletedSessions += 1;
761
766
  }
762
767
 
768
+ // Reap finalized media that was uploaded during compose but never
769
+ // attached to a post (`postId IS NULL`) past the grace window. Deletes
770
+ // the S3/R2 objects and DB rows (best-effort storage delete). Bounded by
771
+ // the same per-run `limit`; any backlog drains over subsequent runs.
772
+ const orphanIds = await media.listOrphanedMediaIds({
773
+ before: now() - ORPHAN_MEDIA_GRACE_SECONDS,
774
+ limit,
775
+ });
776
+ if (orphanIds.length > 0) {
777
+ await media.deleteByIds(orphanIds, deps.storage);
778
+ }
779
+
763
780
  return {
764
781
  abortedMultipartUploads,
765
782
  deletedSessions,
783
+ deletedOrphanMedia: orphanIds.length,
766
784
  };
767
785
  },
768
786
  };
@@ -84,7 +84,7 @@
84
84
  mobile overrides below cap sizes for small screens.
85
85
  --type-content-scale uniformly scales all content sizes
86
86
  without affecting UI controls. */
87
- --type-content-scale: 0.81;
87
+ --type-content-scale: 0.78;
88
88
  --type-content-display: calc(var(--type-display) * var(--type-content-scale));
89
89
  --type-content-title: calc(var(--type-title) * var(--type-content-scale));
90
90
  --type-content-subtitle: calc(
package/src/styles/ui.css CHANGED
@@ -2205,8 +2205,16 @@
2205
2205
  }
2206
2206
 
2207
2207
  .feed-continue-link {
2208
- display: inline-block;
2209
- margin-top: 0.5rem;
2208
+ /* Block so the whole content-width row is the tap target, not just the few
2209
+ characters of "Read more". On mobile the trailing link on the last
2210
+ summary line sits just above this control, so taps aimed here used to
2211
+ land on that link. The larger `margin-top` is a dead (non-clickable)
2212
+ buffer that separates the prose's trailing link from the control, and
2213
+ `padding-bottom` grows the tap area downward, away from the footer
2214
+ links below. Appearance is unchanged — only the hit area and spacing. */
2215
+ display: block;
2216
+ margin-top: 1rem;
2217
+ padding-bottom: 0.6rem;
2210
2218
  font-size: var(--type-ui-meta);
2211
2219
  color: var(--site-text-secondary);
2212
2220
  }
@@ -2215,6 +2223,19 @@
2215
2223
  text-decoration: underline;
2216
2224
  }
2217
2225
 
2226
+ /* Untitled note expand-in-place. The full body is rendered with a zero-width
2227
+ `data-note-break` marker at the summary boundary; clamping hides only the
2228
+ marker's following siblings, leaving the visible summary untouched so the
2229
+ note grows in place without the page jumping. note-expand.ts toggles
2230
+ `data-note-clamp` to reveal/hide the tail. */
2231
+ [data-note-break] {
2232
+ display: none;
2233
+ }
2234
+
2235
+ [data-note-clamp] [data-note-break] ~ * {
2236
+ display: none;
2237
+ }
2238
+
2218
2239
  .post-header-block {
2219
2240
  margin-bottom: 1rem;
2220
2241
  }
@@ -2331,16 +2352,39 @@
2331
2352
 
2332
2353
  .sidenote,
2333
2354
  .marginnote {
2355
+ /* Layout: float into the right margin. A floated note anchors to its containing
2356
+ block's right *content* edge, so if a theme gives that block inline-end padding
2357
+ (e.g. a padded blockquote card) the anchor is inset and the note drifts left.
2358
+ Themes expose that inset via --sidenote-anchor-inset (default 0) — best derived
2359
+ from the same token that sets the padding — so the note still reaches the gutter
2360
+ no matter how the container is indented. Core's own blocks add no inset. */
2334
2361
  float: right;
2335
2362
  clear: right;
2336
- margin-right: var(--layout-sidenote-margin);
2363
+ margin-right: calc(
2364
+ var(--layout-sidenote-margin) - var(--sidenote-anchor-inset, 0px)
2365
+ );
2337
2366
  width: var(--layout-sidenote-width);
2338
2367
  margin-top: 0.3rem;
2339
- margin-bottom: 0;
2340
- font-size: var(--type-secondary);
2341
- line-height: 1.3;
2368
+ /* Breathing room so multiple notes from one paragraph don't touch when they stack. */
2369
+ margin-bottom: 1.4rem;
2342
2370
  vertical-align: baseline;
2343
2371
  position: relative;
2372
+
2373
+ /* Typographic isolation. A footnote is an annotation, not part of the text it
2374
+ hangs off of, so it must render identically no matter what its reference sits
2375
+ inside. The body span is emitted at the reference position (see tiptap-render
2376
+ renderSidenoteReference), so when the [^n] is inside a blockquote / bold run /
2377
+ heading the span inherits that context. Pin every inheritable type property to
2378
+ the canonical note look to cut every such leak — not just the blockquote
2379
+ serif+italic case. */
2380
+ font-family: var(--font-body);
2381
+ font-size: var(--type-sm);
2382
+ font-style: normal;
2383
+ font-weight: 400;
2384
+ font-variant: normal;
2385
+ line-height: 1.3;
2386
+ letter-spacing: var(--type-body-tracking, normal);
2387
+ text-align: left;
2344
2388
  color: var(--site-reading-meta);
2345
2389
  }
2346
2390
 
@@ -2358,12 +2402,16 @@
2358
2402
  content: counter(sidenote-counter);
2359
2403
  font-size: var(--type-code);
2360
2404
  top: -0.5rem;
2361
- left: 0.1rem;
2405
+ /* Real margins (not `left`, which reserves no space) so the superscript
2406
+ number keeps a gap from the words on both sides — matters for CJK, which
2407
+ has no inter-word spacing. */
2408
+ margin-left: 0.1rem;
2409
+ margin-right: 0.25rem;
2362
2410
  }
2363
2411
 
2364
2412
  .sidenote::before {
2365
2413
  content: counter(sidenote-counter) " ";
2366
- font-size: var(--type-code);
2414
+ font-size: var(--type-xs);
2367
2415
  top: -0.5rem;
2368
2416
  }
2369
2417
 
@@ -2372,13 +2420,6 @@
2372
2420
  font-size: var(--type-code);
2373
2421
  }
2374
2422
 
2375
- blockquote .sidenote,
2376
- blockquote .marginnote {
2377
- margin-right: -82%;
2378
- min-width: 59%;
2379
- text-align: left;
2380
- }
2381
-
2382
2423
  input.margin-toggle {
2383
2424
  display: none;
2384
2425
  }
@@ -2637,13 +2678,28 @@
2637
2678
  secondary to the focused post but not so much that text becomes gray
2638
2679
  and hard to read. Combined with the mask-image bottom fade this gives
2639
2680
  a "context drifts into the background" effect without dimming the
2640
- whole shell to the point of illegibility. */
2641
- .thread-context-shell {
2681
+ whole shell to the point of illegibility.
2682
+
2683
+ Fade per item, not the whole shell: a blanket `opacity` on the shell
2684
+ makes it a stacking context that *caps* descendant opacity, so the gap
2685
+ marker ("N more posts") could never read brighter than the faded
2686
+ context around it. Fading each item individually leaves the gap — a
2687
+ real route into the rest of the thread, not background context — at
2688
+ full strength. The items cover everything inside the shell (feed:
2689
+ context + gap; detail page: ancestor detail items), so nothing slips
2690
+ through undimmed. */
2691
+ .thread-context-shell .thread-item {
2642
2692
  opacity: 0.6;
2643
2693
  transition: opacity 0.22s ease;
2644
2694
  }
2645
2695
 
2646
- .thread-context-shell:not([data-collapsed]) {
2696
+ .thread-context-shell:not([data-collapsed]) .thread-item {
2697
+ opacity: 1;
2698
+ }
2699
+
2700
+ /* The gap is a structural "thread continues here" marker — never faded,
2701
+ collapsed or expanded. */
2702
+ .thread-context-shell .thread-item.thread-item-gap {
2647
2703
  opacity: 1;
2648
2704
  }
2649
2705
 
@@ -2708,33 +2764,78 @@
2708
2764
  padding-bottom: 0.35rem;
2709
2765
  }
2710
2766
 
2767
+ /* Gap marker on the rail: swap the single post dot for a short column of
2768
+ small beads (a vertical "⋮") so the thread line visibly "skips" a run
2769
+ of posts — the familiar threaded-conversation cue for collapsed
2770
+ content. Smaller and quieter than the real post dots, sitting on the
2771
+ same rail x-position. The continuous rail line still shows through
2772
+ behind the beads, so thread continuity reads intact. */
2773
+ .thread-group-preview .thread-item-gap::before,
2774
+ .thread-group-detail .thread-item-gap::before {
2775
+ top: 50%;
2776
+ transform: translateY(-50%);
2777
+ left: calc(
2778
+ var(--site-thread-rail-line-left) + var(--site-thread-rail-line-width) /
2779
+ 2 - 2px - var(--site-thread-rail-indent)
2780
+ );
2781
+ width: 4px;
2782
+ height: 24px;
2783
+ border: 0;
2784
+ border-radius: 0;
2785
+ background: radial-gradient(
2786
+ circle,
2787
+ var(--site-threadline) 1.75px,
2788
+ transparent 2px
2789
+ );
2790
+ background-size: 4px 8px;
2791
+ background-repeat: repeat-y;
2792
+ background-position: center top;
2793
+ box-shadow: none;
2794
+ }
2795
+
2796
+ /* "N more posts" — a calm route into the rest of the thread, styled as a
2797
+ sibling of `.thread-context-toggle` (same muted colour + size) rather
2798
+ than a pill or an underlined link. A small chevron does the "this is
2799
+ tappable / leads somewhere" work an underline does poorly on a short
2800
+ standalone label; it points right because the link navigates into the
2801
+ thread rather than expanding in place. Full opacity keeps it from
2802
+ fading into the context above. */
2711
2803
  .thread-gap-link {
2712
2804
  display: inline-flex;
2713
2805
  align-items: center;
2714
- gap: 0.35rem;
2715
- padding: 0.38rem 0.78rem;
2716
- border: 1px dashed var(--site-thread-context-border);
2717
- border-radius: 999px;
2718
- background: var(--site-thread-gap-bg);
2806
+ gap: 0.4rem;
2807
+ padding: 0.25rem 0;
2719
2808
  color: var(--site-text-secondary);
2720
2809
  font-size: var(--type-thread-context-meta);
2721
- line-height: 1.3;
2810
+ font-weight: 500;
2811
+ line-height: 1.2;
2722
2812
  text-decoration: none;
2813
+ transition: color 0.18s ease;
2814
+ }
2815
+
2816
+ .thread-gap-link::after {
2817
+ content: "";
2818
+ width: 0.36em;
2819
+ height: 0.36em;
2820
+ border: 1.5px solid currentColor;
2821
+ border-left: 0;
2822
+ border-bottom: 0;
2823
+ opacity: 0.65;
2824
+ transform: rotate(45deg);
2723
2825
  transition:
2724
- border-color 0.18s ease,
2725
- color 0.18s ease,
2726
- background-color 0.18s ease;
2826
+ transform 0.18s ease,
2827
+ opacity 0.18s ease;
2727
2828
  }
2728
2829
 
2729
2830
  .thread-gap-link:hover {
2730
- border-color: color-mix(
2731
- in srgb,
2732
- var(--site-accent) 22%,
2733
- var(--site-divider)
2734
- );
2735
2831
  color: var(--site-text-primary);
2736
2832
  }
2737
2833
 
2834
+ .thread-gap-link:hover::after {
2835
+ opacity: 1;
2836
+ transform: translateX(2px) rotate(45deg);
2837
+ }
2838
+
2738
2839
  .thread-item-hero {
2739
2840
  padding-top: 0.95rem;
2740
2841
  }
@@ -7739,9 +7840,12 @@
7739
7840
  min-height: 72px;
7740
7841
  }
7741
7842
 
7742
- /* Focused thread post gets a subtle left accent */
7843
+ /* Focused thread post gets a subtle left accent. Use an inset box-shadow, not
7844
+ a border: a border adds 2px of layout width only while focused, so switching
7845
+ focus between thread posts shifted each one sideways ("jitter"). box-shadow
7846
+ paints in the same spot without affecting layout. */
7743
7847
  .compose-thread-post:focus-within > jant-compose-editor {
7744
- border-left: 2px solid transparent;
7848
+ box-shadow: inset 2px 0 0 transparent;
7745
7849
  }
7746
7850
 
7747
7851
  /* "Add to thread" row inside thread compose layout */
@@ -7852,6 +7956,22 @@
7852
7956
  padding: 6px 0 4px;
7853
7957
  }
7854
7958
 
7959
+ /* Reply / edit-reply compose (a .compose-editor-row directly under
7960
+ .compose-thread-layout — not the multi-post thread variant) needs clear
7961
+ separation from the parent post shown above it. A thread post is meant to
7962
+ sit tight on the rail, so this is scoped away from thread compose. Uses a
7963
+ static :not() rather than :has() on purpose: a :has() here re-evaluates on
7964
+ focus changes inside the row and caused a one-frame horizontal reflow
7965
+ ("jitter") when clicking into the editor. */
7966
+ .compose-thread-layout:not(.compose-thread-compose-layout)
7967
+ .compose-editor-row {
7968
+ padding-top: 14px;
7969
+ }
7970
+
7971
+ .compose-thread-post-header + .compose-body {
7972
+ padding-top: 8px;
7973
+ }
7974
+
7855
7975
  /* Thread format selector: pill-tag style (lighter than the main segmented) */
7856
7976
  .compose-thread-segmented {
7857
7977
  background: transparent;
@@ -8630,6 +8750,15 @@
8630
8750
  margin-bottom: 1.4rem;
8631
8751
  }
8632
8752
 
8753
+ /* In inline-format compose (reply / edit-reply / thread — the editors wrapped
8754
+ in .compose-editor-row), the format header sits right above the body, so the
8755
+ first block's 1.4rem top margin reads as a gap between the format pills and
8756
+ the input. Drop it there so the text hugs the header. Standalone new/edit
8757
+ editors keep their natural lead-in. */
8758
+ .compose-editor-row .compose-tiptap-body .tiptap > :first-child {
8759
+ margin-top: 0;
8760
+ }
8761
+
8633
8762
  .compose-tiptap-body .tiptap ul {
8634
8763
  list-style-type: disc;
8635
8764
  padding-left: 1.5em;
@@ -163,7 +163,7 @@ export const CONFIG_FIELDS = {
163
163
  envKeys: ["ASSET_BASE_URL"],
164
164
  },
165
165
  UPLOAD_MAX_FILE_SIZE_MB: {
166
- defaultValue: "500",
166
+ defaultValue: "1024",
167
167
  envOnly: true,
168
168
  envKeys: ["UPLOAD_MAX_FILE_SIZE_MB"],
169
169
  },
@@ -59,6 +59,8 @@ export interface ArchiveFilters {
59
59
  mediaKinds?: MediaKind[];
60
60
  hasMedia?: boolean;
61
61
  hasTitle?: boolean;
62
+ /** true = threads (roots with replies), false = single posts (no replies) */
63
+ hasReplies?: boolean;
62
64
  visibility?: ArchiveVisibility;
63
65
  view?: ArchiveView;
64
66
  }
@@ -122,6 +124,7 @@ export interface CollectionsPageProps {
122
124
  items: CollectionDirectoryItem[];
123
125
  isAuthenticated: boolean;
124
126
  sitePathPrefix?: string;
127
+ siteOrigin?: string;
125
128
  }
126
129
 
127
130
  // =============================================================================
@@ -743,6 +743,19 @@ export const ComposeForm: FC<ComposeFormProps> = ({
743
743
  "@context: Compose dialog header title when composing a thread",
744
744
  }),
745
745
  ),
746
+ replyTitle: i18n._(
747
+ msg({
748
+ message: "Reply",
749
+ comment:
750
+ "@context: Compose dialog header title when replying to a post",
751
+ }),
752
+ ),
753
+ editTitle: i18n._(
754
+ msg({
755
+ message: "Edit",
756
+ comment: "@context: Compose dialog header title when editing a post",
757
+ }),
758
+ ),
746
759
  slashHint: i18n._(
747
760
  msg({
748
761
  message: "Type / for commands",
@@ -31,13 +31,11 @@ export function AccountMenuContent({
31
31
  demoMode = false,
32
32
  hostedControlPlaneAccountUrl,
33
33
  hostedControlPlaneProviderLabel,
34
- hostedControlPlaneSiteDeleteUrl,
35
34
  }: {
36
35
  sitePathPrefix?: string;
37
36
  demoMode?: boolean;
38
37
  hostedControlPlaneAccountUrl?: string | null;
39
38
  hostedControlPlaneProviderLabel?: string | null;
40
- hostedControlPlaneSiteDeleteUrl?: string | null;
41
39
  }) {
42
40
  const { i18n } = useLingui();
43
41
  const isHosted = Boolean(hostedControlPlaneAccountUrl);
@@ -266,43 +264,6 @@ export function AccountMenuContent({
266
264
  />
267
265
  </SettingsDirectorySection>
268
266
 
269
- {!demoMode && isHosted && hostedControlPlaneSiteDeleteUrl ? (
270
- <SettingsDirectorySection
271
- title={i18n._(
272
- msg({
273
- message: "Danger Zone",
274
- comment:
275
- "@context: Settings group label for destructive account actions",
276
- }),
277
- )}
278
- tone="danger"
279
- >
280
- <SettingsDirectoryLink
281
- href={hostedControlPlaneSiteDeleteUrl}
282
- icon={ICONS.trash}
283
- tone="danger"
284
- name={i18n._(
285
- msg({
286
- message: "Delete Hosted Site",
287
- comment:
288
- "@context: Settings item — open the hosted site danger zone in the control plane",
289
- }),
290
- )}
291
- description={i18n._(
292
- msg({
293
- message:
294
- "Open the hosted site controls in {providerLabel} to cancel billing or permanently delete this site.",
295
- comment:
296
- "@context: Settings item description for the hosted delete-site entry in the account menu",
297
- }),
298
- {
299
- providerLabel,
300
- },
301
- )}
302
- />
303
- </SettingsDirectorySection>
304
- ) : null}
305
-
306
267
  {!demoMode && !isHosted && (
307
268
  <SettingsDirectorySection
308
269
  title={i18n._(
@@ -22,6 +22,28 @@ function ChevronRight() {
22
22
  );
23
23
  }
24
24
 
25
+ function ExternalLinkIndicator() {
26
+ return (
27
+ <svg
28
+ class="settings-directory-item-chevron"
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ width="16"
31
+ height="16"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ stroke-width="2"
36
+ stroke-linecap="round"
37
+ stroke-linejoin="round"
38
+ aria-hidden="true"
39
+ >
40
+ <path d="M15 3h6v6" />
41
+ <path d="M10 14 21 3" />
42
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
43
+ </svg>
44
+ );
45
+ }
46
+
25
47
  export function SettingsDirectorySection({
26
48
  title,
27
49
  tone = "default",
@@ -45,10 +67,12 @@ export function SettingsDirectoryItemContent({
45
67
  icon,
46
68
  name,
47
69
  description,
70
+ external = false,
48
71
  }: {
49
72
  icon: string;
50
73
  name: string;
51
74
  description: string;
75
+ external?: boolean;
52
76
  }) {
53
77
  return (
54
78
  <>
@@ -59,7 +83,7 @@ export function SettingsDirectoryItemContent({
59
83
  <span class="settings-directory-item-name">{name}</span>
60
84
  <span class="settings-directory-item-desc">{description}</span>
61
85
  </span>
62
- <ChevronRight />
86
+ {external ? <ExternalLinkIndicator /> : <ChevronRight />}
63
87
  </>
64
88
  );
65
89
  }
@@ -93,6 +117,7 @@ export function SettingsDirectoryLink({
93
117
  icon={icon}
94
118
  name={name}
95
119
  description={description}
120
+ external={target === "_blank"}
96
121
  />
97
122
  </a>
98
123
  );