@motion-proto/live-tokens 0.6.2 → 0.7.1

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 (212) hide show
  1. package/README.md +14 -13
  2. package/dist-plugin/index.cjs +147 -136
  3. package/dist-plugin/index.d.cts +1 -1
  4. package/dist-plugin/index.d.ts +1 -1
  5. package/dist-plugin/index.js +145 -135
  6. package/package.json +25 -40
  7. package/src/{component-editor → editor/component-editor}/BadgeEditor.svelte +8 -82
  8. package/src/{component-editor → editor/component-editor}/CalloutEditor.svelte +4 -4
  9. package/src/{component-editor → editor/component-editor}/CardEditor.svelte +28 -76
  10. package/src/{component-editor → editor/component-editor}/CollapsibleSectionEditor.svelte +3 -3
  11. package/src/{component-editor → editor/component-editor}/CornerBadgeEditor.svelte +31 -93
  12. package/src/{component-editor → editor/component-editor}/DialogEditor.svelte +60 -57
  13. package/src/editor/component-editor/ImageEditor.svelte +30 -0
  14. package/src/{component-editor → editor/component-editor}/InlineEditActionsEditor.svelte +6 -4
  15. package/src/editor/component-editor/MenuSelectEditor.svelte +160 -0
  16. package/src/{component-editor → editor/component-editor}/NotificationEditor.svelte +64 -37
  17. package/src/{component-editor → editor/component-editor}/ProgressBarEditor.svelte +5 -4
  18. package/src/{component-editor → editor/component-editor}/RadioButtonEditor.svelte +3 -3
  19. package/src/{component-editor → editor/component-editor}/SectionDividerEditor.svelte +57 -84
  20. package/src/{component-editor → editor/component-editor}/SegmentedControlEditor.svelte +2 -2
  21. package/src/{component-editor → editor/component-editor}/StandardButtonsEditor.svelte +16 -20
  22. package/src/{component-editor → editor/component-editor}/TabBarEditor.svelte +9 -14
  23. package/src/{component-editor → editor/component-editor}/TableEditor.svelte +9 -18
  24. package/src/{component-editor → editor/component-editor}/TooltipEditor.svelte +11 -47
  25. package/src/{component-editor → editor/component-editor}/registry.ts +28 -18
  26. package/src/{component-editor → editor/component-editor}/scaffolding/AngleDial.svelte +2 -2
  27. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentEditorBase.svelte +3 -51
  28. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentFileManager.svelte +144 -416
  29. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentFileMenu.svelte +18 -170
  30. package/src/{component-editor → editor/component-editor}/scaffolding/ComponentsTab.svelte +2 -2
  31. package/src/{component-editor → editor/component-editor}/scaffolding/CopyFromMenu.svelte +44 -4
  32. package/src/{component-editor → editor/component-editor}/scaffolding/DividerEditor.svelte +1 -1
  33. package/src/{component-editor → editor/component-editor}/scaffolding/FieldsetWrapper.svelte +1 -1
  34. package/src/{component-editor → editor/component-editor}/scaffolding/GradientCard.svelte +6 -6
  35. package/src/{component-editor → editor/component-editor}/scaffolding/LinkageChart.svelte +6 -6
  36. package/src/{component-editor → editor/component-editor}/scaffolding/LinkedBlock.svelte +6 -11
  37. package/src/editor/component-editor/scaffolding/NonStylableConfig.svelte +38 -0
  38. package/src/{component-editor → editor/component-editor}/scaffolding/SaveAsDialog.svelte +66 -12
  39. package/src/editor/component-editor/scaffolding/ShadowBackdrop.svelte +72 -0
  40. package/src/editor/component-editor/scaffolding/ShadowBackdropControls.svelte +132 -0
  41. package/src/editor/component-editor/scaffolding/StateBlock.svelte +257 -0
  42. package/src/{component-editor → editor/component-editor}/scaffolding/TokenLayout.svelte +9 -7
  43. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +644 -0
  44. package/src/{component-editor → editor/component-editor}/scaffolding/editorContext.ts +19 -9
  45. package/src/{component-editor → editor/component-editor}/scaffolding/linkedBlock.ts +2 -2
  46. package/src/{component-editor → editor/component-editor}/scaffolding/types.ts +14 -0
  47. package/src/{lib → editor/core/components}/componentConfigService.ts +2 -2
  48. package/src/{lib → editor/core/components}/componentPersist.ts +5 -5
  49. package/src/editor/core/flashStatus.ts +30 -0
  50. package/src/{lib → editor/core/fonts}/fontLoader.ts +2 -2
  51. package/src/{lib → editor/core/fonts}/fontMigration.ts +4 -4
  52. package/src/{lib → editor/core/fonts}/fontParse.ts +1 -1
  53. package/src/editor/core/manifests/manifestService.ts +116 -0
  54. package/src/{lib → editor/core/palettes}/paletteDerivation.ts +2 -2
  55. package/src/{lib → editor/core/palettes}/tokenRegistry.ts +5 -5
  56. package/src/editor/core/productionPulse.ts +37 -0
  57. package/src/{lib → editor/core/routing}/router.ts +1 -1
  58. package/src/{lib/files/versionedFileResource.ts → editor/core/storage/files/versionedFileResourceClient.ts} +8 -1
  59. package/src/{lib → editor/core/store}/editorCore.ts +24 -8
  60. package/src/{lib → editor/core/store}/editorPersistence.ts +3 -3
  61. package/src/{lib → editor/core/store}/editorRenderer.ts +2 -2
  62. package/src/{lib → editor/core/store}/editorStore.ts +17 -17
  63. package/src/{lib → editor/core/store}/editorTypes.ts +1 -1
  64. package/src/{lib → editor/core/themes}/slices/columns.ts +2 -2
  65. package/src/{lib → editor/core/themes}/slices/components.ts +2 -2
  66. package/src/{lib → editor/core/themes}/slices/fonts.ts +1 -1
  67. package/src/{lib → editor/core/themes}/slices/gradients.ts +2 -2
  68. package/src/{lib → editor/core/themes}/slices/overlays.ts +1 -1
  69. package/src/{lib → editor/core/themes}/slices/palettes.ts +1 -1
  70. package/src/{lib → editor/core/themes}/slices/shadows.ts +3 -3
  71. package/src/{lib → editor/core/themes}/themeInit.ts +6 -6
  72. package/src/{lib → editor/core/themes}/themeService.ts +6 -6
  73. package/src/{lib → editor/core/themes}/themeTypes.ts +11 -7
  74. package/src/editor/index.ts +69 -0
  75. package/src/{lib → editor/overlay}/LiveEditorOverlay.svelte +79 -125
  76. package/src/{lib → editor/overlay}/columnsOverlay.ts +2 -2
  77. package/src/{pages → editor/pages}/ComponentEditorPage.svelte +12 -12
  78. package/src/{pages → editor/pages}/Editor.svelte +4 -4
  79. package/src/{pages → editor/pages}/EditorShell.svelte +18 -36
  80. package/src/{styles → editor/styles}/ui-editor.css +41 -21
  81. package/src/{styles → editor/styles}/ui-form-controls.css +8 -8
  82. package/src/{ui → editor/ui}/BezierCurveEditor.svelte +8 -8
  83. package/src/{ui → editor/ui}/ColorEditPanel.svelte +13 -13
  84. package/src/{ui → editor/ui}/EditorViewSwitcher.svelte +8 -6
  85. package/src/editor/ui/FileLoadList.svelte +350 -0
  86. package/src/editor/ui/FilePill.svelte +80 -0
  87. package/src/{ui → editor/ui}/FontStackEditor.svelte +7 -7
  88. package/src/{ui → editor/ui}/GradientEditor.svelte +11 -11
  89. package/src/{ui → editor/ui}/GradientStopPicker.svelte +1 -1
  90. package/src/editor/ui/ManifestFileManager.svelte +371 -0
  91. package/src/{ui → editor/ui}/PaletteEditor.svelte +132 -598
  92. package/src/{ui → editor/ui}/ProjectFontsSection.svelte +102 -144
  93. package/src/{ui → editor/ui}/SurfacesTab.svelte +3 -3
  94. package/src/{ui → editor/ui}/TextTab.svelte +3 -3
  95. package/src/{ui → editor/ui}/ThemeFileManager.svelte +286 -519
  96. package/src/{ui → editor/ui}/UICopyPopover.svelte +4 -4
  97. package/src/{ui → editor/ui}/UIFontFamilySelector.svelte +6 -6
  98. package/src/{ui → editor/ui}/UIFontSizeSelector.svelte +1 -1
  99. package/src/editor/ui/UIInfoPopover.svelte +244 -0
  100. package/src/{ui → editor/ui}/UILineHeightSelector.svelte +5 -5
  101. package/src/{ui → editor/ui}/UILinkToggle.svelte +2 -2
  102. package/src/{ui → editor/ui}/UIPaddingSelector.svelte +6 -6
  103. package/src/{ui → editor/ui}/UIPaletteSelector.svelte +26 -26
  104. package/src/editor/ui/UIPillButton.svelte +138 -0
  105. package/src/{ui → editor/ui}/UIRadio.svelte +2 -2
  106. package/src/{ui → editor/ui}/UIRelinkConfirmPopover.svelte +4 -4
  107. package/src/editor/ui/UISquareButton.svelte +172 -0
  108. package/src/{ui → editor/ui}/UITokenSelector.svelte +10 -10
  109. package/src/{ui → editor/ui}/UIVariantSelector.svelte +1 -1
  110. package/src/{ui → editor/ui}/VariablesTab.svelte +31 -8
  111. package/src/{ui → editor/ui}/palette/GradientStopEditor.svelte +13 -13
  112. package/src/{ui → editor/ui}/palette/OverridesPanel.svelte +13 -13
  113. package/src/{ui → editor/ui}/palette/PaletteBase.svelte +8 -5
  114. package/src/{ui → editor/ui}/palette/paletteEditorState.ts +1 -1
  115. package/src/editor/ui/palette/paletteMath.ts +275 -0
  116. package/src/{ui → editor/ui}/sections/ColumnsSection.svelte +137 -17
  117. package/src/{ui → editor/ui}/sections/GradientsSection.svelte +7 -7
  118. package/src/{ui → editor/ui}/sections/OverlaysSection.svelte +17 -17
  119. package/src/{ui → editor/ui}/sections/ShadowsSection.svelte +22 -22
  120. package/src/{ui → editor/ui}/sections/TokenScaleTable.svelte +3 -3
  121. package/src/{components → system/components}/Badge.svelte +0 -36
  122. package/src/{components → system/components}/Card.svelte +8 -62
  123. package/src/{components → system/components}/CornerBadge.svelte +8 -24
  124. package/src/{components → system/components}/Dialog.svelte +1 -1
  125. package/src/system/components/FloatingTokenTags.css +256 -0
  126. package/src/system/components/FloatingTokenTags.svelte +592 -0
  127. package/src/{components → system/components}/InlineEditActions.svelte +6 -4
  128. package/src/system/components/MenuSelect.svelte +229 -0
  129. package/src/{components → system/components}/ProgressBar.svelte +29 -11
  130. package/src/{components → system/components}/SegmentedControl.svelte +49 -43
  131. package/src/{components → system/components}/TabBar.svelte +81 -65
  132. package/src/{components → system/components}/Table.svelte +17 -3
  133. package/src/{components → system/components}/Tooltip.svelte +6 -4
  134. package/src/system/styles/CONVENTIONS.md +178 -0
  135. package/src/{styles → system/styles}/fonts.css +6 -3
  136. package/src/{styles → system/styles}/tokens.css +149 -29
  137. package/src/component-editor/ImageEditor.svelte +0 -74
  138. package/src/component-editor/scaffolding/NonStylableConfig.svelte +0 -62
  139. package/src/component-editor/scaffolding/ShadowBackdrop.svelte +0 -37
  140. package/src/component-editor/scaffolding/ShadowBackdropControls.svelte +0 -61
  141. package/src/component-editor/scaffolding/StateBlock.svelte +0 -132
  142. package/src/component-editor/scaffolding/VariantGroup.svelte +0 -310
  143. package/src/data/google-fonts.json +0 -75
  144. package/src/lib/index.ts +0 -68
  145. package/src/lib/presetService.ts +0 -214
  146. package/src/lib/productionPulse.ts +0 -32
  147. package/src/ui/PresetFileManager.svelte +0 -1116
  148. package/src/ui/UnsavedComponentsDialog.svelte +0 -315
  149. /package/src/{styles → app}/site.css +0 -0
  150. /package/src/{component-editor → editor/component-editor}/index.ts +0 -0
  151. /package/src/{component-editor → editor/component-editor}/scaffolding/DemoHeader.svelte +0 -0
  152. /package/src/{component-editor → editor/component-editor}/scaffolding/TypeEditor.svelte +0 -0
  153. /package/src/{component-editor → editor/component-editor}/scaffolding/buildTypeGroupTokens.ts +0 -0
  154. /package/src/{component-editor → editor/component-editor}/scaffolding/componentSectionType.ts +0 -0
  155. /package/src/{component-editor → editor/component-editor}/scaffolding/componentSources.ts +0 -0
  156. /package/src/{component-editor → editor/component-editor}/scaffolding/defaultSections.ts +0 -0
  157. /package/src/{component-editor → editor/component-editor}/scaffolding/siblings.ts +0 -0
  158. /package/src/{lib → editor/core/components}/componentConfigKeys.ts +0 -0
  159. /package/src/{lib → editor/core}/cssVarSync.ts +0 -0
  160. /package/src/{lib → editor/core/palettes}/oklch.ts +0 -0
  161. /package/src/{lib → editor/core/routing}/navLinkTypes.ts +0 -0
  162. /package/src/{lib → editor/core/routing}/parentRouteStore.ts +0 -0
  163. /package/src/{lib → editor/core/storage}/storage.ts +0 -0
  164. /package/src/{lib → editor/core/store}/editorConfig.ts +0 -0
  165. /package/src/{lib → editor/core/store}/editorConfigStore.ts +0 -0
  166. /package/src/{lib → editor/core/store}/editorKeybindings.ts +0 -0
  167. /package/src/{lib → editor/core/store}/editorViewStore.ts +0 -0
  168. /package/src/{lib → editor/core/themes}/migrations/2026-04-24-component-prefix-and-suffix-renames.ts +0 -0
  169. /package/src/{lib → editor/core/themes}/migrations/2026-04-24-legacy-keys-and-bg-to-canvas.ts +0 -0
  170. /package/src/{lib → editor/core/themes}/migrations/2026-04-27-segmentedcontrol-disabled-flatten.ts +0 -0
  171. /package/src/{lib → editor/core/themes}/migrations/2026-05-08-collapsiblesection-frame-and-cleanup.ts +0 -0
  172. /package/src/{lib → editor/core/themes}/migrations/2026-05-08-collapsiblesection-variant-namespace.ts +0 -0
  173. /package/src/{lib → editor/core/themes}/migrations/2026-05-10-sectiondivider-gradient-stops.ts +0 -0
  174. /package/src/{lib → editor/core/themes}/migrations/2026-05-13-primary-to-brand.ts +0 -0
  175. /package/src/{lib → editor/core/themes}/migrations/index.ts +0 -0
  176. /package/src/{lib → editor/core/themes}/parsers/globalRootBlock.ts +0 -0
  177. /package/src/{lib → editor/core/themes}/slices/domainVars.ts +0 -0
  178. /package/src/{lib → editor/overlay}/ColumnsOverlay.svelte +0 -0
  179. /package/src/{lib → editor/overlay}/overlayState.ts +0 -0
  180. /package/src/{pages → editor/pages}/ComponentEditorPage.svelte.d.ts +0 -0
  181. /package/src/{pages → editor/pages}/Editor.svelte.d.ts +0 -0
  182. /package/src/{ui → editor/ui}/Toggle.svelte +0 -0
  183. /package/src/{ui → editor/ui}/UIDialog.svelte +0 -0
  184. /package/src/{ui → editor/ui}/UIFontWeightSelector.svelte +0 -0
  185. /package/src/{ui → editor/ui}/UIOptionItem.svelte +0 -0
  186. /package/src/{ui → editor/ui}/UIOptionList.svelte +0 -0
  187. /package/src/{ui → editor/ui}/UIRadioGroup.svelte +0 -0
  188. /package/src/{lib → editor/ui}/copyPopover.ts +0 -0
  189. /package/src/{ui → editor/ui}/curveEngine.ts +0 -0
  190. /package/src/{ui → editor/ui}/index.ts +0 -0
  191. /package/src/{ui → editor/ui}/keepInViewport.ts +0 -0
  192. /package/src/{ui → editor/ui}/palette/ScaleCurveEditor.svelte +0 -0
  193. /package/src/{lib → editor/ui}/scrollSection.ts +0 -0
  194. /package/src/{ui → editor/ui}/sections/tokenScales.ts +0 -0
  195. /package/src/{ui → editor/ui}/variantScales.ts +0 -0
  196. /package/src/{assets → system/assets}/newspaper.webp +0 -0
  197. /package/src/{assets → system/assets}/offering.webp +0 -0
  198. /package/src/{components → system/components}/Button.svelte +0 -0
  199. /package/src/{components → system/components}/Callout.svelte +0 -0
  200. /package/src/{components → system/components}/CollapsibleSection.svelte +0 -0
  201. /package/src/{components → system/components}/Image.svelte +0 -0
  202. /package/src/{components → system/components}/Notification.svelte +0 -0
  203. /package/src/{components → system/components}/RadioButton.svelte +0 -0
  204. /package/src/{components → system/components}/SectionDivider.svelte +0 -0
  205. /package/src/{components → system/components}/types.ts +0 -0
  206. /package/src/{styles → system/styles}/_padding.scss +0 -0
  207. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-italic-latin-ext.woff2 +0 -0
  208. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-italic-latin.woff2 +0 -0
  209. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-roman-latin-ext.woff2 +0 -0
  210. /package/src/{styles → system/styles}/fonts/Fraunces/Fraunces-roman-latin.woff2 +0 -0
  211. /package/src/{styles → system/styles}/fonts/Manrope/Manrope-latin-ext.woff2 +0 -0
  212. /package/src/{styles → system/styles}/fonts/Manrope/Manrope-latin.woff2 +0 -0
