six-degrees 0.4.8 → 0.4.10

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 (239) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/routes-manifest.json +6 -0
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +13 -13
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +12 -12
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js +2 -1
  23. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/company-scores/route.js +2 -1
  25. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/scraper/route.js +2 -2
  39. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/settings/route.js +3 -2
  41. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js +2 -1
  43. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js +2 -1
  45. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/api/social/messages/route/app-paths-manifest.json +3 -0
  48. package/.next/standalone/.next/server/app/api/social/messages/route/build-manifest.json +12 -0
  49. package/.next/standalone/.next/server/app/api/social/messages/route/server-reference-manifest.json +4 -0
  50. package/.next/standalone/.next/server/app/api/social/messages/route.js +9 -0
  51. package/.next/standalone/.next/server/app/api/social/messages/route.js.map +5 -0
  52. package/.next/standalone/.next/server/app/api/social/messages/route.js.nft.json +1 -0
  53. package/.next/standalone/.next/server/app/api/social/messages/route_client-reference-manifest.js +3 -0
  54. package/.next/standalone/.next/server/app/api/social/route.js +4 -2
  55. package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/import.html +1 -1
  65. package/.next/standalone/.next/server/app/import.rsc +15 -15
  66. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +15 -15
  67. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
  68. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +14 -14
  69. package/.next/standalone/.next/server/app/index.html +1 -1
  70. package/.next/standalone/.next/server/app/index.rsc +15 -15
  71. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  72. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  73. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  74. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/launch.html +2 -2
  77. package/.next/standalone/.next/server/app/launch.rsc +15 -15
  78. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +15 -15
  79. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
  80. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +14 -14
  81. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  82. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  83. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  84. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  85. package/.next/standalone/.next/server/app/paths.html +1 -1
  86. package/.next/standalone/.next/server/app/paths.rsc +15 -15
  87. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +15 -15
  88. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
  89. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +14 -14
  90. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  91. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  92. package/.next/standalone/.next/server/app/profile.html +1 -1
  93. package/.next/standalone/.next/server/app/profile.rsc +15 -15
  94. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +15 -15
  95. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
  96. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +14 -14
  97. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  98. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  99. package/.next/standalone/.next/server/app/queue.html +1 -1
  100. package/.next/standalone/.next/server/app/queue.rsc +15 -15
  101. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +15 -15
  102. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
  103. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +14 -14
  104. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  105. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
  106. package/.next/standalone/.next/server/app/scores.html +1 -1
  107. package/.next/standalone/.next/server/app/scores.rsc +15 -15
  108. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +15 -15
  109. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
  110. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +14 -14
  111. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  112. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/standalone/.next/server/app/settings.html +1 -1
  114. package/.next/standalone/.next/server/app/settings.rsc +15 -15
  115. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  116. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  117. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +14 -14
  118. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  119. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/app/setup.html +1 -1
  121. package/.next/standalone/.next/server/app/setup.rsc +15 -15
  122. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +15 -15
  123. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  124. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +14 -14
  125. package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
  126. package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
  127. package/.next/standalone/.next/server/app/social.html +1 -1
  128. package/.next/standalone/.next/server/app/social.rsc +15 -15
  129. package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +15 -15
  130. package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +2 -2
  131. package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +14 -14
  132. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  133. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06mi9k2._.js +3 -0
  134. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08f5awg._.js +8 -0
  135. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0bat8nx._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17hjtzl._.js +8 -0
  137. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20tmem7._.js +1 -1
  138. package/.next/standalone/.next/server/chunks/_0ea9qwy._.js +3 -0
  139. package/.next/standalone/.next/server/chunks/_1_fynia._.js +6 -6
  140. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  141. package/.next/standalone/.next/server/chunks/_1xh07bp._.js +1 -1
  142. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_social_messages_route_actions_0skh09r.js +3 -0
  143. package/.next/standalone/.next/server/chunks/lib_18cr1gi._.js +8 -0
  144. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +1 -1
  145. package/.next/standalone/.next/server/chunks/lib_rpc_03i8xef.js +1 -1
  146. package/.next/standalone/.next/server/chunks/lib_rpc_08fc3rp.js +1 -1
  147. package/.next/standalone/.next/server/chunks/lib_rpc_08r2t_q.js +1 -1
  148. package/.next/standalone/.next/server/chunks/lib_rpc_0btpv1t.js +1 -1
  149. package/.next/standalone/.next/server/chunks/lib_rpc_0c9d9ti.js +5 -5
  150. package/.next/standalone/.next/server/chunks/lib_rpc_0cm2ev3.js +1 -1
  151. package/.next/standalone/.next/server/chunks/lib_rpc_0iovswz.js +1 -1
  152. package/.next/standalone/.next/server/chunks/lib_rpc_0is8flp.js +1 -1
  153. package/.next/standalone/.next/server/chunks/lib_rpc_0v8ajtd.js +1 -1
  154. package/.next/standalone/.next/server/chunks/lib_rpc_0xgmvvt.js +1 -1
  155. package/.next/standalone/.next/server/chunks/lib_rpc_144mzbf.js +1 -1
  156. package/.next/standalone/.next/server/chunks/lib_rpc_1bggi0n.js +1 -1
  157. package/.next/standalone/.next/server/chunks/lib_rpc_1dkcu9f.js +1 -1
  158. package/.next/standalone/.next/server/chunks/lib_rpc_1o0jb6w.js +1 -1
  159. package/.next/standalone/.next/server/chunks/lib_scoring_1-e4ur0.js +3 -0
  160. package/.next/standalone/.next/server/chunks/node_modules_next_1zc5q0a._.js +1 -1
  161. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1feicqq._.js → [root-of-the-server]__0qdeuyo._.js} +1 -1
  162. package/.next/standalone/.next/server/chunks/ssr/_0c_y09g._.js +3 -0
  163. package/.next/standalone/.next/server/chunks/ssr/_0ezj4e0._.js +1 -1
  164. package/.next/standalone/.next/server/chunks/ssr/{_1mj739k._.js → _0l8cxmo._.js} +2 -2
  165. package/.next/standalone/.next/server/chunks/ssr/_1errbuf._.js +3 -0
  166. package/.next/standalone/.next/server/chunks/ssr/{_0wxbq78._.js → app_components_19c2d9c._.js} +2 -2
  167. package/.next/standalone/.next/server/chunks/ssr/app_components_ui_0pdmz2z.js +3 -0
  168. package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +1 -1
  169. package/.next/standalone/.next/server/chunks/ssr/app_settings_page_1f8mjj_.js +1 -1
  170. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +1 -1
  171. package/.next/standalone/.next/server/chunks/ssr/app_social_page_0h7zxda.js +3 -0
  172. package/.next/standalone/.next/server/chunks/ssr/{node_modules_1wax83z._.js → node_modules_0h91jdk._.js} +2 -2
  173. package/.next/standalone/.next/server/chunks/ssr/{node_modules_1nkgb45._.js → node_modules_1f8t_rp._.js} +1 -1
  174. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  176. package/.next/standalone/.next/server/pages/404.html +1 -1
  177. package/.next/standalone/.next/server/pages/500.html +1 -1
  178. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  179. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  180. package/.next/standalone/.next/static/chunks/07dyt47mp4s9-.js +1 -0
  181. package/.next/standalone/.next/static/chunks/{3q3pmt5vv-4pi.js → 160wjb73ddwqd.js} +1 -1
  182. package/.next/standalone/.next/static/chunks/{1z-zp93cyoxvz.js → 1c_n0973t07l0.js} +1 -1
  183. package/.next/standalone/.next/static/chunks/{0bo1dxn5gwzpk.css → 1xptlvtcz6654.css} +1 -1
  184. package/.next/standalone/.next/static/chunks/{2_wcobfqry2uj.js → 206rm6j42gl_a.js} +1 -1
  185. package/.next/standalone/.next/static/chunks/2coxzjm_k6ilr.js +1 -0
  186. package/.next/{static/chunks/0g_p4p7oci18u.js → standalone/.next/static/chunks/2kfhc58df2xyo.js} +1 -1
  187. package/.next/standalone/.next/static/chunks/2pw6_osz1n_te.js +1 -0
  188. package/.next/standalone/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
  189. package/.next/standalone/CHANGELOG.md +48 -0
  190. package/.next/standalone/app/api/social/messages/route.js +130 -0
  191. package/.next/standalone/app/api/social/route.js +89 -47
  192. package/.next/standalone/app/components/GalaxyLab.js +4 -1
  193. package/.next/standalone/app/components/ScanStatusBar.js +112 -50
  194. package/.next/standalone/app/components/settings/DataSection.js +2 -1
  195. package/.next/standalone/app/globals.css +4 -0
  196. package/.next/standalone/app/setup/page.js +4 -3
  197. package/.next/standalone/app/social/Conversations.js +246 -0
  198. package/.next/standalone/app/social/page.js +88 -9
  199. package/.next/standalone/lib/galaxy-lab.js +6 -1
  200. package/.next/standalone/lib/island.js +36 -0
  201. package/.next/standalone/lib/linkedin-export.js +175 -5
  202. package/.next/standalone/lib/social-store.js +73 -0
  203. package/.next/standalone/package-lock.json +2 -2
  204. package/.next/standalone/package.json +1 -1
  205. package/.next/standalone/scripts/scrape.py +156 -23
  206. package/.next/static/chunks/07dyt47mp4s9-.js +1 -0
  207. package/.next/static/chunks/{3q3pmt5vv-4pi.js → 160wjb73ddwqd.js} +1 -1
  208. package/.next/static/chunks/{1z-zp93cyoxvz.js → 1c_n0973t07l0.js} +1 -1
  209. package/.next/static/chunks/{0bo1dxn5gwzpk.css → 1xptlvtcz6654.css} +1 -1
  210. package/.next/static/chunks/{2_wcobfqry2uj.js → 206rm6j42gl_a.js} +1 -1
  211. package/.next/static/chunks/2coxzjm_k6ilr.js +1 -0
  212. package/.next/{standalone/.next/static/chunks/0g_p4p7oci18u.js → static/chunks/2kfhc58df2xyo.js} +1 -1
  213. package/.next/static/chunks/2pw6_osz1n_te.js +1 -0
  214. package/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
  215. package/CHANGELOG.md +48 -0
  216. package/lib/galaxy-lab.js +6 -1
  217. package/lib/island.js +36 -0
  218. package/lib/linkedin-export.js +175 -5
  219. package/lib/social-store.js +73 -0
  220. package/package.json +1 -1
  221. package/scripts/scrape.py +156 -23
  222. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17kn1mk._.js +0 -8
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dmc7-t._.js +0 -8
  224. package/.next/standalone/.next/server/chunks/ssr/_005q9n1._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_217nddo._.js +0 -3
  226. package/.next/standalone/.next/server/chunks/ssr/app_0i3fw_m._.js +0 -3
  227. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_client_components_server-async-storage_0t1dato.js +0 -3
  228. package/.next/standalone/.next/static/chunks/0y1yms5kez2f3.js +0 -1
  229. package/.next/standalone/.next/static/chunks/14m1zu-8warte.js +0 -1
  230. package/.next/standalone/.next/static/chunks/15qbli9udo0vh.js +0 -1
  231. package/.next/static/chunks/0y1yms5kez2f3.js +0 -1
  232. package/.next/static/chunks/14m1zu-8warte.js +0 -1
  233. package/.next/static/chunks/15qbli9udo0vh.js +0 -1
  234. /package/.next/standalone/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_buildManifest.js +0 -0
  235. /package/.next/standalone/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_clientMiddlewareManifest.js +0 -0
  236. /package/.next/standalone/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_ssgManifest.js +0 -0
  237. /package/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_buildManifest.js +0 -0
  238. /package/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_clientMiddlewareManifest.js +0 -0
  239. /package/.next/static/{T0eWMsKjgSiPnmx1E0q3G → 7zWJnSkfSoRhRsqY6ZKtD}/_ssgManifest.js +0 -0
