auricui-skill 0.0.0-stage → 0.1.2

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 (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/install.mjs +66 -0
  4. package/package.json +29 -4
  5. package/skill/SKILL.md +64 -0
  6. package/skill/agents/openai.yaml +4 -0
  7. package/skill/assets/examples/ApprovalCard.tsx +8 -0
  8. package/skill/assets/examples/BasicApp.tsx +38 -0
  9. package/skill/assets/examples/BriefingCard.tsx +8 -0
  10. package/skill/assets/examples/ChatControls.tsx +49 -0
  11. package/skill/assets/examples/ChatPanel.tsx +73 -0
  12. package/skill/assets/examples/Checkbox.tsx +13 -0
  13. package/skill/assets/examples/Combobox.tsx +14 -0
  14. package/skill/assets/examples/Drawer.tsx +10 -0
  15. package/skill/assets/examples/MemoryCard.tsx +9 -0
  16. package/skill/assets/examples/MessageList.tsx +8 -0
  17. package/skill/assets/examples/Theme.tsx +14 -0
  18. package/skill/assets/examples/ToolCall.tsx +8 -0
  19. package/skill/assets/examples/Tree.tsx +15 -0
  20. package/skill/assets/examples/examples.css +11 -0
  21. package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
  22. package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
  23. package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
  24. package/skill/assets/upstream/LICENSE +21 -0
  25. package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
  26. package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
  27. package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
  28. package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
  29. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
  30. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
  31. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
  32. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
  33. package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
  34. package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
  35. package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
  36. package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
  37. package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
  38. package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
  39. package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
  40. package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
  41. package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
  42. package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
  43. package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
  44. package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
  45. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
  46. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
  47. package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
  48. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
  49. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
  50. package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
  51. package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
  52. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
  53. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
  54. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
  55. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
  56. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
  57. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
  58. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
  59. package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
  60. package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
  61. package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
  62. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
  63. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
  64. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
  65. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
  66. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
  67. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
  68. package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
  69. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
  70. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
  71. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
  72. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
  73. package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
  74. package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
  75. package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
  76. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
  77. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
  78. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
  79. package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
  80. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
  81. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
  82. package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
  83. package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
  84. package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
  85. package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
  86. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
  87. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
  88. package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
  89. package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
  90. package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
  91. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
  92. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
  93. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
  94. package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
  95. package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
  96. package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
  97. package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
  98. package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
  99. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
  100. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
  101. package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
  102. package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
  103. package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
  104. package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
  105. package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
  106. package/skill/assets/upstream/apps/desktop/package.json +51 -0
  107. package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
  108. package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
  109. package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
  110. package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
  111. package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
  112. package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
  113. package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
  114. package/skill/assets/upstream/packages/openfilm/package.json +88 -0
  115. package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
  116. package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
  117. package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
  118. package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
  119. package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
  120. package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
  121. package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
  122. package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
  123. package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
  124. package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
  125. package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
  126. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
  127. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
  128. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
  129. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
  130. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
  131. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
  132. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
  133. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
  134. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
  135. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
  136. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
  137. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
  138. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
  139. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
  140. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
  141. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
  142. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
  143. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
  144. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
  145. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
  146. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
  147. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
  148. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
  149. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
  150. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
  151. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
  152. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
  153. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
  154. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
  155. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
  156. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
  157. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
  158. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
  159. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
  160. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
  161. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
  162. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
  163. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
  164. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
  165. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
  166. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
  167. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
  168. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
  169. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
  170. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
  171. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
  172. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
  173. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
  174. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
  175. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
  176. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
  177. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
  178. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
  179. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
  180. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
  181. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
  182. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
  183. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
  184. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
  185. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
  186. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
  187. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
  188. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
  189. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
  190. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
  191. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
  192. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
  193. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
  194. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
  195. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
  196. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
  197. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
  198. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
  199. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
  200. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
  201. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
  202. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
  203. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
  204. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
  205. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
  206. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
  207. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
  208. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
  209. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
  210. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
  211. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
  212. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
  213. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
  214. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
  215. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
  216. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
  217. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
  218. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
  219. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
  220. package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
  221. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
  222. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
  223. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
  224. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
  225. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
  226. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
  227. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
  228. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
  229. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
  230. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
  231. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
  232. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
  233. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
  234. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
  235. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
  236. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
  237. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
  238. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
  239. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
  240. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
  241. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
  242. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
  243. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
  244. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
  245. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
  246. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
  247. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
  248. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
  249. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
  250. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
  251. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
  252. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
  253. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
  254. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
  255. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
  256. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
  257. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
  258. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
  259. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
  260. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
  261. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
  262. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
  263. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
  264. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
  265. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
  266. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
  267. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
  268. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
  269. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
  270. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
  271. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
  272. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
  273. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
  274. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
  275. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
  276. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
  277. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
  278. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
  279. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
  280. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
  281. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
  282. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
  283. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
  284. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
  285. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
  286. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
  287. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
  288. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
  289. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
  290. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
  291. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
  292. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
  293. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
  294. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
  295. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
  296. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
  297. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
  298. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
  299. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
  300. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
  301. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
  302. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
  303. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
  304. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
  305. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
  306. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
  307. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
  308. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
  309. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
  310. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
  311. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
  312. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
  313. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
  314. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
  315. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
  316. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
  317. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
  318. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
  319. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
  320. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
  321. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
  322. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
  323. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
  324. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
  325. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
  326. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
  327. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
  328. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
  329. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
  330. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
  331. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
  332. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
  333. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
  334. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
  335. package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
  336. package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
  337. package/skill/assets/upstream/packages/shared/package.json +45 -0
  338. package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
  339. package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
  340. package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
  341. package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
  342. package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
  343. package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
  344. package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
  345. package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
  346. package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
  347. package/skill/references/agent-components.md +33 -0
  348. package/skill/references/api-index.json +2693 -0
  349. package/skill/references/chat-components.md +65 -0
  350. package/skill/references/components.md +23 -0
  351. package/skill/references/core-components.md +44 -0
  352. package/skill/references/dependencies.md +22 -0
  353. package/skill/references/esm-package.md +48 -0
  354. package/skill/references/integration.md +43 -0
  355. package/skill/references/library-manifest.json +12 -0
  356. package/skill/references/source-manifest.json +325 -0
  357. package/skill/references/studio-components.md +24 -0
  358. package/skill/references/theme-motion.md +49 -0
  359. package/skill/references/troubleshooting.md +27 -0
  360. package/skill/references/verification.md +13 -0
  361. package/skill/scripts/check_dependencies.py +109 -0
  362. package/skill/scripts/inspect_api.py +60 -0
@@ -0,0 +1,1357 @@
1
+ /**
2
+ * How a picture looks, as the inspector shows it: crop and fill, appearance, adjustments, effects, type — the same
3
+ * sections, the same controls, for a whole clip, a layer inside a page, and several of either.
4
+ *
5
+ * Each section works on a `Look`: one or more things, each read and written as standard CSS properties (see
6
+ * lib/clip-look). A control turns what it is given into the properties to write for each thing (`LookEdit`), from
7
+ * that thing's own values — so `+10` on several opacities raises each. What the properties are stored in (a clip's
8
+ * `style` in film.html, a layer's override) is the panel's business, not the section's.
9
+ */
10
+ import * as React from 'react';
11
+ import {
12
+ AlignCenter, AlignJustify, AlignLeft, AlignRight, Ban, ChevronDown, Circle, Crop as CropIcon, Heart, Minus, Plus, RotateCcw, Scan, Square,
13
+ SquareDashed, Star, Strikethrough, Underline,
14
+ } from 'lucide-react';
15
+
16
+ import type { useT } from '@/i18n';
17
+ import {
18
+ ADJUST_KEYS, BLEND_MODES, FADE_DEFAULT, INNER_SHADOW_DEFAULT, PLACED_STROKE_DEFAULT, SHADOW_DEFAULT, adjustOf, backdropBlurCss,
19
+ backdropBlurOf, cropCss, cropForRatio, cropOf, cropRatio, filterParts, framingCss, framingOf, innerShadowCss, innerShadowOf,
20
+ ownedDeclaration, placedStrokeCss, placedStrokeOf, pxOf, radiusCorners, shadowOf, strokeOf, textColorCss, textColorOf, withAdjust, withShadow, type AdjustKey, type Crop, type Fade,
21
+ type FadeSide, type InnerShadow, type PlacedStroke, type Shadow, type StrokePosition, type StrokeStyle,
22
+ } from '@/lib/clip-look';
23
+ import {
24
+ MASK_FIELDS, MASK_SHAPES, fadeIn, maskDefault, maskOf, maskOwned, withFade, withMask, type Mask, type MaskBox, type MaskShape,
25
+ } from '@/lib/clip-mask';
26
+ import type { MaskSession } from '@/lib/stage-mask';
27
+ import { previewSettled, type Look, type LookEdit, type LookRead, type LookSet } from '@/lib/look';
28
+ import { knownAvailable, stylesOf } from '@/lib/font-menu';
29
+ import {
30
+ NAMED_WEIGHTS, isItalic, joinFontFamily, parseStyleValue, snapStyle, splitFontFamily, styleOptions, styleValue, weightKey, weightNumber,
31
+ type FontStyles,
32
+ } from '@/lib/font-styles';
33
+ import { FONT_SIZES, metricOf } from '@/lib/type-metrics';
34
+ import { familyRenders, useInstalledFonts, useProjectFonts } from '@/lib/use-fonts';
35
+ import { declarations } from '../../../../src/film-doc.mjs';
36
+ import {
37
+ ColorField, EdgeGlyph, FontSizeGlyph, ICON, LetterSpacingGlyph, LineHeightGlyph, MIXED, MetricField, NumField, RadiusGlyph, RotateGlyph, Row, Section, SectionAction, Segmented, SelectField, SliderField, SubLabel, type FieldApply,
38
+ } from './inspector-fields';
39
+ import { FontFamilyPicker } from './FontFamilyPicker';
40
+ import { Popover } from './Popover';
41
+
42
+ type T = ReturnType<typeof useT>;
43
+
44
+ /** What a property is when nothing sets it: writing "none of mine" over a look the film's CSS gives, this is written. */
45
+ export const NEUTRAL: Record<string, string> = {
46
+ opacity: '1', filter: 'none', 'clip-path': 'none', 'border-radius': '0', 'mix-blend-mode': 'normal', 'object-fit': 'contain',
47
+ 'object-position': '50% 50%', 'mask-image': 'none', border: 'none', 'text-transform': 'none',
48
+ outline: 'none', 'outline-offset': '0px', 'box-shadow': 'none', 'backdrop-filter': 'none', 'text-decoration-line': 'none',
49
+ 'border-top-left-radius': '0', 'border-top-right-radius': '0', 'border-bottom-right-radius': '0', 'border-bottom-left-radius': '0',
50
+ };
51
+
52
+ /** Whether `v` looks as nothing set (`1`, `none`, `0px`, …). */
53
+ export function isNeutral(prop: string, v: string | undefined): boolean {
54
+ if (v == null || v === '') return true;
55
+ const s = v.trim().toLowerCase();
56
+ if (s === NEUTRAL[prop]) return true;
57
+ if (prop === 'opacity') return Number(s) === 1;
58
+ if (prop.endsWith('-radius') || prop === 'outline-offset') return pxOf(s) === 0;
59
+ if (prop === 'object-position') { const f = framingOf(s); return f != null && f[0] === 50 && f[1] === 50; }
60
+ if (prop === 'border' || prop === 'outline') return strokeOf(s) === undefined;
61
+ return s === 'none' || s === 'normal';
62
+ }
63
+
64
+ export const camelOf = (prop: string) => prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
65
+ export const kebabOf = (key: string) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
66
+
67
+ const round = (n: number, d = 1) => Math.round(n * 10 ** d) / 10 ** d;
68
+
69
+ /* ───────────────────────────── crop & fill ───────────────────────────── */
70
+
71
+ const RATIOS: { id: string; ratio?: number }[] = [
72
+ { id: 'free' }, { id: 'original' }, { id: '16:9', ratio: 16 / 9 }, { id: '9:16', ratio: 9 / 16 }, { id: '1:1', ratio: 1 }, { id: '4:3', ratio: 4 / 3 },
73
+ ];
74
+
75
+ /**
76
+ * How the picture fills its box (`object-fit`), the crop of its edges (`clip-path: inset()`, in % of the box, its
77
+ * corners kept round), a crop to a shape (centered), round corners, and where the picture sits in its box
78
+ * (`object-position`). A `clip-path` of another shape is the raw CSS's, and the crop is not shown over it.
79
+ */
80
+ export function CropSection({
81
+ look, t, fit, framing, box, own, onStartCrop,
82
+ }: {
83
+ look: Look;
84
+ t: T;
85
+ /** Whether the picture can be fitted or framed in its box (a video, a still, an image): a page always shows whole. */
86
+ fit: boolean;
87
+ framing: boolean;
88
+ /** The box on the stage (unturned): what a crop to a shape is worked out on. Unknown: no shapes. */
89
+ box?: { w: number; h: number } | null;
90
+ /** The picture's own size: the "Original" shape. */
91
+ own?: { w: number; h: number } | null;
92
+ onStartCrop?: () => void;
93
+ }) {
94
+ const crop = look.common((read) => cropOf(read('clip-path')));
95
+ const fitValue = look.common((read) => {
96
+ const v = read('object-fit') ?? 'contain';
97
+ return v === 'cover' || v === 'fill' ? v : 'contain';
98
+ });
99
+ const frame = look.common((read) => framingOf(read('object-position')));
100
+ const cropUnreadable = look.common((read) => cropOf(read('clip-path')) == null);
101
+ const touched = look.common((read) => !isNeutral('clip-path', read('clip-path')) || !isNeutral('object-position', read('object-position'))
102
+ || (read('object-fit') ?? 'contain') !== 'contain');
103
+
104
+ const cropEdit = (side: number, apply: FieldApply): LookEdit => (read): LookSet => {
105
+ const now = cropOf(read('clip-path'));
106
+ if (!now) return {};
107
+ const next = [...now] as Crop;
108
+ /* the opposite edge stays: the two never meet */
109
+ next[side] = Math.max(0, Math.min(99 - now[(side + 2) % 4]!, round(apply(now[side]!), 2)));
110
+ return { 'clip-path': cropCss(next, pxOf(read('border-radius')) ?? 0) };
111
+ };
112
+ const ratioOf = (id: string) => (id === 'original' ? (own && own.h > 0 ? own.w / own.h : undefined) : RATIOS.find((r) => r.id === id)?.ratio);
113
+ const shape = (() => {
114
+ if (!box || crop === MIXED || !crop) return 'free';
115
+ const now = cropRatio(crop, box.w, box.h);
116
+ for (const r of RATIOS) {
117
+ const ratio = ratioOf(r.id);
118
+ if (!ratio) continue;
119
+ /* a crop to a shape is centered, and as large as fits */
120
+ const want = cropForRatio(box.w, box.h, ratio);
121
+ if (Math.abs(now - ratio) / ratio < 0.005 && want.every((v, i) => Math.abs(v - crop[i]!) < 0.1)) return r.id;
122
+ }
123
+ return 'free';
124
+ })();
125
+ const toShape = (id: string) => {
126
+ const ratio = ratioOf(id);
127
+ if (!ratio || !box) return;
128
+ look.commit((read) => ({ 'clip-path': cropCss(cropForRatio(box.w, box.h, ratio), pxOf(read('border-radius')) ?? 0) }));
129
+ };
130
+ const sides: { i: number; side: FadeSide; label: string }[] = [
131
+ { i: 0, side: 'top', label: t('inspector.cropTop') },
132
+ { i: 2, side: 'bottom', label: t('inspector.cropBottom') },
133
+ { i: 3, side: 'left', label: t('inspector.cropLeft') },
134
+ { i: 1, side: 'right', label: t('inspector.cropRight') },
135
+ ];
136
+ return (
137
+ <Section
138
+ id="crop"
139
+ title={t('inspector.cropFill')}
140
+ aside={(
141
+ <>
142
+ {onStartCrop ? (
143
+ <button
144
+ type="button"
145
+ onClick={onStartCrop}
146
+ className="flex h-5 items-center gap-1 rounded-[4px] px-1.5 text-[10.5px] font-medium text-[var(--accent)] transition hover:bg-[var(--bg-hover)]"
147
+ >
148
+ <CropIcon size={11} />{t('inspector.cropStart')}
149
+ </button>
150
+ ) : null}
151
+ {touched === true || touched === MIXED ? (
152
+ <SectionAction
153
+ label={t('inspector.resetSection')}
154
+ onClick={() => look.commit(() => ({ 'object-fit': null, 'object-position': null, 'clip-path': null, 'border-radius': null }))}
155
+ >
156
+ <RotateCcw size={11} />
157
+ </SectionAction>
158
+ ) : null}
159
+ </>
160
+ )}
161
+ >
162
+ {fit ? (
163
+ <Segmented
164
+ label={t('inspector.fit')}
165
+ value={fitValue}
166
+ options={[
167
+ { value: 'contain', label: t('inspector.fitContain') },
168
+ { value: 'cover', label: t('inspector.fitCover') },
169
+ { value: 'fill', label: t('inspector.fitFill') },
170
+ ]}
171
+ /* said outright: "none of mine" on a picture in a page brings back the page's own, which for an <img> is
172
+ stretched (CSS's fill) */
173
+ onChange={(v) => look.commit(() => ({ 'object-fit': v }))}
174
+ />
175
+ ) : null}
176
+ {cropUnreadable === false ? (
177
+ <>
178
+ <Row>
179
+ {sides.map(({ i, side, label }) => (
180
+ <NumField
181
+ key={side}
182
+ label={label}
183
+ prefix={<EdgeGlyph side={side} />}
184
+ value={crop === MIXED || !crop ? MIXED : crop[i]}
185
+ unit="%"
186
+ min={0}
187
+ max={99}
188
+ step={0.5}
189
+ onPreview={(_, apply) => look.preview(cropEdit(i, apply))}
190
+ onCommit={(_, apply) => look.commit(cropEdit(i, apply))}
191
+ onRevert={() => look.revert()}
192
+ />
193
+ ))}
194
+ </Row>
195
+ {box && look.n === 1 ? (
196
+ <Segmented
197
+ label={t('inspector.cropShape')}
198
+ value={shape}
199
+ small
200
+ options={RATIOS.filter((r) => r.id !== 'original' || (own && own.h > 0)).map((r) => ({
201
+ value: r.id,
202
+ label: r.id === 'free' ? t('inspector.cropFree') : r.id === 'original' ? t('inspector.cropOriginal') : r.id,
203
+ }))}
204
+ onChange={toShape}
205
+ />
206
+ ) : null}
207
+ </>
208
+ ) : (
209
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.cropCustom')}</p>
210
+ )}
211
+ {framing && frame !== null ? (
212
+ <>
213
+ <SubLabel>{t('inspector.framing')}</SubLabel>
214
+ <Row>
215
+ {([0, 1] as const).map((axis) => {
216
+ const edit = (apply: FieldApply): LookEdit => (read) => {
217
+ const now = framingOf(read('object-position')) ?? [50, 50];
218
+ const next: [number, number] = [...now];
219
+ next[axis] = round(apply(now[axis]), 2);
220
+ return { 'object-position': framingCss(next) };
221
+ };
222
+ return (
223
+ <NumField
224
+ key={axis}
225
+ label={axis === 0 ? t('inspector.framingX') : t('inspector.framingY')}
226
+ prefix={axis === 0 ? 'X' : 'Y'}
227
+ value={frame === MIXED ? MIXED : frame[axis]}
228
+ unit="%"
229
+ min={-100}
230
+ max={200}
231
+ onPreview={(_, apply) => look.preview(edit(apply))}
232
+ onCommit={(_, apply) => look.commit(edit(apply))}
233
+ onRevert={() => look.revert()}
234
+ />
235
+ );
236
+ })}
237
+ </Row>
238
+ </>
239
+ ) : null}
240
+ </Section>
241
+ );
242
+ }
243
+
244
+ /* ───────────────────────────── mask ───────────────────────────── */
245
+
246
+ /** The shape buttons' glyphs: a line, a band, then lucide's. */
247
+ export function MaskGlyph({ shape }: { shape: MaskShape | 'none' }) {
248
+ if (shape === 'linear' || shape === 'mirror') {
249
+ return (
250
+ <svg width={ICON} height={ICON} viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
251
+ <rect x="1.5" y="1.5" width="11" height="11" rx="2" />
252
+ {shape === 'linear'
253
+ ? <path d="M1.5 7.5h11V10.5a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2z" fill="currentColor" stroke="none" />
254
+ : <path d="M1.5 5h11v4h-11z" fill="currentColor" stroke="none" />}
255
+ </svg>
256
+ );
257
+ }
258
+ const Icon = { none: Ban, ellipse: Circle, rect: Square, star: Star, heart: Heart }[shape];
259
+ return <Icon size={ICON} strokeWidth={1.6} />;
260
+ }
261
+
262
+ /**
263
+ * A mask: a shape the picture shows through (or, inverted, is hidden by), placed, sized, turned and
264
+ * feathered in % of the box. Written as `mask-image` layers (lib/clip-mask: what each shape is in CSS, and how it sits
265
+ * with the faded edges, which share the property, and with the crop's clip-path). A mask written by hand is "custom":
266
+ * the raw CSS (Advanced) has it.
267
+ *
268
+ * "Edit on picture" draws the mask's outline and handles over the thing in hand (`onEditMask`, see lib/stage-mask):
269
+ * the stage previews a drag and writes once, at the release; it is turned on with a new shape.
270
+ */
271
+ export function MaskSection({
272
+ look, t, box, target, onEditMask, ask, onAsked,
273
+ }: {
274
+ look: Look;
275
+ t: T;
276
+ /** The box on the stage (unturned): its proportions place a line's stops. Unknown: as if square. */
277
+ box?: MaskBox | null;
278
+ /** Whose mask, for the stage's handles. */
279
+ target?: MaskSession['target'] | null;
280
+ onEditMask?: (session: MaskSession | null) => void;
281
+ /** A shape picked on the picture (the selection bar's Mask): put on, with its handles, once. */
282
+ ask?: { shape: MaskShape | 'none'; n: number } | null;
283
+ onAsked?: () => void;
284
+ }) {
285
+ const mbox = box && box.w > 0 && box.h > 0 ? box : null;
286
+ const mask = look.common((read) => maskOf(read('mask-image'), read('mask-composite'), mbox));
287
+ const now = mask === MIXED ? null : mask;
288
+ const [editing, setEditing] = React.useState(false);
289
+ const canEdit = Boolean(onEditMask && target && look.n === 1);
290
+
291
+ const write = (next: Mask | null): LookEdit => (read) => withMask(read('mask-image'), read('mask-composite'), next, mbox) ?? {};
292
+ const edit = (fn: (m: Mask) => Mask): LookEdit => (read) => {
293
+ const m = maskOf(read('mask-image'), read('mask-composite'), mbox);
294
+ return m ? write(fn(m))(read) : {};
295
+ };
296
+ const pick = (shape: string) => {
297
+ if (shape === 'none') { setEditing(false); look.commit(write(null)); return; }
298
+ look.commit((read) => {
299
+ const m = maskOf(read('mask-image'), read('mask-composite'), mbox);
300
+ return m === null ? {} : write(maskDefault(shape as MaskShape, mbox, m))(read);
301
+ });
302
+ if (canEdit) setEditing(true);
303
+ };
304
+
305
+ React.useEffect(() => {
306
+ if (!ask) return;
307
+ pick(ask.shape);
308
+ onAsked?.();
309
+ }, [ask?.n]); // eslint-disable-line react-hooks/exhaustive-deps
310
+
311
+ /* the stage's handles: the mask as written, written back through this look (one change at the release) */
312
+ const lookRef = React.useRef(look);
313
+ lookRef.current = look;
314
+ const boxRef = React.useRef(mbox);
315
+ boxRef.current = mbox;
316
+ const maskKey = editing && canEdit && now ? JSON.stringify(now) : null;
317
+ const targetKey = target ? JSON.stringify(target) : null;
318
+ React.useEffect(() => {
319
+ if (!onEditMask) return;
320
+ if (!maskKey || !targetKey) { onEditMask(null); return; }
321
+ const put = (m: Mask): LookEdit => (read) => withMask(read('mask-image'), read('mask-composite'), m, boxRef.current) ?? {};
322
+ onEditMask({
323
+ target: JSON.parse(targetKey) as MaskSession['target'],
324
+ mask: JSON.parse(maskKey) as Mask,
325
+ preview: (m) => lookRef.current.preview(put(m)),
326
+ commit: (m) => lookRef.current.commit(put(m)),
327
+ cancel: () => lookRef.current.revert(),
328
+ });
329
+ }, [maskKey, targetKey, onEditMask]);
330
+ React.useEffect(() => () => onEditMask?.(null), [onEditMask]);
331
+
332
+ const num = (key: 'x' | 'y' | 'w' | 'h' | 'rotate' | 'radius', label: string, prefix: React.ReactNode, unit: string, range: { min?: number; max?: number } = {}) => {
333
+ const change = (apply: FieldApply) => edit((m) => ({ ...m, [key]: round(apply(m[key]), 2) }));
334
+ return (
335
+ <NumField
336
+ label={label}
337
+ prefix={prefix}
338
+ value={mask === MIXED ? MIXED : now?.[key]}
339
+ unit={unit}
340
+ {...range}
341
+ onPreview={(_, apply) => look.preview(change(apply))}
342
+ onCommit={(_, apply) => look.commit(change(apply))}
343
+ onRevert={() => look.revert()}
344
+ />
345
+ );
346
+ };
347
+ const fields = now ? MASK_FIELDS[now.shape] : null;
348
+ const shapeValue = mask === MIXED ? MIXED : mask === null ? 'custom' : mask?.shape ?? 'none';
349
+ const featherEdit = (apply: FieldApply) => edit((m) => ({ ...m, feather: Math.max(0, Math.min(100, round(apply(m.feather)))) }));
350
+
351
+ return (
352
+ <Section
353
+ id="mask"
354
+ title={t('inspector.mask')}
355
+ aside={(
356
+ <>
357
+ {canEdit && now ? (
358
+ <button
359
+ type="button"
360
+ aria-pressed={editing}
361
+ onClick={() => setEditing((v) => !v)}
362
+ className={`flex h-5 items-center gap-1 rounded-[4px] px-1.5 text-[10.5px] font-medium transition hover:bg-[var(--bg-hover)] ${editing ? 'bg-[var(--fill-tsp)] text-[var(--accent)]' : 'text-[var(--text-muted)]'}`}
363
+ >
364
+ <SquareDashed size={11} />{t('inspector.maskEditOnPicture')}
365
+ </button>
366
+ ) : null}
367
+ {mask ? (
368
+ <SectionAction label={t('inspector.maskRemove')} onClick={() => pick('none')}><RotateCcw size={11} /></SectionAction>
369
+ ) : null}
370
+ </>
371
+ )}
372
+ >
373
+ {mask === null ? (
374
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.maskCustom')}</p>
375
+ ) : (
376
+ <Segmented
377
+ label={t('inspector.maskShape')}
378
+ value={shapeValue}
379
+ options={(['none', ...MASK_SHAPES] as const).map((s) => ({
380
+ value: s,
381
+ title: t(`inspector.maskShapes.${s}`),
382
+ label: <MaskGlyph shape={s} />,
383
+ }))}
384
+ onChange={pick}
385
+ />
386
+ )}
387
+ {now && fields ? (
388
+ <>
389
+ <Row>
390
+ {num('x', t('inspector.maskX'), 'X', '%', { min: -500, max: 600 })}
391
+ {num('y', t('inspector.maskY'), 'Y', '%', { min: -500, max: 600 })}
392
+ </Row>
393
+ {fields.size === 'box' ? (
394
+ <Row>
395
+ {num('w', t('inspector.maskW'), 'W', '%', { min: 0.5, max: 1000 })}
396
+ {num('h', t('inspector.maskH'), 'H', '%', { min: 0.5, max: 1000 })}
397
+ </Row>
398
+ ) : null}
399
+ <Row>
400
+ {fields.size === 'band' ? num('h', t('inspector.maskBand'), <LineHeightGlyph />, '%', { min: 0.5, max: 1000 }) : null}
401
+ {fields.rotate ? num('rotate', t('inspector.rotate'), <RotateGlyph />, '°') : null}
402
+ {fields.radius ? num('radius', t('inspector.radius'), <RadiusGlyph />, '%', { min: 0, max: 500 }) : null}
403
+ <button
404
+ type="button"
405
+ aria-pressed={now.invert}
406
+ onClick={() => look.commit(edit((m) => ({ ...m, invert: !m.invert })))}
407
+ className={`flex h-[26px] items-center justify-center gap-1 rounded-[5px] text-[11px] transition ${now.invert
408
+ ? 'bg-[var(--bg)] text-[var(--text)] shadow-[0_0_0_0.5px_var(--border-strong)]'
409
+ : 'bg-[var(--fill-tsp)] text-[var(--text-muted)] hover:text-[var(--text)]'}`}
410
+ >
411
+ {t('inspector.maskInvert')}
412
+ </button>
413
+ {[fields.size === 'band', fields.rotate, fields.radius].filter(Boolean).length % 2 === 0 ? <span /> : null}
414
+ </Row>
415
+ {fields.feather ? (
416
+ <SliderField
417
+ label={t('inspector.maskFeather')}
418
+ value={mask === MIXED ? MIXED : now.feather}
419
+ min={0}
420
+ max={50}
421
+ fieldMax={100}
422
+ neutral={0}
423
+ unit="%"
424
+ onPreview={(_, apply) => look.preview(featherEdit(apply))}
425
+ onCommit={(_, apply) => look.commit(featherEdit(apply))}
426
+ onRevert={() => look.revert()}
427
+ />
428
+ ) : null}
429
+ </>
430
+ ) : null}
431
+ </Section>
432
+ );
433
+ }
434
+
435
+ /* ───────────────────────────── appearance ───────────────────────────── */
436
+
437
+ /** Opacity (`opacity`) and blending (`mix-blend-mode`), with names people use; `children` adds fills. */
438
+ /** A box's four corners (CSS order: top left, top right, bottom right, bottom left). */
439
+ const CORNERS = ['top-left', 'top-right', 'bottom-right', 'bottom-left'] as const;
440
+ type Corner = (typeof CORNERS)[number];
441
+
442
+ /**
443
+ * Corner radius: one number for all four, or (the button beside it) each corner its own. A crop cuts the
444
+ * box's own corners away, so it carries the same roundness (one for all: a crop's shape rounds its corners alike).
445
+ */
446
+ function RadiusField({ look, t }: { look: Look; t: T }) {
447
+ /* the shorthand, as written or as the page draws it */
448
+ const cornerOf = (read: LookRead, c: Corner) => radiusCorners(read('border-radius'))[CORNERS.indexOf(c)]!;
449
+ const corners = look.common((read) => CORNERS.map((c) => cornerOf(read, c)).join(' '));
450
+ const uneven = corners !== MIXED && new Set(corners.split(' ')).size > 1;
451
+ const [each, setEach] = React.useState(uneven);
452
+ React.useEffect(() => { if (uneven) setEach(true); }, [uneven]);
453
+ const cropped = (read: LookRead) => { const now = cropOf(read('clip-path')); return now && now.some((v) => v > 0) ? now : null; };
454
+ const all = look.common((read) => (CORNERS.every((c) => cornerOf(read, c) === cornerOf(read, 'top-left')) ? cornerOf(read, 'top-left') : MIXED));
455
+ const allEdit = (apply: FieldApply): LookEdit => (read) => {
456
+ const r = Math.max(0, round(apply(cornerOf(read, 'top-left'))));
457
+ const crop = cropped(read);
458
+ return { 'border-radius': r > 0 ? `${r}px` : null, ...(crop ? { 'clip-path': cropCss(crop, r) } : {}) };
459
+ };
460
+ const cornerEdit = (corner: Corner, apply: FieldApply): LookEdit => (read) => {
461
+ const now = CORNERS.map((c) => cornerOf(read, c));
462
+ now[CORNERS.indexOf(corner)] = Math.max(0, round(apply(now[CORNERS.indexOf(corner)]!)));
463
+ return { 'border-radius': now.every((v) => v === 0) ? null : now.map((v) => `${v}px`).join(' ') };
464
+ };
465
+ const bind = (edit: (apply: FieldApply) => LookEdit) => ({
466
+ onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
467
+ onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
468
+ onRevert: () => look.revert(),
469
+ });
470
+ const croppedNow = look.common((read) => cropped(read) != null) === true;
471
+ return (
472
+ <>
473
+ <div className="grid grid-cols-[1fr_24px] items-center gap-1.5">
474
+ <NumField label={t('inspector.radius')} prefix={<RadiusGlyph />} value={each ? MIXED : all} unit="px" min={0} max={100_000}
475
+ placeholder={each ? t('inspector.cornersEach') : undefined} {...bind(allEdit)} />
476
+ <button
477
+ type="button"
478
+ aria-pressed={each}
479
+ aria-label={t('inspector.cornersEach')}
480
+ title={croppedNow ? t('inspector.cornersCropped') : t('inspector.cornersEach')}
481
+ disabled={croppedNow}
482
+ onClick={() => setEach((v) => !v)}
483
+ className={`flex h-[26px] w-6 items-center justify-center rounded-[5px] transition disabled:opacity-40 ${each ? 'bg-[var(--bg-active)] text-[var(--text)]' : 'text-[var(--text-faint)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)]'}`}
484
+ >
485
+ <Scan size={12} />
486
+ </button>
487
+ </div>
488
+ {each && !croppedNow ? (
489
+ <Row>
490
+ {CORNERS.map((c) => (
491
+ <NumField key={c} label={t(`inspector.corners.${c}`)} prefix={<CornerGlyph corner={c} />}
492
+ value={look.common((read) => cornerOf(read, c))} min={0} max={100_000} {...bind((apply) => cornerEdit(c, apply))} />
493
+ ))}
494
+ </Row>
495
+ ) : null}
496
+ </>
497
+ );
498
+ }
499
+
500
+ /** A corner's mark: the quarter of a rounded square at that corner. */
501
+ function CornerGlyph({ corner }: { corner: Corner }) {
502
+ const turn = { 'top-left': 0, 'top-right': 90, 'bottom-right': 180, 'bottom-left': 270 }[corner];
503
+ return (
504
+ <svg width="9" height="9" viewBox="0 0 9 9" aria-hidden style={{ transform: `rotate(${turn}deg)` }}>
505
+ <path d="M1.5 8V5a3.5 3.5 0 0 1 3.5-3.5H8" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
506
+ </svg>
507
+ );
508
+ }
509
+
510
+ /**
511
+ * Opacity, corner radius (`radius`: for what has corners — a box, a picture, a line with its own fill) and blend,
512
+ * the Appearance section.
513
+ */
514
+ /**
515
+ * What the things in hand looked like before a menu showed something else on the picture (a hovered font, weight,
516
+ * size or blend mode, shown on the picture before it is picked): the stage measures a layer again after
517
+ * each preview, so by then it reads as the preview. Giving up (`restore`) paints that back; a pick (`commit`) keeps it.
518
+ */
519
+ function usePreviewed(look: Look) {
520
+ const before = React.useRef<Record<string, string> | null>(null);
521
+ const remember = (props: readonly string[]) => {
522
+ const now = look.common((read) => Object.fromEntries(props.map((p) => [p, read(p) ?? TYPE_NEUTRAL[p] ?? ''])));
523
+ if (now !== MIXED) before.current = { ...now, ...before.current };
524
+ };
525
+ const restore = () => {
526
+ const was = before.current;
527
+ before.current = null;
528
+ if (was) { look.preview(() => was); previewSettled(); } else look.revert();
529
+ };
530
+ /** Show an edit, what it changes remembered first. */
531
+ const preview = (edit: LookEdit) => {
532
+ const keys = look.common((read) => Object.keys(edit(read)).sort());
533
+ remember(keys === MIXED ? [] : keys);
534
+ look.preview(edit);
535
+ };
536
+ const commit = (edit: LookEdit) => { before.current = null; look.commit(edit); };
537
+ return { before, remember, restore, preview, commit };
538
+ }
539
+
540
+ export function AppearanceSection({ look, t, children, radius = false, aside }: { look: Look; t: T; children?: React.ReactNode; radius?: boolean; aside?: React.ReactNode }) {
541
+ const previewed = usePreviewed(look);
542
+ const opacity = look.common((read) => {
543
+ const v = Number(read('opacity') ?? 1);
544
+ return Number.isFinite(v) ? round(v * 100) : 100;
545
+ });
546
+ const blend = look.common((read) => read('mix-blend-mode') ?? 'normal');
547
+ const opacityEdit = (apply: FieldApply): LookEdit => (read) => {
548
+ const now = Number(read('opacity') ?? 1);
549
+ const n = Math.max(0, Math.min(100, apply(Number.isFinite(now) ? now * 100 : 100)));
550
+ return { opacity: n >= 100 ? null : String(Math.round(n * 10) / 1000) };
551
+ };
552
+ return (
553
+ <Section id="appearance" title={t('inspector.appearance')} {...(aside ? { aside } : {})}>
554
+ <SliderField
555
+ label={t('inspector.opacity')}
556
+ value={opacity}
557
+ min={0}
558
+ max={100}
559
+ neutral={100}
560
+ unit="%"
561
+ onPreview={(_, apply) => look.preview(opacityEdit(apply))}
562
+ onCommit={(_, apply) => look.commit(opacityEdit(apply))}
563
+ onRevert={() => look.revert()}
564
+ />
565
+ {radius ? <RadiusField look={look} t={t} /> : null}
566
+ <SelectField
567
+ label={t('inspector.blend')}
568
+ prefix={t('inspector.blendShort')}
569
+ value={blend}
570
+ options={BLEND_MODES.map((b) => ({ value: b, label: t(`inspector.blendModes.${b}`) }))}
571
+ onPreview={(v) => previewed.preview(() => ({ 'mix-blend-mode': v }))}
572
+ onRevert={previewed.restore}
573
+ onCommit={(v) => previewed.commit(() => ({ 'mix-blend-mode': v === 'normal' ? null : v }))}
574
+ />
575
+ {children}
576
+ </Section>
577
+ );
578
+ }
579
+
580
+ /* ───────────────────────────── fill ───────────────────────────── */
581
+
582
+ /**
583
+ * Fill: what paints the thing — a line's color, a box's background, a shape's fill. One the thing may go
584
+ * without (a box's background) has "−" to take it off, and "+" brings one back.
585
+ */
586
+ export function FillSection({ look, t, fills, add }: {
587
+ look: Look;
588
+ t: T;
589
+ /** The fills shown: their CSS property, label, and whether it can be taken off. */
590
+ fills: { prop: string; label: string; removable?: boolean }[];
591
+ /** A fill to offer with "+" when it is not there (a box with no background). */
592
+ add?: { prop: string; label: string; value: string } | null;
593
+ }) {
594
+ if (!fills.length && !add) return null;
595
+ return (
596
+ <Section
597
+ id="fill"
598
+ title={t('inspector.fill')}
599
+ aside={add ? (
600
+ <SectionAction label={t('inspector.fillAdd')} onClick={() => look.commit(() => ({ [add.prop]: add.value }))}><Plus size={12} /></SectionAction>
601
+ ) : null}
602
+ >
603
+ {fills.map((f) => {
604
+ /* words: the color they are drawn in, which a fill of their page's own paints over `color` */
605
+ const of = (read: LookRead) => (f.prop === 'color' ? textColorOf(read) : read(f.prop));
606
+ const to = (c: string): LookEdit => (read) => (f.prop === 'color' ? textColorCss(read, c) : { [f.prop]: c });
607
+ return (
608
+ <div key={f.prop} className="flex items-center gap-1.5">
609
+ <div className="min-w-0 flex-1">
610
+ <ColorField
611
+ label={f.label}
612
+ value={look.common((read) => of(read) ?? '')}
613
+ onPreview={(c) => look.preview(to(c))}
614
+ onCommit={(c) => look.commit(to(c))}
615
+ />
616
+ </div>
617
+ {f.removable ? (
618
+ /* see-through, not "none of mine": that would bring back the page's own fill */
619
+ <SectionAction label={t('inspector.fillRemove')} onClick={() => look.commit(() => ({ [f.prop]: 'transparent' }))}><Minus size={12} /></SectionAction>
620
+ ) : null}
621
+ </div>
622
+ );
623
+ })}
624
+ </Section>
625
+ );
626
+ }
627
+
628
+ /* ───────────────────────────── stroke ───────────────────────────── */
629
+
630
+ /**
631
+ * Stroke: color, weight, where it lies against the edge (inside, across, outside) and its line (solid,
632
+ * dashed, dotted). Drawn as an `outline` with its offset (lib/clip-look placedStrokeOf): it adds nothing to the box,
633
+ * so the layout does not move as it is added or thickened.
634
+ */
635
+ export function StrokeSection({ look, t }: { look: Look; t: T }) {
636
+ const previewed = usePreviewed(look);
637
+ const stroke = look.common((read) => placedStrokeOf(read));
638
+ const has = stroke !== undefined;
639
+ const edit = (part: Partial<PlacedStroke> | ((now: PlacedStroke) => Partial<PlacedStroke>)): LookEdit => (read) => {
640
+ const was = placedStrokeOf(read);
641
+ const now: PlacedStroke = was ?? PLACED_STROKE_DEFAULT;
642
+ return placedStrokeCss({ ...now, ...(typeof part === 'function' ? part(now) : part) }, was);
643
+ };
644
+ const width = (apply: FieldApply): LookEdit => edit((now) => ({ width: Math.max(0, round(apply(now.width))) }));
645
+ /* a mask (faded edges, a mask shape) or a crop shows the box and nothing past it: a stroke outside, or half of one
646
+ across the edge, is cut away with it */
647
+ const cut = look.common((read) => {
648
+ const m = read('mask-image');
649
+ const c = read('clip-path');
650
+ return Boolean((m && m !== 'none') || (c && c !== 'none'));
651
+ });
652
+ const position = look.common((read) => placedStrokeOf(read)?.position);
653
+ return (
654
+ <Section
655
+ id="stroke"
656
+ title={t('inspector.stroke')}
657
+ aside={has ? (
658
+ <SectionAction label={t('inspector.effectRemove')} onClick={() => look.commit((read) => placedStrokeCss(null, placedStrokeOf(read)))}><Minus size={12} /></SectionAction>
659
+ ) : (
660
+ <SectionAction label={t('inspector.strokeAdd')} onClick={() => look.commit(edit({}))}><Plus size={12} /></SectionAction>
661
+ )}
662
+ >
663
+ {stroke === null ? (
664
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.effectCustom')}</p>
665
+ ) : has ? (
666
+ <>
667
+ <ColorField
668
+ label={t('inspector.strokeColor')}
669
+ value={look.common((read) => placedStrokeOf(read)?.color ?? '')}
670
+ onPreview={(c) => look.preview(edit({ color: c }))}
671
+ onCommit={(c) => look.commit(edit({ color: c }))}
672
+ />
673
+ <Row>
674
+ <SelectField
675
+ label={t('inspector.strokePosition')}
676
+ value={look.common((read) => placedStrokeOf(read)?.position ?? 'inside')}
677
+ /* a mask or a crop cuts away all that is past the edge: only inside is drawn whole there (a stroke placed
678
+ otherwise before shows where it is, to be moved inside) */
679
+ options={(['inside', 'center', 'outside'] as const)
680
+ .filter((p) => cut === false || p === 'inside' || p === position)
681
+ .map((p) => ({ value: p, label: t(`inspector.strokePositions.${p}`) }))}
682
+ onPreview={(v) => previewed.preview(edit({ position: v as StrokePosition }))}
683
+ onRevert={previewed.restore}
684
+ onCommit={(v) => previewed.commit(edit({ position: v as StrokePosition }))}
685
+ />
686
+ <NumField
687
+ label={t('inspector.strokeWidth')}
688
+ prefix={<span className="block h-[7px] w-[9px] rounded-[1.5px] border-[1.5px]" style={{ borderColor: 'currentColor' }} />}
689
+ value={look.common((read) => placedStrokeOf(read)?.width)}
690
+ unit="px"
691
+ min={0}
692
+ max={1000}
693
+ onPreview={(_, apply) => look.preview(width(apply))}
694
+ onCommit={(_, apply) => look.commit(width(apply))}
695
+ onRevert={() => look.revert()}
696
+ />
697
+ </Row>
698
+ <Segmented
699
+ label={t('inspector.strokeStyle')}
700
+ value={look.common((read) => placedStrokeOf(read)?.style ?? '')}
701
+ small
702
+ options={(['solid', 'dashed', 'dotted'] as const).map((s) => ({
703
+ value: s,
704
+ title: t(`inspector.strokeStyles.${s}`),
705
+ label: <span className="block w-4 border-t-[1.5px]" style={{ borderTopStyle: s, borderTopColor: 'currentColor' }} />,
706
+ }))}
707
+ onChange={(s) => look.commit(edit({ style: s as StrokeStyle }))}
708
+ />
709
+ {cut !== false && position !== 'inside' ? (
710
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.strokeCut')}</p>
711
+ ) : null}
712
+ </>
713
+ ) : null}
714
+ </Section>
715
+ );
716
+ }
717
+
718
+ /* ───────────────────────────── adjust ───────────────────────────── */
719
+
720
+ const ADJUST_RANGE: Record<AdjustKey, { min: number; max: number; unit: string; fieldMax?: number }> = {
721
+ brightness: { min: -100, max: 100, unit: '' },
722
+ contrast: { min: -100, max: 100, unit: '' },
723
+ saturate: { min: -100, max: 100, unit: '' },
724
+ hue: { min: -180, max: 180, unit: '°' },
725
+ blur: { min: 0, max: 40, unit: 'px', fieldMax: 1000 },
726
+ grayscale: { min: 0, max: 100, unit: '%' },
727
+ };
728
+
729
+ /**
730
+ * The picture's color and focus, each a CSS `filter` function (brightness, contrast, saturate, hue-rotate, blur,
731
+ * grayscale): 0 is as is, a double-click puts one back, the reset all. Filter functions without a control here
732
+ * (sepia, a url) are kept as written, in their place.
733
+ */
734
+ /* the picture's color; its blur is an effect (a layer blur) */
735
+ const COLOR_KEYS = ADJUST_KEYS.filter((k) => k !== 'blur');
736
+
737
+ export function AdjustSection({ look, t }: { look: Look; t: T }) {
738
+ const readable = look.common((read) => filterParts(read('filter')) != null);
739
+ const touched = look.common((read) => COLOR_KEYS.some((k) => adjustOf(read('filter'))[k] !== 0));
740
+ const edit = (key: AdjustKey, apply: FieldApply): LookEdit => (read) => {
741
+ const now = adjustOf(read('filter'))[key];
742
+ const r = ADJUST_RANGE[key];
743
+ const n = Math.max(r.min, Math.min(r.fieldMax ?? r.max, round(apply(now ?? 0))));
744
+ return { filter: withAdjust(read('filter'), key, n) };
745
+ };
746
+ if (readable === false) {
747
+ return (
748
+ <Section id="adjust" title={t('inspector.adjust')}>
749
+ <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.adjustCustom')}</p>
750
+ </Section>
751
+ );
752
+ }
753
+ return (
754
+ <Section
755
+ id="adjust"
756
+ title={t('inspector.adjust')}
757
+ aside={touched === false ? null : (
758
+ <SectionAction
759
+ label={t('inspector.resetSection')}
760
+ onClick={() => look.commit((read) => ({ filter: COLOR_KEYS.reduce<string | null>((f, k) => withAdjust(f ?? undefined, k, 0), read('filter') ?? null) }))}
761
+ >
762
+ <RotateCcw size={11} />
763
+ </SectionAction>
764
+ )}
765
+ >
766
+ {COLOR_KEYS.map((key) => {
767
+ const r = ADJUST_RANGE[key];
768
+ const v = look.common((read) => adjustOf(read('filter'))[key]);
769
+ return (
770
+ <SliderField
771
+ key={key}
772
+ label={t(`inspector.adjustKeys.${key}`)}
773
+ value={v === null ? undefined : v}
774
+ {...(v === null ? { placeholder: t('inspector.custom') } : {})}
775
+ min={r.min}
776
+ max={r.max}
777
+ neutral={0}
778
+ unit={r.unit}
779
+ fieldMin={r.min}
780
+ fieldMax={r.fieldMax ?? r.max}
781
+ onPreview={(_, apply) => look.preview(edit(key, apply))}
782
+ onCommit={(_, apply) => look.commit(edit(key, apply))}
783
+ onRevert={() => look.revert()}
784
+ />
785
+ );
786
+ })}
787
+ </Section>
788
+ );
789
+ }
790
+
791
+ /* ───────────────────────────── effects ───────────────────────────── */
792
+
793
+ type EffectId = 'shadow' | 'inner' | 'blur' | 'backdrop' | 'fade';
794
+
795
+ /**
796
+ * Effects, each added with "+" and taken off with "−", none taking room unless used: a drop shadow, an
797
+ * inner shadow (`inner`: for what has a box of its own to cast it in), a layer blur, a background blur — and faded
798
+ * edges, a video editor's.
799
+ *
800
+ * The drop shadow is `filter: drop-shadow()`, not `box-shadow`: it follows what is drawn — the letters of a title on a
801
+ * see-through page, a cut-out PNG — where a box shadow would draw the rectangle behind them. The inner shadow is an
802
+ * `inset` box-shadow; the layer blur `filter: blur()`; the background blur `backdrop-filter: blur()`; faded edges are
803
+ * `mask-image` gradients, one an axis, intersected by `mask-composite`.
804
+ */
805
+ export function EffectsSection({ look, t, inner = false }: { look: Look; t: T; inner?: boolean }) {
806
+ const shadow = look.common((read) => shadowOf(read('filter')));
807
+ const innerShadow = look.common((read) => innerShadowOf(read('box-shadow')));
808
+ const blur = look.common((read) => { const b = adjustOf(read('filter')).blur; return b ? b : undefined; });
809
+ const backdrop = look.common((read) => backdropBlurOf(read('backdrop-filter')));
810
+ /* faded edges share mask-image with the Mask section's shape (clip-mask): read and written beside it */
811
+ const fadeRead = (read: LookRead) => fadeIn(read('mask-image'), read('mask-composite'));
812
+ const fadeWrite = (read: LookRead, next: Fade | null): LookSet => withFade(read('mask-image'), read('mask-composite'), next) ?? {};
813
+ const fade = look.common(fadeRead);
814
+ const filterReadable = look.common((read) => filterParts(read('filter')) != null) !== false;
815
+ const [adding, setAdding] = React.useState(false);
816
+ const has = (v: unknown) => v !== undefined;
817
+ const missing: { id: EffectId; label: string }[] = [
818
+ ...(!has(shadow) && filterReadable ? [{ id: 'shadow' as const, label: t('inspector.shadow') }] : []),
819
+ ...(inner && !has(innerShadow) ? [{ id: 'inner' as const, label: t('inspector.innerShadow') }] : []),
820
+ ...(!has(blur) && filterReadable ? [{ id: 'blur' as const, label: t('inspector.layerBlur') }] : []),
821
+ ...(!has(backdrop) ? [{ id: 'backdrop' as const, label: t('inspector.backgroundBlur') }] : []),
822
+ ...(!has(fade) ? [{ id: 'fade' as const, label: t('inspector.fade') }] : []),
823
+ ];
824
+ const add = (id: EffectId) => {
825
+ setAdding(false);
826
+ if (id === 'shadow') look.commit((read) => ({ filter: withShadow(read('filter'), shadowOf(read('filter')) ?? SHADOW_DEFAULT) }));
827
+ if (id === 'inner') look.commit(() => ({ 'box-shadow': innerShadowCss(INNER_SHADOW_DEFAULT) }));
828
+ if (id === 'blur') look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', 4) }));
829
+ if (id === 'backdrop') look.commit(() => ({ 'backdrop-filter': backdropBlurCss(8) }));
830
+ if (id === 'fade') look.commit((read) => fadeWrite(read, fadeRead(read) ?? FADE_DEFAULT));
831
+ };
832
+ const remove = (id: EffectId) => {
833
+ if (id === 'shadow') look.commit((read) => ({ filter: withShadow(read('filter'), null) }));
834
+ if (id === 'inner') look.commit(() => ({ 'box-shadow': null }));
835
+ if (id === 'blur') look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', 0) }));
836
+ if (id === 'backdrop') look.commit(() => ({ 'backdrop-filter': null }));
837
+ if (id === 'fade') look.commit((read) => fadeWrite(read, null));
838
+ };
839
+ const removeBtn = (id: EffectId) => (
840
+ <SectionAction label={t('inspector.effectRemove')} onClick={() => remove(id)}><Minus size={12} /></SectionAction>
841
+ );
842
+ const bind = (edit: (apply: FieldApply) => LookEdit) => ({
843
+ onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
844
+ onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
845
+ onRevert: () => look.revert(),
846
+ });
847
+
848
+ /* drop shadow: each thing's own, or the default for one without */
849
+ const shadowField = (key: 'x' | 'y' | 'blur') => {
850
+ const edit = (apply: FieldApply): LookEdit => (read) => {
851
+ const now: Shadow = shadowOf(read('filter')) ?? SHADOW_DEFAULT;
852
+ const n = round(apply(now[key]));
853
+ return { filter: withShadow(read('filter'), { ...now, [key]: key === 'blur' ? Math.max(0, n) : n }) };
854
+ };
855
+ return (
856
+ <NumField
857
+ label={t(`inspector.shadow${key === 'x' ? 'X' : key === 'y' ? 'Y' : 'Blur'}`)}
858
+ prefix={key === 'x' ? 'X' : key === 'y' ? 'Y' : 'B'}
859
+ value={look.common((read) => shadowOf(read('filter'))?.[key])}
860
+ unit="px"
861
+ {...(key === 'blur' ? { min: 0 } : {})}
862
+ {...bind(edit)}
863
+ />
864
+ );
865
+ };
866
+ const shadowColor = (color: string): LookEdit => (read) => ({ filter: withShadow(read('filter'), { ...(shadowOf(read('filter')) ?? SHADOW_DEFAULT), color }) });
867
+ /* inner shadow: x, y, blur, spread and color */
868
+ const innerEdit = (part: (now: InnerShadow) => Partial<InnerShadow>): LookEdit => (read) => {
869
+ const now = innerShadowOf(read('box-shadow')) ?? INNER_SHADOW_DEFAULT;
870
+ return { 'box-shadow': innerShadowCss({ ...now, ...part(now) }) };
871
+ };
872
+ const innerField = (key: 'x' | 'y' | 'blur' | 'spread', prefix: string, label: string) => (
873
+ <NumField
874
+ label={label}
875
+ prefix={prefix}
876
+ value={look.common((read) => innerShadowOf(read('box-shadow'))?.[key])}
877
+ unit="px"
878
+ {...(key === 'blur' ? { min: 0 } : {})}
879
+ {...bind((apply) => innerEdit((now) => ({ [key]: key === 'blur' ? Math.max(0, round(apply(now[key]))) : round(apply(now[key])) })))}
880
+ />
881
+ );
882
+ const fadeOn = (side: FadeSide) => look.common((read) => fadeRead(read)?.sides[side] ?? false);
883
+ const fadeSize = look.common((read) => fadeRead(read)?.size ?? null);
884
+ const fadeToggle = (side: FadeSide) => {
885
+ const on = fadeOn(side) === true;
886
+ look.commit((read) => {
887
+ const now: Fade = fadeRead(read) ?? { ...FADE_DEFAULT, sides: { top: false, right: false, bottom: false, left: false } };
888
+ const sides = { ...now.sides, [side]: !on };
889
+ return fadeWrite(read, Object.values(sides).some(Boolean) ? { ...now, sides } : null);
890
+ });
891
+ };
892
+ const fadeSizeEdit = (apply: FieldApply): LookEdit => (read): LookSet => {
893
+ const now = fadeRead(read);
894
+ return now ? fadeWrite(read, { ...now, size: Math.max(0.5, Math.min(50, round(apply(now.size)))) }) : {};
895
+ };
896
+ const custom = (label: string) => (
897
+ <SubLabel>{label} · <span className="text-[var(--text-faint)]">{t('inspector.effectCustom')}</span></SubLabel>
898
+ );
899
+ const none = !has(shadow) && !has(innerShadow) && !has(blur) && !has(backdrop) && !has(fade);
900
+
901
+ return (
902
+ <Section
903
+ id="effects"
904
+ title={t('inspector.effects')}
905
+ aside={missing.length ? (
906
+ <Popover
907
+ open={adding}
908
+ onOpenChange={setAdding}
909
+ align="end"
910
+ trigger={(
911
+ <button type="button" aria-label={t('inspector.effectAdd')} className="flex h-5 w-5 items-center justify-center rounded-[5px] text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]">
912
+ <Plus size={12} />
913
+ </button>
914
+ )}
915
+ >
916
+ <div className="min-w-[140px] rounded-[7px] border border-[var(--border)] bg-[var(--surface)] p-1 shadow-[var(--shadow-lg)]">
917
+ {missing.map((m) => (
918
+ <button
919
+ key={m.id}
920
+ type="button"
921
+ onClick={() => add(m.id)}
922
+ className="flex w-full items-center rounded-[5px] px-2 py-1.5 text-left text-[11.5px] text-[var(--text)] transition hover:bg-[var(--bg-hover)]"
923
+ >
924
+ {m.label}
925
+ </button>
926
+ ))}
927
+ </div>
928
+ </Popover>
929
+ ) : null}
930
+ >
931
+ {none ? <p className="text-[10.5px] leading-[15px] text-[var(--text-faint)]">{t('inspector.effectsNone')}</p> : null}
932
+ {shadow === null ? custom(t('inspector.shadow')) : has(shadow) ? (
933
+ <>
934
+ <SubLabel aside={removeBtn('shadow')}>{t('inspector.shadow')}</SubLabel>
935
+ <Row cols={3}>{shadowField('x')}{shadowField('y')}{shadowField('blur')}</Row>
936
+ <ColorField
937
+ label={t('inspector.shadowColor')}
938
+ value={look.common((read) => shadowOf(read('filter'))?.color ?? '')}
939
+ onPreview={(c) => look.preview(shadowColor(c))}
940
+ onCommit={(c) => look.commit(shadowColor(c))}
941
+ />
942
+ </>
943
+ ) : null}
944
+ {innerShadow === null ? custom(t('inspector.innerShadow')) : has(innerShadow) ? (
945
+ <>
946
+ <SubLabel aside={removeBtn('inner')}>{t('inspector.innerShadow')}</SubLabel>
947
+ <Row>{innerField('x', 'X', t('inspector.shadowX'))}{innerField('y', 'Y', t('inspector.shadowY'))}</Row>
948
+ <Row>{innerField('blur', 'B', t('inspector.shadowBlur'))}{innerField('spread', 'S', t('inspector.shadowSpread'))}</Row>
949
+ <ColorField
950
+ label={t('inspector.shadowColor')}
951
+ value={look.common((read) => innerShadowOf(read('box-shadow'))?.color ?? '')}
952
+ onPreview={(c) => look.preview(innerEdit(() => ({ color: c })))}
953
+ onCommit={(c) => look.commit(innerEdit(() => ({ color: c })))}
954
+ />
955
+ </>
956
+ ) : null}
957
+ {blur === null ? custom(t('inspector.layerBlur')) : has(blur) ? (
958
+ <>
959
+ <SubLabel aside={removeBtn('blur')}>{t('inspector.layerBlur')}</SubLabel>
960
+ <SliderField
961
+ label={t('inspector.layerBlur')}
962
+ value={blur === MIXED ? MIXED : blur ?? 0}
963
+ min={0}
964
+ max={40}
965
+ neutral={0}
966
+ unit="px"
967
+ fieldMin={0}
968
+ fieldMax={1000}
969
+ onPreview={(_, apply) => look.preview((read) => ({ filter: withAdjust(read('filter'), 'blur', Math.max(0, round(apply(adjustOf(read('filter')).blur ?? 0)))) }))}
970
+ onCommit={(_, apply) => look.commit((read) => ({ filter: withAdjust(read('filter'), 'blur', Math.max(0, round(apply(adjustOf(read('filter')).blur ?? 0)))) }))}
971
+ onRevert={() => look.revert()}
972
+ />
973
+ </>
974
+ ) : null}
975
+ {backdrop === null ? custom(t('inspector.backgroundBlur')) : has(backdrop) ? (
976
+ <>
977
+ <SubLabel aside={removeBtn('backdrop')}>{t('inspector.backgroundBlur')}</SubLabel>
978
+ <SliderField
979
+ label={t('inspector.backgroundBlur')}
980
+ value={backdrop === MIXED ? MIXED : backdrop ?? 0}
981
+ min={0}
982
+ max={40}
983
+ neutral={0}
984
+ unit="px"
985
+ fieldMin={0}
986
+ fieldMax={1000}
987
+ onPreview={(_, apply) => look.preview((read) => ({ 'backdrop-filter': backdropBlurCss(Math.max(0, round(apply(backdropBlurOf(read('backdrop-filter')) ?? 0)))) }))}
988
+ onCommit={(_, apply) => look.commit((read) => ({ 'backdrop-filter': backdropBlurCss(Math.max(0, round(apply(backdropBlurOf(read('backdrop-filter')) ?? 0)))) }))}
989
+ onRevert={() => look.revert()}
990
+ />
991
+ </>
992
+ ) : null}
993
+ {fade === null ? custom(t('inspector.fade')) : has(fade) ? (
994
+ <>
995
+ <SubLabel aside={removeBtn('fade')}>{t('inspector.fade')}</SubLabel>
996
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1.5">
997
+ <div role="group" aria-label={t('inspector.fadeSides')} className="flex h-[26px] items-center gap-0.5 rounded-[5px] bg-[var(--fill-tsp)] p-px">
998
+ {(['top', 'right', 'bottom', 'left'] as const).map((side) => {
999
+ const on = fadeOn(side) === true;
1000
+ return (
1001
+ <button
1002
+ key={side}
1003
+ type="button"
1004
+ aria-pressed={on}
1005
+ aria-label={t(`inspector.fadeSide.${side}`)}
1006
+ title={t(`inspector.fadeSide.${side}`)}
1007
+ onClick={() => fadeToggle(side)}
1008
+ className={`flex h-6 flex-1 items-center justify-center rounded-[4px] transition ${on
1009
+ ? 'bg-[var(--bg)] text-[var(--text)] shadow-[0_0_0_0.5px_var(--border-strong)]'
1010
+ : 'text-[var(--text-faint)] hover:text-[var(--text)]'}`}
1011
+ >
1012
+ <EdgeGlyph side={side} />
1013
+ </button>
1014
+ );
1015
+ })}
1016
+ </div>
1017
+ <NumField
1018
+ label={t('inspector.fadeSize')}
1019
+ prefix={<LineHeightGlyph />}
1020
+ value={fadeSize === MIXED ? MIXED : fadeSize ?? undefined}
1021
+ unit="%"
1022
+ min={0.5}
1023
+ max={50}
1024
+ onPreview={(_, apply) => look.preview(fadeSizeEdit(apply))}
1025
+ onCommit={(_, apply) => look.commit(fadeSizeEdit(apply))}
1026
+ onRevert={() => look.revert()}
1027
+ />
1028
+ </div>
1029
+ </>
1030
+ ) : null}
1031
+ </Section>
1032
+ );
1033
+ }
1034
+
1035
+ /* ───────────────────────────── typography ───────────────────────────── */
1036
+
1037
+ /** What a type property is when nothing sets it (what giving up a preview paints back when the layer had none). */
1038
+ const TYPE_NEUTRAL: Record<string, string> = { 'font-weight': '400', 'font-style': 'normal', 'line-height': 'normal', 'letter-spacing': 'normal' };
1039
+
1040
+ /** A family's weight menu: its own weights and italics by name ("Semi Bold", "Bold Italic"). */
1041
+ function weightOptions(styles: FontStyles | null, t: T): { value: string; label: string }[] {
1042
+ const list = styleOptions(styles ?? { weights: NAMED_WEIGHTS });
1043
+ return list.map((s) => {
1044
+ const key = weightKey(s.weight);
1045
+ /* a weight between the named ones (Hiragino's W2 is 250) says its number, or two would read the same */
1046
+ const name = `${t(`inspector.weights.w${key}`)}${key === s.weight ? '' : ` ${s.weight}`}`;
1047
+ return { value: styleValue(s), label: s.italic ? t('inspector.weightItalic').replace('{name}', name) : name };
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Type: font (its menu shows each family on the picture as it is pointed at), weight
1053
+ * (the family's own), size (with a menu of common sizes), line height, letter spacing, alignment, case
1054
+ * (`text-transform`) and color. Text in an SVG has no line layout and is colored by its fill.
1055
+ */
1056
+ export function TypographySection({ look, t, svg, sample }: { look: Look; t: T; svg?: boolean; sample: string }) {
1057
+ const installed = useInstalledFonts();
1058
+ const { fonts: project, page, ready } = useProjectFonts();
1059
+ const family = look.common((read) => splitFontFamily(read('font-family') ?? '')[0]);
1060
+ const style = look.common((read) => ({ weight: weightNumber(read('font-weight')) ?? 400, italic: isItalic(read('font-style')) }));
1061
+ const size = look.common((read) => pxOf(read('font-size')));
1062
+ const lineHeight = look.common((read) => metricOf('line-height', read('line-height')));
1063
+ const spacing = look.common((read) => metricOf('letter-spacing', read('letter-spacing')));
1064
+ const align = look.common((read) => read('text-align') ?? '');
1065
+ const textCase = look.common((read) => read('text-transform') ?? 'none');
1066
+ const decoration = look.common((read) => {
1067
+ const v = read('text-decoration-line') ?? 'none';
1068
+ return /underline/.test(v) ? 'underline' : /line-through/.test(v) ? 'line-through' : 'none';
1069
+ });
1070
+ const styles = family === MIXED || !family ? null : stylesOf(family, installed ?? [], project);
1071
+ /* not drawn as named: not installed, not declared for the page — once the lists came, and the browser agrees */
1072
+ const missing = React.useMemo(() => {
1073
+ if (family === MIXED || !family || installed == null || !ready) return false;
1074
+ const known = knownAvailable(family, installed, project, page);
1075
+ return known == null ? !familyRenders(family) : !known;
1076
+ }, [family, installed, project, page, ready]);
1077
+ /* families tried are worked out from what the type was before (usePreviewed) */
1078
+ const { before, remember, restore, preview, commit } = usePreviewed(look);
1079
+ /* a weight (and its slant) from the menu's value; italic taken off a style that had it */
1080
+ const weightEdit = (v: string): LookEdit | null => {
1081
+ const next = parseStyleValue(v);
1082
+ return next ? (read) => ({ 'font-weight': String(next.weight), 'font-style': next.italic ? 'italic' : isItalic(read('font-style')) ? 'normal' : null }) : null;
1083
+ };
1084
+ /**
1085
+ * A new family, the fallbacks kept; a weight or an italic it does not have snapped to what it has. Shown only
1086
+ * (`preview`): the weight and slant of before are shown again too, after a family that snapped them.
1087
+ */
1088
+ const familyEdit = (next: string, preview: boolean, was = before.current): LookEdit => (current) => {
1089
+ const read: LookRead = was ? (p) => was[p] ?? current(p) : current;
1090
+ const set: LookSet = { 'font-family': joinFontFamily(next, splitFontFamily(read('font-family') ?? '')[1]) };
1091
+ const has = stylesOf(next, installed ?? [], project);
1092
+ const now = { weight: weightNumber(read('font-weight')) ?? 400, italic: isItalic(read('font-style')) };
1093
+ const snapped = has ? snapStyle(has, now) : now;
1094
+ if (snapped.weight !== now.weight) set['font-weight'] = String(snapped.weight);
1095
+ else if (was && preview) set['font-weight'] = read('font-weight') ?? '400';
1096
+ if (now.italic && !snapped.italic) set['font-style'] = 'normal';
1097
+ else if (was && preview) set['font-style'] = read('font-style') ?? 'normal';
1098
+ return set;
1099
+ };
1100
+ const num = (prop: string, now: (read: LookRead) => number | undefined, out: (n: number) => string, lo: number, hi: number) => (apply: FieldApply): LookEdit => (read) => {
1101
+ const n = Math.max(lo, Math.min(hi, round(apply(now(read) ?? 0), 2)));
1102
+ return { [prop]: out(n) };
1103
+ };
1104
+ const sizeEdit = num('font-size', (r) => pxOf(r('font-size')), (n) => `${n}px`, 1, 5000);
1105
+ const bind = (edit: (apply: FieldApply) => LookEdit) => ({
1106
+ onPreview: (_: number, apply: FieldApply) => look.preview(edit(apply)),
1107
+ onCommit: (_: number, apply: FieldApply) => look.commit(edit(apply)),
1108
+ onRevert: () => look.revert(),
1109
+ });
1110
+ const metric = (prop: string) => ({
1111
+ onPreview: (css: string) => { remember([prop]); look.preview(() => ({ [prop]: css })); },
1112
+ onCommit: (css: string) => { before.current = null; look.commit(() => ({ [prop]: css })); },
1113
+ onRevert: restore,
1114
+ });
1115
+ const CASES = [
1116
+ { value: 'none', label: '—', title: t('inspector.caseNone') },
1117
+ { value: 'uppercase', label: 'AG', title: t('inspector.caseUpper') },
1118
+ { value: 'lowercase', label: 'ag', title: t('inspector.caseLower') },
1119
+ { value: 'capitalize', label: 'Ag', title: t('inspector.caseTitle') },
1120
+ ];
1121
+ return (
1122
+ <Section id="typography" title={t('inspector.typography')}>
1123
+ <FontFamilyPicker
1124
+ label={t('inspector.fontFamily')}
1125
+ value={family === MIXED ? '' : family}
1126
+ sample={sample}
1127
+ missing={missing}
1128
+ onPreview={(next) => {
1129
+ remember(['font-family', 'font-weight', 'font-style']);
1130
+ look.preview(familyEdit(next, true));
1131
+ }}
1132
+ onRevert={restore}
1133
+ onCommit={(next) => {
1134
+ const edit = familyEdit(next, false);
1135
+ before.current = null;
1136
+ look.commit(edit);
1137
+ }}
1138
+ className="group/field relative flex h-[26px] min-w-0 w-full items-center overflow-hidden rounded-[5px] bg-[var(--fill-tsp)] ring-1 ring-inset ring-transparent transition-[box-shadow,background-color] hover:ring-[var(--border)] focus-within:bg-[var(--bg)] focus-within:ring-[1.5px] focus-within:ring-[var(--accent)]"
1139
+ />
1140
+ <Row>
1141
+ <SelectField
1142
+ label={t('inspector.fontWeight')}
1143
+ value={style === MIXED ? MIXED : styleValue(style)}
1144
+ options={weightOptions(styles, t)}
1145
+ onPreview={(v) => { const edit = weightEdit(v); if (edit) preview(edit); }}
1146
+ onRevert={restore}
1147
+ onCommit={(v) => { const edit = weightEdit(v); if (edit) commit(edit); }}
1148
+ />
1149
+ <NumField
1150
+ label={t('inspector.fontSize')}
1151
+ prefix={<FontSizeGlyph />}
1152
+ value={size}
1153
+ unit="px"
1154
+ min={1}
1155
+ max={5000}
1156
+ {...bind(sizeEdit)}
1157
+ trailing={(
1158
+ <FontSizeMenu
1159
+ t={t}
1160
+ onPreview={(n) => preview(() => ({ 'font-size': `${n}px` }))}
1161
+ onRevert={restore}
1162
+ onPick={(n) => commit(() => ({ 'font-size': `${n}px` }))}
1163
+ />
1164
+ )}
1165
+ />
1166
+ </Row>
1167
+ <Row>
1168
+ {svg ? null : (
1169
+ <MetricField label={t('inspector.lineHeight')} prefix={<LineHeightGlyph />} kind="line-height" value={lineHeight} placeholder={t('inspector.auto')} {...metric('line-height')} />
1170
+ )}
1171
+ <MetricField label={t('inspector.letterSpacing')} prefix={<LetterSpacingGlyph />} kind="letter-spacing" value={spacing} placeholder="0" {...metric('letter-spacing')} />
1172
+ {svg ? <span /> : null}
1173
+ </Row>
1174
+ {svg ? null : (
1175
+ <Row>
1176
+ <Segmented
1177
+ label={t('inspector.textAlign')}
1178
+ value={align}
1179
+ options={[
1180
+ { value: 'left', title: t('inspector.alignTextLeft'), label: <AlignLeft size={ICON} /> },
1181
+ { value: 'center', title: t('inspector.alignTextCenter'), label: <AlignCenter size={ICON} /> },
1182
+ { value: 'right', title: t('inspector.alignTextRight'), label: <AlignRight size={ICON} /> },
1183
+ { value: 'justify', title: t('inspector.alignTextJustify'), label: <AlignJustify size={ICON} /> },
1184
+ ]}
1185
+ onChange={(v) => look.commit(() => ({ 'text-align': v }))}
1186
+ />
1187
+ <Segmented
1188
+ label={t('inspector.textCase')}
1189
+ value={textCase}
1190
+ small
1191
+ options={CASES}
1192
+ onChange={(v) => look.commit(() => ({ 'text-transform': v === 'none' ? null : v }))}
1193
+ />
1194
+ </Row>
1195
+ )}
1196
+ {svg ? null : (
1197
+ <Row>
1198
+ <Segmented
1199
+ label={t('inspector.textDecoration')}
1200
+ value={decoration}
1201
+ small
1202
+ options={[
1203
+ { value: 'none', label: '—', title: t('inspector.decorations.none') },
1204
+ { value: 'underline', label: <Underline size={ICON} />, title: t('inspector.decorations.underline') },
1205
+ { value: 'line-through', label: <Strikethrough size={ICON} />, title: t('inspector.decorations.lineThrough') },
1206
+ ]}
1207
+ onChange={(v) => look.commit(() => ({ 'text-decoration-line': v === 'none' ? null : v }))}
1208
+ />
1209
+ <span />
1210
+ </Row>
1211
+ )}
1212
+ </Section>
1213
+ );
1214
+ }
1215
+
1216
+ /** The font size field's menu: the common sizes; the one under the pointer shown before it is picked. */
1217
+ function FontSizeMenu({ t, onPick, onPreview, onRevert }: { t: T; onPick: (n: number) => void; onPreview: (n: number) => void; onRevert: () => void }) {
1218
+ const [open, setOpen] = React.useState(false);
1219
+ const previewing = React.useRef(false);
1220
+ const show = (n: number | null) => {
1221
+ if (n != null) { previewing.current = true; onPreview(n); }
1222
+ else if (previewing.current) { previewing.current = false; onRevert(); }
1223
+ };
1224
+ return (
1225
+ <Popover
1226
+ open={open}
1227
+ onOpenChange={(next) => { if (!next) show(null); setOpen(next); }}
1228
+ placement="auto"
1229
+ align="end"
1230
+ trigger={(
1231
+ <button
1232
+ type="button"
1233
+ aria-label={t('inspector.fontSizes')}
1234
+ aria-haspopup="listbox"
1235
+ aria-expanded={open}
1236
+ className="flex h-[26px] w-[18px] shrink-0 items-center justify-center text-[var(--text-faint)] hover:text-[var(--text)]"
1237
+ >
1238
+ <ChevronDown size={11} aria-hidden />
1239
+ </button>
1240
+ )}
1241
+ >
1242
+ <div
1243
+ data-film-inspector=""
1244
+ role="listbox"
1245
+ aria-label={t('inspector.fontSizes')}
1246
+ onMouseLeave={() => show(null)}
1247
+ className="w-[72px] overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--panel)] py-1 shadow-[var(--shadow-lg)]"
1248
+ style={{ maxHeight: 'min(320px, var(--popover-max-h, 320px))' }}
1249
+ >
1250
+ {FONT_SIZES.map((n) => (
1251
+ <button
1252
+ key={n}
1253
+ type="button"
1254
+ role="option"
1255
+ aria-selected={false}
1256
+ onMouseEnter={() => show(n)}
1257
+ onClick={() => { previewing.current = false; setOpen(false); onPick(n); }}
1258
+ className="block w-full px-3 py-[3px] text-left text-[11.5px] tabular-nums text-[var(--text)] hover:bg-[var(--bg-hover)]"
1259
+ >
1260
+ {n}
1261
+ </button>
1262
+ ))}
1263
+ </div>
1264
+ </Popover>
1265
+ );
1266
+ }
1267
+
1268
+ /* ───────────────────────────── advanced ───────────────────────────── */
1269
+
1270
+ /** The declarations of `css` none of the controls says (as `prop: value` lines). */
1271
+ export function rawDeclarations(css: string): string {
1272
+ const all = declarations(css);
1273
+ const get = (p: string) => all.find(([k]) => k === p)?.[1];
1274
+ return all.filter(([k, v]) => !ownedDeclaration(k, v, get) && !maskOwned(k, v, get)).map(([k, v]) => `${k}: ${v}`).join(';\n');
1275
+ }
1276
+
1277
+ /** What was typed into the raw CSS, as properties to write: those shown before and not typed again go. */
1278
+ export function rawEdit(before: string, typed: string): LookSet {
1279
+ const set: LookSet = {};
1280
+ for (const [k] of declarations(before)) set[k] = null;
1281
+ for (const [k, v] of declarations(typed.replace(/\n/g, ';'))) if (k) set[k] = v;
1282
+ return set;
1283
+ }
1284
+
1285
+ /**
1286
+ * The raw CSS, folded away: what the controls above do not say, as written, one property a line (⌘↵ or leaving
1287
+ * keeps it, Esc gives up). A clip's `class` is here too.
1288
+ */
1289
+ export function AdvancedSection({
1290
+ t, css, onCommit, cls, onClear,
1291
+ }: {
1292
+ t: T;
1293
+ /** The declarations to show (rawDeclarations). */
1294
+ css: string;
1295
+ onCommit: (set: LookSet) => void;
1296
+ /** The element's `class` (a clip's), and how to write it. */
1297
+ cls?: { value: string; onCommit: (next: string | null) => void };
1298
+ /** Takes every change of the person's away (a layer's overrides). */
1299
+ onClear?: () => void;
1300
+ }) {
1301
+ const [draft, setDraft] = React.useState<string | null>(null);
1302
+ const [clsDraft, setClsDraft] = React.useState<string | null>(null);
1303
+ const dropDraft = React.useRef(false);
1304
+ return (
1305
+ <Section
1306
+ id="advanced"
1307
+ title={t('inspector.advanced')}
1308
+ closedByDefault
1309
+ aside={onClear ? (
1310
+ <SectionAction label={t('inspector.clearOverride')} onClick={onClear}><RotateCcw size={11} /></SectionAction>
1311
+ ) : null}
1312
+ >
1313
+ {cls ? (
1314
+ <label className="flex flex-col gap-1">
1315
+ <span className="text-[10.5px] text-[var(--text-muted)]">{t('inspector.classes')}</span>
1316
+ <input
1317
+ type="text"
1318
+ spellCheck={false}
1319
+ value={clsDraft ?? cls.value}
1320
+ placeholder={t('inspector.classesEmpty')}
1321
+ onChange={(e) => setClsDraft(e.target.value)}
1322
+ onBlur={() => {
1323
+ const next = clsDraft?.trim().split(/\s+/).filter(Boolean).join(' ');
1324
+ if (!dropDraft.current && next != null && next !== cls.value) cls.onCommit(next || null);
1325
+ dropDraft.current = false;
1326
+ setClsDraft(null);
1327
+ }}
1328
+ onKeyDown={(e) => {
1329
+ if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
1330
+ if (e.key === 'Escape') { e.preventDefault(); dropDraft.current = true; e.currentTarget.blur(); }
1331
+ }}
1332
+ className="h-[26px] w-full rounded-[5px] bg-[var(--fill-tsp)] px-2 font-mono text-[10.5px] text-[var(--text)] outline-none ring-1 ring-inset ring-transparent placeholder:text-[var(--text-faint)] hover:ring-[var(--border)] focus:bg-[var(--bg)] focus:ring-[1.5px] focus:ring-[var(--accent)]"
1333
+ />
1334
+ </label>
1335
+ ) : null}
1336
+ <span className="text-[10.5px] text-[var(--text-muted)]">{t('inspector.clipCss')}</span>
1337
+ <textarea
1338
+ aria-label={t('inspector.clipCss')}
1339
+ spellCheck={false}
1340
+ placeholder={t('inspector.clipCssEmpty')}
1341
+ value={draft ?? css}
1342
+ onChange={(e) => setDraft(e.target.value)}
1343
+ onBlur={() => {
1344
+ if (!dropDraft.current && draft != null && draft.trim() !== css.trim()) onCommit(rawEdit(css, draft));
1345
+ dropDraft.current = false;
1346
+ setDraft(null);
1347
+ }}
1348
+ onKeyDown={(e) => {
1349
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.currentTarget.blur(); }
1350
+ if (e.key === 'Escape') { e.preventDefault(); dropDraft.current = true; e.currentTarget.blur(); }
1351
+ }}
1352
+ rows={Math.min(8, Math.max(2, (draft ?? css).split('\n').length))}
1353
+ className="block w-full resize-y rounded-[5px] bg-[var(--fill-tsp)] px-2 py-1.5 font-mono text-[10.5px] leading-[1.5] text-[var(--text)] outline-none ring-1 ring-inset ring-transparent placeholder:text-[var(--text-faint)] hover:ring-[var(--border)] focus:bg-[var(--bg)] focus:ring-[1.5px] focus:ring-[var(--accent)]"
1354
+ />
1355
+ </Section>
1356
+ );
1357
+ }