@@ -1,18 +1,20 @@
1
1
  <script lang="ts">
2
- import { stopPropagation } from 'svelte/legacy';
3
-
4
- import { onMount, onDestroy } from 'svelte';
5
- import type { ThemeMeta } from '../lib/themeTypes';
6
- import { listThemes, deleteTheme, setActiveFile, getProductionInfo, setProductionFile } from '../lib/themeService';
7
- import { activeFileName } from '../lib/editorConfigStore';
8
- import { dirty } from '../lib/editorStore';
9
- import { productionRevision, bumpProductionRevision, themeProductionInfo } from '../lib/productionPulse';
10
- import UIDialog from './UIDialog.svelte';
2
+ import { onMount } from 'svelte';
3
+ import type { ThemeMeta } from '../core/themes/themeTypes';
4
+ import { listThemes, deleteTheme, setActiveFile, getProductionInfo, setProductionFile, sanitizeFileName } from '../core/themes/themeService';
5
+ import { listManifests, saveAsManifest } from '../core/manifests/manifestService';
6
+ import { activeFileName } from '../core/store/editorConfigStore';
7
+ import { dirty } from '../core/store/editorStore';
8
+ import { productionRevision, bumpProductionRevision, themeProductionInfo } from '../core/productionPulse';
9
+ import { flashStatus } from '../core/flashStatus';
10
+ import UIInfoPopover from './UIInfoPopover.svelte';
11
+ import FileLoadList from './FileLoadList.svelte';
12
+ import FilePill from './FilePill.svelte';
11
13
  import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