@@ -6,6 +6,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.10] - 2026-09-29
10
+
11
+ ### Changed
12
+ - **The scan status bar is now a notch.** A small pill hangs from the bottom of the header,
13
+ centred, on every page. It shows nothing while nothing runs, only a tiny dimmed *Auto scan*
14
+ while the all-day mode waits, and while a scan runs, what it's doing with a thin progress
15
+ line. Hover or click to open it: who, today's searches, the latest line, *Details* and
16
+ *Stop*. Other long jobs show there too, starting with recording the Galaxy's replay.
17
+
18
+ ## [0.4.9] - 2026-09-29
19
+
20
+ ### Added
21
+ - **Conversations, in the Social tab.** Every conversation with one of your connections, from your
22
+ export and the live messages sync: who it's with (name and tier), when it was last active, who
23
+ wrote last, unread, how many messages, and *Open on LinkedIn* (the thread when the sync found
24
+ its link, else their profile). Waiting on you comes first, then S and A tier, then the most
25
+ recent. Group conversations are shown and marked, and aren't counted in warmth. A search box
26
+ finds people by name.
27
+ - **Keep my messages on this computer**, a switch that's off unless you turn it on. When it's on,
28
+ the messages themselves, what other people wrote to you included, are kept in the app's data
29
+ folder (a file of their own beside the Social tab's), and a conversation opens as a thread,
30
+ newest at the bottom, a page at a time so a thread of thousands opens as fast as a short one.
31
+ The search box then finds what was said, too. They're never sent anywhere, and "Save a copy of
32
+ my network" doesn't carry them (Settings says so). Switching it off asks first, then deletes
33
+ them; *Forget it* deletes them with everything else.
34
+ - **The live messages sync reads a little more of the list.** Each conversation's link, whether
35
+ it's a group, and its newest message: who wrote last, which now feeds warmth and Waiting on you
36
+ where your export is missing or older, and its words, which go to the app only while Keep my
37
+ messages is on. It still opens no conversation, and its saved samples still keep no words.
38
+
39
+ ### Changed
40
+ - **Auto scan is slower and has its own ceilings.** Sittings of 8 pages, then an hour's rest;
41
+ searches only from 9:00 to 18:00; never more than 40 searches in 24 hours or 200 in 7 days,
42
+ however high the daily budget is set (the account restricted on 28 September did 373 in a
43
+ day with the budget at 500); and two days with no searches after any check from LinkedIn.
44
+ - **The scan status bar sits under the buttons.** On each page it now sits in its own row
45
+ under the header, pushing the page down instead of covering it. On Network Circle it floats
46
+ just under the header, measured, so a header that wraps to two rows no longer hides it.
47
+
48
+ ### Fixed
49
+ - **The live messages sync keeps what it finds.** It read your conversations but matched none of
50
+ them: the pattern for a profile link never matched, and LinkedIn's messaging gives a member
51
+ link your connections list doesn't have. It now matches each person to your connections by
52
+ link, or by a name only one connection has (the name isn't kept), and scrolls the list's own
53
+ panel so more than the first 20 conversations load.
54
+ - **Messaging samples keep no words.** The sync's saved samples of LinkedIn's data (for tuning
55
+ the reader) now drop every piece of writing and every name before they're saved.
56
+
9
57
  ## [0.4.8] - 2026-09-29
10
58
 
11
59
  ### Changed
@@ -0,0 +1,130 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { conversationList } from '../../../../lib/linkedin-export';
3
+ import { socialFiles, readJson, writeJsonAtomic, readMessages, deleteMessages } from '../../../../lib/social-store';
4
+
5
+ // The Social tab's Conversations: the list, one thread at a time, and the
6
+ // messages themselves when Keep my messages is on (lib/social-store.js has the
7
+ // two files and why they're apart).
8
+ //
9
+ // GET the list, no words: who, when, who wrote last,
10
+ // unread, how many, the thread's link
11
+ // GET ?id=<id> | ?with=<key> one thread, a page at a time (before, limit):
12
+ // its messages only if they're kept
13
+ // GET ?q=<words> the conversations whose kept messages say that
14
+ // POST an import's messages, in parts
15
+ // DELETE the messages gone, the list kept
16
+ //
17
+ // Nothing here goes anywhere but this computer's browser: no route of the app
18
+ // sends messages on, and a copy of the network doesn't carry this file.
19
+
20
+ const PAGE = 100;
21
+ const MAX_TEXT = 20000;
22
+
23
+ function state() {
24
+ const files = socialFiles();
25
+ if (!files) return null;
26
+ const social = readJson(files.social, {}) || {};
27
+ const keep = social.keepMessages === true;
28
+ const kept = keep ? readMessages(files.messages) : { threads: {}, previews: {} };
29
+ return { files, social, keep, kept, list: conversationList(social.conversations, social.live) };
30
+ }
31
+
32
+ /** A conversation's kept messages, oldest first, with the live sync's newest one after them when it's newer. */
33
+ function messagesOf(c, kept) {
34
+ const msgs = kept.threads[c.id]?.messages || [];
35
+ const preview = !c.group && c.people.length === 1 ? kept.previews[c.people[0]] : null;
36
+ if (preview?.text && (!msgs.length || (preview.t || 0) > (msgs[msgs.length - 1].t || 0))) return [...msgs, preview];
37
+ return msgs;
38
+ }
39
+
40
+ export async function GET(request) {
41
+ const s = state();
42
+ if (!s) return Response.json({ keepMessages: false, conversations: [] });
43
+ const url = new URL(request.url);
44
+ const id = url.searchParams.get('id');
45
+ const withKey = url.searchParams.get('with');
46
+
47
+ if (id || withKey) {
48
+ const c = id
49
+ ? s.list.find((x) => x.id === id)
50
+ : s.list.filter((x) => !x.group && x.people[0] === withKey).sort((a, b) => (b.last || 0) - (a.last || 0))[0];
51
+ if (!c) return Response.json({ error: 'That conversation isn’t here.' }, { status: 404 });
52
+ if (!s.keep) return Response.json({ kept: false, id: c.id, total: c.count });
53
+ const msgs = messagesOf(c, s.kept);
54
+ const total = msgs.length;
55
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || PAGE));
56
+ const before = url.searchParams.has('before') ? Math.max(0, Math.min(total, Number(url.searchParams.get('before')) || 0)) : total;
57
+ const start = Math.max(0, before - limit);
58
+ return Response.json({ kept: true, id: c.id, title: s.kept.threads[c.id]?.title || '', total, start, messages: msgs.slice(start, before) });
59
+ }
60
+
61
+ const q = String(url.searchParams.get('q') || '').trim().toLowerCase();
62
+ if (url.searchParams.has('q')) {
63
+ if (!s.keep || q.length < 2) return Response.json({ ids: [] });
64
+ const ids = s.list.filter((c) => messagesOf(c, s.kept).some((m) => String(m.text || '').toLowerCase().includes(q))).map((c) => c.id);
65
+ return Response.json({ ids });
66
+ }
67
+
68
+ const conversations = s.list.map((c) => ({
69
+ ...c,
70
+ title: s.kept.threads[c.id]?.title || '',
71
+ hasText: s.keep && messagesOf(c, s.kept).length > 0,
72
+ }));
73
+ return Response.json({ keepMessages: s.keep, conversations });
74
+ }
75
+
76
+ /** One message as it's kept, or null when it isn't one. */
77
+ function cleanMessage(m) {
78
+ if (!m || typeof m !== 'object' || !Number.isFinite(Number(m.t))) return null;
79
+ return { t: Number(m.t), fromMe: m.fromMe === true, text: typeof m.text === 'string' ? m.text.slice(0, MAX_TEXT) : '' };
80
+ }
81
+
82
+ // An import's messages: { part, parts, threads: { id: { title, messages } } }.
83
+ // Each part stays under the 10 MB a request can carry here, so a long history
84
+ // comes in several. They gather in the `incoming` file and replace the kept
85
+ // messages only when the last one lands, so a half-sent import never shows as
86
+ // the whole thing. Refused while Keep my messages is off, whatever the page sent.
87
+ export async function POST(request) {
88
+ const s = state();
89
+ if (!s) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
90
+ if (!s.keep) return Response.json({ error: 'Keep my messages is off, so nothing was saved.' }, { status: 409 });
91
+ let body;
92
+ try { body = await request.json(); } catch { return Response.json({ error: 'That isn’t readable.' }, { status: 400 }); }
93
+ const part = Number(body?.part);
94
+ const parts = Number(body?.parts);
95
+ if (!Number.isInteger(part) || !Number.isInteger(parts) || part < 0 || part >= parts) {
96
+ return Response.json({ error: 'That isn’t a part of an import.' }, { status: 400 });
97
+ }
98
+ let into;
99
+ if (part === 0) {
100
+ // A new import replaces the export's messages; the live sync's previews stay.
101
+ into = { threads: {}, previews: s.kept.previews || {} };
102
+ } else {
103
+ into = readJson(s.files.incoming, null);
104
+ if (!into?.threads) return Response.json({ error: 'The start of this import is missing. Choose the export folder again.' }, { status: 409 });
105
+ }
106
+ const threads = body?.threads && typeof body.threads === 'object' ? body.threads : {};
107
+ for (const [id, t] of Object.entries(threads)) {
108
+ if (!id || id.length > 200 || !t || !Array.isArray(t.messages)) continue;
109
+ const to = into.threads[id] || (into.threads[id] = { title: '', messages: [] });
110
+ if (typeof t.title === 'string' && t.title && !to.title) to.title = t.title.slice(0, 200);
111
+ for (const m of t.messages) {
112
+ const c = cleanMessage(m);
113
+ if (c) to.messages.push(c);
114
+ }
115
+ }
116
+ if (part === parts - 1) {
117
+ writeJsonAtomic(s.files.messages, into);
118
+ rmSync(s.files.incoming, { force: true });
119
+ } else {
120
+ writeJsonAtomic(s.files.incoming, into);
121
+ }
122
+ return Response.json({ ok: true, part, parts, done: part === parts - 1 });
123
+ }
124
+
125
+ // The words gone, the list and the numbers kept: what switching Keep my
126
+ // messages off does (the PATCH that turns it off deletes them as well).
127
+ export async function DELETE() {
128
+ deleteMessages(socialFiles());
129
+ return Response.json({ ok: true });
130
+ }
@@ -1,40 +1,66 @@
1
- import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { dataDir } from '../../../lib/db-client';
1
+ import { rmSync } from 'node:fs';
2
+ import { db } from '../../../lib/db';
4
3
  import { resolveProfile } from '../../../lib/profile';
