@brftech/filex-core 0.42.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
package/src/lib/tags.ts CHANGED
@@ -22,13 +22,83 @@
22
22
 
23
23
  const TTL_MS = 60_000;
24
24
 
25
+ /**
26
+ * The two kinds of tag (v0.43.0, the owner's decision "tagler personal ve team
27
+ * olarak ikiye ayrılır"):
28
+ *
29
+ * personal — the person's own label, like a star. Nobody else sees it.
30
+ * team — shared with everyone in the tenant who can see the file;
31
+ * adding or removing one needs edit permission on the file.
32
+ *
33
+ * ⚠ Before v0.43.0 there was one kind and it was shared with EVERY account on
34
+ * the server while the code called it per-user — a tester's "müşteri teklifi"
35
+ * showed in other people's panels and they could remove it. A tag on screen
36
+ * therefore always says which kind it is (`TagKindIcon` + the words), so
37
+ * nobody has to guess who can see it.
38
+ */
39
+ export type TagKind = 'personal' | 'team';
40
+
41
+ /** A tag as the server hands it out: its name as typed and its kind. */
42
+ export interface TagItem {
43
+ name: string;
44
+ kind: TagKind;
45
+ }
46
+
47
+ export function isTagKind(v: unknown): v is TagKind {
48
+ return v === 'personal' || v === 'team';
49
+ }
50
+
51
+ /**
52
+ * Whether two names are one tag, the way the server decides it
53
+ * (`internal/tagname.Key`): whitespace collapsed, NFC, the four Latin i's —
54
+ * I, ı, İ, i — as one letter, then case-insensitive. The server is the
55
+ * authority; the client uses this only so the picker does not offer to add a
56
+ * tag the file already carries under different capitals.
57
+ *
58
+ * ⚠ The i's are mapped BEFORE lower-casing: JS lower-cases `İ` to "i" + U+0307 (a
59
+ * combining dot) and leaves `ı` alone, so `toLowerCase` alone would call
60
+ * "IŞIK" and "ışık" two tags — the Turkish case the server folds together.
61
+ */
62
+ export function tagKey(name: string): string {
63
+ return String(name ?? '')
64
+ .normalize('NFC')
65
+ .trim()
66
+ .replace(/\s+/g, ' ')
67
+ .replace(/[Iıİ]/g, 'i')
68
+ .toLowerCase();
69
+ }
70
+
71
+ /**
72
+ * The items in a tag answer. A v0.43+ server sends `items: [{name, kind}]`; an
73
+ * older one sends only `tags: string[]` — and on an older server every tag was
74
+ * shared with everybody, so those are TEAM tags. Anything malformed is dropped.
75
+ */
76
+ export function tagItemsOf(body: unknown): TagItem[] {
77
+ const b = (body ?? {}) as { items?: unknown; tags?: unknown };
78
+ if (Array.isArray(b.items)) {
79
+ return b.items.filter(
80
+ (x: unknown): x is TagItem =>
81
+ !!x &&
82
+ typeof (x as TagItem).name === 'string' &&
83
+ (x as TagItem).name !== '' &&
84
+ isTagKind((x as TagItem).kind),
85
+ );
86
+ }
87
+ if (Array.isArray(b.tags)) {
88
+ return b.tags
89
+ .filter((x: unknown): x is string => typeof x === 'string' && x !== '')
90
+ .map((name) => ({ name, kind: 'team' as const }));
91
+ }
92
+ return [];
93
+ }
94
+
25
95
  interface CacheEntry {
26
96
  at: number;
27
- tags: string[];
97
+ tags: TagItem[];
28
98
  }
29
99
 
30
100
  const cache = new Map<string, CacheEntry>();
31
- const inflight = new Map<string, Promise<string[]>>();
101
+ const inflight = new Map<string, Promise<TagItem[]>>();
32
102
 