12
14
 
13
15
  interface Props {
14
16
  saveStatus?: 'idle' | 'saving' | 'saved' | 'error';
15
- onsave?: (payload: { fileName: string; displayName: string }) => void;
17
+ onsave?: (payload: { fileName: string; displayName: string }) => void | Promise<void>;
16
18
  onload?: (payload: { fileName: string }) => void;
17
19
  }
18
20
 
@@ -23,12 +25,14 @@
23
25
  let saveAsDialog = $state(false);
24
26
  let currentDisplayName = $state('Default Theme');
25
27
 
26
- let prodApplyStatus: 'idle' | 'applying' | 'done' | 'error' = $state('idle');
28
+ type ProdApplyStatus = 'idle' | 'applying' | 'done' | 'error';
29
+ let prodApplyStatus: ProdApplyStatus = $state('idle');
30
+ const setProdApplyStatus = (s: ProdApplyStatus) => (prodApplyStatus = s);
27
31
 
28
- let infoOpen = $state(false);
29
- let infoBtnEl = $state<HTMLButtonElement | undefined>(undefined);
30
- let infoPopoverEl = $state<HTMLDivElement | undefined>(undefined);
31
- let infoPopoverReady = $state(false);
32
+ // Set when Adopt is clicked on a dirty+default theme: the SaveAs dialog opens
33
+ // for the user to name their theme, and on confirm the Adopt resumes
34
+ // automatically so the user gets one flow ("save and adopt") from one click.
35
+ let adoptAfterSave = false;
32
36
 
33
37
  let prodIsInSync = $derived($themeProductionInfo?.fileName === $activeFileName);
34
38
  let editorIsApplied = $derived(prodIsInSync && !$dirty);
@@ -36,6 +40,14 @@
36
40
 
37
41
  let isDefaultActive = $derived($activeFileName === 'default');
38
42
 