4
+ import { matchLive } from '../../../lib/linkedin-export';
5
+ import { socialFiles, readJson, writeJsonAtomic, readMessages, deleteMessages } from '../../../lib/social-store';
5
6
 
6
7
  // The Social tab's saved findings (lib/linkedin-export.js): numbers and dates
7
- // from your own LinkedIn export, one file per profile in the data folder. No
8
- // message text ever reaches here. GET reads it, POST replaces it, DELETE
9
- // forgets it.
8
+ // from your own LinkedIn export and the live messages sync, one file per
9
+ // profile in the data folder. No message text is kept in this file: the words,
10
+ // when Keep my messages is on, go to their own file (lib/social-store.js,
11
+ // app/api/social/messages). GET reads it, POST replaces it, PUT takes a live
12
+ // sync, PATCH flips a switch, DELETE forgets it all.
10
13
 
11
- function fileFor() {
12
- const me = resolveProfile({ create: false });
13
- if (!me?.id || !/^[\w-]+$/.test(String(me.id))) return null;
14
- return path.join(dataDir(), `social-${me.id}.json`);
14
+ // A person's key: their profile link, as lib/separation.js keyFor makes it.
15
+ const KEY_OK = (k) => typeof k === 'string' && k.length > 0 && k.length < 300;
16
+
17
+ /** An import's list of conversations, with nothing but the fields the list shows. */
18
+ function cleanConversations(list) {
19
+ if (!Array.isArray(list)) return [];
20
+ const out = [];
21
+ for (const c of list) {
22
+ if (!c || typeof c.id !== 'string' || !c.id || c.id.length > 200 || !Array.isArray(c.people)) continue;
23
+ const people = c.people.filter(KEY_OK).slice(0, 200);
24
+ if (!people.length) continue;
25
+ out.push({
26
+ id: c.id,
27
+ people,
28
+ group: c.group === true,
29
+ last: Number(c.last) || null,
30
+ lastFromThem: typeof c.lastFromThem === 'boolean' ? c.lastFromThem : null,
31
+ count: Number.isFinite(Number(c.count)) ? Number(c.count) : null,
32
+ });
33
+ }
34
+ return out;
15
35
  }
16
36
 
17
37
  export async function GET() {
18
- const file = fileFor();
19
- if (!file) return Response.json({ social: null });
20
- try { return Response.json({ social: JSON.parse(readFileSync(file, 'utf8')) }); } catch { return Response.json({ social: null }); }
38
+ const files = socialFiles();
39
+ if (!files) return Response.json({ social: null });
40
+ const social = readJson(files.social, null);
41
+ if (!social) return Response.json({ social: null });
42
+ // The list of conversations can run to thousands; the Galaxy and the tab's
43
+ // counts don't need it, and GET /api/social/messages serves it.
44
+ const { conversations, ...rest } = social;
45
+ return Response.json({ social: { ...rest, conversationCount: Array.isArray(conversations) ? conversations.length : 0 } });
21
46
  }
22
47
 
23
48
  export async function POST(request) {
24
- const file = fileFor();
25
- if (!file) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
49
+ const files = socialFiles();
50
+ if (!files) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
26
51
  let body;
27
52
  try { body = await request.json(); } catch { return Response.json({ error: 'That isn’t readable.' }, { status: 400 }); }
28
53
  // Only the known fields, so nothing else rides along into the file.
29
- let before = {};
30
- try { before = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* none yet */ }
54
+ const before = readJson(files.social, {}) || {};
31
55
  const clean = {
32
56
  live: before.live || {},
33
57
  liveAt: before.liveAt || null,
34
58
  autoSync: before.autoSync === true,
59
+ keepMessages: before.keepMessages === true,
35
60
  importedAt: new Date().toISOString(),
36
61
  asOf: Number(body?.asOf) || null,
37
62
  people: body?.people && typeof body.people === 'object' ? body.people : {},
63
+ conversations: cleanConversations(body?.conversations),
38
64
  chapters: Array.isArray(body?.chapters) ? body.chapters : [],
39
65
  posts: body?.posts && typeof body.posts === 'object' ? body.posts : null,
40
66
  invites: body?.invites && typeof body.invites === 'object' ? body.invites : null,
@@ -46,51 +72,67 @@ export async function POST(request) {
46
72
  if (!clean.emails) delete p.email;
47
73
  }
48
74
  }
49
- const tmp = `${file}.${process.pid}.tmp`;
50
- writeFileSync(tmp, JSON.stringify(clean));
51
- renameSync(tmp, file);
52
- return Response.json({ ok: true, people: Object.keys(clean.people).length });
75
+ writeJsonAtomic(files.social, clean);
76
+ return Response.json({ ok: true, people: Object.keys(clean.people).length, conversations: clean.conversations.length });
53
77
  }
54
78
 
55
79
  // From the scanner's experimental messages sync (scripts/scrape.py sync_messages):
56
- // per person, when your 1:1 conversation was last active and unread count.
57
- // Merged in beside what the export gave, kept under `live`.
80
+ // per person, when your 1:1 conversation was last active, unread count, the
81
+ // thread's link, who wrote last and the newest message. Merged in beside what
82
+ // the export gave, kept under `live`. The newest message's words go to the
83
+ // messages file, and only while Keep my messages is on.
58
84
  export async function PUT(request) {
59
- const file = fileFor();
60
- if (!file) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
85
+ const files = socialFiles();
86
+ if (!files) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
61
87
  let body;
62
88
  try { body = await request.json(); } catch { return Response.json({ error: 'That isn’t readable.' }, { status: 400 }); }
63
- let saved = {};
64
- try { saved = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* first sync */ }
89
+ const saved = readJson(files.social, {}) || {};
90
+ // Matched to your connections here, so the names the sync sends go no further.
91
+ const me = resolveProfile({ create: false });
92
+ let conns = [];
93
+ try {
94
+ const q = db.from('linkedin_connections').select('name, profile_url, id').eq('degree', 1);
95
+ if (me?.id) q.eq('user_id', me.id);
96
+ conns = (await q).data || [];
97
+ } catch { /* no connections yet */ }
98
+ const { live: matched, unmatched } = matchLive(body?.live || {}, conns);
65
99
  const live = {};
66
- for (const [url, v] of Object.entries(body?.live || {})) {
67
- if (typeof url !== 'string' || !url.includes('/in/') || !v || typeof v !== 'object') continue;
68
- live[url] = { last: Number(v.last) || null, unread: Number.isFinite(Number(v.unread)) ? Number(v.unread) : null };
100
+ const previews = {};
101
+ for (const [key, { preview, ...rest }] of Object.entries(matched)) {
102
+ live[key] = rest;
103
+ if (saved.keepMessages === true && preview?.text) previews[key] = preview;
104
+ }
105
+ writeJsonAtomic(files.social, { ...saved, live, liveAt: new Date().toISOString() });
106
+ if (Object.keys(previews).length) {
107
+ const kept = readJson(files.messages, {}) || {};
108
+ writeJsonAtomic(files.messages, { ...kept, previews: { ...(kept.previews || {}), ...previews } });
69
109
  }
70
- const next = { ...saved, live, liveAt: new Date().toISOString() };
71
- const tmp = `${file}.${process.pid}.tmp`;
72
- writeFileSync(tmp, JSON.stringify(next));
73
- renameSync(tmp, file);
74
- return Response.json({ ok: true, people: Object.keys(live).length });
110
+ return Response.json({ ok: true, people: Object.keys(live).length, unmatched, previews: Object.keys(previews).length });
75
111
  }
76
112
 
77
- // The Social tab's "once a day" switch for the live messages sync.
113
+ // The Social tab's switches: "once a day" for the live messages sync, and
114
+ // Keep my messages. Only the ones sent change. Turning Keep my messages off
115
+ // deletes the kept messages here too, so the words can't outlive the switch
116
+ // whatever the page did first.
78
117
  export async function PATCH(request) {
79
- const file = fileFor();
80
- if (!file) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
118
+ const files = socialFiles();
119
+ if (!files) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
81
120
  let body = {};
82
121
  try { body = await request.json(); } catch { /* nothing to change */ }
83
- let saved = {};
84
- try { saved = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* none yet */ }
85
- const next = { ...saved, autoSync: body?.autoSync === true };
86
- const tmp = `${file}.${process.pid}.tmp`;
87
- writeFileSync(tmp, JSON.stringify(next));
88
- renameSync(tmp, file);
89
- return Response.json({ ok: true, autoSync: next.autoSync });
122
+ const next = { ...(readJson(files.social, {}) || {}) };
123
+ if (typeof body?.autoSync === 'boolean') next.autoSync = body.autoSync;
124
+ if (typeof body?.keepMessages === 'boolean') next.keepMessages = body.keepMessages;
125
+ if (next.keepMessages !== true) deleteMessages(files);
126
+ writeJsonAtomic(files.social, next);
127
+ return Response.json({ ok: true, autoSync: next.autoSync === true, keepMessages: next.keepMessages === true });
90
128
  }
91
129
 
130
+ // Forget it: both files, the numbers and the words.
92
131
  export async function DELETE() {
93
- const file = fileFor();
94
- if (file) rmSync(file, { force: true });
132
+ const files = socialFiles();
133
+ if (files) {
134
+ rmSync(files.social, { force: true });
135
+ deleteMessages(files);
136
+ }
95
137
  return Response.json({ ok: true });
96
138
  }
@@ -12,6 +12,7 @@ import {
12
12
  layoutsNow, watchLayouts, saveLayout, applyLayout, forgetLayout, milestones,
13
13
  } from '../../lib/galaxy-lab';
14
14
  import { savePicture, recordReplay, canRecord } from '../../lib/galaxy-export';
15
+ import { showActivity } from '../../lib/island';
15
16
 
16
17
  const noLayouts = [];
17
18
 
@@ -89,7 +90,9 @@ export default function GalaxyLab() {
89
90
  const run = async (what, job) => {
90
91
  setBusy(what);
91
92
  setNote(null);
92
- try { await job(); } catch (e) { setNote(e.message || 'That didn’t work.'); } finally { setBusy(null); }
93
+ // A recording runs for the whole replay: the notch under the header shows it.
94
+ const done = what === 'video' ? showActivity({ id: 'replay-video', label: 'Recording the replay', detail: 'The video saves when it ends' }) : () => {};
95
+ try { await job(); } catch (e) { setNote(e.message || 'That didn’t work.'); } finally { setBusy(null); done(); }
93
96
  };
94
97
  const canReplay = clock.min != null && clock.max != null && clock.max > clock.min;
95
98
  const preset = FORCES.every((f) => lab[f.key] === LAB_DEFAULTS[f.key]) ? 'rings'
@@ -1,16 +1,21 @@
1
1
  'use client';
2
2
 
3
- // The scan status bar: while a scan runs, every page shows what it's doing,
4
- // for whom, how far it's got, and today's LinkedIn budget, with Stop. Stop
5
- // saves what was read, and the Scan page's Resume carries on from that page.
6
- // It sits just under the header's tabs (Network Circle, Degrees…), on every
7
- // page, the Scan page too (Blake, 2026-09-29). It only reports: the
8
- // pacing and the caps live in the scanner (scripts/scrape.py, lib/linkedin-limits.js).
3
+ // The notch: a small pill hanging from the bottom of the header, centred, on
4
+ // every page (Blake, 2026-09-29: "a notch ui of the thing scanning"). It stays
5
+ // out of the way: nothing at all while nothing runs; a tiny dimmed "Auto scan"
6
+ // when the all-day mode is on but idle; while a scan runs, what it's doing and
7
+ // how far it's got. Hover or click it to open it: who, today's LinkedIn budget,
8
+ // the latest line, Details and Stop. Stop saves what was read, and the Scan
9
+ // page's Resume carries on from that page. Other long jobs can show here too
10
+ // (lib/island.js). It only reports: the pacing and the caps live in the scanner
11
+ // (scripts/scrape.py, lib/linkedin-limits.js).
9
12
 
10
13
  import { useEffect, useState, useSyncExternalStore } from 'react';
14
+ import { usePathname } from 'next/navigation';
11
15
  import Link from 'next/link';
12
16
  import { watchScanner, scannerNow, stopScrape } from '../../lib/scraper-client';
13
17
  import { watchAllDay, allDayNow } from '../../lib/experimental-client';
18
+ import { watchActivities, activitiesNow, noActivities } from '../../lib/island';
14
19
 
15
20
  const WHAT = {
16
21
  full: 'Scanning your network',
@@ -34,63 +39,120 @@ const tone = (used, cap) => (!cap ? '#8b9a9a' : used / cap >= 0.9 ? '#ff6b6b' :
34
39
  export default function ScanStatusBar() {
35
40
  const [job, setJob] = useState(() => scannerNow());
36
41
  const [stopping, setStopping] = useState(false);
37
- // The Scan page's experimental all-day Auto-Bridge switch: with it on, the bar
38
- // stays under the tabs even while nothing runs, so you can see the mode is on.
42
+ const [open, setOpen] = useState(false);
43
+ const [pinned, setPinned] = useState(false);
39
44
  const allDay = useSyncExternalStore(watchAllDay, allDayNow, () => false);
45
+ const others = useSyncExternalStore(watchActivities, activitiesNow, noActivities);
40
46
  useEffect(() => watchScanner(() => {
41
47
  const now = scannerNow();
42
48
  setJob(now);
43
49
  if (!now.running) setStopping(false);
44
50
  }), []);
45
- const place = {
46
- position: 'fixed', left: '50%', top: 'var(--scan-bar-top, 94px)', transform: 'translateX(-50%)', zIndex: 60,
47
- maxWidth: 'calc(100vw - 32px)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'nowrap', whiteSpace: 'nowrap',
48
- padding: '8px 12px', borderRadius: 12, fontSize: 12, color: '#cfd8d8', background: 'rgba(14,16,32,0.94)',
49
- };
50
- if (!job?.running) {
51
- if (!allDay) return null;
52
- return (
53
- <div role="status" style={{ ...place, opacity: 0.55, border: '1px solid rgba(255,255,255,0.1)' }}>
54
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#556', flexShrink: 0 }} />
55
- <b style={{ color: '#b8c4c4' }}>Auto scan</b>
56
- <span style={{ color: '#8b9a9a' }}>ready · press Auto scan beside Scan to start</span>
57
- </div>
58
- );
59
- }
60
51
 
61
- const p = job.progress;
52
+ // Hangs from the header's bottom edge, measured: the header can load late,
53
+ // be swapped for a new one, or wrap to two rows.
54
+ const pathname = usePathname();
55
+ const [top, setTop] = useState(null);
56
+ useEffect(() => {
57
+ let header = null;
58
+ let ro = null;
59
+ let queued = 0;
60
+ const measure = () => {
61
+ queued = 0;
62
+ const now = document.querySelector('header');
63
+ if (now !== header) {
64
+ ro?.disconnect();
65
+ ro = null;
66
+ header = now;
67
+ if (header) { ro = new ResizeObserver(later); ro.observe(header); }
68
+ }
69
+ const at = header ? Math.round(header.getBoundingClientRect().bottom) : null;
70
+ setTop((was) => (was === at ? was : at));
71
+ };
72
+ const later = () => { if (!queued) queued = requestAnimationFrame(measure); };
73
+ later();
74
+ const mo = new MutationObserver(later);
75
+ mo.observe(document.body, { childList: true, subtree: true });
76
+ window.addEventListener('resize', later);
77
+ return () => { cancelAnimationFrame(queued); ro?.disconnect(); mo.disconnect(); window.removeEventListener('resize', later); };
78
+ }, [pathname]);
79
+
80
+ const running = !!job?.running;
81
+ const other = others[others.length - 1] || null;
82
+ if (!running && !other && !allDay) return null;
83
+
84
+ const p = job?.progress;
62
85
  const step = p?.kind === 'batch' && p.total ? `${p.current || p.done || 0} of ${p.total}`
63
- : p?.kind === 'walk' && p.total ? `${(p.done || 0).toLocaleString()} of ${p.total.toLocaleString()} read`
86
+ : p?.kind === 'walk' && p.total ? `${(p.done || 0).toLocaleString()} of ${p.total.toLocaleString()}`
64
87
  : p?.kind === 'saving' ? 'saving' : null;
65
- const b = job.budget;
66
- const last = [...(job.log || [])].reverse().find((l) => l && l.trim())?.trim();
88
+ const fraction = p?.total ? Math.min(1, (p.current || p.done || 0) / p.total) : other?.progress ?? null;
89
+ const b = job?.budget;
90
+ const last = running ? [...(job.log || [])].reverse().find((l) => l && l.trim())?.trim() : null;
91
+ const expanded = open || pinned;
92
+
93
+ // What the pill says, smallest first.
94
+ const dot = running ? '#00ff88' : other ? (other.tone === 'warn' ? '#FFD700' : '#3498DB') : '#556';
95
+ const label = running ? (WHAT[job.action] || 'Scanning') : other ? other.label : 'Auto scan';
96
+ const short = running ? step : other ? other.detail : null;
67
97
 
68
98
  return (
69
- <div role="status" aria-live="polite" style={{ ...place, border: '1px solid rgba(0,255,136,0.25)' }}>
70
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#00ff88', flexShrink: 0 }} />
71
- <b style={{ color: '#fff' }}>{WHAT[job.action] || 'Scanning'}</b>
72
- {job.target?.name && <span>{job.target.name}</span>}
73
- {step && <span style={{ color: '#8b9a9a' }}>{step}</span>}
74
- {b && (
75
- <span style={{ color: tone(b.searches, b.cap) }} title="Searches on your LinkedIn account in the last 24 hours, and your daily budget">
76
- {b.searches}{b.cap ? ` of ${b.cap}` : ''} searches today
77
- </span>
99
+ <div
100
+ role="status"
101
+ aria-live="polite"
102
+ onMouseEnter={() => setOpen(true)}
103
+ onMouseLeave={() => setOpen(false)}
104
+ style={{
105
+ position: 'fixed', left: '50%', top: top ?? 'var(--scan-bar-top, 94px)', transform: 'translateX(-50%)', zIndex: 60,
106
+ maxWidth: 'calc(100vw - 32px)', minWidth: running || other ? 180 : 0,
107
+ padding: expanded ? '8px 14px 10px' : '4px 12px 6px',
108
+ borderRadius: '0 0 16px 16px', borderTop: 'none',
109
+ border: `1px solid ${running ? 'rgba(0,255,136,0.25)' : 'rgba(255,255,255,0.1)'}`,
110
+ background: 'rgba(8,10,22,0.96)', color: '#cfd8d8', fontSize: 12,
111
+ opacity: running || other || expanded ? 1 : 0.55,
112
+ boxShadow: running ? '0 6px 20px rgba(0,0,0,0.35)' : 'none',
113
+ transition: 'padding 0.18s ease, opacity 0.18s ease',
114
+ }}
115
+ >
116
+ <button
117
+ onClick={() => setPinned((v) => !v)}
118
+ aria-expanded={expanded}
119
+ title={expanded ? 'Close' : 'Open'}
120
+ style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'none', border: 'none', color: 'inherit', font: 'inherit', cursor: 'pointer', padding: 0, whiteSpace: 'nowrap' }}
121
+ >
122
+ <span className={running ? 'notch-pulse' : undefined} style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }} />
123
+ <b style={{ color: running || other ? '#fff' : '#b8c4c4', fontWeight: 600 }}>{label}</b>
124
+ {short && <span style={{ color: '#8b9a9a' }}>{short}</span>}
125
+ {!running && !other && expanded && <span style={{ color: '#8b9a9a' }}>ready · press Auto scan beside Scan to start</span>}
126
+ </button>
127
+ {fraction != null && (
128
+ <div style={{ height: 2, marginTop: 5, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
129
+ <div style={{ width: `${Math.round(fraction * 100)}%`, height: '100%', background: running ? '#00ff88' : '#3498DB', transition: 'width 0.4s ease' }} />
130
+ </div>
78
131
  )}
79
- {last && (
80
- <span style={{ color: '#667', flex: '0 1 240px', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={last}>
81
- {last}
82
- </span>
132
+ {expanded && running && (
133
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
134
+ {job.target?.name && <span>{job.target.name}</span>}
135
+ {b && (
136
+ <span style={{ color: tone(b.searches, b.cap) }} title="Searches on your LinkedIn account in the last 24 hours, and your daily budget">
137
+ {b.searches}{b.cap ? ` of ${b.cap}` : ''} searches today
138
+ </span>
139
+ )}
140
+ {last && (
141
+ <span style={{ color: '#667', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={last}>{last}</span>
142
+ )}
143
+ <Link href="/setup" style={{ color: '#3498DB', textDecoration: 'none' }}>Details</Link>
144
+ <button
145
+ onClick={async () => { setStopping(true); try { await stopScrape(); } catch { setStopping(false); } }}
146
+ disabled={stopping}
147
+ title="Stops after saving what's been read; Resume on the Scan page carries on from the same page"
148
+ style={{
149
+ padding: '3px 10px', borderRadius: 8, fontSize: 12, cursor: stopping ? 'default' : 'pointer',
150
+ background: 'rgba(255,107,107,0.12)', color: '#ff9b9b', border: '1px solid rgba(255,107,107,0.35)',
151
+ }}
152
+ >{stopping ? 'Stopping…' : 'Stop'}</button>
153
+ </div>
83
154
  )}
84
- <Link href="/setup" style={{ color: '#3498DB', textDecoration: 'none' }}>Details</Link>
85
- <button
86
- onClick={async () => { setStopping(true); try { await stopScrape(); } catch { setStopping(false); } }}
87
- disabled={stopping}
88
- title="Stops after saving what's been read; Resume on the Scan page carries on from the same page"
89
- style={{
90
- padding: '4px 10px', borderRadius: 8, fontSize: 12, cursor: stopping ? 'default' : 'pointer',
91
- background: 'rgba(255,107,107,0.12)', color: '#ff9b9b', border: '1px solid rgba(255,107,107,0.35)',
92
- }}
93
- >{stopping ? 'Stopping…' : 'Stop'}</button>
155
+ {expanded && !running && other?.detail && <div style={{ marginTop: 6, color: '#8b9a9a' }}>{other.detail}</div>}
94
156
  </div>
95
157
  );
96
158
  }
@@ -334,7 +334,8 @@ export default function DataSection() {
334
334
  <Body style={small}>
335
335
  The copy holds the names, headlines and photos of the people in your network. Keep it private: don’t
336
336
  post it or share it, and delete it once it has been imported. A network opened from a LinkedIn CSV lives
337
- only in its browser tab, so it isn’t in the copy; import the CSV again on the new computer.
337
+ only in its browser tab, so it isn’t in the copy; import the CSV again on the new computer. The Social
338
+ tab’s findings, and any messages you keep there, stay on this computer and aren’t in the copy either.
338
339
  </Body>
339
340
 
340
341
  <h3 style={subhead}>Bring in a network from another computer</h3>
@@ -42,3 +42,7 @@ html:has([data-field-question]) {
42
42
  /* Where the scan status bar sits: just under the header (app/components/ScanStatusBar.js). */
43
43
  :root { --scan-bar-top: 94px; }
44
44
  @media (max-width: 767px) { :root { --scan-bar-top: 66px; } }
45
+
46
+ /* The notch's running dot (app/components/ScanStatusBar.js). */
47
+ @keyframes notch-pulse { 50% { opacity: 0.35; } }
48
+ @media (prefers-reduced-motion: no-preference) { .notch-pulse { animation: notch-pulse 1.6s ease-in-out infinite; } }
@@ -448,9 +448,10 @@ function SetupInner() {
448
448
  <input type="checkbox" checked={experimental} disabled={running} style={{ marginTop: 3 }}
449
449
  onChange={(e) => { setExperimental(e.target.checked); window.dispatchEvent(new Event('six-degrees:experimental')); }} />
450
450
  <span>
451
- <b style={{ color: '#FFD700' }}>Experimental:</b> all-day pacing. Up to 10 pages in a sitting,
452
- then a 45-minute rest; searches only from 9:00 to 19:00; at the daily budget it waits for it to
453
- free up instead of stopping; and it saves every page. It also reads LinkedIn&rsquo;s own data beside
451
+ <b style={{ color: '#FFD700' }}>Experimental:</b> all-day pacing. Up to 8 pages in a sitting,
452
+ then an hour&rsquo;s rest; searches only from 9:00 to 18:00; never more than 40 searches in a day
453
+ or 200 in a week, however high your budget; two days&rsquo; rest after any check from LinkedIn; at
454
+ the budget it waits instead of stopping; and it saves every page. It also reads LinkedIn&rsquo;s own data beside
454
455
  the page text, to fill gaps and measure how the two compare. It keeps running while the app is open.
455
456
  </span>
456
457
  </label>