33
103
  export interface TagListOptions {
34
104
  apiBase?: string;
@@ -45,11 +115,50 @@ export function invalidateTagCache(): void {
45
115
  }
46
116
 
47
117
  /**
48
- * Every distinct tag, alphabetical, as the backend returns them. Never
49
- * throws: an older backend with no such route, or a caller with no
50
- * permission, gets an empty list and the panel simply shows no tag section.
118
+ * etiket:k2 — "the tags changed", told to every explorer on the page.
119
+ *
120
+ * ⚠⚠ This used to travel as a component EVENT (TagPicker `change` → the
121
+ * explorer's `onNodeTagsChanged`), and Vue drops an event emitted by a
122
+ * component that has already been unmounted. The save is asynchronous, so a
123
+ * person who added a tag and closed the dialog before the server answered —
124
+ * a fraction of a second on a busy server — saved the tag and kept the panel
125
+ * that said "No tags yet". Measured: the full Playwright run (a loaded
126
+ * server) showed exactly that, while the spec alone (an idle one) passed.
127
+ * A module-level announcement does not depend on the component still being
128
+ * there, and it reaches EVERY explorer on the page, not only the one that
129
+ * hosted the picker.
130
+ */
131
+ const changeListeners = new Set<() => void>();
132
+
133
+ /** Listen for tag writes; returns the function that stops listening. */
134
+ export function onTagsChanged(fn: () => void): () => void {
135
+ changeListeners.add(fn);
136
+ return () => {
137
+ changeListeners.delete(fn);
138
+ };
139
+ }
140
+
141
+ /** A tag write succeeded: drop the cache, then tell every listener. The
142
+ * listeners re-ask WITHOUT forcing, so N explorers still cost one request
143
+ * (the first creates it, the rest join it in flight). */
144
+ export function announceTagsChanged(): void {
145
+ invalidateTagCache();
146
+ for (const fn of [...changeListeners]) {
147
+ try {
148
+ fn();
149
+ } catch {
150
+ /* one listener's failure must not silence the others */
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Every tag the person can see — both kinds — alphabetical, as the backend
157
+ * returns them. Never throws: an older backend with no such route, or a
158
+ * caller with no permission, gets an empty list and the panel simply shows
159
+ * no tag section.
51
160
  */
52
- export async function fetchAllTags(opts: TagListOptions = {}): Promise<string[]> {
161
+ export async function fetchAllTags(opts: TagListOptions = {}): Promise<TagItem[]> {
53
162
  const base = opts.apiBase ?? '';
54
163
  const key = base || '(same-origin)';
55
164
  if (!opts.force) {
@@ -65,10 +174,7 @@ export async function fetchAllTags(opts: TagListOptions = {}): Promise<string[]>
65
174
  credentials: opts.authCredentials ?? 'same-origin',
66
175
  });
67
176
  if (!res.ok) return [];
68
- const body = await res.json();
69
- const tags: string[] = Array.isArray(body?.tags)
70
- ? body.tags.filter((x: unknown): x is string => typeof x === 'string' && x !== '')
71
- : [];
177
+ const tags = tagItemsOf(await res.json());
72
178
  cache.set(key, { at: Date.now(), tags });
73
179
  return tags;
74
180
  } catch {
@@ -84,16 +190,19 @@ export async function fetchAllTags(opts: TagListOptions = {}): Promise<string[]>
84
190
  /**
85
191
  * Nodes carrying `tag`, as raw node rows (`{id, path, name, type, …}`) — the
86
192
  * same shape `star/list` and `recent` answer with, so the caller maps them
87
- * through its own `nodeRowToFileNode`.
193
+ * through its own `nodeRowToFileNode`. `kind` narrows to one kind; '' = both
194
+ * (what a `#.tag~x` link from before v0.43 means).
88
195
  */
89
196
  export async function fetchTaggedRows(
90
197
  tag: string,
91
198
  opts: TagListOptions = {},
92
199
  limit = 200,
200
+ kind: TagKind | '' = '',
93
201
  ): Promise<Record<string, unknown>[]> {
94
202
  const base = opts.apiBase ?? '';
203
+ const k = kind ? `&kind=${kind}` : '';
95
204
  const res = await fetch(
96
- `${base}/api/files/manager/tagged?tag=${encodeURIComponent(tag)}&limit=${limit}`,
205
+ `${base}/api/files/manager/tagged?tag=${encodeURIComponent(tag)}&limit=${limit}${k}`,
97
206
  {
98
207
  headers: await (opts.authHeaders ?? (() => ({})))(),
99
208
  credentials: opts.authCredentials ?? 'same-origin',
package/src/lib/themes.ts CHANGED
@@ -45,21 +45,43 @@
45
45
  */
46
46
 
47
47
  import { ref, type Ref } from 'vue';
48
+ import { hasSession, registerPersonalMirror, savePref, setLocalPref } from './prefs';
48
49
 
49
50
  /** Map of `--fe-*` custom property → value. */
50
51
  export type ThemeTokenMap = Record<string, string>;
51
52
 
52
53
  export interface ThemeDef {
53
- /** Stable id — persisted in localStorage, never shown to users. */
54
+ /** Stable id — what a palette choice stores (on the account where the host
55
+ * keeps preferences there, `lib/prefs`; mirrored in localStorage for the
56
+ * first paint), never shown to users. */
54
57
  id: string;
55
- /** i18n catalogue key for the display name (tr + en). */
56
- nameKey: string;
58
+ /**
59
+ * i18n catalogue key for the display name (tr + en).
60
+ *
61
+ * ⚠ Built-in palettes only. An operator's own theme carries `name` instead:
62
+ * "Acme Bulut" is not a string this product translates, and putting it
63
+ * through `t()` would print the key straight back at them the first time
64
+ * somebody named a theme something the catalogue did not contain.
65
+ */
66
+ nameKey?: string;
67
+ /** Literal display name — operator-defined themes only (tema:v1). */
68
+ name?: string;
57
69
  /** Palette applied when the resolved mode is light. */
58
70
  light: ThemeTokenMap;
59
71
  /** Palette applied when the resolved mode is dark. */
60
72
  dark: ThemeTokenMap;
61
73
  }
62
74
 
75
+ /**
76
+ * tema:v1 — the prefix that keeps the two namespaces apart.
77
+ *
78
+ * An operator-defined theme's id is always `custom:<slug>`, so no name an
79
+ * operator types can ever shadow `night`, `forest` or `default`. The server
80
+ * stores the slug bare and adds the prefix on the way out
81
+ * (backend/internal/api/handlers/themes.go).
82
+ */
83
+ export const CUSTOM_THEME_PREFIX = 'custom:';
84
+
63
85
  /**
64
86
  * ⚠⚠ `filex.palette`, NOT `filex.theme` — and the rename is a BUG FIX, not
65
87
  * tidying.
@@ -476,25 +498,135 @@ export const THEMES: ThemeDef[] = [
476
498
  },
477
499
  ];
478
500
 
501
+ /* ------------------------------------------------------------------ */
502
+ /* tema:v1 — operator-defined themes */
503
+ /* ------------------------------------------------------------------ */
504
+
505
+ /**
506
+ * The instance's own themes, fetched once per page load from the public
507
+ * `GET /api/appearance` and published here.
508
+ *
509
+ * ⚠⚠ THE SERVER'S LIST IS THE ONLY DEFINITION OF "EXISTS", and that is what
510
+ * makes deleting a theme safe. Nothing goes and rewrites the stored choice of
511
+ * everybody who was using a deleted theme — there is more than one place such
512
+ * a choice can live (this browser's localStorage, a per-account document on
513
+ * the server) and a cleanup pass that missed one would leave somebody pinned
514
+ * to a palette that no longer paints anything. Instead every lookup RESOLVES:
515
+ * `themeById` returns undefined for an id the server did not send, and every
516
+ * caller already treats undefined as "the stock palette".
517
+ */
518
+ const customThemes: Ref<ThemeDef[]> = ref([]);
519
+
520
+ /**
521
+ * Replace the operator-defined themes. Called once at boot with the payload
522
+ * from `/api/appearance`.
523
+ *
524
+ * ⚠ It also RE-RESOLVES the active selection, which is the client half of
525
+ * "deleting a theme puts anybody using it back on the default": a browser
526
+ * holding `custom:acme` in localStorage keeps it across the fetch, and the
527
+ * moment a list arrives that does not contain it, the selection drops to
528
+ * `default`.
529
+ *
530
+ * ⚠⚠ The correction is LOCAL — `applyStoredPalette`, never `setTheme`. Third
531
+ * merge repair, and the most destructive of the three had it been left.
532
+ * `setTheme` now writes the account, so correcting through it would send a
533
+ * fact the SERVER just told us straight back to the server, as though the
534
+ * person had chosen it. That is harmless only when the correction is right.
535
+ * It is not always right: this browser's mirror can hold a stale id from
536
+ * before the account was read, the two fetches have no ordering, and a
537
+ * correction fired from that stale value would overwrite the account's real
538
+ * choice — on every device — for a theme that was never deleted.
539
+ *
540
+ * Leaving the account's dead id untouched costs nothing: every read resolves
541
+ * it to the stock palette anyway, which is the whole point of building
542
+ * deletion as resolution rather than as a cleanup pass. The first deliberate
543
+ * pick the person makes writes a live id over it.
544
+ */
545
+ export function setCustomThemes(defs: ThemeDef[]): void {
546
+ customThemes.value = Array.isArray(defs) ? defs : [];
547
+ if (themeId.value !== DEFAULT_THEME_ID && !themeById(themeId.value)) {
548
+ applyStoredPalette(DEFAULT_THEME_ID);
549
+ }
550
+ }
551
+
552
+ /** Reactive handle for components that render the palette list. */
553
+ export function useCustomThemes(): { customThemes: Ref<ThemeDef[]> } {
554
+ return { customThemes };
555
+ }
556
+
557
+ /**
558
+ * Every palette a person may pick: the built-ins, then the instance's own.
559
+ *
560
+ * ⚠ Operator themes come LAST and are never interleaved. The built-in order
561
+ * is the order of the gallery cards people have learned, and a theme named
562
+ * "Amber Kurumsal" sorting itself between Amber and Lilac would move the card
563
+ * under somebody's cursor the first time a theme was added.
564
+ */
565
+ export function allThemes(): ThemeDef[] {
566
+ return [...THEMES, ...customThemes.value];
567
+ }
568
+
479
569
  export function themeById(id: string | null | undefined): ThemeDef | undefined {
480
- return THEMES.find((t) => t.id === id);
570
+ if (!id) return undefined;
571
+ return THEMES.find((t) => t.id === id) ?? customThemes.value.find((t) => t.id === id);
481
572
  }
482
573
 
483
- /** Every token key ANY theme touches — used to fully clear inline overrides
484
- * when switching themes (a theme that skips a token must not inherit the
485
- * previous theme's value for it). */
486
- const ALL_TOKEN_KEYS: string[] = Array.from(
487
- new Set(THEMES.flatMap((t) => [...Object.keys(t.light), ...Object.keys(t.dark)])),
488
- );
574
+ /** The display name of a theme, translated for built-ins and literal for an
575
+ * operator's own. */
576
+ export function themeName(theme: ThemeDef, t: (key: string) => string): string {
577
+ return theme.name ?? (theme.nameKey ? t(theme.nameKey) : theme.id);
578
+ }
579
+
580
+ /**
581
+ * Every token key ANY theme touches — used to fully clear inline overrides
582
+ * when switching themes (a theme that skips a token must not inherit the
583
+ * previous theme's value for it).
584
+ *
585
+ * ⚠⚠ A FUNCTION, not the constant it used to be. Computed once at module load
586
+ * it covered only the built-in palettes, so switching FROM an operator theme
587
+ * that set a token no built-in declares would leave that token's inline value
588
+ * on the root element — the new palette applied, and one stray colour from the
589
+ * old one survived it. It has to be recomputed against the list that is
590
+ * actually installed.
591
+ */
592
+ function allTokenKeys(): string[] {
593
+ return Array.from(
594
+ new Set(allThemes().flatMap((t) => [...Object.keys(t.light), ...Object.keys(t.dark)])),
595
+ );
596
+ }
489
597
 
490
598
  /* ------------------------------------------------------------------ */
491
599
  /* Shared reactive state + persistence */
492
600
  /* ------------------------------------------------------------------ */
493
601
 
602
+ /**
603
+ * Whether an id is worth holding on to before the server's theme list has
604
+ * arrived.
605
+ *
606
+ * ⚠ `themeById` cannot answer this at module load: the operator themes are
607
+ * fetched over the network and the first paint happens long before they land.
608
+ * A boot check that rejected every `custom:` id would silently reset the
609
+ * palette of anybody whose choice was an operator theme, on every page load —
610
+ * the preference would look like it never saved. So a prefixed id is kept on
611
+ * trust here and re-resolved for real by `setCustomThemes` once the list is
612
+ * in. Until then nothing paints it, which is the stock palette: correct, and
613
+ * the same thing the person would see anyway while the fetch is in flight.
614
+ */
615
+ function plausibleThemeId(v: string | null): boolean {
616
+ return !!v && (!!themeById(v) || v.startsWith(CUSTOM_THEME_PREFIX));
617
+ }
618
+
494
619
  function readStoredThemeId(): string {
620
+ // ⚠⚠ THE MIRROR BELONGS TO A PERSON, so with nobody signed in it is not read
621
+ // at all (`lib/prefs` → `hasSession`). It is not a machine's palette: it is a
622
+ // cache of ONE ACCOUNT's answer, and handing it to a signed-out window paints
623
+ // the last person who used this browser onto the sign-in page — which is what
624
+ // this line was measured doing on 2026-09-21, and is also how the operator's
625
+ // own default came to be suppressed (`instanceThemes.hasOwnChoice`).
626
+ if (!hasSession()) return DEFAULT_THEME_ID;
495
627
  try {
496
628
  const v = localStorage.getItem(THEME_LS_KEY);
497
- if (v && themeById(v)) return v;
629
+ if (plausibleThemeId(v)) return v as string;
498
630
  // One-time carry-over from the shared key. `themeById` is what makes this
499
631
  // safe: 'light' / 'dark' / 'auto' are not palette ids, so a value the app's
500
632
  // mode wrote can never be mistaken for a palette here.
@@ -516,15 +648,105 @@ const themeId: Ref<string> = ref(
516
648
  typeof window === 'undefined' ? DEFAULT_THEME_ID : readStoredThemeId(),
517
649
  );
518
650
 
651
+ /**
652
+ * Pick a palette.
653
+ *
654
+ * ⚠⚠ It is saved on the ACCOUNT, not only in this browser (`lib/prefs`).
655
+ * A palette lived in `localStorage` alone until v3, which meant a person who
656
+ * chose one on their laptop opened the product on their phone in the stock
657
+ * colours and had to choose again — reported by Burak as exactly that. The
658
+ * localStorage write stays as the FIRST-PAINT cache: it is what this browser
659
+ * reads before the account's answer can arrive, so the window does not flash
660
+ * the default on the way to the chosen one.
661
+ */
519
662
  export function setTheme(id: string): void {
520
- const valid = themeById(id) ? id : DEFAULT_THEME_ID;
663
+ const valid = plausibleThemeId(id) ? id : DEFAULT_THEME_ID;
521
664
  themeId.value = valid;
522
665
  try {
523
- if (valid === DEFAULT_THEME_ID) localStorage.removeItem(THEME_LS_KEY);
524
- else localStorage.setItem(THEME_LS_KEY, valid);
666
+ // ⚠ tema:v1 — the stock palette is WRITTEN, not cleared. It used to be
667
+ // removed, on the reasonable-sounding grounds that "no key" and "the
668
+ // default" mean the same thing. They stopped meaning the same thing the
669
+ // moment an instance could have a default of its own: an operator whose
670
+ // house style is a brand theme needs to tell "this person has not chosen"
671
+ // from "this person deliberately chose the product's own colours", and
672
+ // with the key cleared those are the same state. The second one would
673
+ // have had the brand theme re-applied over it on every page load, which
674
+ // reads as a preference that will not save.
675
+ localStorage.setItem(THEME_LS_KEY, valid);
525
676
  } catch {
526
677
  /* quota / private mode */
527
678
  }
679
+ // ⚠⚠ `valid`, NEVER ''. This line is a merge repair and it must not go back.
680
+ // `THEME_LS_KEY` and `PREF_LS_KEYS.palette` are the SAME string,
681
+ // 'filex.palette'; `savePref` mirrors into local storage and `setLocalPref`
682
+ // DELETES the key when handed an empty value. So a `'' for stock` ternary
683
+ // here wrote the key on the line above and erased it on this one:
684
+ // `setTheme('default')` left no trace of itself, the account learned
685
+ // nothing, and "deliberately chose the product's own colours" collapsed back
686
+ // into "has never chosen" — the very state an instance default paints over,
687
+ // on every page load. "Never chosen" is the key being ABSENT, and nothing
688
+ // else.
689
+ savePref('palette', valid);
690
+ }
691
+
692
+ /**
693
+ * The account's palette has arrived: paint it, without sending it back.
694
+ *
695
+ * ⚠ Separate from `setTheme` on purpose. Echoing a value that came FROM the
696
+ * server back TO it is how a hydration turns into a write, and a write into
697
+ * another device's surprise.
698
+ *
699
+ * ⚠⚠ `plausibleThemeId`, not `themeById` — the second merge repair, and the
700
+ * race it closes is worth spelling out. The account's answer and the operator
701
+ * themes are two independent fetches with no ordering between them. When the
702
+ * account arrives first and says `custom:acme`, `themeById` cannot know that
703
+ * id yet (the list is still in flight), would call it invalid, and would drop
704
+ * the person onto the stock palette — writing that demotion into this
705
+ * browser's mirror. `setCustomThemes` then finds the selection already at the
706
+ * default and sees nothing to correct, so the choice is gone rather than
707
+ * restored. Holding a prefixed id on trust and letting the list resolve it for
708
+ * real is the same contract `readStoredThemeId` already follows at boot.
709
+ */
710
+ export function applyStoredPalette(id: string | undefined | null): void {
711
+ // ⚠⚠ NOTHING is not an ANSWER. An account that carries no `palette` key has
712
+ // never been asked the question on this server; it has not said "the stock
713
+ // palette". Treating the two the same is how e2e 98-custom-theme.spec.ts:97
714
+ // went red: the browser painted its own `custom:e2e-acme` at boot, then
715
+ // `App.vue` hydrated an account document with no palette in it and called
716
+ // this function with `''`, which resolved to the stock id and overwrote both
717
+ // the selection AND this browser's mirror. `expect.poll(--fe-primary)` won
718
+ // the race against the fetch and the unpolled `--fe-bg` read on the very
719
+ // next line lost it, which is exactly what a flake caused by a real bug
720
+ // looks like.
721
+ //
722
+ // ⚠ The other three preferences already got this right and only the palette
723
+ // did not — `applyAccountTheme` (web/src/lib/theme.ts), `applyAccountDensity`
724
+ // (web/src/lib/density.ts) and `applyPrefLocale` (web/src/i18n) each return
725
+ // early on a value the account does not carry. This was the odd one out.
726
+ //
727
+ // ⚠⚠ Silence is not the same as an invalid id, either: `setCustomThemes`
728
+ // deliberately calls this with `DEFAULT_THEME_ID` to demote somebody off a
729
+ // theme the operator deleted, and that IS an answer and must still paint.
730
+ if (!id) return;
731
+ const valid = plausibleThemeId(id) ? id : DEFAULT_THEME_ID;
732
+ themeId.value = valid;
733
+ setLocalPref('palette', valid);
734
+ }
735
+
736
+ /**
737
+ * Paint a palette this person did not choose — the instance's own default,
738
+ * applied to somebody who has never expressed a preference.
739
+ *
740
+ * ⚠⚠ It records NOTHING: not the account, not this browser's mirror. An
741
+ * instance default is the operator's standing answer, not a personal choice,
742
+ * and the difference is load-bearing in both directions. Written to the
743
+ * account, it would overwrite on one device a choice the person made on
744
+ * another. Written to the mirror, it would make `hasOwnChoice()` true, so the
745
+ * day the operator picks a different house theme every existing person would
746
+ * stay pinned to the old one, permanently, with no way to tell why.
747
+ */
748
+ export function applyInstanceDefault(id: string | undefined | null): void {
749
+ if (id && plausibleThemeId(id)) themeId.value = id;
528
750
  }
529
751
 
530
752
  /** Reactive handle for components: `{ themeId, setTheme }`. */
@@ -552,9 +774,23 @@ export type ThemeModePref = 'host' | 'auto' | 'light' | 'dark';
552
774
 
553
775
  export const THEME_MODE_LS_KEY = 'filex.thememode';
554
776
 
777
+ /**
778
+ * ⚠ It is a PERSON's key, so a sign-out takes it with the rest
779
+ * (`lib/prefs` → `forgetPersonalPrefs`). Registered from here rather than
780
+ * listed over there because a storage key written down in two modules is the
781
+ * drift `lib/density`' header was written about.
782
+ */
783
+ registerPersonalMirror(THEME_MODE_LS_KEY);
784
+
555
785
  const MODE_VALUES: ThemeModePref[] = ['host', 'auto', 'light', 'dark'];
556
786
 
557
787
  function readStoredThemeMode(): ThemeModePref {
788
+ // ⚠⚠ Same rule as the palette, same reason: light or dark is a PERSON's
789
+ // answer. `'host'` is the neutral one — the explorer then falls back to
790
+ // `config.theme || 'auto'` (FileExplorer.vue) and `'auto'` is
791
+ // `prefers-color-scheme`, which is exactly what the owner asked a signed-out
792
+ // window to follow.
793
+ if (!hasSession()) return 'host';
558
794
  try {
559
795
  const v = localStorage.getItem(THEME_MODE_LS_KEY) as ThemeModePref | null;
560
796
  return v && MODE_VALUES.includes(v) ? v : 'host';
@@ -578,6 +814,30 @@ export function setThemeMode(mode: ThemeModePref): void {
578
814
  }
579
815
  }
580
816
 
817
+ /**
818
+ * The session answer changed — re-decide whose look this window wears.
819
+ *
820
+ * ⚠⚠ Needed because the two reads above happen at MODULE LOAD, and the only
821
+ * synchronous evidence available then is `hasSession()`'s stored hint. Two
822
+ * things can contradict it later and both must be obeyed:
823
+ * • the document turns out to be a public link (`sealSessionless` in the
824
+ * host's boot), and
825
+ * • `/api/auth/me` answers "nobody" on a browser whose hint said otherwise —
826
+ * a session that expired, or somebody who closed the tab instead of
827
+ * signing out. That is the shared-machine case, and leaving it unhandled
828
+ * would keep the leak alive for exactly the people most likely to hit it.
829
+ *
830
+ * ⚠ It re-READS rather than assuming: called after a sign-in it restores the
831
+ * person's own palette from the mirror, so the function is one answer to the
832
+ * question, not a one-way demotion.
833
+ *
834
+ * ⚠ It touches no storage. Nothing here is a choice anybody made.
835
+ */
836
+ export function resolveSessionLook(): void {
837
+ themeId.value = readStoredThemeId();
838
+ themeMode.value = readStoredThemeMode();
839
+ }
840
+
581
841
  /** Reactive handle for components: `{ themeMode, setThemeMode }`. */
582
842
  export function useThemeModeState(): {
583
843
  themeMode: Ref<ThemeModePref>;
@@ -587,10 +847,16 @@ export function useThemeModeState(): {
587
847
  }
588
848
 
589
849
  // Cross-tab sync, same contract as the theme id above.
850
+ //
851
+ // ⚠⚠ Gated on the session for the same reason the boot read is. A `storage`
852
+ // event is broadcast to EVERY tab on the origin, so a signed-in tab changing
853
+ // the mode would otherwise push that person's answer into a tab sitting on the
854
+ // sign-in page or on somebody's share link — the leak coming back through a
855
+ // side door after the front one was shut.
590
856
  if (typeof window !== 'undefined') {
591
857
  try {
592
858
  window.addEventListener('storage', (e) => {
593
- if (e.key !== THEME_MODE_LS_KEY) return;
859
+ if (e.key !== THEME_MODE_LS_KEY || !hasSession()) return;
594
860
  const v = e.newValue as ThemeModePref | null;
595
861
  themeMode.value = v && MODE_VALUES.includes(v) ? v : 'host';
596
862
  });
@@ -603,8 +869,10 @@ if (typeof window !== 'undefined') {
603
869
  if (typeof window !== 'undefined') {
604
870
  try {
605
871
  window.addEventListener('storage', (e) => {
606
- if (e.key !== THEME_LS_KEY) return;
607
- themeId.value = e.newValue && themeById(e.newValue) ? e.newValue : DEFAULT_THEME_ID;
872
+ // ⚠ `hasSession()` — see the mode listener above. Same event, same
873
+ // origin-wide broadcast, same leak.
874
+ if (e.key !== THEME_LS_KEY || !hasSession()) return;
875
+ themeId.value = plausibleThemeId(e.newValue) ? (e.newValue as string) : DEFAULT_THEME_ID;
608
876
  });
609
877
  } catch {
610
878
  /* non-browser env */
@@ -622,7 +890,7 @@ if (typeof window !== 'undefined') {
622
890
  * root subtree is always correct regardless of host CSS.
623
891
  */
624
892
  export function applyThemeToEl(el: HTMLElement, id: string, dark: boolean): void {
625
- for (const key of ALL_TOKEN_KEYS) el.style.removeProperty(key);
893
+ for (const key of allTokenKeys()) el.style.removeProperty(key);
626
894
  if (id === DEFAULT_THEME_ID) return;
627
895
  const theme = themeById(id);
628
896
  if (!theme) return;