43
+ // Display names already in use by protected files. Passed to SaveAsDialog so
44
+ // a user can't shadow the default by typing its label ("Default Theme"),
45
+ // which sanitizes to "default-theme" and would otherwise slip past the
46
+ // filename-only check.
47
+ let protectedDisplayNames = $derived(
48
+ files.filter((f) => f.fileName === 'default').map((f) => f.name),
49
+ );
50
+
39
51
  async function refreshFiles() {
40
52
  try {
41
53
  files = await listThemes();
@@ -61,76 +73,9 @@
61
73
  onMount(async () => {
62
74
  await refreshFiles();
63
75
  await refreshProduction();
64
- window.addEventListener('keydown', handleKeydown);
65
- document.addEventListener('mousedown', handleDocumentMousedown, true);
66
- });
67
-
68
- onDestroy(() => {
69
- window.removeEventListener('keydown', handleKeydown);
70
- document.removeEventListener('mousedown', handleDocumentMousedown, true);
71
76
  });
72
77
 
73
- function handleKeydown(e: KeyboardEvent) {
74
- if (e.key === 'Escape' && infoOpen) {
75
- infoOpen = false;
76
- }
77
- }
78
-
79
- function handleDocumentMousedown(e: MouseEvent) {
80
- if (!infoOpen) return;
81
- const target = e.target as Element | null;
82
- if (target && !target.closest('.tfm-info-btn, .tfm-info-popover')) {
83
- infoOpen = false;
84
- }
85
- }
86
-
87
- /** Anchor the fixed-position popover to the right of the info button. The
88
- * sidebar lives on the left, so flow into the content area; flip up if the
89
- * button is near the bottom of the viewport. Mirrors the preset popover so
90
- * the two info surfaces feel identical. */
91
- function positionInfoPopover(): void {
92
- const btn = infoBtnEl;
93
- const pop = infoPopoverEl;
94
- if (!btn || !pop) return;
95
- const br = btn.getBoundingClientRect();
96
- const pr = pop.getBoundingClientRect();
97
- const margin = 8;
98
- const vw = window.innerWidth;
99
- const vh = window.innerHeight;
100
- let left = br.right + margin;
101
- if (left + pr.width > vw - margin) {
102
- left = br.left + br.width / 2 - pr.width / 2;
103
- if (left < margin) left = margin;
104
- if (left + pr.width > vw - margin) left = vw - margin - pr.width;
105
- }
106
- let top = br.bottom + margin;
107
- if (top + pr.height > vh - margin) {
108
- top = br.top - margin - pr.height;
109
- if (top < margin) top = margin;
110
- }
111
- pop.style.left = `${left}px`;
112
- pop.style.top = `${top}px`;
113
- infoPopoverReady = true;
114
- }
115
-
116
- $effect(() => {
117
- if (!infoOpen) {
118
- infoPopoverReady = false;
119
- return;
120
- }
121
- let raf1 = requestAnimationFrame(() => {
122
- raf1 = requestAnimationFrame(positionInfoPopover);
123
- });
124
- window.addEventListener('scroll', positionInfoPopover, true);
125
- window.addEventListener('resize', positionInfoPopover);
126
- return () => {
127
- cancelAnimationFrame(raf1);
128
- window.removeEventListener('scroll', positionInfoPopover, true);
129
- window.removeEventListener('resize', positionInfoPopover);
130
- };
131
- });
132
-
133
- // Refresh production state when any production pointer flips (e.g. a preset
78
+ // Refresh production state when any production pointer flips (e.g. a manifest
134
79
  // is adopted elsewhere). Skip the initial tick — onMount already loaded it.
135
80
  let pulseInitialised = false;
136
81
  $effect(() => {
@@ -143,35 +88,93 @@
143
88
  });
144
89
 
145
90
  function handleSave() {
146
- if (isDefaultActive) return;
91
+ // Default is read-only — quietly redirect the first Save into Save As so the
92
+ // user gets one motion ("name it, save it") instead of bumping into a
93
+ // disabled button and having to find Save As themselves.
94
+ if (isDefaultActive) {
95
+ openSaveAs();
96
+ return;
97
+ }
147
98
  onsave?.({ fileName: $activeFileName, displayName: currentDisplayName });
148
99
  }
149
100
 
150
101
  function openSaveAs() {
151
102
  showFileList = false;
103
+ // Standalone Save As must not piggyback on an Adopt that was cancelled
104
+ // earlier — clear the latch so confirmSaveAs doesn't fire a stale Adopt.
105
+ adoptAfterSave = false;
152
106
  saveAsDialog = true;
153
107
  }
154
108
 
155
- function confirmSaveAs(detail: { displayName: string; fileName: string }) {
109
+ async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
156
110
  const { displayName, fileName } = detail;
157
- onsave?.({ fileName, displayName });
111
+ await onsave?.({ fileName, displayName });
158
112
  $activeFileName = fileName;
159
113
  currentDisplayName = displayName;
160
- setTimeout(() => refreshFiles(), 500);
114
+ await refreshFiles();
115
+ if (adoptAfterSave) {
116
+ adoptAfterSave = false;
117
+ await handleApplyToProduction();
118
+ }
119
+ }
120
+
121
+ // Pick a manifest filename that doesn't collide with anything on disk so
122
+ // auto-creating doesn't clobber a manifest the user customized earlier.
123
+ async function pickFreshManifestName(base: string): Promise<string> {
124
+ const baseFile = sanitizeFileName(base);
125
+ let manifests: { fileName: string }[];
126
+ try {
127
+ manifests = await listManifests();
128
+ } catch {
129
+ return baseFile;
130
+ }
131
+ const taken = new Set(manifests.map((m) => m.fileName));
132
+ if (!taken.has(baseFile)) return baseFile;
133
+ for (let n = 1; n < 1000; n++) {
134
+ const suffix = String(n).padStart(2, '0');
135
+ const candidate = `${baseFile}_${suffix}`;
136
+ if (!taken.has(candidate)) return candidate;
137
+ }
138
+ return `${baseFile}_${Date.now()}`;
161
139
  }
162
140
 
163
141
  async function handleApplyToProduction() {
164
142
  if (prodIsInSync) return;
143
+
144
+ // Dirty edits on the protected default theme can't be saved to that file,
145
+ // and adopting the on-disk default would silently strand the user's
146
+ // changes. Open the theme SaveAs dialog and resume Adopt after save.
147
+ if (isDefaultActive && $dirty) {
148
+ adoptAfterSave = true;
149
+ saveAsDialog = true;
150
+ return;
151
+ }
152
+
165
153
  prodApplyStatus = 'applying';
166
154
  try {
167
155
  await setProductionFile($activeFileName);
168
156
  await refreshProduction();
169
157
  bumpProductionRevision();
170
- prodApplyStatus = 'done';
171
- setTimeout(() => { prodApplyStatus = 'idle'; }, 2000);
172
- } catch {
173
- prodApplyStatus = 'error';
174
- setTimeout(() => { prodApplyStatus = 'idle'; }, 3000);
158
+ flashStatus(setProdApplyStatus, 'done');
159
+ } catch (err) {
160
+ const e = err as Error & { code?: string };
161
+ if (e.code === 'ACTIVE_IS_PROTECTED') {
162
+ // Default manifest is active auto-create a user manifest and retry.
163
+ // No second dialog: the user clicked one button (Adopt) and gave the
164
+ // theme a name; the manifest is bookkeeping they shouldn't have to
165
+ // think about.
166
+ prodApplyStatus = 'idle';
167
+ try {
168
+ const targetName = await pickFreshManifestName('my-manifest');
169
+ await saveAsManifest(targetName, targetName);
170
+ } catch {
171
+ flashStatus(setProdApplyStatus, 'error', { durationMs: 3000 });
172
+ return;
173
+ }
174
+ await handleApplyToProduction();
175
+ return;
176
+ }
177
+ flashStatus(setProdApplyStatus, 'error', { durationMs: 3000 });
175
178
  }
176
179
  }
177
180
 
@@ -189,12 +192,62 @@
189
192
  onload?.({ fileName: file.fileName });
190
193
  }
191
194
 
195
+ // Two-step arm pattern. First click flips `revertArmed` so the button
196
+ // morphs into a "Discard?" affordance; second click commits. Click-out,
197
+ // Escape, a 3s timeout, or the dirty flag clearing all disarm.
198
+ let revertArmed = $state(false);
199
+ let revertTimer: ReturnType<typeof setTimeout> | null = null;
200
+
201
+ function disarmRevert() {
202
+ revertArmed = false;
203
+ if (revertTimer) {
204
+ clearTimeout(revertTimer);
205
+ revertTimer = null;
206
+ }
207
+ }
208
+
209
+ function handleRevert() {
210
+ if (!$dirty) return;
211
+ if (!revertArmed) {
212
+ revertArmed = true;
213
+ revertTimer = setTimeout(disarmRevert, 3000);
214
+ return;
215
+ }
216
+ disarmRevert();
217
+ onload?.({ fileName: $activeFileName });
218
+ }
219
+
220
+ $effect(() => {
221
+ if (!$dirty && revertArmed) disarmRevert();
222
+ });
223
+
224
+ $effect(() => {
225
+ if (!revertArmed) return;
226
+ const onDocClick = (e: MouseEvent) => {
227
+ if (!(e.target as HTMLElement).closest('.tfm-revert-btn')) disarmRevert();
228
+ };
229
+ const onKey = (e: KeyboardEvent) => {
230
+ if (e.key === 'Escape') disarmRevert();
231
+ };
232
+ document.addEventListener('click', onDocClick, true);
233
+ document.addEventListener('keydown', onKey);
234
+ return () => {
235
+ document.removeEventListener('click', onDocClick, true);
236
+ document.removeEventListener('keydown', onKey);
237
+ };
238
+ });
239
+
192
240
  async function handleDelete(file: ThemeMeta) {
193
241
  if (file.fileName === 'default') return;
242
+ if (file.fileName === $themeProductionInfo?.fileName) return;
194
243
  try {
244
+ // Capture before refreshFiles() reads the server's reverted active back
245
+ // into local state — otherwise the "was this the active file?" check
246
+ // below sees the post-revert value and skips the reload.
247
+ const wasActive = file.fileName === $activeFileName;
195
248
  await deleteTheme(file.fileName);
196
249
  await refreshFiles();
197
- if (file.fileName === $activeFileName) {
250
+ if (wasActive) {
198
251
  $activeFileName = 'default';
199
252
  currentDisplayName = 'Default Theme';
200
253
  onload?.({ fileName: 'default' });
@@ -209,57 +262,16 @@
209
262
  if (showFileList) refreshFiles();
210
263
  }
211
264
 
212
- const dateFormatter = new Intl.DateTimeFormat(undefined, {
213
- month: 'short',
214
- day: 'numeric',
215
- hour: 'numeric',
216
- minute: '2-digit',
217
- });
218
-
219
- function formatUpdatedAt(iso: string): string {
220
- if (!iso) return '';
221
- const d = new Date(iso);
222
- if (Number.isNaN(d.getTime())) return '';
223
- return dateFormatter.format(d);
224
- }
225
-
226
- type SortKey = 'name' | 'updatedAt';
227
- let sortKey: SortKey = $state('updatedAt');
228
- let sortDir: 'asc' | 'desc' = $state('desc');
229
-
230
- function toggleSort(key: SortKey) {
231
- if (sortKey === key) {
232
- sortDir = sortDir === 'asc' ? 'desc' : 'asc';
233
- } else {
234
- sortKey = key;
235
- sortDir = key === 'name' ? 'asc' : 'desc';
236
- }
237
- }
238
-
239
- let sortedFiles = $derived([...files].sort((a, b) => {
240
- let cmp = 0;
241
- if (sortKey === 'name') {
242
- cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
243
- } else {
244
- cmp = (a.updatedAt || '').localeCompare(b.updatedAt || '');
245
- }
246
- return sortDir === 'asc' ? cmp : -cmp;
247
- }));
248
265
  </script>
249
266
 
250
267
  <div class="theme-file-manager">
251
268
  <div class="tfm-header">
252
269
  <span class="tfm-header-label">Theme</span>
253
- <button
254
- type="button"
255
- class="tfm-info-btn"
256
- aria-label="About themes"
257
- aria-expanded={infoOpen}
258
- bind:this={infoBtnEl}
259
- onclick={() => (infoOpen = !infoOpen)}
260
- >
261
- <i class="fas fa-circle-info"></i>
262
- </button>
270
+ <UIInfoPopover title="Themes" ariaLabel="About themes">
271
+ <p>
272
+ A <strong>theme</strong> saves the design tokens for a site, components use these tokens to define their appearance.
273
+ </p>
274
+ </UIInfoPopover>
263
275
  </div>
264
276
 
265
277
  <div class="tfm-cards" class:in-sync={prodIsInSync}>
@@ -278,11 +290,32 @@
278
290
  >
279
291
  <i class="tfm-status-dot" aria-hidden="true"></i>
280
292
  <span>{$dirty ? 'unsaved' : editorIsApplied ? 'live' : 'saved'}</span>
293
+ {#if $dirty}
294
+ <button
295
+ type="button"
296
+ class="tfm-revert-btn"
297
+ class:armed={revertArmed}
298
+ onclick={handleRevert}
299
+ title={revertArmed
300
+ ? 'Click again to discard unsaved changes'
301
+ : 'Revert to last saved version'}
302
+ aria-label={revertArmed ? 'Confirm discard unsaved changes' : 'Revert unsaved changes'}
303
+ >
304
+ <i class="fas fa-rotate-left" aria-hidden="true"></i>
305
+ {#if revertArmed}<span class="tfm-revert-label">Discard?</span>{/if}
306
+ </button>
307
+ {/if}
281
308
  </span>
282
309
  </div>
283
- <div class="tfm-pill" class:dirty={$dirty} class:applied={editorIsApplied}>
284
- <span class="tfm-pill-name" title={currentDisplayName}>{currentDisplayName}</span>
285
- </div>
310
+ <FilePill
311
+ name={currentDisplayName}
312
+ isProtected={isDefaultActive}
313
+ dirty={$dirty}
314
+ applied={editorIsApplied}
315
+ protectedTitle="Protected system theme"
316
+ title={currentDisplayName}
317
+ style="display: flex;"
318
+ />
286
319
  <div class="tfm-card-actions tfm-card-actions-stack">
287
320
  <button
288
321
  class="tfm-btn tfm-btn-row save-btn"
@@ -290,9 +323,9 @@
290
323
  class:saved={saveStatus === 'saved'}
291
324
  class:error={saveStatus === 'error'}
292
325
  onclick={handleSave}
293
- disabled={saveStatus === 'saving' || isDefaultActive}
326
+ disabled={saveStatus === 'saving'}
294
327
  title={isDefaultActive
295
- ? 'Default is read-only — use Save As to capture under a new name'
328
+ ? 'Save to a new theme file'
296
329
  : 'Save to current file'}
297
330
  >
298
331
  <i
@@ -361,110 +394,49 @@
361
394
  <span>{prodIsInSync ? 'live' : 'out of sync'}</span>
362
395
  </span>
363
396
  </div>
364
- <div class="tfm-pill" class:applied={prodIsInSync}>
365
- <span class="tfm-pill-name" title={prodName}>{prodName}</span>
366
- </div>
397
+ <FilePill
398
+ name={prodName}
399
+ isProtected={$themeProductionInfo?.fileName === 'default'}
400
+ applied={prodIsInSync}
401
+ protectedTitle="Protected system theme"
402
+ title={prodName}
403
+ style="display: flex;"
404
+ />
367
405
  </div>
368
406
  </div>
369
407
  </div>
370
408
 
371
- {#if infoOpen}
372
- <div
373
- class="tfm-info-popover"
374
- class:ready={infoPopoverReady}
375
- role="dialog"
376
- aria-label="About themes"
377
- bind:this={infoPopoverEl}
378
- >
379
- <header class="tfm-info-header">
380
- <span class="tfm-info-title">Themes</span>
381
- <button
382
- type="button"
383
- class="tfm-info-close"
384
- aria-label="Close"
385
- onclick={() => (infoOpen = false)}
386
- >
387
- <i class="fas fa-xmark"></i>
388
- </button>
389
- </header>
390
- <div class="tfm-info-body">
391
- <p>
392
- A <strong>theme</strong> saves the design tokens for a site, components use these tokens to define their appearance.
393
- </p>
394
- </div>
395
- </div>
396
- {/if}
397
-
398
- <UIDialog
409
+ <FileLoadList
399
410
  bind:show={showFileList}
400
411
  title="Load Theme"
401
- cancelLabel="Close"
402
- width="420px"
403
- >
404
- <div class="load-list">
405
- <div class="load-header">
406
- <button
407
- class="sort-btn name-col"
408
- class:active-sort={sortKey === 'name'}
409
- onclick={() => toggleSort('name')}
410
- >
411
- <span>Name</span>
412
- {#if sortKey === 'name'}
413
- <i class="fas {sortDir === 'asc' ? 'fa-caret-up' : 'fa-caret-down'}"></i>
414
- {/if}
415
- </button>
416
- <button
417
- class="sort-btn date-col"
418
- class:active-sort={sortKey === 'updatedAt'}
419
- onclick={() => toggleSort('updatedAt')}
420
- >
421
- <span>Date</span>
422
- {#if sortKey === 'updatedAt'}
423
- <i class="fas {sortDir === 'asc' ? 'fa-caret-up' : 'fa-caret-down'}"></i>
424
- {/if}
425
- </button>
426
- <span class="header-spacer"></span>
427
- </div>
428
- {#each sortedFiles as file}
429
- <div class="load-item" class:active={file.fileName === $activeFileName}>
430
- <button class="load-name-btn" onclick={() => handleLoad(file)}>
431
- {file.name}
432
- </button>
433
- <span class="updated-at" title={file.updatedAt}>{formatUpdatedAt(file.updatedAt)}</span>
434
- {#if file.fileName === $activeFileName}
435
- <span class="active-badge">active</span>
436
- {/if}
437
- {#if file.fileName !== 'default'}
438
- <button
439
- class="file-delete-btn"
440
- onclick={stopPropagation(() => handleDelete(file))}
441
- title="Delete {file.name}"
442
- >
443
- <i class="fas fa-trash-alt"></i>
444
- </button>
445
- {/if}
446
- </div>
447
- {/each}
448
- {#if files.length === 0}
449
- <div class="load-item empty">No saved files</div>
450
- {/if}
451
- </div>
452
- </UIDialog>
412
+ {files}
413
+ activeFileName={$activeFileName}
414
+ sortable
415
+ showUpdatedAt
416
+ systemBadge={{ label: 'system', title: 'Protected system theme' }}
417
+ emptyMessage="No saved files"
418
+ canDelete={(file) => file.fileName !== 'default' && file.fileName !== $themeProductionInfo?.fileName}
419
+ onload={handleLoad}
420
+ ondelete={handleDelete}
421
+ />
453
422
 
454
423
  <SaveAsDialog
455
424
  bind:show={saveAsDialog}
456
425
  {currentDisplayName}
457
426
  {files}
427
+ currentFileName={$activeFileName}
428
+ reservedDisplayNames={protectedDisplayNames}
458
429
  title="Save Theme As"
459
430
  placeholder="Theme name…"
460
- reservedNameMessage='The name "default" is reserved for the initial distribution.'
431
+ reservedNameMessage='That name is reserved for the protected default theme.'
432
+ branchFromDefaultName="My Theme"
461
433
  onsave={confirmSaveAs}
462
434
  />
463
435
 
464
436
  <style>
465
437
  .theme-file-manager {
466
438
  --tfm-applied: #5aa85e;
467
- --tfm-rail-neutral: var(--ui-border-default);
439
+ --tfm-rail-neutral: var(--ui-border);
468
440
  --tfm-rail-dirty: var(--ui-highlight);
469
441
  --tfm-rail-applied: var(--tfm-applied);
470
442
 
@@ -488,29 +460,9 @@
488
460
  letter-spacing: 0.05em;
489
461
  }
490
462
 
491
- .tfm-info-btn {
492
- display: inline-flex;
493
- align-items: center;
494
- justify-content: center;
495
- width: 22px;
496
- height: 22px;
497
- padding: 0;
498
- background: transparent;
499
- border: 0;
500
- color: var(--ui-text-tertiary);
501
- font-size: 0.95rem;
502
- line-height: 1;
503
- cursor: pointer;
504
- transition: color var(--ui-transition-fast);
505
- }
506
-
507
- .tfm-info-btn:hover,
508
- .tfm-info-btn[aria-expanded='true'] {
509
- color: var(--ui-text-primary);
510
- }
511
-
512
- /* Two-card pipeline (Editor → Production) — mirrors PresetFileManager so
513
- theme and preset surfaces share one visual idiom. */
463
+ /* Two-card pipeline (Editor → Production) — theme card + production card
464
+ surface the per-artifact pipeline. The manifest panel sits one level up
465
+ and tracks active vs default rather than editor vs production. */
514
466
  .tfm-cards {
515
467
  display: flex;
516
468
  flex-direction: column;
@@ -524,7 +476,7 @@
524
476
  gap: var(--ui-space-6);
525
477
  padding: var(--ui-space-8) var(--ui-space-10) var(--ui-space-10) var(--ui-space-16);
526
478
  background: var(--ui-surface-lower);
527
- border: 1px solid var(--ui-border-subtle);
479
+ border: 1px solid var(--ui-border-low);
528
480
  border-radius: var(--ui-radius-md);
529
481
  }
530
482
 
@@ -595,34 +547,87 @@
595
547
  opacity: 1;
596
548
  }
597
549
 
598
- .tfm-pill {
599
- display: flex;
550
+ /* Revert button — paired with the "unsaved" status label. Lives only when
551
+ dirty, in the highlight color so it reads as part of the status group
552
+ rather than a peer of the action stack below. Two-step arm: first click
553
+ adds .armed (label appears, fills with highlight), second click commits. */
554
+ .tfm-revert-btn {
555
+ position: relative;
556
+ display: inline-flex;
600
557
  align-items: center;
601
- padding: var(--ui-space-6) var(--ui-space-10);
602
- background: var(--ui-surface-lowest);
603
- border: 1px solid var(--ui-border-subtle);
604
- border-radius: var(--ui-radius-md);
605
- transition: border-color var(--ui-transition-fast), box-shadow var(--ui-transition-fast);
558
+ justify-content: center;
559
+ gap: 4px;
560
+ height: 18px;
561
+ margin-left: 2px;
562
+ padding: 0 4px;
563
+ background: transparent;
564
+ border: 1px solid color-mix(in srgb, var(--ui-highlight) 35%, transparent);
565
+ border-radius: 4px;
566
+ color: var(--ui-highlight);
567
+ font-size: 0.65rem;
568
+ line-height: 1;
569
+ cursor: pointer;
570
+ opacity: 0.85;
571
+ overflow: hidden;
572
+ transition:
573
+ background var(--ui-transition-fast),
574
+ border-color var(--ui-transition-fast),
575
+ color var(--ui-transition-fast),
576
+ opacity var(--ui-transition-fast),
577
+ padding var(--ui-transition-fast);
578
+ }
579
+
580
+ .tfm-revert-btn:hover {
581
+ background: color-mix(in srgb, var(--ui-highlight) 18%, transparent);
582
+ border-color: color-mix(in srgb, var(--ui-highlight) 65%, transparent);
583
+ opacity: 1;
606
584
  }
607
585
 
608
- .tfm-pill.dirty {
609
- border-color: color-mix(in srgb, var(--ui-highlight) 60%, var(--ui-border-subtle));
610
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-highlight) 35%, transparent);
586
+ .tfm-revert-btn:focus-visible {
587
+ outline: 2px solid color-mix(in srgb, var(--ui-highlight) 60%, transparent);
588
+ outline-offset: 1px;
611
589
  }
612
590
 
613
- .tfm-pill.applied {
614
- border-color: color-mix(in srgb, var(--tfm-applied) 50%, var(--ui-border-subtle));
591
+ .tfm-revert-btn i {
592
+ font-size: 0.65rem;
593
+ transition: transform var(--ui-transition-fast);
615
594
  }
616
595
 
617
- .tfm-pill-name {
618
- flex: 1;
619
- min-width: 0;
620
- font-size: var(--ui-font-size-md);
621
- font-weight: var(--ui-font-weight-semibold);
622
- color: var(--ui-text-primary);
596
+ .tfm-revert-btn.armed {
597
+ background: var(--ui-highlight);
598
+ border-color: var(--ui-highlight);
599
+ color: var(--ui-surface-lowest, #111);
600
+ opacity: 1;
601
+ padding: 0 6px;
602
+ }
603
+
604
+ .tfm-revert-btn.armed i {
605
+ transform: rotate(-35deg);
606
+ }
607
+
608
+ .tfm-revert-label {
609
+ font-weight: var(--ui-font-weight-semibold, 600);
610
+ letter-spacing: 0.02em;
623
611
  white-space: nowrap;
624
- overflow: hidden;
625
- text-overflow: ellipsis;
612
+ }
613
+
614
+ /* Time-window cue — thin bar drains across the bottom edge as the 3s
615
+ auto-disarm window elapses. Pure CSS, runs once per arm cycle. */
616
+ .tfm-revert-btn.armed::after {
617
+ content: '';
618
+ position: absolute;
619
+ left: 0;
620
+ bottom: 0;
621
+ height: 2px;
622
+ width: 100%;
623
+ background: color-mix(in srgb, var(--ui-surface-lowest, #111) 70%, transparent);
624
+ transform-origin: left center;
625
+ animation: tfm-revert-drain 3s linear forwards;
626
+ }
627
+
628
+ @keyframes tfm-revert-drain {
629
+ from { transform: scaleX(1); }
630
+ to { transform: scaleX(0); }
626
631
  }
627
632
 
628
633
  .tfm-card-actions {
@@ -666,7 +671,7 @@
666
671
  margin: calc(var(--ui-space-2) * -1) 0;
667
672
  padding: var(--ui-space-6) var(--ui-space-12);
668
673
  background: color-mix(in srgb, var(--tfm-applied) 18%, var(--ui-surface-high));
669
- border: 1px solid color-mix(in srgb, var(--tfm-applied) 45%, var(--ui-border-medium));
674
+ border: 1px solid color-mix(in srgb, var(--tfm-applied) 45%, var(--ui-border-high));
670
675
  border-radius: var(--ui-radius-md);
671
676
  color: var(--ui-text-primary);
672
677
  font-size: var(--ui-font-size-md);
@@ -686,7 +691,7 @@
686
691
 
687
692
  .tfm-adopt-btn:hover:not(:disabled) {
688
693
  background: color-mix(in srgb, var(--tfm-applied) 30%, var(--ui-surface-higher));
689
- border-color: color-mix(in srgb, var(--tfm-applied) 70%, var(--ui-border-strong));
694
+ border-color: color-mix(in srgb, var(--tfm-applied) 70%, var(--ui-border-higher));
690
695
  }
691
696
 
692
697
  .tfm-adopt-btn:disabled {
@@ -695,7 +700,7 @@
695
700
 
696
701
  .tfm-adopt-btn.in-sync {
697
702
  background: transparent;
698
- border-color: var(--ui-border-subtle);
703
+ border-color: var(--ui-border-low);
699
704
  color: var(--ui-text-muted);
700
705
  opacity: 0.7;
701
706
  }
@@ -714,7 +719,7 @@
714
719
  gap: var(--ui-space-4);
715
720
  padding: var(--ui-space-6) var(--ui-space-10);
716
721
  background: var(--ui-surface);
717
- border: 1px solid var(--ui-border-subtle);
722
+ border: 1px solid var(--ui-border-low);
718
723
  border-radius: var(--ui-radius-md);
719
724
  color: var(--ui-text-secondary);
720
725
  font-size: var(--ui-font-size-md);
@@ -735,7 +740,7 @@
735
740
  .tfm-btn:hover:not(:disabled) {
736
741
  background: var(--ui-surface-high);
737
742
  color: var(--ui-text-primary);
738
- border-color: var(--ui-border-default);
743
+ border-color: var(--ui-border);
739
744
  }
740
745
 
741
746
  .tfm-btn:disabled {
@@ -745,19 +750,19 @@
745
750
 
746
751
  .tfm-btn.active {
747
752
  background: var(--ui-surface);
748
- border-color: var(--ui-border-default);
753
+ border-color: var(--ui-border);
749
754
  color: var(--ui-text-primary);
750
755
  }
751
756
 
752
757
  .save-btn {
753
758
  background: var(--ui-surface-high);
754
- border-color: var(--ui-border-medium);
759
+ border-color: var(--ui-border-high);
755
760
  color: var(--ui-text-primary);
756
761
  }
757
762
 
758
763
  .save-btn:hover:not(:disabled) {
759
764
  background: var(--ui-surface-higher);
760
- border-color: var(--ui-border-strong);
765
+ border-color: var(--ui-border-higher);
761
766
  }
762
767
 
763
768
  .save-btn.saving i { animation: spin 1s linear infinite; }
@@ -770,244 +775,6 @@
770
775
  color: var(--ui-text-muted);
771
776
  }
772
777
 
773
- .load-list {
774
- display: flex;
775
- flex-direction: column;
776
- max-height: 60vh;
777
- overflow-y: auto;
778
- }
779
-
780
- .load-header {
781
- display: flex;
782
- align-items: center;
783
- gap: 6px;
784
- padding: 4px 6px;
785
- border-bottom: 1px solid #3a3a3a;
786
- position: sticky;
787
- top: 0;
788
- background: var(--ui-surface, #1a1a1a);
789
- z-index: 1;
790
- }
791
-
792
- .sort-btn {
793
- display: inline-flex;
794
- align-items: center;
795
- gap: 4px;
796
- padding: 4px 0;
797
- background: none;
798
- border: none;
799
- color: #888;
800
- font-size: 11px;
801
- font-weight: 600;
802
- text-transform: uppercase;
803
- letter-spacing: 0.04em;
804
- cursor: pointer;
805
- text-align: left;
806
- }
807
-
808
- .sort-btn:hover {
809
- color: #ccc;
810
- }
811
-
812
- .sort-btn.active-sort {
813
- color: #e0e0e0;
814
- }
815
-
816
- .sort-btn i {
817
- font-size: 10px;
818
- opacity: 0.85;
819
- }
820
-
821
- .sort-btn.name-col {
822
- flex: 1;
823
- min-width: 0;
824
- padding-left: 4px;
825
- }
826
-
827
- .sort-btn.date-col {
828
- flex-shrink: 0;
829
- }
830
-
831
- .header-spacer {
832
- flex-shrink: 0;
833
- width: 24px;
834
- }
835
-
836
- .load-item {
837
- display: flex;
838
- align-items: center;
839
- gap: 6px;
840
- padding: 4px 6px;
841
- border-bottom: 1px solid #2a2a2a;
842
- }
843
-
844
- .load-item:last-child {
845
- border-bottom: none;
846
- }
847
-
848
- .load-item.empty {
849
- padding: 16px;
850
- color: #888;
851
- font-size: 14px;
852
- text-align: center;
853
- }
854
-
855
- .load-name-btn {
856
- flex: 1;
857
- min-width: 0;
858
- overflow: hidden;
859
- text-overflow: ellipsis;
860
- white-space: nowrap;
861
- padding: 6px 4px;
862
- background: none;
863
- border: none;
864
- color: #aaa;
865
- font-size: 14px;
866
- cursor: pointer;
867
- text-align: left;
868
- border-radius: 3px;
869
- }
870
-
871
- .load-name-btn:hover {
872
- color: #e0e0e0;
873
- }
874
-
875
- .load-item.active .load-name-btn {
876
- color: #e0e0e0;
877
- font-weight: 600;
878
- }
879
-
880
- .updated-at {
881
- flex-shrink: 0;
882
- font-size: 12px;
883
- color: #777;
884
- font-variant-numeric: tabular-nums;
885
- white-space: nowrap;
886
- }
887
-
888
- .active-badge {
889
- flex-shrink: 0;
890
- font-size: 12px;
891
- padding: 1px 6px;
892
- border-radius: 3px;
893
- background: #333;
894
- color: #ccc;
895
- }
896
-
897
- .file-delete-btn {
898
- flex-shrink: 0;
899
- display: flex;
900
- align-items: center;
901
- justify-content: center;
902
- width: 24px;
903
- height: 24px;
904
- padding: 0;
905
- background: none;
906
- border: none;
907
- color: #555;
908
- font-size: 12px;
909
- cursor: pointer;
910
- opacity: 0;
911
- }
912
-
913
- .load-item:hover .file-delete-btn {
914
- opacity: 1;
915
- }
916
-
917
- .file-delete-btn:hover {
918
- color: #ccc;
919
- }
920
-
921
- /* Info popover — fixed positioning escapes the sidebar's overflow and any
922
- parent stacking context. JS in this file anchors it to the right of the
923
- info button (the sidebar is on the left, so there's room to flow into
924
- the main content area without obscuring the button). */
925
- .tfm-info-popover {
926
- position: fixed;
927
- top: 0;
928
- left: 0;
929
- width: 22rem;
930
- max-width: calc(100vw - var(--ui-space-24));
931
- padding: 0;
932
- background: var(--ui-surface-higher);
933
- border: 1px solid var(--ui-border-medium);
934
- border-radius: var(--ui-radius-lg);
935
- box-shadow: var(--ui-shadow-lg);
936
- z-index: 1000;
937
- color: var(--ui-text-secondary);
938
- font-family: var(--ui-font-family, system-ui, sans-serif);
939
- overflow: hidden;
940
- visibility: hidden;
941
- animation: tfm-info-in 140ms ease-out;
942
- }
943
-
944
- .tfm-info-popover.ready {
945
- visibility: visible;
946
- }
947
-
948
- .tfm-info-header {
949
- display: flex;
950
- align-items: center;
951
- justify-content: space-between;
952
- gap: var(--ui-space-8);
953
- padding: var(--ui-space-10) var(--ui-space-12) var(--ui-space-10) var(--ui-space-16);
954
- border-bottom: 1px solid var(--ui-border-subtle);
955
- }
956
-
957
- .tfm-info-title {
958
- color: var(--ui-text-primary);
959
- font-size: var(--ui-font-size-sm);
960
- font-weight: var(--ui-font-weight-semibold);
961
- letter-spacing: -0.01em;
962
- line-height: 1.2;
963
- }
964
-
965
- .tfm-info-close {
966
- display: inline-flex;
967
- align-items: center;
968
- justify-content: center;
969
- width: var(--ui-space-24);
970
- height: var(--ui-space-24);
971
- padding: 0;
972
- background: transparent;
973
- border: 0;
974
- border-radius: var(--ui-radius-sm);
975
- color: var(--ui-text-tertiary);
976
- font-size: var(--ui-font-size-xs);
977
- line-height: 1;
978
- cursor: pointer;
979
- transition: color var(--ui-transition-fast), background var(--ui-transition-fast);
980
- }
981
-
982
- .tfm-info-close:hover {
983
- color: var(--ui-text-primary);
984
- background: var(--ui-hover);
985
- }
986
-
987
- .tfm-info-body {
988
- padding: var(--ui-space-16);
989
- }
990
-
991
- .tfm-info-popover p {
992
- margin: 0 0 var(--ui-space-12) 0;
993
- font-size: var(--ui-font-size-xs);
994
- line-height: 1.55;
995
- }
996
-
997
- .tfm-info-popover p:last-child {
998
- margin-bottom: 0;
999
- }
1000
-
1001
- .tfm-info-popover strong {
1002
- color: var(--ui-text-primary);
1003
- font-weight: var(--ui-font-weight-semibold);
1004
- }
1005
-
1006
- @keyframes tfm-info-in {
1007
- from { opacity: 0; transform: translateY(-3px); }
1008
- to { opacity: 1; transform: translateY(0); }
1009
- }
1010
-
1011
778
  @keyframes tfm-pulse {
1012
779
  0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui-highlight) 22%, transparent); }
1013
780
  50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-highlight) 10%, transparent); }