Map1en commited on
Commit
759cc6f
·
unverified ·
1 Parent(s): d9d5954

chore: replace prettier with oxfmt and apply formatting (#472)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +18 -0
  2. .oxfmtrc.json +22 -0
  3. .prettierrc +0 -6
  4. .vscode/extensions.json +9 -0
  5. bun.lock +43 -4
  6. package.json +11 -11
  7. ui/.oxlintrc.json +2 -14
  8. ui/app/(app)/layout.tsx +1 -1
  9. ui/app/(app)/page.tsx +8 -12
  10. ui/app/global-error.tsx +1 -5
  11. ui/app/globals.css +2 -2
  12. ui/app/layout.tsx +2 -6
  13. ui/app/providers.tsx +4 -3
  14. ui/components/ActivityBubble.tsx +24 -46
  15. ui/components/AppErrorBoundary.tsx +6 -8
  16. ui/components/AppInitializationSkeleton.tsx +8 -13
  17. ui/components/Canvas.tsx +1 -4
  18. ui/components/Image.tsx +3 -9
  19. ui/components/MenuBar.tsx +27 -65
  20. ui/components/Navigator.tsx +22 -43
  21. ui/components/PageManagerDialog.tsx +19 -48
  22. ui/components/Panels.tsx +9 -19
  23. ui/components/SettingsDialog.tsx +80 -173
  24. ui/components/Updater.tsx +18 -40
  25. ui/components/canvas/CanvasToolbar.tsx +42 -111
  26. ui/components/canvas/StatusBar.tsx +5 -4
  27. ui/components/canvas/TextBlockLayer.tsx +8 -17
  28. ui/components/canvas/ToolRail.tsx +16 -33
  29. ui/components/canvas/Workspace.tsx +50 -81
  30. ui/components/canvas/zoomGestures.ts +2 -8
  31. ui/components/panels/LayersPanel.tsx +17 -39
  32. ui/components/panels/RenderControlsPanel.tsx +48 -104
  33. ui/components/panels/TextBlocksPanel.tsx +22 -35
  34. ui/components/ui/accordion.tsx +5 -7
  35. ui/components/ui/alert-dialog.tsx +11 -24
  36. ui/components/ui/button.tsx +7 -9
  37. ui/components/ui/color-picker.tsx +5 -9
  38. ui/components/ui/context-menu.tsx +19 -47
  39. ui/components/ui/dialog.tsx +10 -18
  40. ui/components/ui/draft-textarea.tsx +1 -0
  41. ui/components/ui/font-select.tsx +34 -68
  42. ui/components/ui/input.tsx +3 -3
  43. ui/components/ui/label.tsx +2 -5
  44. ui/components/ui/menubar.tsx +20 -44
  45. ui/components/ui/popover.tsx +5 -11
  46. ui/components/ui/progress.tsx +3 -6
  47. ui/components/ui/scroll-area.tsx +5 -7
  48. ui/components/ui/select.tsx +12 -27
  49. ui/components/ui/separator.tsx +2 -2
  50. ui/components/ui/slider.tsx +5 -10
.editorconfig ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+
9
+ [*.{js,jsx,ts,tsx,json,jsonc,css,md,yml,yaml}]
10
+ indent_style = space
11
+ indent_size = 2
12
+
13
+ [*.rs]
14
+ indent_style = space
15
+ indent_size = 4
16
+
17
+ [*.md]
18
+ trim_trailing_whitespace = false
.oxfmtrc.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
3
+ "semi": false,
4
+ "singleQuote": true,
5
+ "jsxSingleQuote": true,
6
+ "sortImports": {
7
+ "internalPattern": ["@/"],
8
+ "newlinesBetween": true,
9
+ "groups": [
10
+ "builtin",
11
+ "external",
12
+ "internal",
13
+ ["parent", "sibling", "index"],
14
+ "style",
15
+ "unknown"
16
+ ]
17
+ },
18
+ "sortTailwindcss": {
19
+ "stylesheet": "ui/app/globals.css",
20
+ "functions": ["cn", "clsx", "cva", "tw"]
21
+ }
22
+ }
.prettierrc DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "plugins": ["prettier-plugin-tailwindcss"],
3
- "semi": false,
4
- "singleQuote": true,
5
- "jsxSingleQuote": true
6
- }
 
 
 
 
 
 
 
.vscode/extensions.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "oxc.oxc-vscode",
4
+ "EditorConfig.EditorConfig",
5
+ "bradlc.vscode-tailwindcss",
6
+ "rust-lang.rust-analyzer"
7
+ ],
8
+ "unwantedRecommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
9
+ }
bun.lock CHANGED
@@ -7,8 +7,7 @@
7
  "@playwright/test": "^1.59.1",
8
  "@tauri-apps/cli": "^2.10.1",
9
  "git-cliff": "^2.12.0",
10
- "prettier": "^3.8.3",
11
- "prettier-plugin-tailwindcss": "^0.7.2",
12
  },
13
  },
14
  "ui": {
@@ -371,6 +370,44 @@
371
 
372
  "@orval/zod": ["@orval/zod@8.8.0", "", { "dependencies": { "@orval/core": "8.8.0", "remeda": "^2.33.6" } }, "sha512-ouKzo7srJXuwsZmNzp8nXkM6lwiYCZoSRYFH1JFAYF77SK7AEoFul8AYObzebqmHIXC4GLUNOsxHT9N0NE8zmQ=="],
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
375
 
376
  "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
@@ -1211,6 +1248,8 @@
1211
 
1212
  "orval": ["orval@8.8.0", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.8.0", "@orval/axios": "8.8.0", "@orval/core": "8.8.0", "@orval/fetch": "8.8.0", "@orval/hono": "8.8.0", "@orval/mcp": "8.8.0", "@orval/mock": "8.8.0", "@orval/query": "8.8.0", "@orval/solid-start": "8.8.0", "@orval/swr": "8.8.0", "@orval/zod": "8.8.0", "@scalar/json-magic": "^0.12.4", "@scalar/openapi-parser": "^0.25.6", "@scalar/openapi-types": "0.6.1", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-jcHcAmXCvC0g+1acsUOv722ICuXCJ0tmRl3+e0kj1lgFVsuGYame+jRZUrNtpkEZgF1RlDtmL91yFsVHv3X1eA=="],
1213
 
 
 
1214
  "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
1215
 
1216
  "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
@@ -1255,8 +1294,6 @@
1255
 
1256
  "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
1257
 
1258
- "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
1259
-
1260
  "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
1261
 
1262
  "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
@@ -1375,6 +1412,8 @@
1375
 
1376
  "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
1377
 
 
 
1378
  "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
1379
 
1380
  "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
 
7
  "@playwright/test": "^1.59.1",
8
  "@tauri-apps/cli": "^2.10.1",
9
  "git-cliff": "^2.12.0",
10
+ "oxfmt": "^0.45.0",
 
11
  },
12
  },
13
  "ui": {
 
370
 
371
  "@orval/zod": ["@orval/zod@8.8.0", "", { "dependencies": { "@orval/core": "8.8.0", "remeda": "^2.33.6" } }, "sha512-ouKzo7srJXuwsZmNzp8nXkM6lwiYCZoSRYFH1JFAYF77SK7AEoFul8AYObzebqmHIXC4GLUNOsxHT9N0NE8zmQ=="],
372
 
373
+ "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.45.0", "", { "os": "android", "cpu": "arm" }, "sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w=="],
374
+
375
+ "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.45.0", "", { "os": "android", "cpu": "arm64" }, "sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg=="],
376
+
377
+ "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.45.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww=="],
378
+
379
+ "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.45.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ=="],
380
+
381
+ "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.45.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg=="],
382
+
383
+ "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ=="],
384
+
385
+ "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w=="],
386
+
387
+ "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A=="],
388
+
389
+ "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g=="],
390
+
391
+ "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.45.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA=="],
392
+
393
+ "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ=="],
394
+
395
+ "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg=="],
396
+
397
+ "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.45.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w=="],
398
+
399
+ "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg=="],
400
+
401
+ "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw=="],
402
+
403
+ "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.45.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg=="],
404
+
405
+ "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.45.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg=="],
406
+
407
+ "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.45.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA=="],
408
+
409
+ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.45.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug=="],
410
+
411
  "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
412
 
413
  "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
 
1248
 
1249
  "orval": ["orval@8.8.0", "", { "dependencies": { "@commander-js/extra-typings": "^14.0.0", "@orval/angular": "8.8.0", "@orval/axios": "8.8.0", "@orval/core": "8.8.0", "@orval/fetch": "8.8.0", "@orval/hono": "8.8.0", "@orval/mcp": "8.8.0", "@orval/mock": "8.8.0", "@orval/query": "8.8.0", "@orval/solid-start": "8.8.0", "@orval/swr": "8.8.0", "@orval/zod": "8.8.0", "@scalar/json-magic": "^0.12.4", "@scalar/openapi-parser": "^0.25.6", "@scalar/openapi-types": "0.6.1", "chokidar": "^5.0.0", "commander": "^14.0.2", "enquirer": "^2.4.1", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "js-yaml": "4.1.1", "remeda": "^2.33.6", "string-argv": "^0.3.2", "tsconfck": "^3.1.6", "typedoc": "^0.28.17", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-jcHcAmXCvC0g+1acsUOv722ICuXCJ0tmRl3+e0kj1lgFVsuGYame+jRZUrNtpkEZgF1RlDtmL91yFsVHv3X1eA=="],
1250
 
1251
+ "oxfmt": ["oxfmt@0.45.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.45.0", "@oxfmt/binding-android-arm64": "0.45.0", "@oxfmt/binding-darwin-arm64": "0.45.0", "@oxfmt/binding-darwin-x64": "0.45.0", "@oxfmt/binding-freebsd-x64": "0.45.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", "@oxfmt/binding-linux-arm64-gnu": "0.45.0", "@oxfmt/binding-linux-arm64-musl": "0.45.0", "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-musl": "0.45.0", "@oxfmt/binding-linux-s390x-gnu": "0.45.0", "@oxfmt/binding-linux-x64-gnu": "0.45.0", "@oxfmt/binding-linux-x64-musl": "0.45.0", "@oxfmt/binding-openharmony-arm64": "0.45.0", "@oxfmt/binding-win32-arm64-msvc": "0.45.0", "@oxfmt/binding-win32-ia32-msvc": "0.45.0", "@oxfmt/binding-win32-x64-msvc": "0.45.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg=="],
1252
+
1253
  "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
1254
 
1255
  "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
 
1294
 
1295
  "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
1296
 
 
 
1297
  "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
1298
 
1299
  "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
 
1412
 
1413
  "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
1414
 
1415
+ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
1416
+
1417
  "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
1418
 
1419
  "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
package.json CHANGED
@@ -1,20 +1,20 @@
1
  {
2
- "devDependencies": {
3
- "@playwright/test": "^1.59.1",
4
- "@tauri-apps/cli": "^2.10.1",
5
- "git-cliff": "^2.12.0",
6
- "prettier": "^3.8.3",
7
- "prettier-plugin-tailwindcss": "^0.7.2"
8
- },
9
  "scripts": {
10
  "dev": "bun run scripts/dev.ts tauri dev -- --profile=release-with-debug",
11
  "build": "bun run scripts/dev.ts tauri build --no-bundle",
12
  "cargo": "bun run scripts/dev.ts cargo",
13
  "lint:ui": "bun run --filter ui lint",
14
- "format": "prettier --write ui/ --ignore-path ui/.gitignore",
 
15
  "test:e2e": "playwright test"
16
  },
17
- "workspaces": [
18
- "ui/"
19
- ]
 
 
 
20
  }
 
1
  {
2
+ "workspaces": [
3
+ "ui/"
4
+ ],
 
 
 
 
5
  "scripts": {
6
  "dev": "bun run scripts/dev.ts tauri dev -- --profile=release-with-debug",
7
  "build": "bun run scripts/dev.ts tauri build --no-bundle",
8
  "cargo": "bun run scripts/dev.ts cargo",
9
  "lint:ui": "bun run --filter ui lint",
10
+ "format": "oxfmt ui package.json .oxfmtrc.json .vscode",
11
+ "format:check": "oxfmt --check ui package.json .oxfmtrc.json .vscode",
12
  "test:e2e": "playwright test"
13
  },
14
+ "devDependencies": {
15
+ "@playwright/test": "^1.59.1",
16
+ "@tauri-apps/cli": "^2.10.1",
17
+ "git-cliff": "^2.12.0",
18
+ "oxfmt": "^0.45.0"
19
+ }
20
  }
ui/.oxlintrc.json CHANGED
@@ -1,12 +1,6 @@
1
  {
2
  "$schema": "../node_modules/oxlint/configuration_schema.json",
3
- "plugins": [
4
- "eslint",
5
- "typescript",
6
- "unicorn",
7
- "oxc",
8
- "react"
9
- ],
10
  "categories": {
11
  "correctness": "error"
12
  },
@@ -30,11 +24,5 @@
30
  "typescript/no-this-alias": "warn",
31
  "unicorn/prefer-string-starts-ends-with": "warn"
32
  },
33
- "ignorePatterns": [
34
- ".next/",
35
- "out/",
36
- "dist/",
37
- "build/",
38
- "coverage/"
39
- ]
40
  }
 
1
  {
2
  "$schema": "../node_modules/oxlint/configuration_schema.json",
3
+ "plugins": ["eslint", "typescript", "unicorn", "oxc", "react"],
 
 
 
 
 
 
4
  "categories": {
5
  "correctness": "error"
6
  },
 
24
  "typescript/no-this-alias": "warn",
25
  "unicorn/prefer-string-starts-ends-with": "warn"
26
  },
27
+ "ignorePatterns": [".next/", "out/", "dist/", "build/", "coverage/"]
 
 
 
 
 
 
28
  }
ui/app/(app)/layout.tsx CHANGED
@@ -4,7 +4,7 @@ import { MenuBar } from '@/components/MenuBar'
4
 
5
  export default function AppLayout({ children }: { children: React.ReactNode }) {
6
  return (
7
- <div className='bg-background flex h-screen w-screen flex-col overflow-hidden'>
8
  <MenuBar />
9
  {children}
10
  </div>
 
4
 
5
  export default function AppLayout({ children }: { children: React.ReactNode }) {
6
  return (
7
+ <div className='flex h-screen w-screen flex-col overflow-hidden bg-background'>
8
  <MenuBar />
9
  {children}
10
  </div>
ui/app/(app)/page.tsx CHANGED
@@ -1,18 +1,14 @@
1
  'use client'
2
 
3
- import { Panels } from '@/components/Panels'
4
- import { Workspace, StatusBar } from '@/components/Canvas'
5
- import { Navigator } from '@/components/Navigator'
6
  import { ActivityBubble } from '@/components/ActivityBubble'
 
7
  import { AppInitializationSkeleton } from '@/components/AppInitializationSkeleton'
 
 
 
8
  import { useGetMeta } from '@/lib/api/system/system'
9
- import {
10
- Group,
11
- Panel,
12
- Separator,
13
- useDefaultLayout,
14
- } from 'react-resizable-panels'
15
- import { AppErrorBoundary } from '@/components/AppErrorBoundary'
16
 
17
  const LAYOUT_ID = 'koharu-main-layout-v2'
18
 
@@ -46,7 +42,7 @@ export default function Page() {
46
  <Panel id='left' defaultSize={180} minSize={120} maxSize={300}>
47
  <Navigator />
48
  </Panel>
49
- <Separator className='bg-border/40 hover:bg-border w-1 transition-colors' />
50
  <Panel id='center' minSize={480}>
51
  <AppErrorBoundary>
52
  <div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
@@ -55,7 +51,7 @@ export default function Page() {
55
  </div>
56
  </AppErrorBoundary>
57
  </Panel>
58
- <Separator className='bg-border/40 hover:bg-border w-1 transition-colors' />
59
  <Panel id='right' defaultSize={280} minSize={260} maxSize={400}>
60
  <AppErrorBoundary>
61
  <Panels />
 
1
  'use client'
2
 
3
+ import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels'
4
+
 
5
  import { ActivityBubble } from '@/components/ActivityBubble'
6
+ import { AppErrorBoundary } from '@/components/AppErrorBoundary'
7
  import { AppInitializationSkeleton } from '@/components/AppInitializationSkeleton'
8
+ import { Workspace, StatusBar } from '@/components/Canvas'
9
+ import { Navigator } from '@/components/Navigator'
10
+ import { Panels } from '@/components/Panels'
11
  import { useGetMeta } from '@/lib/api/system/system'
 
 
 
 
 
 
 
12
 
13
  const LAYOUT_ID = 'koharu-main-layout-v2'
14
 
 
42
  <Panel id='left' defaultSize={180} minSize={120} maxSize={300}>
43
  <Navigator />
44
  </Panel>
45
+ <Separator className='w-1 bg-border/40 transition-colors hover:bg-border' />
46
  <Panel id='center' minSize={480}>
47
  <AppErrorBoundary>
48
  <div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
 
51
  </div>
52
  </AppErrorBoundary>
53
  </Panel>
54
+ <Separator className='w-1 bg-border/40 transition-colors hover:bg-border' />
55
  <Panel id='right' defaultSize={280} minSize={260} maxSize={400}>
56
  <AppErrorBoundary>
57
  <Panels />
ui/app/global-error.tsx CHANGED
@@ -4,11 +4,7 @@ import * as Sentry from '@sentry/nextjs'
4
  import NextError from 'next/error'
5
  import { useEffect } from 'react'
6
 
7
- export default function GlobalError({
8
- error,
9
- }: {
10
- error: Error & { digest?: string }
11
- }) {
12
  useEffect(() => {
13
  Sentry.captureException(error)
14
  }, [error])
 
4
  import NextError from 'next/error'
5
  import { useEffect } from 'react'
6
 
7
+ export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
 
 
 
 
8
  useEffect(() => {
9
  Sentry.captureException(error)
10
  }, [error])
ui/app/globals.css CHANGED
@@ -137,8 +137,8 @@
137
  body {
138
  @apply bg-background text-foreground select-none;
139
  font-family:
140
- var(--font-inter), var(--font-noto-sc), var(--font-noto-tc),
141
- var(--font-noto-jp), ui-sans-serif, system-ui, sans-serif;
142
  }
143
  input,
144
  textarea {
 
137
  body {
138
  @apply bg-background text-foreground select-none;
139
  font-family:
140
+ var(--font-inter), var(--font-noto-sc), var(--font-noto-tc), var(--font-noto-jp),
141
+ ui-sans-serif, system-ui, sans-serif;
142
  }
143
  input,
144
  textarea {
ui/app/layout.tsx CHANGED
@@ -1,10 +1,6 @@
1
  import type { Metadata } from 'next'
2
- import {
3
- Inter,
4
- Noto_Sans_JP,
5
- Noto_Sans_SC,
6
- Noto_Sans_TC,
7
- } from 'next/font/google'
8
  import './globals.css'
9
  import Providers from '@/app/providers'
10
 
 
1
  import type { Metadata } from 'next'
2
+ import { Inter, Noto_Sans_JP, Noto_Sans_SC, Noto_Sans_TC } from 'next/font/google'
3
+
 
 
 
 
4
  import './globals.css'
5
  import Providers from '@/app/providers'
6
 
ui/app/providers.tsx CHANGED
@@ -1,12 +1,13 @@
1
  'use client'
2
 
 
 
3
  import { useEffect, type ReactNode } from 'react'
4
  import { I18nextProvider } from 'react-i18next'
5
- import { ThemeProvider } from 'next-themes'
6
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
  import ClientOnly from '@/components/ClientOnly'
8
- import { UpdaterProvider } from '@/components/Updater'
9
  import { TooltipProvider } from '@/components/ui/tooltip'
 
10
  import i18n from '@/lib/i18n'
11
  import { ProcessingProvider } from '@/lib/machines'
12
 
 
1
  'use client'
2
 
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { ThemeProvider } from 'next-themes'
5
  import { useEffect, type ReactNode } from 'react'
6
  import { I18nextProvider } from 'react-i18next'
7
+
 
8
  import ClientOnly from '@/components/ClientOnly'
 
9
  import { TooltipProvider } from '@/components/ui/tooltip'
10
+ import { UpdaterProvider } from '@/components/Updater'
11
  import i18n from '@/lib/i18n'
12
  import { ProcessingProvider } from '@/lib/machines'
13
 
ui/components/ActivityBubble.tsx CHANGED
@@ -1,12 +1,13 @@
1
  'use client'
2
 
 
3
  import { type ReactNode } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
- import { CircleXIcon } from 'lucide-react'
6
- import { useListDownloads } from '@/lib/api/downloads/downloads'
7
  import { Button } from '@/components/ui/button'
8
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
9
  import { useProcessing } from '@/lib/machines'
 
10
 
11
  type TranslateFunc = ReturnType<typeof useTranslation>['t']
12
 
@@ -17,7 +18,7 @@ const clampProgress = (value?: number) => {
17
 
18
  function BubbleCard({ children }: { children: ReactNode }) {
19
  return (
20
- <div className='border-border bg-card/95 rounded-2xl border p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur'>
21
  {children}
22
  </div>
23
  )
@@ -26,18 +27,18 @@ function BubbleCard({ children }: { children: ReactNode }) {
26
  function ProgressBar({ percent }: { percent?: number }) {
27
  return (
28
  <div className='mt-3 flex items-center gap-2'>
29
- <div className='bg-muted relative h-1.5 flex-1 overflow-hidden rounded-full'>
30
  {typeof percent === 'number' ? (
31
  <div
32
- className='bg-primary h-full rounded-full transition-[width] duration-700 ease-out'
33
  style={{ width: `${percent}%` }}
34
  />
35
  ) : (
36
- <div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
37
  )}
38
  </div>
39
  {typeof percent === 'number' && (
40
- <span className='text-muted-foreground w-12 text-right text-[11px] font-semibold tabular-nums'>
41
  {percent}%
42
  </span>
43
  )}
@@ -57,14 +58,10 @@ function DownloadCard({
57
  return (
58
  <BubbleCard>
59
  <div className='flex items-start gap-3'>
60
- <div className='bg-primary mt-1 h-2.5 w-2.5 animate-pulse rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
61
  <div className='flex-1'>
62
- <div className='text-foreground text-sm font-semibold'>
63
- {t('download.title')}
64
- </div>
65
- <div className='text-muted-foreground truncate text-xs'>
66
- {filename}
67
- </div>
68
  <ProgressBar percent={percent} />
69
  </div>
70
  </div>
@@ -82,7 +79,7 @@ function ErrorCard({
82
  t: TranslateFunc
83
  }) {
84
  return (
85
- <div className='bg-card/95 rounded-2xl border border-red-200/80 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-red-900/80'>
86
  <div className='flex items-start gap-3'>
87
  <div className='mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-950/70 dark:text-red-400'>
88
  <CircleXIcon className='size-4' />
@@ -140,14 +137,9 @@ function OperationCard({
140
  }) {
141
  const ctx = machineState.context
142
  const hasProgressNumbers = ctx.total > 0
143
- const progress = clampProgress(
144
- hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined,
145
- )
146
  const displayCurrent = hasProgressNumbers
147
- ? Math.min(
148
- ctx.total,
149
- Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1),
150
- )
151
  : undefined
152
  const total = hasProgressNumbers ? ctx.total : undefined
153
 
@@ -182,27 +174,24 @@ function OperationCard({
182
  const subtitleParts = isPipelineAll
183
  ? [stepLabel]
184
  : [imageText, stepText ?? stepLabel].filter(Boolean)
185
- const subtitle =
186
- subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
187
 
188
  const title = getOperationTitle(machineState, t)
189
 
190
  return (
191
  <BubbleCard>
192
  <div data-testid='operation-card' className='flex items-start gap-3'>
193
- <div className='bg-primary mt-1 h-2.5 w-2.5 rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
194
  <div className='flex-1'>
195
  <div className='flex items-start justify-between gap-2'>
196
  <div className='flex flex-col gap-1'>
197
- <div className='text-foreground text-sm font-semibold'>
198
- {title}
199
- </div>
200
- <div className='text-muted-foreground text-xs'>
201
  {subtitle || t('operations.inProgress')}
202
  </div>
203
  </div>
204
  {isPipelineAll && total && typeof displayCurrent === 'number' ? (
205
- <span className='bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[11px] font-medium'>
206
  {t('operations.imageProgress', {
207
  current: displayCurrent,
208
  total,
@@ -248,22 +237,16 @@ export function ActivityBubble() {
248
  .filter((d) => d.status === 'started' || d.status === 'downloading')
249
  .map((d) => ({
250
  ...d,
251
- percent:
252
- d.total && d.total > 0
253
- ? Math.round((d.downloaded / d.total) * 100)
254
- : undefined,
255
  }))
256
 
257
  const errorMessage = uiError?.message
258
 
259
- if (!errorMessage && !isProcessing && activeDownloads.length === 0)
260
- return null
261
 
262
  return (
263
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
264
- {errorMessage && (
265
- <ErrorCard message={errorMessage} onDismiss={clearUiError} t={t} />
266
- )}
267
  {isProcessing && (
268
  <OperationCard
269
  machineState={machineState}
@@ -273,12 +256,7 @@ export function ActivityBubble() {
273
  />
274
  )}
275
  {activeDownloads.map((d) => (
276
- <DownloadCard
277
- key={d.filename}
278
- filename={d.filename}
279
- percent={d.percent}
280
- t={t}
281
- />
282
  ))}
283
  </div>
284
  )
 
1
  'use client'
2
 
3
+ import { CircleXIcon } from 'lucide-react'
4
  import { type ReactNode } from 'react'
5
  import { useTranslation } from 'react-i18next'
6
+
 
7
  import { Button } from '@/components/ui/button'
8
+ import { useListDownloads } from '@/lib/api/downloads/downloads'
9
  import { useProcessing } from '@/lib/machines'
10
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
11
 
12
  type TranslateFunc = ReturnType<typeof useTranslation>['t']
13
 
 
18
 
19
  function BubbleCard({ children }: { children: ReactNode }) {
20
  return (
21
+ <div className='rounded-2xl border border-border bg-card/95 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur'>
22
  {children}
23
  </div>
24
  )
 
27
  function ProgressBar({ percent }: { percent?: number }) {
28
  return (
29
  <div className='mt-3 flex items-center gap-2'>
30
+ <div className='relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted'>
31
  {typeof percent === 'number' ? (
32
  <div
33
+ className='h-full rounded-full bg-primary transition-[width] duration-700 ease-out'
34
  style={{ width: `${percent}%` }}
35
  />
36
  ) : (
37
+ <div className='activity-progress-indeterminate absolute inset-0 w-1/2 rounded-full bg-linear-to-r from-primary/40 via-primary to-primary/40' />
38
  )}
39
  </div>
40
  {typeof percent === 'number' && (
41
+ <span className='w-12 text-right text-[11px] font-semibold text-muted-foreground tabular-nums'>
42
  {percent}%
43
  </span>
44
  )}
 
58
  return (
59
  <BubbleCard>
60
  <div className='flex items-start gap-3'>
61
+ <div className='mt-1 h-2.5 w-2.5 animate-pulse rounded-full bg-primary shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
62
  <div className='flex-1'>
63
+ <div className='text-sm font-semibold text-foreground'>{t('download.title')}</div>
64
+ <div className='truncate text-xs text-muted-foreground'>{filename}</div>
 
 
 
 
65
  <ProgressBar percent={percent} />
66
  </div>
67
  </div>
 
79
  t: TranslateFunc
80
  }) {
81
  return (
82
+ <div className='rounded-2xl border border-red-200/80 bg-card/95 p-4 shadow-[0_15px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-red-900/80'>
83
  <div className='flex items-start gap-3'>
84
  <div className='mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-950/70 dark:text-red-400'>
85
  <CircleXIcon className='size-4' />
 
137
  }) {
138
  const ctx = machineState.context
139
  const hasProgressNumbers = ctx.total > 0
140
+ const progress = clampProgress(hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined)
 
 
141
  const displayCurrent = hasProgressNumbers
142
+ ? Math.min(ctx.total, Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1))
 
 
 
143
  : undefined
144
  const total = hasProgressNumbers ? ctx.total : undefined
145
 
 
174
  const subtitleParts = isPipelineAll
175
  ? [stepLabel]
176
  : [imageText, stepText ?? stepLabel].filter(Boolean)
177
+ const subtitle = subtitleParts.filter(Boolean).join(' \u00b7 ') || t('operations.inProgress')
 
178
 
179
  const title = getOperationTitle(machineState, t)
180
 
181
  return (
182
  <BubbleCard>
183
  <div data-testid='operation-card' className='flex items-start gap-3'>
184
+ <div className='mt-1 h-2.5 w-2.5 rounded-full bg-primary shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
185
  <div className='flex-1'>
186
  <div className='flex items-start justify-between gap-2'>
187
  <div className='flex flex-col gap-1'>
188
+ <div className='text-sm font-semibold text-foreground'>{title}</div>
189
+ <div className='text-xs text-muted-foreground'>
 
 
190
  {subtitle || t('operations.inProgress')}
191
  </div>
192
  </div>
193
  {isPipelineAll && total && typeof displayCurrent === 'number' ? (
194
+ <span className='rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground'>
195
  {t('operations.imageProgress', {
196
  current: displayCurrent,
197
  total,
 
237
  .filter((d) => d.status === 'started' || d.status === 'downloading')
238
  .map((d) => ({
239
  ...d,
240
+ percent: d.total && d.total > 0 ? Math.round((d.downloaded / d.total) * 100) : undefined,
 
 
 
241
  }))
242
 
243
  const errorMessage = uiError?.message
244
 
245
+ if (!errorMessage && !isProcessing && activeDownloads.length === 0) return null
 
246
 
247
  return (
248
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
249
+ {errorMessage && <ErrorCard message={errorMessage} onDismiss={clearUiError} t={t} />}
 
 
250
  {isProcessing && (
251
  <OperationCard
252
  machineState={machineState}
 
256
  />
257
  )}
258
  {activeDownloads.map((d) => (
259
+ <DownloadCard key={d.filename} filename={d.filename} percent={d.percent} t={t} />
 
 
 
 
 
260
  ))}
261
  </div>
262
  )
ui/components/AppErrorBoundary.tsx CHANGED
@@ -2,22 +2,20 @@
2
 
3
  import * as Sentry from '@sentry/nextjs'
4
  import { useQueryClient } from '@tanstack/react-query'
5
- import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
6
  import { type ReactNode } from 'react'
 
 
7
  import { Button } from '@/components/ui/button'
8
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
9
 
10
  function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
11
  const queryClient = useQueryClient()
12
- const errorMessage =
13
- error instanceof Error ? error.message : 'Unexpected error'
14
 
15
  return (
16
- <div className='bg-muted/40 flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 p-4 text-center'>
17
- <p className='text-foreground text-sm font-semibold'>
18
- Something went wrong.
19
- </p>
20
- <p className='text-muted-foreground max-w-md text-xs'>{errorMessage}</p>
21
  <div className='flex flex-wrap items-center justify-center gap-2'>
22
  <Button size='sm' variant='outline' onClick={resetErrorBoundary}>
23
  Retry
 
2
 
3
  import * as Sentry from '@sentry/nextjs'
4
  import { useQueryClient } from '@tanstack/react-query'
 
5
  import { type ReactNode } from 'react'
6
+ import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
7
+
8
  import { Button } from '@/components/ui/button'
9
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
 
11
  function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
12
  const queryClient = useQueryClient()
13
+ const errorMessage = error instanceof Error ? error.message : 'Unexpected error'
 
14
 
15
  return (
16
+ <div className='flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 bg-muted/40 p-4 text-center'>
17
+ <p className='text-sm font-semibold text-foreground'>Something went wrong.</p>
18
+ <p className='max-w-md text-xs text-muted-foreground'>{errorMessage}</p>
 
 
19
  <div className='flex flex-wrap items-center justify-center gap-2'>
20
  <Button size='sm' variant='outline' onClick={resetErrorBoundary}>
21
  Retry
ui/components/AppInitializationSkeleton.tsx CHANGED
@@ -2,8 +2,9 @@
2
 
3
  import { useMemo } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
- import { useListDownloads } from '@/lib/api/downloads/downloads'
6
  import { Progress } from '@/components/ui/progress'
 
7
  import type { DownloadState } from '@/lib/api/schemas'
8
 
9
  const summarizeDownloads = (downloads?: DownloadState[] | null) => {
@@ -14,15 +15,11 @@ const summarizeDownloads = (downloads?: DownloadState[] | null) => {
14
  for (const d of downloads) {
15
  total += d.total ?? 0
16
  downloaded += d.downloaded
17
- if (d.status === 'started' || d.status === 'downloading')
18
- active = d.filename
19
  }
20
  return {
21
  filename: active,
22
- percent:
23
- total > 0
24
- ? Math.min(100, Math.round((downloaded / total) * 100))
25
- : undefined,
26
  }
27
  }
28
 
@@ -35,7 +32,7 @@ export function AppInitializationSkeleton() {
35
  const progress = useMemo(() => summarizeDownloads(downloads), [downloads])
36
 
37
  return (
38
- <div className='bg-background flex min-h-0 flex-1 items-center justify-center'>
39
  <div className='flex flex-col items-center gap-6'>
40
  <img
41
  src='/icon-large.png'
@@ -45,16 +42,14 @@ export function AppInitializationSkeleton() {
45
  />
46
 
47
  <div className='flex flex-col items-center gap-1'>
48
- <h1 className='text-foreground text-lg font-semibold tracking-widest uppercase'>
49
  Koharu
50
  </h1>
51
- <p className='text-muted-foreground text-xs'>
52
- {t('common.initializing')}
53
- </p>
54
  </div>
55
 
56
  <div className='w-56'>
57
- <p className='text-muted-foreground mb-1.5 h-4 truncate text-center text-[11px]'>
58
  {progress?.filename ?? '\u00A0'}
59
  </p>
60
  <Progress
 
2
 
3
  import { useMemo } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+
6
  import { Progress } from '@/components/ui/progress'
7
+ import { useListDownloads } from '@/lib/api/downloads/downloads'
8
  import type { DownloadState } from '@/lib/api/schemas'
9
 
10
  const summarizeDownloads = (downloads?: DownloadState[] | null) => {
 
15
  for (const d of downloads) {
16
  total += d.total ?? 0
17
  downloaded += d.downloaded
18
+ if (d.status === 'started' || d.status === 'downloading') active = d.filename
 
19
  }
20
  return {
21
  filename: active,
22
+ percent: total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : undefined,
 
 
 
23
  }
24
  }
25
 
 
32
  const progress = useMemo(() => summarizeDownloads(downloads), [downloads])
33
 
34
  return (
35
+ <div className='flex min-h-0 flex-1 items-center justify-center bg-background'>
36
  <div className='flex flex-col items-center gap-6'>
37
  <img
38
  src='/icon-large.png'
 
42
  />
43
 
44
  <div className='flex flex-col items-center gap-1'>
45
+ <h1 className='text-lg font-semibold tracking-widest text-foreground uppercase'>
46
  Koharu
47
  </h1>
48
+ <p className='text-xs text-muted-foreground'>{t('common.initializing')}</p>
 
 
49
  </div>
50
 
51
  <div className='w-56'>
52
+ <p className='mb-1.5 h-4 truncate text-center text-[11px] text-muted-foreground'>
53
  {progress?.filename ?? '\u00A0'}
54
  </p>
55
  <Progress
ui/components/Canvas.tsx CHANGED
@@ -2,7 +2,4 @@
2
 
3
  export { Workspace } from '@/components/canvas/Workspace'
4
  export { StatusBar } from '@/components/canvas/StatusBar'
5
- export {
6
- fitCanvasToViewport,
7
- resetCanvasScale,
8
- } from '@/components/canvas/canvasViewport'
 
2
 
3
  export { Workspace } from '@/components/canvas/Workspace'
4
  export { StatusBar } from '@/components/canvas/StatusBar'
5
+ export { fitCanvasToViewport, resetCanvasScale } from '@/components/canvas/canvasViewport'
 
 
 
ui/components/Image.tsx CHANGED
@@ -2,11 +2,8 @@
2
 
3
  import type { CSSProperties } from 'react'
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
- import {
6
- cancelObjectUrlRevoke,
7
- convertToBlob,
8
- revokeObjectUrlLater,
9
- } from '@/lib/util'
10
 
11
  type ImageProps = {
12
  data?: Uint8Array
@@ -213,10 +210,7 @@ export function Image({
213
  style={{
214
  ...baseStyle,
215
  opacity: nextSrc ? (crossfade ? 0 : opacity) : opacity,
216
- transition:
217
- nextSrc && crossfade
218
- ? `opacity ${FADE_DURATION_MS}ms ease`
219
- : undefined,
220
  }}
221
  />
222
  )}
 
2
 
3
  import type { CSSProperties } from 'react'
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
+
6
+ import { cancelObjectUrlRevoke, convertToBlob, revokeObjectUrlLater } from '@/lib/util'
 
 
 
7
 
8
  type ImageProps = {
9
  data?: Uint8Array
 
210
  style={{
211
  ...baseStyle,
212
  opacity: nextSrc ? (crossfade ? 0 : opacity) : opacity,
213
+ transition: nextSrc && crossfade ? `opacity ${FADE_DURATION_MS}ms ease` : undefined,
 
 
 
214
  }}
215
  />
216
  )}
ui/components/MenuBar.tsx CHANGED
@@ -1,13 +1,13 @@
1
  'use client'
2
 
3
- import { useCallback, useEffect, useState } from 'react'
4
  import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
5
- import { isTauri, openExternalUrl } from '@/lib/backend'
6
  import { useTranslation } from 'react-i18next'
7
 
 
 
8
  const isMacOS = () =>
9
- typeof navigator !== 'undefined' &&
10
- /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
11
 
12
  const windowControls = {
13
  async close() {
@@ -27,8 +27,10 @@ const windowControls = {
27
  return getCurrentWindow().isMaximized()
28
  },
29
  }
30
- import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
31
  import Image from 'next/image'
 
 
 
32
  import {
33
  Menubar,
34
  MenubarContent,
@@ -37,11 +39,10 @@ import {
37
  MenubarSeparator,
38
  MenubarTrigger,
39
  } from '@/components/ui/menubar'
40
- import { SettingsDialog, type TabId } from '@/components/SettingsDialog'
41
  import { useProcessing } from '@/lib/machines'
42
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
43
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
44
- import type { PipelineJobRequest } from '@/lib/api/schemas'
45
 
46
  type MenuItem = {
47
  label: string
@@ -61,9 +62,7 @@ export function MenuBar() {
61
  const { send } = useProcessing()
62
  const [settingsOpen, setSettingsOpen] = useState(false)
63
  const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
64
- const hasDocument = useEditorUiStore(
65
- (state) => state.currentDocumentId !== null,
66
- )
67
 
68
  const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
69
  const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
@@ -88,26 +87,22 @@ export function MenuBar() {
88
  const fileMenuItems: MenuItem[] = [
89
  {
90
  label: t('menu.openFile'),
91
- onSelect: () =>
92
- send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }),
93
  testId: 'menu-file-open',
94
  },
95
  {
96
  label: t('menu.addFile'),
97
- onSelect: () =>
98
- send({ type: 'START_IMPORT', mode: 'append', source: 'files' }),
99
  testId: 'menu-file-add',
100
  },
101
  {
102
  label: t('menu.openFolder'),
103
- onSelect: () =>
104
- send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }),
105
  testId: 'menu-file-open-folder',
106
  },
107
  {
108
  label: t('menu.addFolder'),
109
- onSelect: () =>
110
- send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }),
111
  testId: 'menu-file-add-folder',
112
  },
113
  {
@@ -180,8 +175,7 @@ export function MenuBar() {
180
  },
181
  {
182
  label: t('menu.processAll'),
183
- onSelect: () =>
184
- send({ type: 'START_PIPELINE', request: buildPipelineRequest() }),
185
  testId: 'menu-process-all',
186
  },
187
  ],
@@ -203,19 +197,13 @@ export function MenuBar() {
203
  const isWindowsTauri = isTauri() && !isMacOS()
204
 
205
  return (
206
- <div className='border-border bg-background text-foreground flex h-8 items-center border-b text-[13px]'>
207
  {/* macOS traffic lights */}
208
  {isNativeMacOS && <MacOSControls />}
209
 
210
  {/* Logo */}
211
  <div className='flex h-full items-center pl-2 select-none'>
212
- <Image
213
- src='/icon.png'
214
- alt='Koharu'
215
- width={18}
216
- height={18}
217
- draggable={false}
218
- />
219
  </div>
220
 
221
  {/* Menu items */}
@@ -223,16 +211,11 @@ export function MenuBar() {
223
  <MenubarMenu>
224
  <MenubarTrigger
225
  data-testid='menu-file-trigger'
226
- className='hover:bg-accent data-[state=open]:bg-accent rounded px-3 py-1.5 font-medium'
227
  >
228
  {t('menu.file')}
229
  </MenubarTrigger>
230
- <MenubarContent
231
- className='min-w-36'
232
- align='start'
233
- sideOffset={5}
234
- alignOffset={-3}
235
- >
236
  {fileMenuItems.map((item) => (
237
  <MenubarItem
238
  key={item.label}
@@ -266,16 +249,11 @@ export function MenuBar() {
266
  <MenubarMenu key={label}>
267
  <MenubarTrigger
268
  data-testid={triggerTestId}
269
- className='hover:bg-accent data-[state=open]:bg-accent rounded px-3 py-1.5 font-medium'
270
  >
271
  {label}
272
  </MenubarTrigger>
273
- <MenubarContent
274
- className='min-w-36'
275
- align='start'
276
- sideOffset={5}
277
- alignOffset={-3}
278
- >
279
  {items.map((item) => (
280
  <MenubarItem
281
  key={item.label}
@@ -297,15 +275,10 @@ export function MenuBar() {
297
  </MenubarMenu>
298
  ))}
299
  <MenubarMenu>
300
- <MenubarTrigger className='hover:bg-accent data-[state=open]:bg-accent rounded px-3 py-1.5 font-medium'>
301
  {t('menu.help')}
302
  </MenubarTrigger>
303
- <MenubarContent
304
- className='min-w-36'
305
- align='start'
306
- sideOffset={5}
307
- alignOffset={-3}
308
- >
309
  {helpMenuItems.map((item) => (
310
  <MenubarItem
311
  key={item.label}
@@ -337,19 +310,12 @@ export function MenuBar() {
337
  </Menubar>
338
 
339
  {/* Draggable region */}
340
- <div
341
- data-tauri-drag-region
342
- className='flex h-full flex-1 items-center justify-center'
343
- />
344
 
345
  {/* Window controls for Windows */}
346
  {isWindowsTauri && <WindowControls />}
347
 
348
- <SettingsDialog
349
- open={settingsOpen}
350
- onOpenChange={setSettingsOpen}
351
- defaultTab={settingsTab}
352
- />
353
  </div>
354
  )
355
  }
@@ -407,7 +373,7 @@ function WindowControls() {
407
  <div className='flex h-full'>
408
  <button
409
  onClick={() => void windowControls.minimize()}
410
- className='hover:bg-accent flex h-full w-11 items-center justify-center'
411
  >
412
  <MinusIcon className='size-4' />
413
  </button>
@@ -415,13 +381,9 @@ function WindowControls() {
415
  onClick={() => {
416
  void windowControls.toggleMaximize().then(updateMaximized)
417
  }}
418
- className='hover:bg-accent flex h-full w-11 items-center justify-center'
419
  >
420
- {maximized ? (
421
- <CopyIcon className='size-3.5' />
422
- ) : (
423
- <SquareIcon className='size-3.5' />
424
- )}
425
  </button>
426
  <button
427
  onClick={() => void windowControls.close()}
 
1
  'use client'
2
 
 
3
  import { MinusIcon, SquareIcon, XIcon, CopyIcon } from 'lucide-react'
4
+ import { useCallback, useEffect, useState } from 'react'
5
  import { useTranslation } from 'react-i18next'
6
 
7
+ import { isTauri, openExternalUrl } from '@/lib/backend'
8
+
9
  const isMacOS = () =>
10
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
 
11
 
12
  const windowControls = {
13
  async close() {
 
27
  return getCurrentWindow().isMaximized()
28
  },
29
  }
 
30
  import Image from 'next/image'
31
+
32
+ import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas'
33
+ import { SettingsDialog, type TabId } from '@/components/SettingsDialog'
34
  import {
35
  Menubar,
36
  MenubarContent,
 
39
  MenubarSeparator,
40
  MenubarTrigger,
41
  } from '@/components/ui/menubar'
42
+ import type { PipelineJobRequest } from '@/lib/api/schemas'
43
  import { useProcessing } from '@/lib/machines'
44
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
45
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
46
 
47
  type MenuItem = {
48
  label: string
 
62
  const { send } = useProcessing()
63
  const [settingsOpen, setSettingsOpen] = useState(false)
64
  const [settingsTab, setSettingsTab] = useState<TabId>('appearance')
65
+ const hasDocument = useEditorUiStore((state) => state.currentDocumentId !== null)
 
 
66
 
67
  const buildPipelineRequest = (documentId?: string): PipelineJobRequest => {
68
  const { selectedTarget, selectedLanguage, renderEffect, renderStroke } =
 
87
  const fileMenuItems: MenuItem[] = [
88
  {
89
  label: t('menu.openFile'),
90
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }),
 
91
  testId: 'menu-file-open',
92
  },
93
  {
94
  label: t('menu.addFile'),
95
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'files' }),
 
96
  testId: 'menu-file-add',
97
  },
98
  {
99
  label: t('menu.openFolder'),
100
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }),
 
101
  testId: 'menu-file-open-folder',
102
  },
103
  {
104
  label: t('menu.addFolder'),
105
+ onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }),
 
106
  testId: 'menu-file-add-folder',
107
  },
108
  {
 
175
  },
176
  {
177
  label: t('menu.processAll'),
178
+ onSelect: () => send({ type: 'START_PIPELINE', request: buildPipelineRequest() }),
 
179
  testId: 'menu-process-all',
180
  },
181
  ],
 
197
  const isWindowsTauri = isTauri() && !isMacOS()
198
 
199
  return (
200
+ <div className='flex h-8 items-center border-b border-border bg-background text-[13px] text-foreground'>
201
  {/* macOS traffic lights */}
202
  {isNativeMacOS && <MacOSControls />}
203
 
204
  {/* Logo */}
205
  <div className='flex h-full items-center pl-2 select-none'>
206
+ <Image src='/icon.png' alt='Koharu' width={18} height={18} draggable={false} />
 
 
 
 
 
 
207
  </div>
208
 
209
  {/* Menu items */}
 
211
  <MenubarMenu>
212
  <MenubarTrigger
213
  data-testid='menu-file-trigger'
214
+ className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'
215
  >
216
  {t('menu.file')}
217
  </MenubarTrigger>
218
+ <MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
 
 
 
 
 
219
  {fileMenuItems.map((item) => (
220
  <MenubarItem
221
  key={item.label}
 
249
  <MenubarMenu key={label}>
250
  <MenubarTrigger
251
  data-testid={triggerTestId}
252
+ className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'
253
  >
254
  {label}
255
  </MenubarTrigger>
256
+ <MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
 
 
 
 
 
257
  {items.map((item) => (
258
  <MenubarItem
259
  key={item.label}
 
275
  </MenubarMenu>
276
  ))}
277
  <MenubarMenu>
278
+ <MenubarTrigger className='rounded px-3 py-1.5 font-medium hover:bg-accent data-[state=open]:bg-accent'>
279
  {t('menu.help')}
280
  </MenubarTrigger>
281
+ <MenubarContent className='min-w-36' align='start' sideOffset={5} alignOffset={-3}>
 
 
 
 
 
282
  {helpMenuItems.map((item) => (
283
  <MenubarItem
284
  key={item.label}
 
310
  </Menubar>
311
 
312
  {/* Draggable region */}
313
+ <div data-tauri-drag-region className='flex h-full flex-1 items-center justify-center' />
 
 
 
314
 
315
  {/* Window controls for Windows */}
316
  {isWindowsTauri && <WindowControls />}
317
 
318
+ <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab={settingsTab} />
 
 
 
 
319
  </div>
320
  )
321
  }
 
373
  <div className='flex h-full'>
374
  <button
375
  onClick={() => void windowControls.minimize()}
376
+ className='flex h-full w-11 items-center justify-center hover:bg-accent'
377
  >
378
  <MinusIcon className='size-4' />
379
  </button>
 
381
  onClick={() => {
382
  void windowControls.toggleMaximize().then(updateMaximized)
383
  }}
384
+ className='flex h-full w-11 items-center justify-center hover:bg-accent'
385
  >
386
+ {maximized ? <CopyIcon className='size-3.5' /> : <SquareIcon className='size-3.5' />}
 
 
 
 
387
  </button>
388
  <button
389
  onClick={() => void windowControls.close()}
ui/components/Navigator.tsx CHANGED
@@ -1,22 +1,18 @@
1
  'use client'
2
 
3
- import { useRef, useState } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { LayoutGridIcon } from 'lucide-react'
 
6
  import { useTranslation } from 'react-i18next'
7
- import {
8
- useListDocuments,
9
- getGetDocumentThumbnailUrl,
10
- } from '@/lib/api/documents/documents'
11
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
12
- import { Button } from '@/components/ui/button'
13
  import { PageManagerDialog } from '@/components/PageManagerDialog'
 
14
  import { ScrollArea } from '@/components/ui/scroll-area'
 
 
15
 
16
  const THUMBNAIL_DPR =
17
- typeof window !== 'undefined'
18
- ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3)
19
- : 2
20
 
21
  // Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding
22
  const ROW_HEIGHT = 230
@@ -26,12 +22,8 @@ export function Navigator() {
26
  const { data: documents = [] } = useListDocuments()
27
  const totalPages = documents.length
28
  const currentDocumentId = useEditorUiStore((state) => state.currentDocumentId)
29
- const setCurrentDocumentId = useEditorUiStore(
30
- (state) => state.setCurrentDocumentId,
31
- )
32
- const currentDocumentIndex = documents.findIndex(
33
- (d) => d.id === currentDocumentId,
34
- )
35
  const viewportRef = useRef<HTMLDivElement | null>(null)
36
  const { t } = useTranslation()
37
  const [pageManagerOpen, setPageManagerOpen] = useState(false)
@@ -47,17 +39,15 @@ export function Navigator() {
47
  <div
48
  data-testid='navigator-panel'
49
  data-total-pages={totalPages}
50
- className='bg-muted/50 flex h-full min-h-0 w-full flex-col border-r'
51
  >
52
- <div className='border-border flex items-center justify-between border-b px-2 py-1.5'>
53
  <div>
54
- <p className='text-muted-foreground text-xs tracking-wide uppercase'>
55
  {t('navigator.title')}
56
  </p>
57
- <p className='text-foreground text-xs font-semibold'>
58
- {totalPages
59
- ? t('navigator.pages', { count: totalPages })
60
- : t('navigator.empty')}
61
  </p>
62
  </div>
63
  {totalPages > 1 && (
@@ -74,9 +64,9 @@ export function Navigator() {
74
  )}
75
  </div>
76
 
77
- <div className='text-muted-foreground flex items-center gap-1.5 px-2 py-1.5 text-xs'>
78
  {totalPages > 0 ? (
79
- <span className='bg-secondary text-secondary-foreground px-2 py-0.5 font-mono text-[10px]'>
80
  #{currentDocumentIndex + 1}
81
  </span>
82
  ) : (
@@ -85,10 +75,7 @@ export function Navigator() {
85
  </div>
86
 
87
  <ScrollArea className='min-h-0 flex-1' viewportRef={viewportRef}>
88
- <div
89
- className='relative w-full'
90
- style={{ height: virtualizer.getTotalSize() }}
91
- >
92
  {virtualizer.getVirtualItems().map((virtualRow) => {
93
  const doc = documents[virtualRow.index]
94
  return (
@@ -113,10 +100,7 @@ export function Navigator() {
113
  </div>
114
  </ScrollArea>
115
 
116
- <PageManagerDialog
117
- open={pageManagerOpen}
118
- onOpenChange={setPageManagerOpen}
119
- />
120
  </div>
121
  )
122
  }
@@ -128,12 +112,7 @@ type PagePreviewProps = {
128
  onSelect: () => void
129
  }
130
 
131
- function PagePreview({
132
- index,
133
- documentId,
134
- selected,
135
- onSelect,
136
- }: PagePreviewProps) {
137
  const src = documentId
138
  ? getGetDocumentThumbnailUrl(documentId, { size: 200 * THUMBNAIL_DPR })
139
  : undefined
@@ -145,7 +124,7 @@ function PagePreview({
145
  data-testid={`navigator-page-${index}`}
146
  data-page-index={index}
147
  data-selected={selected}
148
- className='bg-card data-[selected=true]:border-primary flex h-full w-full flex-col gap-0.5 rounded border border-transparent p-1.5 text-left shadow-sm'
149
  >
150
  <div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded'>
151
  {src ? (
@@ -156,11 +135,11 @@ function PagePreview({
156
  className='max-h-full max-w-full rounded object-contain'
157
  />
158
  ) : (
159
- <div className='bg-muted h-full w-full rounded' />
160
  )}
161
  </div>
162
- <div className='text-muted-foreground flex shrink-0 items-center text-xs'>
163
- <div className='text-foreground mx-auto font-semibold'>{index + 1}</div>
164
  </div>
165
  </Button>
166
  )
 
1
  'use client'
2
 
 
3
  import { useVirtualizer } from '@tanstack/react-virtual'
4
  import { LayoutGridIcon } from 'lucide-react'
5
+ import { useRef, useState } from 'react'
6
  import { useTranslation } from 'react-i18next'
7
+
 
 
 
 
 
8
  import { PageManagerDialog } from '@/components/PageManagerDialog'
9
+ import { Button } from '@/components/ui/button'
10
  import { ScrollArea } from '@/components/ui/scroll-area'
11
+ import { useListDocuments, getGetDocumentThumbnailUrl } from '@/lib/api/documents/documents'
12
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
13
 
14
  const THUMBNAIL_DPR =
15
+ typeof window !== 'undefined' ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) : 2
 
 
16
 
17
  // Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding
18
  const ROW_HEIGHT = 230
 
22
  const { data: documents = [] } = useListDocuments()
23
  const totalPages = documents.length
24
  const currentDocumentId = useEditorUiStore((state) => state.currentDocumentId)
25
+ const setCurrentDocumentId = useEditorUiStore((state) => state.setCurrentDocumentId)
26
+ const currentDocumentIndex = documents.findIndex((d) => d.id === currentDocumentId)
 
 
 
 
27
  const viewportRef = useRef<HTMLDivElement | null>(null)
28
  const { t } = useTranslation()
29
  const [pageManagerOpen, setPageManagerOpen] = useState(false)
 
39
  <div
40
  data-testid='navigator-panel'
41
  data-total-pages={totalPages}
42
+ className='flex h-full min-h-0 w-full flex-col border-r bg-muted/50'
43
  >
44
+ <div className='flex items-center justify-between border-b border-border px-2 py-1.5'>
45
  <div>
46
+ <p className='text-xs tracking-wide text-muted-foreground uppercase'>
47
  {t('navigator.title')}
48
  </p>
49
+ <p className='text-xs font-semibold text-foreground'>
50
+ {totalPages ? t('navigator.pages', { count: totalPages }) : t('navigator.empty')}
 
 
51
  </p>
52
  </div>
53
  {totalPages > 1 && (
 
64
  )}
65
  </div>
66
 
67
+ <div className='flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground'>
68
  {totalPages > 0 ? (
69
+ <span className='bg-secondary px-2 py-0.5 font-mono text-[10px] text-secondary-foreground'>
70
  #{currentDocumentIndex + 1}
71
  </span>
72
  ) : (
 
75
  </div>
76
 
77
  <ScrollArea className='min-h-0 flex-1' viewportRef={viewportRef}>
78
+ <div className='relative w-full' style={{ height: virtualizer.getTotalSize() }}>
 
 
 
79
  {virtualizer.getVirtualItems().map((virtualRow) => {
80
  const doc = documents[virtualRow.index]
81
  return (
 
100
  </div>
101
  </ScrollArea>
102
 
103
+ <PageManagerDialog open={pageManagerOpen} onOpenChange={setPageManagerOpen} />
 
 
 
104
  </div>
105
  )
106
  }
 
112
  onSelect: () => void
113
  }
114
 
115
+ function PagePreview({ index, documentId, selected, onSelect }: PagePreviewProps) {
 
 
 
 
 
116
  const src = documentId
117
  ? getGetDocumentThumbnailUrl(documentId, { size: 200 * THUMBNAIL_DPR })
118
  : undefined
 
124
  data-testid={`navigator-page-${index}`}
125
  data-page-index={index}
126
  data-selected={selected}
127
+ className='flex h-full w-full flex-col gap-0.5 rounded border border-transparent bg-card p-1.5 text-left shadow-sm data-[selected=true]:border-primary'
128
  >
129
  <div className='flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded'>
130
  {src ? (
 
135
  className='max-h-full max-w-full rounded object-contain'
136
  />
137
  ) : (
138
+ <div className='h-full w-full rounded bg-muted' />
139
  )}
140
  </div>
141
+ <div className='flex shrink-0 items-center text-xs text-muted-foreground'>
142
+ <div className='mx-auto font-semibold text-foreground'>{index + 1}</div>
143
  </div>
144
  </Button>
145
  )
ui/components/PageManagerDialog.tsx CHANGED
@@ -1,6 +1,5 @@
1
  'use client'
2
 
3
- import { useCallback, useEffect, useMemo, useState } from 'react'
4
  import {
5
  DndContext,
6
  KeyboardSensor,
@@ -20,35 +19,27 @@ import {
20
  import { CSS } from '@dnd-kit/utilities'
21
  import { useQueryClient } from '@tanstack/react-query'
22
  import { GripVerticalIcon, Loader2Icon } from 'lucide-react'
 
23
  import { useTranslation } from 'react-i18next'
 
 
 
24
  import {
25
  getGetDocumentThumbnailUrl,
26
  getListDocumentsQueryKey,
27
  useListDocuments,
28
  useReorderDocuments,
29
  } from '@/lib/api/documents/documents'
30
- import { Button } from '@/components/ui/button'
31
- import {
32
- Dialog,
33
- DialogContent,
34
- DialogDescription,
35
- DialogTitle,
36
- } from '@/components/ui/dialog'
37
 
38
  const THUMBNAIL_DPR =
39
- typeof window !== 'undefined'
40
- ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3)
41
- : 2
42
 
43
  type PageManagerDialogProps = {
44
  open: boolean
45
  onOpenChange: (open: boolean) => void
46
  }
47
 
48
- export function PageManagerDialog({
49
- open,
50
- onOpenChange,
51
- }: PageManagerDialogProps) {
52
  const { data: documents = [] } = useListDocuments()
53
  const queryClient = useQueryClient()
54
  const { t } = useTranslation()
@@ -56,10 +47,7 @@ export function PageManagerDialog({
56
 
57
  const [orderedIds, setOrderedIds] = useState<string[]>([])
58
 
59
- const docsById = useMemo(
60
- () => Object.fromEntries(documents.map((d) => [d.id, d])),
61
- [documents],
62
- )
63
 
64
  useEffect(() => {
65
  if (open) {
@@ -116,9 +104,7 @@ export function PageManagerDialog({
116
  <div className='flex items-center justify-between px-6 pt-6 pb-2'>
117
  <div>
118
  <DialogTitle>{t('navigator.pageManager.title')}</DialogTitle>
119
- <DialogDescription>
120
- {t('navigator.pageManager.description')}
121
- </DialogDescription>
122
  </div>
123
  </div>
124
 
@@ -128,28 +114,20 @@ export function PageManagerDialog({
128
  collisionDetection={closestCenter}
129
  onDragEnd={handleDragEnd}
130
  >
131
- <SortableContext
132
- items={orderedIds}
133
- strategy={rectSortingStrategy}
134
- >
135
  <div
136
  data-testid='page-manager-grid'
137
  className='grid grid-cols-3 gap-3 sm:grid-cols-4'
138
  >
139
  {orderedIds.map((id, index) => (
140
- <SortablePageCard
141
- key={id}
142
- id={id}
143
- index={index}
144
- name={docsById[id]?.name}
145
- />
146
  ))}
147
  </div>
148
  </SortableContext>
149
  </DndContext>
150
  </div>
151
 
152
- <div className='border-border flex items-center justify-end gap-2 border-t px-6 py-4'>
153
  <Button
154
  variant='outline'
155
  onClick={() => onOpenChange(false)}
@@ -162,9 +140,7 @@ export function PageManagerDialog({
162
  onClick={handleSave}
163
  disabled={!hasChanges || reorderMutation.isPending}
164
  >
165
- {reorderMutation.isPending && (
166
- <Loader2Icon className='mr-2 h-4 w-4 animate-spin' />
167
- )}
168
  {t('common.save')}
169
  </Button>
170
  </div>
@@ -180,14 +156,9 @@ type SortablePageCardProps = {
180
  }
181
 
182
  function SortablePageCard({ id, index, name }: SortablePageCardProps) {
183
- const {
184
- attributes,
185
- listeners,
186
- setNodeRef,
187
- transform,
188
- transition,
189
- isDragging,
190
- } = useSortable({ id })
191
 
192
  const style: React.CSSProperties = {
193
  transform: CSS.Transform.toString(transform),
@@ -215,8 +186,8 @@ function PageCard({ id, index, name, dragging }: PageCardProps) {
215
  return (
216
  <div
217
  data-testid={`page-manager-card-${index}`}
218
- className={`bg-card flex flex-col items-center gap-1 rounded border p-2 shadow-sm select-none ${
219
- dragging ? 'ring-primary ring-2 shadow-lg' : ''
220
  }`}
221
  >
222
  <div className='flex aspect-3/4 w-full items-center justify-center overflow-hidden rounded'>
@@ -228,9 +199,9 @@ function PageCard({ id, index, name, dragging }: PageCardProps) {
228
  className='max-h-full max-w-full rounded object-contain'
229
  />
230
  </div>
231
- <div className='text-muted-foreground flex w-full items-center justify-center gap-1 text-xs'>
232
  <GripVerticalIcon className='h-3.5 w-3.5 shrink-0' />
233
- <span className='text-foreground font-semibold'>{index + 1}</span>
234
  </div>
235
  </div>
236
  )
 
1
  'use client'
2
 
 
3
  import {
4
  DndContext,
5
  KeyboardSensor,
 
19
  import { CSS } from '@dnd-kit/utilities'
20
  import { useQueryClient } from '@tanstack/react-query'
21
  import { GripVerticalIcon, Loader2Icon } from 'lucide-react'
22
+ import { useCallback, useEffect, useMemo, useState } from 'react'
23
  import { useTranslation } from 'react-i18next'
24
+
25
+ import { Button } from '@/components/ui/button'
26
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
27
  import {
28
  getGetDocumentThumbnailUrl,
29
  getListDocumentsQueryKey,
30
  useListDocuments,
31
  useReorderDocuments,
32
  } from '@/lib/api/documents/documents'
 
 
 
 
 
 
 
33
 
34
  const THUMBNAIL_DPR =
35
+ typeof window !== 'undefined' ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) : 2
 
 
36
 
37
  type PageManagerDialogProps = {
38
  open: boolean
39
  onOpenChange: (open: boolean) => void
40
  }
41
 
42
+ export function PageManagerDialog({ open, onOpenChange }: PageManagerDialogProps) {
 
 
 
43
  const { data: documents = [] } = useListDocuments()
44
  const queryClient = useQueryClient()
45
  const { t } = useTranslation()
 
47
 
48
  const [orderedIds, setOrderedIds] = useState<string[]>([])
49
 
50
+ const docsById = useMemo(() => Object.fromEntries(documents.map((d) => [d.id, d])), [documents])
 
 
 
51
 
52
  useEffect(() => {
53
  if (open) {
 
104
  <div className='flex items-center justify-between px-6 pt-6 pb-2'>
105
  <div>
106
  <DialogTitle>{t('navigator.pageManager.title')}</DialogTitle>
107
+ <DialogDescription>{t('navigator.pageManager.description')}</DialogDescription>
 
 
108
  </div>
109
  </div>
110
 
 
114
  collisionDetection={closestCenter}
115
  onDragEnd={handleDragEnd}
116
  >
117
+ <SortableContext items={orderedIds} strategy={rectSortingStrategy}>
 
 
 
118
  <div
119
  data-testid='page-manager-grid'
120
  className='grid grid-cols-3 gap-3 sm:grid-cols-4'
121
  >
122
  {orderedIds.map((id, index) => (
123
+ <SortablePageCard key={id} id={id} index={index} name={docsById[id]?.name} />
 
 
 
 
 
124
  ))}
125
  </div>
126
  </SortableContext>
127
  </DndContext>
128
  </div>
129
 
130
+ <div className='flex items-center justify-end gap-2 border-t border-border px-6 py-4'>
131
  <Button
132
  variant='outline'
133
  onClick={() => onOpenChange(false)}
 
140
  onClick={handleSave}
141
  disabled={!hasChanges || reorderMutation.isPending}
142
  >
143
+ {reorderMutation.isPending && <Loader2Icon className='mr-2 h-4 w-4 animate-spin' />}
 
 
144
  {t('common.save')}
145
  </Button>
146
  </div>
 
156
  }
157
 
158
  function SortablePageCard({ id, index, name }: SortablePageCardProps) {
159
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
160
+ id,
161
+ })
 
 
 
 
 
162
 
163
  const style: React.CSSProperties = {
164
  transform: CSS.Transform.toString(transform),
 
186
  return (
187
  <div
188
  data-testid={`page-manager-card-${index}`}
189
+ className={`flex flex-col items-center gap-1 rounded border bg-card p-2 shadow-sm select-none ${
190
+ dragging ? 'shadow-lg ring-2 ring-primary' : ''
191
  }`}
192
  >
193
  <div className='flex aspect-3/4 w-full items-center justify-center overflow-hidden rounded'>
 
199
  className='max-h-full max-w-full rounded object-contain'
200
  />
201
  </div>
202
+ <div className='flex w-full items-center justify-center gap-1 text-xs text-muted-foreground'>
203
  <GripVerticalIcon className='h-3.5 w-3.5 shrink-0' />
204
+ <span className='font-semibold text-foreground'>{index + 1}</span>
205
  </div>
206
  </div>
207
  )
ui/components/Panels.tsx CHANGED
@@ -1,39 +1,32 @@
1
  'use client'
2
 
3
- import { useTranslation } from 'react-i18next'
4
  import { LayersIcon, SlidersHorizontalIcon } from 'lucide-react'
 
 
5
  import { LayersPanel } from '@/components/panels/LayersPanel'
6
  import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
7
  import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
8
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
9
  import { ScrollArea } from '@/components/ui/scroll-area'
 
10
 
11
  export function Panels() {
12
  const { t } = useTranslation()
13
 
14
  return (
15
- <div className='bg-muted/50 flex h-full min-h-0 w-full flex-col border-l'>
16
  <Tabs
17
  defaultValue='layers'
18
- className='border-border h-60 shrink-0 gap-0 border-b'
19
  data-testid='panels-settings-tabs'
20
  >
21
- <TabsList className='bg-muted/70 m-2 mb-0 grid w-[calc(100%-1rem)] grid-cols-2'>
22
- <TabsTrigger
23
- value='layers'
24
- data-testid='panels-tab-layers'
25
- className='gap-1'
26
- >
27
  <LayersIcon className='size-3.5' />
28
  <span className='text-xs font-semibold tracking-wide uppercase'>
29
  {t('layers.title')}
30
  </span>
31
  </TabsTrigger>
32
- <TabsTrigger
33
- value='layout'
34
- data-testid='panels-tab-layout'
35
- className='gap-1'
36
- >
37
  <SlidersHorizontalIcon className='size-3.5' />
38
  <span className='text-xs font-semibold tracking-wide uppercase'>
39
  {t('panels.render')}
@@ -56,10 +49,7 @@ export function Panels() {
56
  className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
57
  data-testid='panels-layout'
58
  >
59
- <ScrollArea
60
- className='h-full'
61
- viewportClassName='pr-1 [&>div]:!block'
62
- >
63
  <div className='pt-1'>
64
  <RenderControlsPanel />
65
  </div>
 
1
  'use client'
2
 
 
3
  import { LayersIcon, SlidersHorizontalIcon } from 'lucide-react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
  import { LayersPanel } from '@/components/panels/LayersPanel'
7
  import { RenderControlsPanel } from '@/components/panels/RenderControlsPanel'
8
  import { TextBlocksPanel } from '@/components/panels/TextBlocksPanel'
 
9
  import { ScrollArea } from '@/components/ui/scroll-area'
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
11
 
12
  export function Panels() {
13
  const { t } = useTranslation()
14
 
15
  return (
16
+ <div className='flex h-full min-h-0 w-full flex-col border-l bg-muted/50'>
17
  <Tabs
18
  defaultValue='layers'
19
+ className='h-60 shrink-0 gap-0 border-b border-border'
20
  data-testid='panels-settings-tabs'
21
  >
22
+ <TabsList className='m-2 mb-0 grid w-[calc(100%-1rem)] grid-cols-2 bg-muted/70'>
23
+ <TabsTrigger value='layers' data-testid='panels-tab-layers' className='gap-1'>
 
 
 
 
24
  <LayersIcon className='size-3.5' />
25
  <span className='text-xs font-semibold tracking-wide uppercase'>
26
  {t('layers.title')}
27
  </span>
28
  </TabsTrigger>
29
+ <TabsTrigger value='layout' data-testid='panels-tab-layout' className='gap-1'>
 
 
 
 
30
  <SlidersHorizontalIcon className='size-3.5' />
31
  <span className='text-xs font-semibold tracking-wide uppercase'>
32
  {t('panels.render')}
 
49
  className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
50
  data-testid='panels-layout'
51
  >
52
+ <ScrollArea className='h-full' viewportClassName='pr-1 [&>div]:!block'>
 
 
 
53
  <div className='pt-1'>
54
  <RenderControlsPanel />
55
  </div>
ui/components/SettingsDialog.tsx CHANGED
@@ -1,9 +1,6 @@
1
  'use client'
2
 
3
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
  import { useQueryClient } from '@tanstack/react-query'
5
- import { useTheme } from 'next-themes'
6
- import { useTranslation } from 'react-i18next'
7
  import {
8
  SunIcon,
9
  MoonIcon,
@@ -21,29 +18,16 @@ import {
21
  RotateCcwIcon,
22
  AlertTriangleIcon,
23
  } from 'lucide-react'
24
- import {
25
- Dialog,
26
- DialogContent,
27
- DialogTitle,
28
- DialogDescription,
29
- } from '@/components/ui/dialog'
30
  import {
31
  Accordion,
32
  AccordionItem,
33
  AccordionTrigger,
34
  AccordionContent,
35
  } from '@/components/ui/accordion'
36
- import {
37
- Select,
38
- SelectContent,
39
- SelectItem,
40
- SelectTrigger,
41
- SelectValue,
42
- } from '@/components/ui/select'
43
- import { ScrollArea } from '@/components/ui/scroll-area'
44
- import { Button } from '@/components/ui/button'
45
- import { Input } from '@/components/ui/input'
46
- import { Label } from '@/components/ui/label'
47
  import {
48
  AlertDialog,
49
  AlertDialogAction,
@@ -52,15 +36,29 @@ import {
52
  AlertDialogDescription,
53
  AlertDialogTitle,
54
  } from '@/components/ui/alert-dialog'
55
- import { useUpdater, type UpdaterStatus } from '@/components/Updater'
56
- import { isTauri, openExternalUrl } from '@/lib/backend'
 
 
 
57
  import {
58
- getConfig,
59
- getEngineCatalog,
60
- getMeta,
61
- updateConfig,
62
- } from '@/lib/api/system/system'
 
 
63
  import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
 
 
 
 
 
 
 
 
 
64
  import {
65
  getPlatform,
66
  formatShortcut,
@@ -68,14 +66,7 @@ import {
68
  areShortcutsEqual,
69
  isModifierKey,
70
  } from '@/lib/shortcutUtils'
71
- import { supportedLanguages } from '@/lib/i18n'
72
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
73
- import type {
74
- UpdateConfigBody,
75
- ProviderConfig,
76
- LlmProviderCatalog,
77
- GetEngineCatalog200,
78
- } from '@/lib/api/schemas'
79
 
80
  const GITHUB_REPO = 'mayocream/koharu'
81
 
@@ -113,20 +104,15 @@ export function SettingsDialog({
113
  }, [defaultTab, open])
114
 
115
  const [appConfig, setAppConfig] = useState<UpdateConfigBody | null>(null)
116
- const [providerCatalogs, setProviderCatalogs] = useState<
117
- LlmProviderCatalog[]
118
- >([])
119
  const [apiKeyDrafts, setApiKeyDrafts] = useState<Record<string, string>>({})
120
  const [dataPathDraft, setDataPathDraft] = useState('')
121
  const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('')
122
  const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('')
123
  const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('')
124
- const [storageSettingsError, setStorageSettingsError] = useState<
125
- string | null
126
- >(null)
127
  const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false)
128
- const [engineCatalog, setEngineCatalog] =
129
- useState<GetEngineCatalog200 | null>(null)
130
  const [appVersion, setAppVersion] = useState<string>()
131
  const updater = useUpdater()
132
 
@@ -142,7 +128,7 @@ export function SettingsDialog({
142
  setAppConfig(config)
143
  setProviderCatalogs(catalog.providers)
144
  setEngineCatalog(engines)
145
- } catch { }
146
  })()
147
  }, [open])
148
 
@@ -174,12 +160,8 @@ export function SettingsDialog({
174
  setHttpConnectTimeoutDraft(
175
  String(appConfig.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT),
176
  )
177
- setHttpReadTimeoutDraft(
178
- String(appConfig.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT),
179
- )
180
- setHttpMaxRetriesDraft(
181
- String(appConfig.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES),
182
- )
183
  setStorageSettingsError(null)
184
  }, [appConfig])
185
 
@@ -196,10 +178,7 @@ export function SettingsDialog({
196
  }
197
  }
198
 
199
- const upsertProvider = (
200
- id: string,
201
- updater: (p: ProviderConfig) => ProviderConfig,
202
- ) => {
203
  if (!appConfig) return
204
  const providers = [...(appConfig.providers ?? [])]
205
  const idx = providers.findIndex((p) => p.id === id)
@@ -263,13 +242,10 @@ export function SettingsDialog({
263
  const storageSettingsUnchanged =
264
  dataPathDraft.trim() === appConfig?.data?.path &&
265
  httpConnectTimeoutDraft.trim() ===
266
- String(
267
- appConfig?.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT,
268
- ) &&
269
  httpReadTimeoutDraft.trim() ===
270
- String(appConfig?.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT) &&
271
- httpMaxRetriesDraft.trim() ===
272
- String(appConfig?.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES)
273
 
274
  return (
275
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -279,8 +255,8 @@ export function SettingsDialog({
279
 
280
  <div className='flex h-full'>
281
  {/* Sidebar */}
282
- <nav className='bg-muted/30 border-border flex w-[180px] shrink-0 flex-col gap-1 border-r p-3'>
283
- <p className='text-muted-foreground mb-3 px-3 text-[10px] font-semibold tracking-widest uppercase'>
284
  {t('settings.title')}
285
  </p>
286
  {TABS.map(({ id, icon: Icon, labelKey }) => (
@@ -288,7 +264,7 @@ export function SettingsDialog({
288
  key={id}
289
  onClick={() => setTab(id)}
290
  data-active={tab === id}
291
- className='text-muted-foreground hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition'
292
  >
293
  <Icon className='size-4 shrink-0' />
294
  {t(labelKey)}
@@ -322,12 +298,8 @@ export function SettingsDialog({
322
  base_url: v || null,
323
  }))
324
  }
325
- onBaseUrlBlur={() =>
326
- appConfig && void persistConfig(appConfig)
327
- }
328
- onApiKeyChange={(id, v) =>
329
- setApiKeyDrafts((c) => ({ ...c, [id]: v }))
330
- }
331
  onSaveKey={(id) => {
332
  const key = apiKeyDrafts[id]?.trim()
333
  if (!key || !appConfig) return
@@ -349,8 +321,7 @@ export function SettingsDialog({
349
  if (!appConfig) return
350
  const providers = [...(appConfig.providers ?? [])]
351
  const idx = providers.findIndex((p) => p.id === id)
352
- if (idx >= 0)
353
- providers[idx] = { ...providers[idx], api_key: null }
354
  void persistConfig({ ...appConfig, providers }).then(() =>
355
  setApiKeyDrafts((c) => {
356
  const n = { ...c }
@@ -428,7 +399,7 @@ function AppearancePane() {
428
  key={value}
429
  onClick={() => setTheme(value)}
430
  data-active={theme === value}
431
- className='border-border bg-card text-muted-foreground hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground flex flex-col items-center gap-2 rounded-xl border px-4 py-4 transition'
432
  >
433
  <Icon className='size-5' />
434
  <span className='text-xs font-medium'>{t(labelKey)}</span>
@@ -438,10 +409,7 @@ function AppearancePane() {
438
  </Section>
439
 
440
  <Section title={t('settings.language')}>
441
- <Select
442
- value={i18n.language}
443
- onValueChange={(v) => i18n.changeLanguage(v)}
444
- >
445
  <SelectTrigger className='w-full'>
446
  <SelectValue />
447
  </SelectTrigger>
@@ -512,9 +480,7 @@ function EnginesPane({
512
 
513
  return (
514
  <div className='space-y-4'>
515
- <p className='text-muted-foreground text-xs'>
516
- {t('settings.enginesDescription')}
517
- </p>
518
  {sections.map(({ label, key, engines }) => (
519
  <div key={key} className='space-y-1.5'>
520
  <Label className='text-xs'>{label}</Label>
@@ -564,17 +530,14 @@ function ProvidersPane({
564
 
565
  if (!catalogs.length)
566
  return (
567
- <p className='text-muted-foreground py-12 text-center text-sm'>
568
  {t('settings.loadingProviders')}
569
  </p>
570
  )
571
 
572
  return (
573
  <div className='space-y-6'>
574
- <Section
575
- title={t('settings.apiKeys')}
576
- description={t('settings.providersDescription')}
577
- >
578
  <Accordion type='multiple' className='-mx-1'>
579
  {catalogs.map((provider) => {
580
  const cfg = config?.providers?.find((p) => p.id === provider.id)
@@ -590,37 +553,25 @@ function ProvidersPane({
590
  : 'bg-muted-foreground'
591
 
592
  return (
593
- <AccordionItem
594
- key={provider.id}
595
- value={provider.id}
596
- className='border-border'
597
- >
598
  <AccordionTrigger className='px-1 py-3 hover:no-underline'>
599
  <div className='flex items-center gap-2.5'>
600
- <span
601
- className={`size-2 shrink-0 rounded-full ${statusColor}`}
602
- />
603
  <span className='text-sm font-medium'>{provider.name}</span>
604
  </div>
605
  </AccordionTrigger>
606
  <AccordionContent className='space-y-4 px-1 pt-1 pb-4'>
607
  {provider.error && (
608
- <p className='text-muted-foreground text-xs'>
609
- {provider.error}
610
- </p>
611
  )}
612
 
613
  {provider.requiresBaseUrl && (
614
  <div className='space-y-1.5'>
615
- <Label className='text-xs'>
616
- {t('settings.localLlmBaseUrl')}
617
- </Label>
618
  <Input
619
  type='url'
620
  value={cfg?.base_url ?? ''}
621
- onChange={(e) =>
622
- onBaseUrlChange(provider.id, e.target.value)
623
- }
624
  onBlur={onBaseUrlBlur}
625
  placeholder='https://api.example.com/v1'
626
  />
@@ -633,12 +584,9 @@ function ProvidersPane({
633
  <Input
634
  type='password'
635
  value={draft}
636
- onChange={(e) =>
637
- onApiKeyChange(provider.id, e.target.value)
638
- }
639
  onKeyDown={(e) => {
640
- if (e.key === 'Enter' && hasDraft)
641
- onSaveKey(provider.id)
642
  }}
643
  placeholder={
644
  cfg?.api_key === '[REDACTED]'
@@ -648,10 +596,7 @@ function ProvidersPane({
648
  className='[&::-ms-reveal]:hidden'
649
  />
650
  {hasDraft ? (
651
- <Button
652
- size='sm'
653
- onClick={() => onSaveKey(provider.id)}
654
- >
655
  {t('settings.apiKeySave')}
656
  </Button>
657
  ) : cfg?.api_key === '[REDACTED]' ? (
@@ -697,9 +642,7 @@ function KeybindsPane() {
697
  const { t } = useTranslation()
698
  const shortcuts = usePreferencesStore((state) => state.shortcuts)
699
  const setShortcuts = usePreferencesStore((state) => state.setShortcuts)
700
- const resetShortcutsStore = usePreferencesStore(
701
- (state) => state.resetShortcuts,
702
- )
703
  const [pendingShortcuts, setPendingShortcuts] = useState(shortcuts)
704
  const [recordingKey, setRecordingKey] = useState<string | null>(null)
705
  const [error, setError] = useState<string | null>(null)
@@ -821,20 +764,14 @@ function KeybindsPane() {
821
  return (
822
  <div className='flex h-full flex-col gap-6'>
823
  <div className='grow space-y-6 overflow-y-auto pr-2'>
824
- <Section
825
- title={t('settings.keybinds')}
826
- description={t('settings.keybindsDescription')}
827
- >
828
- <div className='bg-card border-border divide-border divide-y overflow-hidden rounded-xl border'>
829
  {SHORTCUT_ITEMS.map((item) => {
830
  const currentVal = pendingShortcuts[item.key]
831
  const hasConflict = (conflictCounts.get(currentVal) || 0) > 1
832
 
833
  return (
834
- <div
835
- key={item.key}
836
- className='flex items-center justify-between px-4 py-3'
837
- >
838
  <div className='flex items-center gap-2'>
839
  <span className='text-sm'>{t(item.labelKey)}</span>
840
  {hasConflict && (
@@ -863,11 +800,11 @@ function KeybindsPane() {
863
  </Section>
864
  </div>
865
 
866
- <div className='border-border flex items-center justify-between border-t pt-4'>
867
  <Button
868
  variant='ghost'
869
  size='sm'
870
- className='text-muted-foreground hover:text-foreground gap-2'
871
  onClick={handleReset}
872
  >
873
  <RotateCcwIcon className='size-4' />
@@ -898,9 +835,7 @@ function KeybindsPane() {
898
  <AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
899
  <AlertDialogContent>
900
  <AlertDialogTitle>{t('settings.shortcutReset')}</AlertDialogTitle>
901
- <AlertDialogDescription>
902
- {t('settings.shortcutResetDescription')}
903
- </AlertDialogDescription>
904
  <div className='flex justify-end gap-2'>
905
  <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
906
  <AlertDialogAction onClick={handleConfirmReset}>
@@ -947,27 +882,18 @@ function StoragePane({
947
 
948
  return (
949
  <>
950
- <Section
951
- title={t('settings.runtime')}
952
- description={t('settings.runtimeDescription')}
953
- >
954
  <div className='space-y-1.5'>
955
  <Label className='text-xs'>{t('settings.dataPath')}</Label>
956
- <Input
957
- type='text'
958
- value={dataPath}
959
- onChange={(e) => onPathChange(e.target.value)}
960
- />
961
- <p className='text-muted-foreground text-xs leading-relaxed'>
962
  {t('settings.dataPathDescription')}
963
  </p>
964
  </div>
965
 
966
  <div className='grid gap-4 md:grid-cols-2'>
967
  <div className='space-y-1.5'>
968
- <Label className='text-xs'>
969
- {t('settings.httpConnectTimeout')}
970
- </Label>
971
  <Input
972
  type='number'
973
  min='1'
@@ -976,7 +902,7 @@ function StoragePane({
976
  value={httpConnectTimeout}
977
  onChange={(e) => onHttpConnectTimeoutChange(e.target.value)}
978
  />
979
- <p className='text-muted-foreground text-xs leading-relaxed'>
980
  {t('settings.httpConnectTimeoutDescription')}
981
  </p>
982
  </div>
@@ -991,7 +917,7 @@ function StoragePane({
991
  value={httpReadTimeout}
992
  onChange={(e) => onHttpReadTimeoutChange(e.target.value)}
993
  />
994
- <p className='text-muted-foreground text-xs leading-relaxed'>
995
  {t('settings.httpReadTimeoutDescription')}
996
  </p>
997
  </div>
@@ -1007,20 +933,18 @@ function StoragePane({
1007
  value={httpMaxRetries}
1008
  onChange={(e) => onHttpMaxRetriesChange(e.target.value)}
1009
  />
1010
- <p className='text-muted-foreground text-xs leading-relaxed'>
1011
  {t('settings.httpMaxRetriesDescription')}
1012
  </p>
1013
  </div>
1014
 
1015
- {error && <p className='text-destructive text-xs'>{error}</p>}
1016
  <div className='flex justify-end pt-1'>
1017
  <Button
1018
  onClick={() => setConfirmOpen(true)}
1019
  disabled={!dataPath.trim() || saving || unchanged}
1020
  >
1021
- {saving
1022
- ? t('settings.restartApplying')
1023
- : t('settings.restartApply')}
1024
  </Button>
1025
  </div>
1026
  </Section>
@@ -1067,30 +991,19 @@ function AboutPane({
1067
 
1068
  return (
1069
  <div className='flex h-full flex-col items-center justify-center gap-5 py-8'>
1070
- <img
1071
- src='/icon-large.png'
1072
- alt='Koharu'
1073
- className='size-20'
1074
- draggable={false}
1075
- />
1076
  <div className='text-center'>
1077
- <h2 className='text-foreground text-lg font-bold tracking-wide'>
1078
- Koharu
1079
- </h2>
1080
- <p className='text-muted-foreground mt-1 text-sm'>
1081
- {t('settings.aboutTagline')}
1082
- </p>
1083
  </div>
1084
 
1085
- <div className='bg-card border-border w-full max-w-sm rounded-xl border p-4'>
1086
  <div className='space-y-3 text-sm'>
1087
  <InfoRow label={t('settings.aboutVersion')}>
1088
  <div className='flex flex-col items-end gap-0.5'>
1089
- <span className='font-mono text-xs font-medium'>
1090
- {version || '...'}
1091
- </span>
1092
  {status === 'loading' && (
1093
- <LoaderIcon className='text-muted-foreground size-3.5 animate-spin' />
1094
  )}
1095
  {status === 'latest' && (
1096
  <span className='flex items-center gap-1 text-xs text-green-500'>
@@ -1120,9 +1033,7 @@ function AboutPane({
1120
  <Button
1121
  variant='link'
1122
  size='xs'
1123
- onClick={() =>
1124
- void openExternalUrl('https://github.com/mayocream')
1125
- }
1126
  >
1127
  Mayo
1128
  </Button>
@@ -1131,9 +1042,7 @@ function AboutPane({
1131
  <Button
1132
  variant='link'
1133
  size='xs'
1134
- onClick={() =>
1135
- void openExternalUrl(`https://github.com/${GITHUB_REPO}`)
1136
- }
1137
  >
1138
  GitHub
1139
  </Button>
@@ -1158,11 +1067,9 @@ function Section({
1158
  return (
1159
  <div className='space-y-3'>
1160
  <div>
1161
- <h3 className='text-foreground text-sm font-semibold'>{title}</h3>
1162
  {description && (
1163
- <p className='text-muted-foreground mt-0.5 text-xs leading-relaxed'>
1164
- {description}
1165
- </p>
1166
  )}
1167
  </div>
1168
  {children}
 
1
  'use client'
2
 
 
3
  import { useQueryClient } from '@tanstack/react-query'
 
 
4
  import {
5
  SunIcon,
6
  MoonIcon,
 
18
  RotateCcwIcon,
19
  AlertTriangleIcon,
20
  } from 'lucide-react'
21
+ import { useTheme } from 'next-themes'
22
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
+ import { useTranslation } from 'react-i18next'
24
+
 
 
25
  import {
26
  Accordion,
27
  AccordionItem,
28
  AccordionTrigger,
29
  AccordionContent,
30
  } from '@/components/ui/accordion'
 
 
 
 
 
 
 
 
 
 
 
31
  import {
32
  AlertDialog,
33
  AlertDialogAction,
 
36
  AlertDialogDescription,
37
  AlertDialogTitle,
38
  } from '@/components/ui/alert-dialog'
39
+ import { Button } from '@/components/ui/button'
40
+ import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
41
+ import { Input } from '@/components/ui/input'
42
+ import { Label } from '@/components/ui/label'
43
+ import { ScrollArea } from '@/components/ui/scroll-area'
44
  import {
45
+ Select,
46
+ SelectContent,
47
+ SelectItem,
48
+ SelectTrigger,
49
+ SelectValue,
50
+ } from '@/components/ui/select'
51
+ import { useUpdater, type UpdaterStatus } from '@/components/Updater'
52
  import { getLlmCatalog, getGetLlmCatalogQueryKey } from '@/lib/api/llm/llm'
53
+ import type {
54
+ UpdateConfigBody,
55
+ ProviderConfig,
56
+ LlmProviderCatalog,
57
+ GetEngineCatalog200,
58
+ } from '@/lib/api/schemas'
59
+ import { getConfig, getEngineCatalog, getMeta, updateConfig } from '@/lib/api/system/system'
60
+ import { isTauri, openExternalUrl } from '@/lib/backend'
61
+ import { supportedLanguages } from '@/lib/i18n'
62
  import {
63
  getPlatform,
64
  formatShortcut,
 
66
  areShortcutsEqual,
67
  isModifierKey,
68
  } from '@/lib/shortcutUtils'
 
69
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
 
 
 
 
 
70
 
71
  const GITHUB_REPO = 'mayocream/koharu'
72
 
 
104
  }, [defaultTab, open])
105
 
106
  const [appConfig, setAppConfig] = useState<UpdateConfigBody | null>(null)
107
+ const [providerCatalogs, setProviderCatalogs] = useState<LlmProviderCatalog[]>([])
 
 
108
  const [apiKeyDrafts, setApiKeyDrafts] = useState<Record<string, string>>({})
109
  const [dataPathDraft, setDataPathDraft] = useState('')
110
  const [httpConnectTimeoutDraft, setHttpConnectTimeoutDraft] = useState('')
111
  const [httpReadTimeoutDraft, setHttpReadTimeoutDraft] = useState('')
112
  const [httpMaxRetriesDraft, setHttpMaxRetriesDraft] = useState('')
113
+ const [storageSettingsError, setStorageSettingsError] = useState<string | null>(null)
 
 
114
  const [isSavingStorageSettings, setIsSavingStorageSettings] = useState(false)
115
+ const [engineCatalog, setEngineCatalog] = useState<GetEngineCatalog200 | null>(null)
 
116
  const [appVersion, setAppVersion] = useState<string>()
117
  const updater = useUpdater()
118
 
 
128
  setAppConfig(config)
129
  setProviderCatalogs(catalog.providers)
130
  setEngineCatalog(engines)
131
+ } catch {}
132
  })()
133
  }, [open])
134
 
 
160
  setHttpConnectTimeoutDraft(
161
  String(appConfig.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT),
162
  )
163
+ setHttpReadTimeoutDraft(String(appConfig.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT))
164
+ setHttpMaxRetriesDraft(String(appConfig.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES))
 
 
 
 
165
  setStorageSettingsError(null)
166
  }, [appConfig])
167
 
 
178
  }
179
  }
180
 
181
+ const upsertProvider = (id: string, updater: (p: ProviderConfig) => ProviderConfig) => {
 
 
 
182
  if (!appConfig) return
183
  const providers = [...(appConfig.providers ?? [])]
184
  const idx = providers.findIndex((p) => p.id === id)
 
242
  const storageSettingsUnchanged =
243
  dataPathDraft.trim() === appConfig?.data?.path &&
244
  httpConnectTimeoutDraft.trim() ===
245
+ String(appConfig?.http?.connect_timeout ?? DEFAULT_HTTP_CONNECT_TIMEOUT) &&
 
 
246
  httpReadTimeoutDraft.trim() ===
247
+ String(appConfig?.http?.read_timeout ?? DEFAULT_HTTP_READ_TIMEOUT) &&
248
+ httpMaxRetriesDraft.trim() === String(appConfig?.http?.max_retries ?? DEFAULT_HTTP_MAX_RETRIES)
 
249
 
250
  return (
251
  <Dialog open={open} onOpenChange={onOpenChange}>
 
255
 
256
  <div className='flex h-full'>
257
  {/* Sidebar */}
258
+ <nav className='flex w-[180px] shrink-0 flex-col gap-1 border-r border-border bg-muted/30 p-3'>
259
+ <p className='mb-3 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase'>
260
  {t('settings.title')}
261
  </p>
262
  {TABS.map(({ id, icon: Icon, labelKey }) => (
 
264
  key={id}
265
  onClick={() => setTab(id)}
266
  data-active={tab === id}
267
+ className='flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground'
268
  >
269
  <Icon className='size-4 shrink-0' />
270
  {t(labelKey)}
 
298
  base_url: v || null,
299
  }))
300
  }
301
+ onBaseUrlBlur={() => appConfig && void persistConfig(appConfig)}
302
+ onApiKeyChange={(id, v) => setApiKeyDrafts((c) => ({ ...c, [id]: v }))}
 
 
 
 
303
  onSaveKey={(id) => {
304
  const key = apiKeyDrafts[id]?.trim()
305
  if (!key || !appConfig) return
 
321
  if (!appConfig) return
322
  const providers = [...(appConfig.providers ?? [])]
323
  const idx = providers.findIndex((p) => p.id === id)
324
+ if (idx >= 0) providers[idx] = { ...providers[idx], api_key: null }
 
325
  void persistConfig({ ...appConfig, providers }).then(() =>
326
  setApiKeyDrafts((c) => {
327
  const n = { ...c }
 
399
  key={value}
400
  onClick={() => setTheme(value)}
401
  data-active={theme === value}
402
+ className='flex flex-col items-center gap-2 rounded-xl border border-border bg-card px-4 py-4 text-muted-foreground transition hover:border-foreground/30 data-[active=true]:border-primary data-[active=true]:text-foreground'
403
  >
404
  <Icon className='size-5' />
405
  <span className='text-xs font-medium'>{t(labelKey)}</span>
 
409
  </Section>
410
 
411
  <Section title={t('settings.language')}>
412
+ <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
 
 
 
413
  <SelectTrigger className='w-full'>
414
  <SelectValue />
415
  </SelectTrigger>
 
480
 
481
  return (
482
  <div className='space-y-4'>
483
+ <p className='text-xs text-muted-foreground'>{t('settings.enginesDescription')}</p>
 
 
484
  {sections.map(({ label, key, engines }) => (
485
  <div key={key} className='space-y-1.5'>
486
  <Label className='text-xs'>{label}</Label>
 
530
 
531
  if (!catalogs.length)
532
  return (
533
+ <p className='py-12 text-center text-sm text-muted-foreground'>
534
  {t('settings.loadingProviders')}
535
  </p>
536
  )
537
 
538
  return (
539
  <div className='space-y-6'>
540
+ <Section title={t('settings.apiKeys')} description={t('settings.providersDescription')}>
 
 
 
541
  <Accordion type='multiple' className='-mx-1'>
542
  {catalogs.map((provider) => {
543
  const cfg = config?.providers?.find((p) => p.id === provider.id)
 
553
  : 'bg-muted-foreground'
554
 
555
  return (
556
+ <AccordionItem key={provider.id} value={provider.id} className='border-border'>
 
 
 
 
557
  <AccordionTrigger className='px-1 py-3 hover:no-underline'>
558
  <div className='flex items-center gap-2.5'>
559
+ <span className={`size-2 shrink-0 rounded-full ${statusColor}`} />
 
 
560
  <span className='text-sm font-medium'>{provider.name}</span>
561
  </div>
562
  </AccordionTrigger>
563
  <AccordionContent className='space-y-4 px-1 pt-1 pb-4'>
564
  {provider.error && (
565
+ <p className='text-xs text-muted-foreground'>{provider.error}</p>
 
 
566
  )}
567
 
568
  {provider.requiresBaseUrl && (
569
  <div className='space-y-1.5'>
570
+ <Label className='text-xs'>{t('settings.localLlmBaseUrl')}</Label>
 
 
571
  <Input
572
  type='url'
573
  value={cfg?.base_url ?? ''}
574
+ onChange={(e) => onBaseUrlChange(provider.id, e.target.value)}
 
 
575
  onBlur={onBaseUrlBlur}
576
  placeholder='https://api.example.com/v1'
577
  />
 
584
  <Input
585
  type='password'
586
  value={draft}
587
+ onChange={(e) => onApiKeyChange(provider.id, e.target.value)}
 
 
588
  onKeyDown={(e) => {
589
+ if (e.key === 'Enter' && hasDraft) onSaveKey(provider.id)
 
590
  }}
591
  placeholder={
592
  cfg?.api_key === '[REDACTED]'
 
596
  className='[&::-ms-reveal]:hidden'
597
  />
598
  {hasDraft ? (
599
+ <Button size='sm' onClick={() => onSaveKey(provider.id)}>
 
 
 
600
  {t('settings.apiKeySave')}
601
  </Button>
602
  ) : cfg?.api_key === '[REDACTED]' ? (
 
642
  const { t } = useTranslation()
643
  const shortcuts = usePreferencesStore((state) => state.shortcuts)
644
  const setShortcuts = usePreferencesStore((state) => state.setShortcuts)
645
+ const resetShortcutsStore = usePreferencesStore((state) => state.resetShortcuts)
 
 
646
  const [pendingShortcuts, setPendingShortcuts] = useState(shortcuts)
647
  const [recordingKey, setRecordingKey] = useState<string | null>(null)
648
  const [error, setError] = useState<string | null>(null)
 
764
  return (
765
  <div className='flex h-full flex-col gap-6'>
766
  <div className='grow space-y-6 overflow-y-auto pr-2'>
767
+ <Section title={t('settings.keybinds')} description={t('settings.keybindsDescription')}>
768
+ <div className='divide-y divide-border overflow-hidden rounded-xl border border-border bg-card'>
 
 
 
769
  {SHORTCUT_ITEMS.map((item) => {
770
  const currentVal = pendingShortcuts[item.key]
771
  const hasConflict = (conflictCounts.get(currentVal) || 0) > 1
772
 
773
  return (
774
+ <div key={item.key} className='flex items-center justify-between px-4 py-3'>
 
 
 
775
  <div className='flex items-center gap-2'>
776
  <span className='text-sm'>{t(item.labelKey)}</span>
777
  {hasConflict && (
 
800
  </Section>
801
  </div>
802
 
803
+ <div className='flex items-center justify-between border-t border-border pt-4'>
804
  <Button
805
  variant='ghost'
806
  size='sm'
807
+ className='gap-2 text-muted-foreground hover:text-foreground'
808
  onClick={handleReset}
809
  >
810
  <RotateCcwIcon className='size-4' />
 
835
  <AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
836
  <AlertDialogContent>
837
  <AlertDialogTitle>{t('settings.shortcutReset')}</AlertDialogTitle>
838
+ <AlertDialogDescription>{t('settings.shortcutResetDescription')}</AlertDialogDescription>
 
 
839
  <div className='flex justify-end gap-2'>
840
  <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
841
  <AlertDialogAction onClick={handleConfirmReset}>
 
882
 
883
  return (
884
  <>
885
+ <Section title={t('settings.runtime')} description={t('settings.runtimeDescription')}>
 
 
 
886
  <div className='space-y-1.5'>
887
  <Label className='text-xs'>{t('settings.dataPath')}</Label>
888
+ <Input type='text' value={dataPath} onChange={(e) => onPathChange(e.target.value)} />
889
+ <p className='text-xs leading-relaxed text-muted-foreground'>
 
 
 
 
890
  {t('settings.dataPathDescription')}
891
  </p>
892
  </div>
893
 
894
  <div className='grid gap-4 md:grid-cols-2'>
895
  <div className='space-y-1.5'>
896
+ <Label className='text-xs'>{t('settings.httpConnectTimeout')}</Label>
 
 
897
  <Input
898
  type='number'
899
  min='1'
 
902
  value={httpConnectTimeout}
903
  onChange={(e) => onHttpConnectTimeoutChange(e.target.value)}
904
  />
905
+ <p className='text-xs leading-relaxed text-muted-foreground'>
906
  {t('settings.httpConnectTimeoutDescription')}
907
  </p>
908
  </div>
 
917
  value={httpReadTimeout}
918
  onChange={(e) => onHttpReadTimeoutChange(e.target.value)}
919
  />
920
+ <p className='text-xs leading-relaxed text-muted-foreground'>
921
  {t('settings.httpReadTimeoutDescription')}
922
  </p>
923
  </div>
 
933
  value={httpMaxRetries}
934
  onChange={(e) => onHttpMaxRetriesChange(e.target.value)}
935
  />
936
+ <p className='text-xs leading-relaxed text-muted-foreground'>
937
  {t('settings.httpMaxRetriesDescription')}
938
  </p>
939
  </div>
940
 
941
+ {error && <p className='text-xs text-destructive'>{error}</p>}
942
  <div className='flex justify-end pt-1'>
943
  <Button
944
  onClick={() => setConfirmOpen(true)}
945
  disabled={!dataPath.trim() || saving || unchanged}
946
  >
947
+ {saving ? t('settings.restartApplying') : t('settings.restartApply')}
 
 
948
  </Button>
949
  </div>
950
  </Section>
 
991
 
992
  return (
993
  <div className='flex h-full flex-col items-center justify-center gap-5 py-8'>
994
+ <img src='/icon-large.png' alt='Koharu' className='size-20' draggable={false} />
 
 
 
 
 
995
  <div className='text-center'>
996
+ <h2 className='text-lg font-bold tracking-wide text-foreground'>Koharu</h2>
997
+ <p className='mt-1 text-sm text-muted-foreground'>{t('settings.aboutTagline')}</p>
 
 
 
 
998
  </div>
999
 
1000
+ <div className='w-full max-w-sm rounded-xl border border-border bg-card p-4'>
1001
  <div className='space-y-3 text-sm'>
1002
  <InfoRow label={t('settings.aboutVersion')}>
1003
  <div className='flex flex-col items-end gap-0.5'>
1004
+ <span className='font-mono text-xs font-medium'>{version || '...'}</span>
 
 
1005
  {status === 'loading' && (
1006
+ <LoaderIcon className='size-3.5 animate-spin text-muted-foreground' />
1007
  )}
1008
  {status === 'latest' && (
1009
  <span className='flex items-center gap-1 text-xs text-green-500'>
 
1033
  <Button
1034
  variant='link'
1035
  size='xs'
1036
+ onClick={() => void openExternalUrl('https://github.com/mayocream')}
 
 
1037
  >
1038
  Mayo
1039
  </Button>
 
1042
  <Button
1043
  variant='link'
1044
  size='xs'
1045
+ onClick={() => void openExternalUrl(`https://github.com/${GITHUB_REPO}`)}
 
 
1046
  >
1047
  GitHub
1048
  </Button>
 
1067
  return (
1068
  <div className='space-y-3'>
1069
  <div>
1070
+ <h3 className='text-sm font-semibold text-foreground'>{title}</h3>
1071
  {description && (
1072
+ <p className='mt-0.5 text-xs leading-relaxed text-muted-foreground'>{description}</p>
 
 
1073
  )}
1074
  </div>
1075
  {children}
ui/components/Updater.tsx CHANGED
@@ -1,24 +1,14 @@
1
  'use client'
2
 
3
- import {
4
- createContext,
5
- useContext,
6
- useEffect,
7
- useState,
8
- type ReactNode,
9
- } from 'react'
10
  import type { Update } from '@tauri-apps/plugin-updater'
 
 
 
11
  import ReactMarkdown from 'react-markdown'
12
  import remarkGfm from 'remark-gfm'
13
- import { Trans, useTranslation } from 'react-i18next'
14
- import { Download, RefreshCw, AlertCircle } from 'lucide-react'
15
- import {
16
- Dialog,
17
- DialogContent,
18
- DialogDescription,
19
- DialogTitle,
20
- } from '@/components/ui/dialog'
21
  import { Button } from '@/components/ui/button'
 
22
  import { Progress } from '@/components/ui/progress'
23
  import { ScrollArea } from '@/components/ui/scroll-area'
24
  import { Separator } from '@/components/ui/separator'
@@ -197,19 +187,17 @@ function PromptView({
197
  return (
198
  <>
199
  <header className='flex items-center gap-3 px-6 pt-6 pb-4'>
200
- <div className='bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full'>
201
  <Download className='size-5' />
202
  </div>
203
  <div className='flex flex-col gap-0.5'>
204
- <DialogTitle className='text-base'>
205
- {t('updater.available.title')}
206
- </DialogTitle>
207
  <DialogDescription>
208
  <Trans
209
  i18nKey='updater.available.description'
210
  values={{ version: update.version }}
211
  components={{
212
- strong: <span className='text-foreground font-medium' />,
213
  }}
214
  />
215
  </DialogDescription>
@@ -218,16 +206,12 @@ function PromptView({
218
  <Separator />
219
  {update.body ? (
220
  <ScrollArea className='h-64'>
221
- <div className='prose prose-sm dark:prose-invert [&_a]:text-primary [&_h3]:text-muted-foreground max-w-none px-6 py-4 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:tracking-wide [&_h3]:uppercase [&_li]:my-0.5 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5'>
222
- <ReactMarkdown remarkPlugins={[remarkGfm]}>
223
- {update.body}
224
- </ReactMarkdown>
225
  </div>
226
  </ScrollArea>
227
  ) : (
228
- <div className='text-muted-foreground px-6 py-6 text-sm'>
229
- {t('updater.noNotes')}
230
- </div>
231
  )}
232
  <Separator />
233
  <footer className='flex justify-end gap-2 px-6 py-4'>
@@ -258,21 +242,17 @@ function DownloadingView({
258
  return (
259
  <div className='flex flex-col gap-4 p-6'>
260
  <div className='flex items-center gap-3'>
261
- <div className='bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full'>
262
  <Download className='size-5 animate-pulse' />
263
  </div>
264
  <div className='flex flex-col gap-0.5'>
265
- <DialogTitle className='text-base'>
266
- {t('updater.downloading.title')}
267
- </DialogTitle>
268
- <DialogDescription>
269
- {t('updater.downloading.subtitle', { version })}
270
- </DialogDescription>
271
  </div>
272
  </div>
273
  <div className='space-y-2'>
274
  <Progress value={percent ?? undefined} />
275
- <div className='text-muted-foreground flex justify-between text-xs tabular-nums'>
276
  <span>
277
  {formatBytes(downloaded)}
278
  {total ? ` / ${formatBytes(total)}` : ''}
@@ -297,13 +277,11 @@ function ErrorView({
297
  return (
298
  <>
299
  <header className='flex items-center gap-3 px-6 pt-6 pb-4'>
300
- <div className='bg-destructive/10 text-destructive flex size-10 items-center justify-center rounded-full'>
301
  <AlertCircle className='size-5' />
302
  </div>
303
  <div className='flex flex-col gap-0.5'>
304
- <DialogTitle className='text-base'>
305
- {t('updater.error.title')}
306
- </DialogTitle>
307
  <DialogDescription className='break-words'>
308
  {t('updater.error.description')}
309
  </DialogDescription>
@@ -311,7 +289,7 @@ function ErrorView({
311
  </header>
312
  <Separator />
313
  <ScrollArea className='max-h-40'>
314
- <pre className='text-muted-foreground px-6 py-4 text-xs break-words whitespace-pre-wrap'>
315
  {message}
316
  </pre>
317
  </ScrollArea>
 
1
  'use client'
2
 
 
 
 
 
 
 
 
3
  import type { Update } from '@tauri-apps/plugin-updater'
4
+ import { Download, RefreshCw, AlertCircle } from 'lucide-react'
5
+ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
6
+ import { Trans, useTranslation } from 'react-i18next'
7
  import ReactMarkdown from 'react-markdown'
8
  import remarkGfm from 'remark-gfm'
9
+
 
 
 
 
 
 
 
10
  import { Button } from '@/components/ui/button'
11
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
12
  import { Progress } from '@/components/ui/progress'
13
  import { ScrollArea } from '@/components/ui/scroll-area'
14
  import { Separator } from '@/components/ui/separator'
 
187
  return (
188
  <>
189
  <header className='flex items-center gap-3 px-6 pt-6 pb-4'>
190
+ <div className='flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary'>
191
  <Download className='size-5' />
192
  </div>
193
  <div className='flex flex-col gap-0.5'>
194
+ <DialogTitle className='text-base'>{t('updater.available.title')}</DialogTitle>
 
 
195
  <DialogDescription>
196
  <Trans
197
  i18nKey='updater.available.description'
198
  values={{ version: update.version }}
199
  components={{
200
+ strong: <span className='font-medium text-foreground' />,
201
  }}
202
  />
203
  </DialogDescription>
 
206
  <Separator />
207
  {update.body ? (
208
  <ScrollArea className='h-64'>
209
+ <div className='prose prose-sm dark:prose-invert max-w-none px-6 py-4 [&_a]:text-primary [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:tracking-wide [&_h3]:text-muted-foreground [&_h3]:uppercase [&_li]:my-0.5 [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5'>
210
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{update.body}</ReactMarkdown>
 
 
211
  </div>
212
  </ScrollArea>
213
  ) : (
214
+ <div className='px-6 py-6 text-sm text-muted-foreground'>{t('updater.noNotes')}</div>
 
 
215
  )}
216
  <Separator />
217
  <footer className='flex justify-end gap-2 px-6 py-4'>
 
242
  return (
243
  <div className='flex flex-col gap-4 p-6'>
244
  <div className='flex items-center gap-3'>
245
+ <div className='flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary'>
246
  <Download className='size-5 animate-pulse' />
247
  </div>
248
  <div className='flex flex-col gap-0.5'>
249
+ <DialogTitle className='text-base'>{t('updater.downloading.title')}</DialogTitle>
250
+ <DialogDescription>{t('updater.downloading.subtitle', { version })}</DialogDescription>
 
 
 
 
251
  </div>
252
  </div>
253
  <div className='space-y-2'>
254
  <Progress value={percent ?? undefined} />
255
+ <div className='flex justify-between text-xs text-muted-foreground tabular-nums'>
256
  <span>
257
  {formatBytes(downloaded)}
258
  {total ? ` / ${formatBytes(total)}` : ''}
 
277
  return (
278
  <>
279
  <header className='flex items-center gap-3 px-6 pt-6 pb-4'>
280
+ <div className='flex size-10 items-center justify-center rounded-full bg-destructive/10 text-destructive'>
281
  <AlertCircle className='size-5' />
282
  </div>
283
  <div className='flex flex-col gap-0.5'>
284
+ <DialogTitle className='text-base'>{t('updater.error.title')}</DialogTitle>
 
 
285
  <DialogDescription className='break-words'>
286
  {t('updater.error.description')}
287
  </DialogDescription>
 
289
  </header>
290
  <Separator />
291
  <ScrollArea className='max-h-40'>
292
+ <pre className='px-6 py-4 text-xs break-words whitespace-pre-wrap text-muted-foreground'>
293
  {message}
294
  </pre>
295
  </ScrollArea>
ui/components/canvas/CanvasToolbar.tsx CHANGED
@@ -1,9 +1,5 @@
1
  'use client'
2
 
3
- import { useEffect, useMemo, useState } from 'react'
4
- import { useTranslation } from 'react-i18next'
5
-
6
- import { motion } from 'motion/react'
7
  import {
8
  ScanIcon,
9
  ScanTextIcon,
@@ -12,10 +8,13 @@ import {
12
  LoaderCircleIcon,
13
  LanguagesIcon,
14
  } from 'lucide-react'
15
- import { Separator } from '@/components/ui/separator'
 
 
 
16
  import { Button } from '@/components/ui/button'
17
  import { Input } from '@/components/ui/input'
18
- import { Textarea } from '@/components/ui/textarea'
19
  import {
20
  Select,
21
  SelectContent,
@@ -23,21 +22,14 @@ import {
23
  SelectTrigger,
24
  SelectValue,
25
  } from '@/components/ui/select'
26
- import {
27
- Popover,
28
- PopoverContent,
29
- PopoverTrigger,
30
- } from '@/components/ui/popover'
31
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
32
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
33
  import { useGetLlm, useGetLlmCatalog } from '@/lib/api/llm/llm'
34
- import type {
35
- LlmCatalog,
36
- LlmCatalogModel,
37
- LlmProviderCatalog,
38
- } from '@/lib/api/schemas'
39
- import { useProcessing } from '@/lib/machines'
40
  import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
 
 
 
41
 
42
  type SelectableLlmModel = {
43
  model: LlmCatalogModel
@@ -48,15 +40,10 @@ const flattenCatalogModels = (catalog?: LlmCatalog): SelectableLlmModel[] => [
48
  ...(catalog?.localModels ?? []).map((model) => ({ model })),
49
  ...(catalog?.providers ?? [])
50
  .filter((provider) => provider.status === 'ready')
51
- .flatMap((provider) =>
52
- provider.models.map((model) => ({ model, provider })),
53
- ),
54
  ]
55
 
56
- const filterCatalogModels = (
57
- models: SelectableLlmModel[],
58
- query: string,
59
- ): SelectableLlmModel[] => {
60
  const normalized = query.trim().toLowerCase()
61
  if (!normalized) return models
62
 
@@ -68,15 +55,13 @@ const filterCatalogModels = (
68
  provider?.name,
69
  provider?.id,
70
  ]
71
- return candidates.some((candidate) =>
72
- candidate?.toLowerCase().includes(normalized),
73
- )
74
  })
75
  }
76
 
77
  export function CanvasToolbar() {
78
  return (
79
- <div className='border-border/60 bg-card text-foreground flex items-center gap-2 border-b px-3 py-2 text-xs'>
80
  <WorkflowButtons />
81
  <div className='flex-1' />
82
  <LlmStatusPopover />
@@ -88,9 +73,7 @@ function WorkflowButtons() {
88
  const { send, isProcessing, state } = useProcessing()
89
  const { data: llmState } = useGetLlm()
90
  const llmReady = llmState?.status === 'ready'
91
- const hasDocument = useEditorUiStore(
92
- (state) => state.currentDocumentId !== null,
93
- )
94
  const { t } = useTranslation()
95
 
96
  const isDetecting = state.matches('detecting')
@@ -110,9 +93,7 @@ function WorkflowButtons() {
110
  <Button
111
  variant='ghost'
112
  size='xs'
113
- onClick={() =>
114
- send({ type: 'START_DETECT', documentId: requireDocumentId() })
115
- }
116
  data-testid='toolbar-detect'
117
  disabled={!hasDocument || isDetecting || isProcessing}
118
  >
@@ -129,9 +110,7 @@ function WorkflowButtons() {
129
  <Button
130
  variant='ghost'
131
  size='xs'
132
- onClick={() =>
133
- send({ type: 'START_RECOGNIZE', documentId: requireDocumentId() })
134
- }
135
  data-testid='toolbar-ocr'
136
  disabled={!hasDocument || isOcr || isProcessing}
137
  >
@@ -177,9 +156,7 @@ function WorkflowButtons() {
177
  <Button
178
  variant='ghost'
179
  size='xs'
180
- onClick={() =>
181
- send({ type: 'START_INPAINT', documentId: requireDocumentId() })
182
- }
183
  data-testid='toolbar-inpaint'
184
  disabled={!hasDocument || isInpainting || isProcessing}
185
  >
@@ -226,20 +203,11 @@ function LlmStatusPopover() {
226
  const { data: llmCatalog } = useGetLlmCatalog()
227
  const [popoverOpen, setPopoverOpen] = useState(false)
228
  const [modelSearchQuery, setModelSearchQuery] = useState('')
229
- const llmModels = useMemo(
230
- () => flattenCatalogModels(llmCatalog),
231
- [llmCatalog],
232
- )
233
  const selectedTarget = useEditorUiStore((state) => state.selectedTarget)
234
- const customSystemPrompt = usePreferencesStore(
235
- (state) => state.customSystemPrompt,
236
- )
237
- const setCustomSystemPrompt = usePreferencesStore(
238
- (state) => state.setCustomSystemPrompt,
239
- )
240
- const llmSelectedLanguage = useEditorUiStore(
241
- (state) => state.selectedLanguage,
242
- )
243
  const { data: llmState } = useGetLlm()
244
  const llmReady = llmState?.status === 'ready'
245
  const { send, state } = useProcessing()
@@ -249,27 +217,19 @@ function LlmStatusPopover() {
249
  const { t } = useTranslation()
250
 
251
  const selectedModel = useMemo(
252
- () =>
253
- llmModels.find(({ model }) =>
254
- sameLlmTarget(model.target, selectedTarget),
255
- ),
256
  [llmModels, selectedTarget],
257
  )
258
  const filteredLlmModels = useMemo(
259
  () => filterCatalogModels(llmModels, modelSearchQuery),
260
  [llmModels, modelSearchQuery],
261
  )
262
- const selectedTargetKey = selectedTarget
263
- ? llmTargetKey(selectedTarget)
264
- : undefined
265
  const selectedModelLanguages = selectedModel?.model.languages ?? []
266
- const selectedIsLoaded =
267
- llmReady && sameLlmTarget(llmState?.target, selectedTarget)
268
 
269
  const handleSetSelectedModel = (key: string) => {
270
- const nextSelection = llmModels.find(
271
- ({ model }) => llmTargetKey(model.target) === key,
272
- )
273
  if (!nextSelection) return
274
 
275
  const nextLanguages = nextSelection.model.languages
@@ -310,9 +270,7 @@ function LlmStatusPopover() {
310
  useEffect(() => {
311
  if (llmModels.length === 0) return
312
 
313
- const hasCurrent = llmModels.some(({ model }) =>
314
- sameLlmTarget(model.target, selectedTarget),
315
- )
316
  const nextModel = hasCurrent ? selectedModel?.model : llmModels[0]?.model
317
  if (!nextModel) return
318
 
@@ -354,23 +312,15 @@ function LlmStatusPopover() {
354
  ? 'bg-rose-400 text-white ring-1 ring-rose-400/30'
355
  : busy
356
  ? 'bg-amber-400 text-white ring-1 ring-amber-400/30'
357
- : 'bg-muted text-muted-foreground ring-border/50 ring-1'
358
  }`}
359
  >
360
  <motion.span
361
  className={`size-1.5 rounded-full ${
362
- llmReady
363
- ? 'bg-white'
364
- : busy
365
- ? 'bg-white'
366
- : 'bg-muted-foreground/40'
367
  }`}
368
  animate={
369
- llmReady
370
- ? { opacity: [1, 0.5, 1] }
371
- : busy
372
- ? { opacity: [1, 0.4, 1] }
373
- : { opacity: 1 }
374
  }
375
  transition={
376
  llmReady || busy
@@ -385,14 +335,10 @@ function LlmStatusPopover() {
385
  LLM
386
  </button>
387
  </PopoverTrigger>
388
- <PopoverContent
389
- align='end'
390
- className='w-[280px] p-0'
391
- data-testid='llm-popover'
392
- >
393
  {/* Model + load */}
394
  <div className='flex flex-col gap-1 px-3 pt-3 pb-2.5'>
395
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
396
  {t('llm.model', { defaultValue: 'Model' })}
397
  </span>
398
  <Input
@@ -405,14 +351,8 @@ function LlmStatusPopover() {
405
  className='h-7 text-xs'
406
  />
407
  <div className='flex items-center gap-1.5'>
408
- <Select
409
- value={selectedTargetKey}
410
- onValueChange={handleSetSelectedModel}
411
- >
412
- <SelectTrigger
413
- data-testid='llm-model-select'
414
- className='min-w-0 flex-1'
415
- >
416
  <SelectValue placeholder={t('llm.selectPlaceholder')} />
417
  </SelectTrigger>
418
  <SelectContent position='popper'>
@@ -425,7 +365,7 @@ function LlmStatusPopover() {
425
  >
426
  <span className='flex items-center gap-1.5'>
427
  {provider ? (
428
- <span className='bg-primary/10 text-primary rounded px-1 py-0.5 text-[9px] leading-none font-semibold uppercase'>
429
  {provider.name}
430
  </span>
431
  ) : null}
@@ -436,7 +376,7 @@ function LlmStatusPopover() {
436
  ) : (
437
  <div
438
  data-testid='llm-model-empty'
439
- className='text-muted-foreground px-2 py-2 text-xs'
440
  >
441
  {t('llm.modelSearchNoResults', {
442
  defaultValue: 'No models found',
@@ -455,12 +395,8 @@ function LlmStatusPopover() {
455
  disabled={!selectedTarget || busy}
456
  className='h-6 shrink-0 gap-1 px-2 text-[11px]'
457
  >
458
- {busy ? (
459
- <LoaderCircleIcon className='size-3 animate-spin' />
460
- ) : null}
461
- {selectedIsLoaded || llmUnloading
462
- ? t('llm.unload')
463
- : t('llm.load')}
464
  </Button>
465
  </div>
466
  </div>
@@ -471,7 +407,7 @@ function LlmStatusPopover() {
471
 
472
  {/* Language + prompt */}
473
  <div className='flex flex-col gap-1 px-3 pt-2.5 pb-3'>
474
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
475
  {t('llm.translationSettings', {
476
  defaultValue: 'Translation',
477
  })}
@@ -483,10 +419,7 @@ function LlmStatusPopover() {
483
  value={llmSelectedLanguage ?? selectedModelLanguages[0]}
484
  onValueChange={handleSetSelectedLanguage}
485
  >
486
- <SelectTrigger
487
- data-testid='llm-language-select'
488
- className='w-full'
489
- >
490
  <SelectValue placeholder={t('llm.languagePlaceholder')} />
491
  </SelectTrigger>
492
  <SelectContent position='popper'>
@@ -506,9 +439,7 @@ function LlmStatusPopover() {
506
  <Textarea
507
  data-testid='llm-system-prompt'
508
  value={customSystemPrompt ?? ''}
509
- onChange={(e) =>
510
- setCustomSystemPrompt(e.target.value || undefined)
511
- }
512
  placeholder={t('llm.systemPromptPlaceholder', {
513
  defaultValue: 'Custom system prompt (optional)',
514
  })}
 
1
  'use client'
2
 
 
 
 
 
3
  import {
4
  ScanIcon,
5
  ScanTextIcon,
 
8
  LoaderCircleIcon,
9
  LanguagesIcon,
10
  } from 'lucide-react'
11
+ import { motion } from 'motion/react'
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import { useTranslation } from 'react-i18next'
14
+
15
  import { Button } from '@/components/ui/button'
16
  import { Input } from '@/components/ui/input'
17
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
18
  import {
19
  Select,
20
  SelectContent,
 
22
  SelectTrigger,
23
  SelectValue,
24
  } from '@/components/ui/select'
25
+ import { Separator } from '@/components/ui/separator'
26
+ import { Textarea } from '@/components/ui/textarea'
 
 
 
 
 
27
  import { useGetLlm, useGetLlmCatalog } from '@/lib/api/llm/llm'
28
+ import type { LlmCatalog, LlmCatalogModel, LlmProviderCatalog } from '@/lib/api/schemas'
 
 
 
 
 
29
  import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets'
30
+ import { useProcessing } from '@/lib/machines'
31
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
32
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
33
 
34
  type SelectableLlmModel = {
35
  model: LlmCatalogModel
 
40
  ...(catalog?.localModels ?? []).map((model) => ({ model })),
41
  ...(catalog?.providers ?? [])
42
  .filter((provider) => provider.status === 'ready')
43
+ .flatMap((provider) => provider.models.map((model) => ({ model, provider }))),
 
 
44
  ]
45
 
46
+ const filterCatalogModels = (models: SelectableLlmModel[], query: string): SelectableLlmModel[] => {
 
 
 
47
  const normalized = query.trim().toLowerCase()
48
  if (!normalized) return models
49
 
 
55
  provider?.name,
56
  provider?.id,
57
  ]
58
+ return candidates.some((candidate) => candidate?.toLowerCase().includes(normalized))
 
 
59
  })
60
  }
61
 
62
  export function CanvasToolbar() {
63
  return (
64
+ <div className='flex items-center gap-2 border-b border-border/60 bg-card px-3 py-2 text-xs text-foreground'>
65
  <WorkflowButtons />
66
  <div className='flex-1' />
67
  <LlmStatusPopover />
 
73
  const { send, isProcessing, state } = useProcessing()
74
  const { data: llmState } = useGetLlm()
75
  const llmReady = llmState?.status === 'ready'
76
+ const hasDocument = useEditorUiStore((state) => state.currentDocumentId !== null)
 
 
77
  const { t } = useTranslation()
78
 
79
  const isDetecting = state.matches('detecting')
 
93
  <Button
94
  variant='ghost'
95
  size='xs'
96
+ onClick={() => send({ type: 'START_DETECT', documentId: requireDocumentId() })}
 
 
97
  data-testid='toolbar-detect'
98
  disabled={!hasDocument || isDetecting || isProcessing}
99
  >
 
110
  <Button
111
  variant='ghost'
112
  size='xs'
113
+ onClick={() => send({ type: 'START_RECOGNIZE', documentId: requireDocumentId() })}
 
 
114
  data-testid='toolbar-ocr'
115
  disabled={!hasDocument || isOcr || isProcessing}
116
  >
 
156
  <Button
157
  variant='ghost'
158
  size='xs'
159
+ onClick={() => send({ type: 'START_INPAINT', documentId: requireDocumentId() })}
 
 
160
  data-testid='toolbar-inpaint'
161
  disabled={!hasDocument || isInpainting || isProcessing}
162
  >
 
203
  const { data: llmCatalog } = useGetLlmCatalog()
204
  const [popoverOpen, setPopoverOpen] = useState(false)
205
  const [modelSearchQuery, setModelSearchQuery] = useState('')
206
+ const llmModels = useMemo(() => flattenCatalogModels(llmCatalog), [llmCatalog])
 
 
 
207
  const selectedTarget = useEditorUiStore((state) => state.selectedTarget)
208
+ const customSystemPrompt = usePreferencesStore((state) => state.customSystemPrompt)
209
+ const setCustomSystemPrompt = usePreferencesStore((state) => state.setCustomSystemPrompt)
210
+ const llmSelectedLanguage = useEditorUiStore((state) => state.selectedLanguage)
 
 
 
 
 
 
211
  const { data: llmState } = useGetLlm()
212
  const llmReady = llmState?.status === 'ready'
213
  const { send, state } = useProcessing()
 
217
  const { t } = useTranslation()
218
 
219
  const selectedModel = useMemo(
220
+ () => llmModels.find(({ model }) => sameLlmTarget(model.target, selectedTarget)),
 
 
 
221
  [llmModels, selectedTarget],
222
  )
223
  const filteredLlmModels = useMemo(
224
  () => filterCatalogModels(llmModels, modelSearchQuery),
225
  [llmModels, modelSearchQuery],
226
  )
227
+ const selectedTargetKey = selectedTarget ? llmTargetKey(selectedTarget) : undefined
 
 
228
  const selectedModelLanguages = selectedModel?.model.languages ?? []
229
+ const selectedIsLoaded = llmReady && sameLlmTarget(llmState?.target, selectedTarget)
 
230
 
231
  const handleSetSelectedModel = (key: string) => {
232
+ const nextSelection = llmModels.find(({ model }) => llmTargetKey(model.target) === key)
 
 
233
  if (!nextSelection) return
234
 
235
  const nextLanguages = nextSelection.model.languages
 
270
  useEffect(() => {
271
  if (llmModels.length === 0) return
272
 
273
+ const hasCurrent = llmModels.some(({ model }) => sameLlmTarget(model.target, selectedTarget))
 
 
274
  const nextModel = hasCurrent ? selectedModel?.model : llmModels[0]?.model
275
  if (!nextModel) return
276
 
 
312
  ? 'bg-rose-400 text-white ring-1 ring-rose-400/30'
313
  : busy
314
  ? 'bg-amber-400 text-white ring-1 ring-amber-400/30'
315
+ : 'bg-muted text-muted-foreground ring-1 ring-border/50'
316
  }`}
317
  >
318
  <motion.span
319
  className={`size-1.5 rounded-full ${
320
+ llmReady ? 'bg-white' : busy ? 'bg-white' : 'bg-muted-foreground/40'
 
 
 
 
321
  }`}
322
  animate={
323
+ llmReady ? { opacity: [1, 0.5, 1] } : busy ? { opacity: [1, 0.4, 1] } : { opacity: 1 }
 
 
 
 
324
  }
325
  transition={
326
  llmReady || busy
 
335
  LLM
336
  </button>
337
  </PopoverTrigger>
338
+ <PopoverContent align='end' className='w-[280px] p-0' data-testid='llm-popover'>
 
 
 
 
339
  {/* Model + load */}
340
  <div className='flex flex-col gap-1 px-3 pt-3 pb-2.5'>
341
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
342
  {t('llm.model', { defaultValue: 'Model' })}
343
  </span>
344
  <Input
 
351
  className='h-7 text-xs'
352
  />
353
  <div className='flex items-center gap-1.5'>
354
+ <Select value={selectedTargetKey} onValueChange={handleSetSelectedModel}>
355
+ <SelectTrigger data-testid='llm-model-select' className='min-w-0 flex-1'>
 
 
 
 
 
 
356
  <SelectValue placeholder={t('llm.selectPlaceholder')} />
357
  </SelectTrigger>
358
  <SelectContent position='popper'>
 
365
  >
366
  <span className='flex items-center gap-1.5'>
367
  {provider ? (
368
+ <span className='rounded bg-primary/10 px-1 py-0.5 text-[9px] leading-none font-semibold text-primary uppercase'>
369
  {provider.name}
370
  </span>
371
  ) : null}
 
376
  ) : (
377
  <div
378
  data-testid='llm-model-empty'
379
+ className='px-2 py-2 text-xs text-muted-foreground'
380
  >
381
  {t('llm.modelSearchNoResults', {
382
  defaultValue: 'No models found',
 
395
  disabled={!selectedTarget || busy}
396
  className='h-6 shrink-0 gap-1 px-2 text-[11px]'
397
  >
398
+ {busy ? <LoaderCircleIcon className='size-3 animate-spin' /> : null}
399
+ {selectedIsLoaded || llmUnloading ? t('llm.unload') : t('llm.load')}
 
 
 
 
400
  </Button>
401
  </div>
402
  </div>
 
407
 
408
  {/* Language + prompt */}
409
  <div className='flex flex-col gap-1 px-3 pt-2.5 pb-3'>
410
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
411
  {t('llm.translationSettings', {
412
  defaultValue: 'Translation',
413
  })}
 
419
  value={llmSelectedLanguage ?? selectedModelLanguages[0]}
420
  onValueChange={handleSetSelectedLanguage}
421
  >
422
+ <SelectTrigger data-testid='llm-language-select' className='w-full'>
 
 
 
423
  <SelectValue placeholder={t('llm.languagePlaceholder')} />
424
  </SelectTrigger>
425
  <SelectContent position='popper'>
 
439
  <Textarea
440
  data-testid='llm-system-prompt'
441
  value={customSystemPrompt ?? ''}
442
+ onChange={(e) => setCustomSystemPrompt(e.target.value || undefined)}
 
 
443
  placeholder={t('llm.systemPromptPlaceholder', {
444
  defaultValue: 'Custom system prompt (optional)',
445
  })}
ui/components/canvas/StatusBar.tsx CHANGED
@@ -1,20 +1,21 @@
1
  'use client'
2
 
3
  import { useTranslation } from 'react-i18next'
4
- import { useCanvasZoom } from '@/hooks/useCanvasZoom'
5
  import { Slider } from '@/components/ui/slider'
 
6
 
7
  export function StatusBar() {
8
  const { scale, setScale, summary } = useCanvasZoom()
9
  const { t } = useTranslation()
10
 
11
  return (
12
- <div className='border-border bg-card text-foreground flex shrink-0 items-center justify-end gap-3 border-t px-2 py-1 text-xs'>
13
  <div className='flex items-center gap-1.5'>
14
  <span className='text-muted-foreground'>{t('statusBar.zoom')}</span>
15
  <Slider
16
  data-testid='zoom-slider'
17
- className='[&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20 w-44 [&_[data-slot=slider-thumb]]:size-2.5'
18
  min={10}
19
  max={100}
20
  step={5}
@@ -25,7 +26,7 @@ export function StatusBar() {
25
  {scale}%
26
  </span>
27
  </div>
28
- <span className='text-muted-foreground ml-auto text-[11px]'>
29
  {t('statusBar.canvas')}: {summary}
30
  </span>
31
  </div>
 
1
  'use client'
2
 
3
  import { useTranslation } from 'react-i18next'
4
+
5
  import { Slider } from '@/components/ui/slider'
6
+ import { useCanvasZoom } from '@/hooks/useCanvasZoom'
7
 
8
  export function StatusBar() {
9
  const { scale, setScale, summary } = useCanvasZoom()
10
  const { t } = useTranslation()
11
 
12
  return (
13
+ <div className='flex shrink-0 items-center justify-end gap-3 border-t border-border bg-card px-2 py-1 text-xs text-foreground'>
14
  <div className='flex items-center gap-1.5'>
15
  <span className='text-muted-foreground'>{t('statusBar.zoom')}</span>
16
  <Slider
17
  data-testid='zoom-slider'
18
+ className='w-44 [&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:size-2.5 [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20'
19
  min={10}
20
  max={100}
21
  step={5}
 
26
  {scale}%
27
  </span>
28
  </div>
29
+ <span className='ml-auto text-[11px] text-muted-foreground'>
30
  {t('statusBar.canvas')}: {summary}
31
  </span>
32
  </div>
ui/components/canvas/TextBlockLayer.tsx CHANGED
@@ -1,12 +1,13 @@
1
  'use client'
2
 
3
- import { useRef } from 'react'
4
  import { useDrag } from '@use-gesture/react'
 
5
  import { useHotkeys } from 'react-hotkeys-hook'
 
 
 
6
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
7
  import { TextBlock } from '@/types'
8
- import { useTextBlocks } from '@/hooks/useTextBlocks'
9
- import { useBlobImage } from '@/hooks/useBlobData'
10
 
11
  type TextBlockLayerProps = {
12
  selectedIndex?: number
@@ -52,11 +53,7 @@ export function TextBlockLayer({
52
  >
53
  {showSprites &&
54
  textBlocks.map((block, index) => (
55
- <BlockSprite
56
- key={`sprite-${block.id ?? index}`}
57
- block={block}
58
- scale={scale}
59
- />
60
  ))}
61
  {textBlocks.map((block, index) => (
62
  <TextBlockItem
@@ -222,7 +219,7 @@ function TextBlockItem({
222
  <div
223
  className={`absolute inset-0 rounded ${
224
  selected
225
- ? 'border-primary bg-primary/15 border-[3px]'
226
  : 'border-2 border-rose-400/60 bg-rose-400/5'
227
  }`}
228
  />
@@ -237,9 +234,7 @@ function TextBlockItem({
237
  </div>
238
 
239
  {/* Resize handles */}
240
- {selected && interactive && (
241
- <ResizeHandles onEdgePointerDown={handleEdgePointerDown} />
242
- )}
243
  </div>
244
  )
245
  }
@@ -265,11 +260,7 @@ function BlockSprite({ block, scale }: { block: TextBlock; scale: number }) {
265
  )
266
  }
267
 
268
- function ResizeHandles({
269
- onEdgePointerDown,
270
- }: {
271
- onEdgePointerDown: (edge: ResizeEdge) => void
272
- }) {
273
  const s = RESIZE_HANDLE_SIZE
274
  const half = s / 2
275
 
 
1
  'use client'
2
 
 
3
  import { useDrag } from '@use-gesture/react'
4
+ import { useRef } from 'react'
5
  import { useHotkeys } from 'react-hotkeys-hook'
6
+
7
+ import { useBlobImage } from '@/hooks/useBlobData'
8
+ import { useTextBlocks } from '@/hooks/useTextBlocks'
9
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
  import { TextBlock } from '@/types'
 
 
11
 
12
  type TextBlockLayerProps = {
13
  selectedIndex?: number
 
53
  >
54
  {showSprites &&
55
  textBlocks.map((block, index) => (
56
+ <BlockSprite key={`sprite-${block.id ?? index}`} block={block} scale={scale} />
 
 
 
 
57
  ))}
58
  {textBlocks.map((block, index) => (
59
  <TextBlockItem
 
219
  <div
220
  className={`absolute inset-0 rounded ${
221
  selected
222
+ ? 'border-[3px] border-primary bg-primary/15'
223
  : 'border-2 border-rose-400/60 bg-rose-400/5'
224
  }`}
225
  />
 
234
  </div>
235
 
236
  {/* Resize handles */}
237
+ {selected && interactive && <ResizeHandles onEdgePointerDown={handleEdgePointerDown} />}
 
 
238
  </div>
239
  )
240
  }
 
260
  )
261
  }
262
 
263
+ function ResizeHandles({ onEdgePointerDown }: { onEdgePointerDown: (edge: ResizeEdge) => void }) {
 
 
 
 
264
  const s = RESIZE_HANDLE_SIZE
265
  const half = s / 2
266
 
ui/components/canvas/ToolRail.tsx CHANGED
@@ -1,30 +1,17 @@
1
  'use client'
2
 
 
3
  import type { ComponentType } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
- import {
6
- MousePointer,
7
- VectorSquare,
8
- Brush,
9
- Bandage,
10
- Eraser,
11
- } from 'lucide-react'
12
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
13
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
14
  import { ToolMode } from '@/types'
15
- import {
16
- Tooltip,
17
- TooltipContent,
18
- TooltipTrigger,
19
- } from '@/components/ui/tooltip'
20
- import {
21
- Popover,
22
- PopoverContent,
23
- PopoverTrigger,
24
- } from '@/components/ui/popover'
25
- import { Slider } from '@/components/ui/slider'
26
- import { Button } from '@/components/ui/button'
27
- import { ColorPicker } from '@/components/ui/color-picker'
28
 
29
  type ModeDefinition = {
30
  value: ToolMode
@@ -73,7 +60,7 @@ export function ToolRail() {
73
  const { t } = useTranslation()
74
 
75
  return (
76
- <div className='border-border bg-card flex w-11 flex-col border-r'>
77
  <div className='flex flex-1 flex-col items-center gap-1 py-2'>
78
  {MODES.map((item) => {
79
  const label = t(item.labelKey)
@@ -100,7 +87,7 @@ export function ToolRail() {
100
  data-testid={item.testId}
101
  data-active={item.value === mode}
102
  onClick={() => setMode(item.value)}
103
- className='text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary border border-transparent'
104
  aria-label={label}
105
  >
106
  <item.icon className='h-4 w-4' />
@@ -148,7 +135,7 @@ function BrushToolWithPopover({
148
  data-testid={item.testId}
149
  data-active={isActive}
150
  onClick={onSelect}
151
- className='text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary border border-transparent'
152
  aria-label={label}
153
  >
154
  <item.icon className='h-4 w-4' />
@@ -164,24 +151,22 @@ function BrushToolWithPopover({
164
  <PopoverContent side='right' align='start' className='w-56'>
165
  <div className='space-y-4 text-sm'>
166
  <div className='space-y-2'>
167
- <p className='text-muted-foreground text-xs font-medium uppercase'>
168
  {t('toolbar.brushSize')}
169
  </p>
170
  <div className='flex items-center gap-2'>
171
  <Slider
172
  data-testid='brush-size-slider'
173
- className='[&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20 flex-1 [&_[data-slot=slider-thumb]]:size-3'
174
  min={8}
175
  max={128}
176
  step={4}
177
  value={[brushSize]}
178
- onValueChange={(vals) =>
179
- setBrushConfig({ size: vals[0] ?? brushSize })
180
- }
181
  />
182
  <Tooltip>
183
  <TooltipTrigger asChild>
184
- <span className='text-muted-foreground w-10 cursor-help text-right tabular-nums'>
185
  {brushSize}px
186
  </span>
187
  </TooltipTrigger>
@@ -193,7 +178,7 @@ function BrushToolWithPopover({
193
  </div>
194
  </div>
195
  <div className='space-y-2'>
196
- <p className='text-muted-foreground text-xs font-medium uppercase'>
197
  {t('toolbar.brushColor')}
198
  </p>
199
  <div className='flex items-center gap-2'>
@@ -207,9 +192,7 @@ function BrushToolWithPopover({
207
  inputTestId='brush-color-input'
208
  pickButtonTestId='brush-color-pick'
209
  />
210
- <span className='text-muted-foreground text-xs'>
211
- {brushColor}
212
- </span>
213
  </div>
214
  </div>
215
  </div>
 
1
  'use client'
2
 
3
+ import { MousePointer, VectorSquare, Brush, Bandage, Eraser } from 'lucide-react'
4
  import type { ComponentType } from 'react'
5
  import { useTranslation } from 'react-i18next'
6
+
7
+ import { Button } from '@/components/ui/button'
8
+ import { ColorPicker } from '@/components/ui/color-picker'
9
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
10
+ import { Slider } from '@/components/ui/slider'
11
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
 
12
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
13
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
14
  import { ToolMode } from '@/types'
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  type ModeDefinition = {
17
  value: ToolMode
 
60
  const { t } = useTranslation()
61
 
62
  return (
63
+ <div className='flex w-11 flex-col border-r border-border bg-card'>
64
  <div className='flex flex-1 flex-col items-center gap-1 py-2'>
65
  {MODES.map((item) => {
66
  const label = t(item.labelKey)
 
87
  data-testid={item.testId}
88
  data-active={item.value === mode}
89
  onClick={() => setMode(item.value)}
90
+ className='border border-transparent text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary'
91
  aria-label={label}
92
  >
93
  <item.icon className='h-4 w-4' />
 
135
  data-testid={item.testId}
136
  data-active={isActive}
137
  onClick={onSelect}
138
+ className='border border-transparent text-muted-foreground data-[active=true]:border-primary data-[active=true]:bg-accent data-[active=true]:text-primary'
139
  aria-label={label}
140
  >
141
  <item.icon className='h-4 w-4' />
 
151
  <PopoverContent side='right' align='start' className='w-56'>
152
  <div className='space-y-4 text-sm'>
153
  <div className='space-y-2'>
154
+ <p className='text-xs font-medium text-muted-foreground uppercase'>
155
  {t('toolbar.brushSize')}
156
  </p>
157
  <div className='flex items-center gap-2'>
158
  <Slider
159
  data-testid='brush-size-slider'
160
+ className='flex-1 [&_[data-slot=slider-range]]:bg-primary [&_[data-slot=slider-thumb]]:size-3 [&_[data-slot=slider-thumb]]:border-primary [&_[data-slot=slider-thumb]]:bg-primary [&_[data-slot=slider-track]]:bg-primary/20'
161
  min={8}
162
  max={128}
163
  step={4}
164
  value={[brushSize]}
165
+ onValueChange={(vals) => setBrushConfig({ size: vals[0] ?? brushSize })}
 
 
166
  />
167
  <Tooltip>
168
  <TooltipTrigger asChild>
169
+ <span className='w-10 cursor-help text-right text-muted-foreground tabular-nums'>
170
  {brushSize}px
171
  </span>
172
  </TooltipTrigger>
 
178
  </div>
179
  </div>
180
  <div className='space-y-2'>
181
+ <p className='text-xs font-medium text-muted-foreground uppercase'>
182
  {t('toolbar.brushColor')}
183
  </p>
184
  <div className='flex items-center gap-2'>
 
192
  inputTestId='brush-color-input'
193
  pickButtonTestId='brush-color-pick'
194
  />
195
+ <span className='text-xs text-muted-foreground'>{brushColor}</span>
 
 
196
  </div>
197
  </div>
198
  </div>
ui/components/canvas/Workspace.tsx CHANGED
@@ -1,58 +1,53 @@
1
  'use client'
2
 
3
- import { useEffect, useRef, useMemo, useCallback } from 'react'
4
- import type React from 'react'
5
  import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
6
  import { useGesture } from '@use-gesture/react'
7
- import {
8
- ContextMenu,
9
- ContextMenuContent,
10
- ContextMenuItem,
11
- ContextMenuTrigger,
12
- } from '@/components/ui/context-menu'
13
  import { useTranslation } from 'react-i18next'
14
- import { listen } from '@/lib/backend'
15
- import { Image } from '@/components/Image'
16
  import {
17
  setCanvasViewport,
18
  setCanvasDocumentSize,
19
  fitCanvasToViewport,
20
  } from '@/components/canvas/canvasViewport'
21
- import { ToolRail } from '@/components/canvas/ToolRail'
22
- import { CanvasToolbar } from '@/components/canvas/CanvasToolbar'
23
  import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
24
- import { useCanvasZoom } from '@/hooks/useCanvasZoom'
25
- import { usePointerToDocument } from '@/hooks/usePointerToDocument'
26
- import { useBlockDrafting } from '@/hooks/useBlockDrafting'
27
- import { useBlockContextMenu } from '@/hooks/useBlockContextMenu'
28
- import { useTextBlocks, useDocumentLayer } from '@/hooks/useTextBlocks'
29
- import { useMaskDrawing } from '@/hooks/useMaskDrawing'
30
- import { useRenderBrushDrawing } from '@/hooks/useRenderBrushDrawing'
31
- import { useBrushLayerDisplay } from '@/hooks/useBrushLayerDisplay'
32
- import { useBrushCursor } from '@/hooks/useBrushCursor'
33
- import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
34
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
35
  import {
36
  resolvePinchMemoScaleRatio,
37
  resolvePinchNextScaleRatio,
38
  } from '@/components/canvas/zoomGestures'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  const BRUSH_CURSOR = 'none'
41
 
42
  export function Workspace() {
43
  useKeyboardShortcuts()
44
  const scale = useEditorUiStore((state) => state.scale)
45
- const showSegmentationMask = useEditorUiStore(
46
- (state) => state.showSegmentationMask,
47
- )
48
- const showInpaintedImage = useEditorUiStore(
49
- (state) => state.showInpaintedImage,
50
- )
51
  const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
52
  const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
53
- const showTextBlocksOverlay = useEditorUiStore(
54
- (state) => state.showTextBlocksOverlay,
55
- )
56
  const mode = useEditorUiStore((state) => state.mode)
57
  const autoFitEnabled = useEditorUiStore((state) => state.autoFitEnabled)
58
  const {
@@ -64,16 +59,8 @@ export function Workspace() {
64
  removeBlock,
65
  } = useTextBlocks()
66
 
67
- const imageData = useDocumentLayer(
68
- currentDocument?.id,
69
- 'image',
70
- currentDocument?.image,
71
- )
72
- const segmentData = useDocumentLayer(
73
- currentDocument?.id,
74
- 'segment',
75
- currentDocument?.segment,
76
- )
77
  const inpaintedData = useDocumentLayer(
78
  currentDocument?.id,
79
  'inpainted',
@@ -84,11 +71,7 @@ export function Workspace() {
84
  'brushLayer',
85
  currentDocument?.brushLayer,
86
  )
87
- const renderedData = useDocumentLayer(
88
- currentDocument?.id,
89
- 'rendered',
90
- currentDocument?.rendered,
91
- )
92
 
93
  useEffect(() => {
94
  if (currentDocument) {
@@ -123,14 +106,11 @@ export function Workspace() {
123
 
124
  const maskPointerEnabled = useMemo(
125
  () =>
126
- mode === 'repairBrush' ||
127
- (mode === 'eraser' && (showSegmentationMask || !showBrushLayer)),
128
  [mode, showSegmentationMask, showBrushLayer],
129
  )
130
  const brushPointerEnabled = useMemo(
131
- () =>
132
- mode === 'brush' ||
133
- (mode === 'eraser' && !showSegmentationMask && showBrushLayer),
134
  [mode, showSegmentationMask, showBrushLayer],
135
  )
136
  const maskDrawing = useMaskDrawing({
@@ -163,19 +143,15 @@ export function Workspace() {
163
  fitCanvasToViewport()
164
  }
165
  }, [currentDocument?.id, autoFitEnabled])
166
- const {
167
- contextMenuBlockIndex,
168
- handleContextMenu,
169
- handleDeleteBlock,
170
- clearContextMenu,
171
- } = useBlockContextMenu({
172
- currentDocument,
173
- pointerToDocument,
174
- selectBlock: setSelectedBlockIndex,
175
- removeBlock: (index) => {
176
- void removeBlock(index)
177
- },
178
- })
179
  const { t } = useTranslation()
180
 
181
  // Listen for Tauri resize events
@@ -241,10 +217,7 @@ export function Workspace() {
241
  memo,
242
  useEditorUiStore.getState().scale / 100,
243
  )
244
- const nextScaleRatio = resolvePinchNextScaleRatio(
245
- memoScaleRatio,
246
- movementScale,
247
- )
248
  applyScale(nextScaleRatio * 100)
249
  return memoScaleRatio
250
  },
@@ -272,9 +245,7 @@ export function Workspace() {
272
  },
273
  )
274
 
275
- const handleCanvasPointerDownCapture = (
276
- event: React.PointerEvent<HTMLDivElement>,
277
- ) => {
278
  if (mode !== 'block' && event.target === event.currentTarget) {
279
  clearSelection()
280
  }
@@ -301,7 +272,7 @@ export function Workspace() {
301
  )
302
 
303
  return (
304
- <div className='bg-muted flex min-h-0 min-w-0 flex-1'>
305
  <ToolRail />
306
  <div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
307
  <CanvasToolbar />
@@ -324,7 +295,7 @@ export function Workspace() {
324
  <div
325
  ref={canvasRef}
326
  data-testid='workspace-canvas'
327
- className='border-border bg-card relative rounded border shadow-sm'
328
  style={{
329
  ...canvasDimensions,
330
  cursor: canvasCursor,
@@ -392,9 +363,7 @@ export function Workspace() {
392
  width: '100%',
393
  height: '100%',
394
  opacity: brushDrawing.visible ? 1 : 0,
395
- pointerEvents: brushPointerEnabled
396
- ? 'auto'
397
- : 'none',
398
  touchAction: 'none',
399
  zIndex: 20,
400
  transition: 'opacity 120ms ease',
@@ -421,7 +390,7 @@ export function Workspace() {
421
  </div>
422
  {draftBlock && (
423
  <div
424
- className='border-primary bg-primary/10 pointer-events-none absolute rounded border-2 border-dashed'
425
  style={{
426
  left: draftBlock.x * scaleRatio,
427
  top: draftBlock.y * scaleRatio,
@@ -443,7 +412,7 @@ export function Workspace() {
443
  </ContextMenuContent>
444
  </ContextMenu>
445
  ) : (
446
- <div className='text-muted-foreground flex h-full w-full items-center justify-center text-sm'>
447
  {t('workspace.importPrompt')}
448
  </div>
449
  )}
@@ -452,13 +421,13 @@ export function Workspace() {
452
  orientation='vertical'
453
  className='flex w-2 touch-none p-px select-none'
454
  >
455
- <ScrollAreaPrimitive.Thumb className='bg-muted-foreground/40 flex-1 rounded' />
456
  </ScrollAreaPrimitive.Scrollbar>
457
  <ScrollAreaPrimitive.Scrollbar
458
  orientation='horizontal'
459
  className='flex h-2 touch-none p-px select-none'
460
  >
461
- <ScrollAreaPrimitive.Thumb className='bg-muted-foreground/40 rounded' />
462
  </ScrollAreaPrimitive.Scrollbar>
463
  </ScrollAreaPrimitive.Root>
464
  </div>
 
1
  'use client'
2
 
 
 
3
  import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
4
  import { useGesture } from '@use-gesture/react'
5
+ import { useEffect, useRef, useMemo, useCallback } from 'react'
6
+ import type React from 'react'
 
 
 
 
7
  import { useTranslation } from 'react-i18next'
8
+
9
+ import { CanvasToolbar } from '@/components/canvas/CanvasToolbar'
10
  import {
11
  setCanvasViewport,
12
  setCanvasDocumentSize,
13
  fitCanvasToViewport,
14
  } from '@/components/canvas/canvasViewport'
 
 
15
  import { TextBlockLayer } from '@/components/canvas/TextBlockLayer'
16
+ import { ToolRail } from '@/components/canvas/ToolRail'
 
 
 
 
 
 
 
 
 
 
17
  import {
18
  resolvePinchMemoScaleRatio,
19
  resolvePinchNextScaleRatio,
20
  } from '@/components/canvas/zoomGestures'
21
+ import { Image } from '@/components/Image'
22
+ import {
23
+ ContextMenu,
24
+ ContextMenuContent,
25
+ ContextMenuItem,
26
+ ContextMenuTrigger,
27
+ } from '@/components/ui/context-menu'
28
+ import { useBlockContextMenu } from '@/hooks/useBlockContextMenu'
29
+ import { useBlockDrafting } from '@/hooks/useBlockDrafting'
30
+ import { useBrushCursor } from '@/hooks/useBrushCursor'
31
+ import { useBrushLayerDisplay } from '@/hooks/useBrushLayerDisplay'
32
+ import { useCanvasZoom } from '@/hooks/useCanvasZoom'
33
+ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
34
+ import { useMaskDrawing } from '@/hooks/useMaskDrawing'
35
+ import { usePointerToDocument } from '@/hooks/usePointerToDocument'
36
+ import { useRenderBrushDrawing } from '@/hooks/useRenderBrushDrawing'
37
+ import { useTextBlocks, useDocumentLayer } from '@/hooks/useTextBlocks'
38
+ import { listen } from '@/lib/backend'
39
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
40
 
41
  const BRUSH_CURSOR = 'none'
42
 
43
  export function Workspace() {
44
  useKeyboardShortcuts()
45
  const scale = useEditorUiStore((state) => state.scale)
46
+ const showSegmentationMask = useEditorUiStore((state) => state.showSegmentationMask)
47
+ const showInpaintedImage = useEditorUiStore((state) => state.showInpaintedImage)
 
 
 
 
48
  const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
49
  const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
50
+ const showTextBlocksOverlay = useEditorUiStore((state) => state.showTextBlocksOverlay)
 
 
51
  const mode = useEditorUiStore((state) => state.mode)
52
  const autoFitEnabled = useEditorUiStore((state) => state.autoFitEnabled)
53
  const {
 
59
  removeBlock,
60
  } = useTextBlocks()
61
 
62
+ const imageData = useDocumentLayer(currentDocument?.id, 'image', currentDocument?.image)
63
+ const segmentData = useDocumentLayer(currentDocument?.id, 'segment', currentDocument?.segment)
 
 
 
 
 
 
 
 
64
  const inpaintedData = useDocumentLayer(
65
  currentDocument?.id,
66
  'inpainted',
 
71
  'brushLayer',
72
  currentDocument?.brushLayer,
73
  )
74
+ const renderedData = useDocumentLayer(currentDocument?.id, 'rendered', currentDocument?.rendered)
 
 
 
 
75
 
76
  useEffect(() => {
77
  if (currentDocument) {
 
106
 
107
  const maskPointerEnabled = useMemo(
108
  () =>
109
+ mode === 'repairBrush' || (mode === 'eraser' && (showSegmentationMask || !showBrushLayer)),
 
110
  [mode, showSegmentationMask, showBrushLayer],
111
  )
112
  const brushPointerEnabled = useMemo(
113
+ () => mode === 'brush' || (mode === 'eraser' && !showSegmentationMask && showBrushLayer),
 
 
114
  [mode, showSegmentationMask, showBrushLayer],
115
  )
116
  const maskDrawing = useMaskDrawing({
 
143
  fitCanvasToViewport()
144
  }
145
  }, [currentDocument?.id, autoFitEnabled])
146
+ const { contextMenuBlockIndex, handleContextMenu, handleDeleteBlock, clearContextMenu } =
147
+ useBlockContextMenu({
148
+ currentDocument,
149
+ pointerToDocument,
150
+ selectBlock: setSelectedBlockIndex,
151
+ removeBlock: (index) => {
152
+ void removeBlock(index)
153
+ },
154
+ })
 
 
 
 
155
  const { t } = useTranslation()
156
 
157
  // Listen for Tauri resize events
 
217
  memo,
218
  useEditorUiStore.getState().scale / 100,
219
  )
220
+ const nextScaleRatio = resolvePinchNextScaleRatio(memoScaleRatio, movementScale)
 
 
 
221
  applyScale(nextScaleRatio * 100)
222
  return memoScaleRatio
223
  },
 
245
  },
246
  )
247
 
248
+ const handleCanvasPointerDownCapture = (event: React.PointerEvent<HTMLDivElement>) => {
 
 
249
  if (mode !== 'block' && event.target === event.currentTarget) {
250
  clearSelection()
251
  }
 
272
  )
273
 
274
  return (
275
+ <div className='flex min-h-0 min-w-0 flex-1 bg-muted'>
276
  <ToolRail />
277
  <div className='relative flex min-h-0 min-w-0 flex-1 flex-col'>
278
  <CanvasToolbar />
 
295
  <div
296
  ref={canvasRef}
297
  data-testid='workspace-canvas'
298
+ className='relative rounded border border-border bg-card shadow-sm'
299
  style={{
300
  ...canvasDimensions,
301
  cursor: canvasCursor,
 
363
  width: '100%',
364
  height: '100%',
365
  opacity: brushDrawing.visible ? 1 : 0,
366
+ pointerEvents: brushPointerEnabled ? 'auto' : 'none',
 
 
367
  touchAction: 'none',
368
  zIndex: 20,
369
  transition: 'opacity 120ms ease',
 
390
  </div>
391
  {draftBlock && (
392
  <div
393
+ className='pointer-events-none absolute rounded border-2 border-dashed border-primary bg-primary/10'
394
  style={{
395
  left: draftBlock.x * scaleRatio,
396
  top: draftBlock.y * scaleRatio,
 
412
  </ContextMenuContent>
413
  </ContextMenu>
414
  ) : (
415
+ <div className='flex h-full w-full items-center justify-center text-sm text-muted-foreground'>
416
  {t('workspace.importPrompt')}
417
  </div>
418
  )}
 
421
  orientation='vertical'
422
  className='flex w-2 touch-none p-px select-none'
423
  >
424
+ <ScrollAreaPrimitive.Thumb className='flex-1 rounded bg-muted-foreground/40' />
425
  </ScrollAreaPrimitive.Scrollbar>
426
  <ScrollAreaPrimitive.Scrollbar
427
  orientation='horizontal'
428
  className='flex h-2 touch-none p-px select-none'
429
  >
430
+ <ScrollAreaPrimitive.Thumb className='rounded bg-muted-foreground/40' />
431
  </ScrollAreaPrimitive.Scrollbar>
432
  </ScrollAreaPrimitive.Root>
433
  </div>
ui/components/canvas/zoomGestures.ts CHANGED
@@ -8,20 +8,14 @@ function clampScaleRatio(scaleRatio: number) {
8
  return Math.max(MIN_SCALE_RATIO, Math.min(MAX_SCALE_RATIO, scaleRatio))
9
  }
10
 
11
- export function resolvePinchMemoScaleRatio(
12
- memo: unknown,
13
- currentScaleRatio: number,
14
- ) {
15
  if (typeof memo === 'number' && Number.isFinite(memo)) {
16
  return clampScaleRatio(memo)
17
  }
18
  return clampScaleRatio(currentScaleRatio)
19
  }
20
 
21
- export function resolvePinchNextScaleRatio(
22
- memoScaleRatio: number,
23
- movementScale: number,
24
- ) {
25
  const baseScaleRatio = clampScaleRatio(memoScaleRatio)
26
  if (!Number.isFinite(movementScale) || movementScale <= 0) {
27
  return baseScaleRatio
 
8
  return Math.max(MIN_SCALE_RATIO, Math.min(MAX_SCALE_RATIO, scaleRatio))
9
  }
10
 
11
+ export function resolvePinchMemoScaleRatio(memo: unknown, currentScaleRatio: number) {
 
 
 
12
  if (typeof memo === 'number' && Number.isFinite(memo)) {
13
  return clampScaleRatio(memo)
14
  }
15
  return clampScaleRatio(currentScaleRatio)
16
  }
17
 
18
+ export function resolvePinchNextScaleRatio(memoScaleRatio: number, movementScale: number) {
 
 
 
19
  const baseScaleRatio = clampScaleRatio(memoScaleRatio)
20
  if (!Number.isFinite(movementScale) || movementScale <= 0) {
21
  return baseScaleRatio
ui/components/panels/LayersPanel.tsx CHANGED
@@ -1,7 +1,5 @@
1
  'use client'
2
 
3
- import { useTranslation } from 'react-i18next'
4
- import { motion } from 'motion/react'
5
  import {
6
  EyeIcon,
7
  EyeOffIcon,
@@ -11,10 +9,13 @@ import {
11
  BandageIcon,
12
  PaintbrushIcon,
13
  } from 'lucide-react'
14
- import { cn } from '@/lib/utils'
 
 
15
  import { Button } from '@/components/ui/button'
16
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
17
  import { useCurrentDocument } from '@/hooks/useTextBlocks'
 
 
18
 
19
  type Layer = {
20
  id: string
@@ -28,30 +29,16 @@ type Layer = {
28
 
29
  export function LayersPanel() {
30
  const currentDocument = useCurrentDocument()
31
- const showInpaintedImage = useEditorUiStore(
32
- (state) => state.showInpaintedImage,
33
- )
34
- const setShowInpaintedImage = useEditorUiStore(
35
- (state) => state.setShowInpaintedImage,
36
- )
37
- const showSegmentationMask = useEditorUiStore(
38
- (state) => state.showSegmentationMask,
39
- )
40
- const setShowSegmentationMask = useEditorUiStore(
41
- (state) => state.setShowSegmentationMask,
42
- )
43
  const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
44
  const setShowBrushLayer = useEditorUiStore((state) => state.setShowBrushLayer)
45
- const showTextBlocksOverlay = useEditorUiStore(
46
- (state) => state.showTextBlocksOverlay,
47
- )
48
- const setShowTextBlocksOverlay = useEditorUiStore(
49
- (state) => state.setShowTextBlocksOverlay,
50
- )
51
  const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
52
- const setShowRenderedImage = useEditorUiStore(
53
- (state) => state.setShowRenderedImage,
54
- )
55
 
56
  const layers: Layer[] = [
57
  {
@@ -69,8 +56,7 @@ export function LayersPanel() {
69
  visible: showTextBlocksOverlay,
70
  setVisible: setShowTextBlocksOverlay,
71
  hasContent:
72
- currentDocument?.textBlocks !== undefined &&
73
- currentDocument.textBlocks.length > 0,
74
  },
75
  {
76
  id: 'brush',
@@ -145,20 +131,14 @@ function LayerItem({ layer }: { layer: Layer }) {
145
  }
146
  }}
147
  disabled={!canToggle}
148
- className={cn(
149
- 'size-5',
150
- canToggle ? 'cursor-pointer' : 'cursor-default',
151
- )}
152
  >
153
  {layer.visible ? (
154
  <EyeIcon
155
- className={cn(
156
- 'size-3.5',
157
- isActive ? 'text-foreground' : 'text-muted-foreground',
158
- )}
159
  />
160
  ) : (
161
- <EyeOffIcon className='text-muted-foreground/40 size-3.5' />
162
  )}
163
  </Button>
164
 
@@ -166,9 +146,7 @@ function LayerItem({ layer }: { layer: Layer }) {
166
  <div
167
  className={cn(
168
  'flex size-5 shrink-0 items-center justify-center rounded',
169
- !layer.hasContent && !isLocked
170
- ? 'text-muted-foreground/40'
171
- : 'text-muted-foreground',
172
  )}
173
  >
174
  {layer.icon === 'RAW' ? (
 
1
  'use client'
2
 
 
 
3
  import {
4
  EyeIcon,
5
  EyeOffIcon,
 
9
  BandageIcon,
10
  PaintbrushIcon,
11
  } from 'lucide-react'
12
+ import { motion } from 'motion/react'
13
+ import { useTranslation } from 'react-i18next'
14
+
15
  import { Button } from '@/components/ui/button'
 
16
  import { useCurrentDocument } from '@/hooks/useTextBlocks'
17
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
18
+ import { cn } from '@/lib/utils'
19
 
20
  type Layer = {
21
  id: string
 
29
 
30
  export function LayersPanel() {
31
  const currentDocument = useCurrentDocument()
32
+ const showInpaintedImage = useEditorUiStore((state) => state.showInpaintedImage)
33
+ const setShowInpaintedImage = useEditorUiStore((state) => state.setShowInpaintedImage)
34
+ const showSegmentationMask = useEditorUiStore((state) => state.showSegmentationMask)
35
+ const setShowSegmentationMask = useEditorUiStore((state) => state.setShowSegmentationMask)
 
 
 
 
 
 
 
 
36
  const showBrushLayer = useEditorUiStore((state) => state.showBrushLayer)
37
  const setShowBrushLayer = useEditorUiStore((state) => state.setShowBrushLayer)
38
+ const showTextBlocksOverlay = useEditorUiStore((state) => state.showTextBlocksOverlay)
39
+ const setShowTextBlocksOverlay = useEditorUiStore((state) => state.setShowTextBlocksOverlay)
 
 
 
 
40
  const showRenderedImage = useEditorUiStore((state) => state.showRenderedImage)
41
+ const setShowRenderedImage = useEditorUiStore((state) => state.setShowRenderedImage)
 
 
42
 
43
  const layers: Layer[] = [
44
  {
 
56
  visible: showTextBlocksOverlay,
57
  setVisible: setShowTextBlocksOverlay,
58
  hasContent:
59
+ currentDocument?.textBlocks !== undefined && currentDocument.textBlocks.length > 0,
 
60
  },
61
  {
62
  id: 'brush',
 
131
  }
132
  }}
133
  disabled={!canToggle}
134
+ className={cn('size-5', canToggle ? 'cursor-pointer' : 'cursor-default')}
 
 
 
135
  >
136
  {layer.visible ? (
137
  <EyeIcon
138
+ className={cn('size-3.5', isActive ? 'text-foreground' : 'text-muted-foreground')}
 
 
 
139
  />
140
  ) : (
141
+ <EyeOffIcon className='size-3.5 text-muted-foreground/40' />
142
  )}
143
  </Button>
144
 
 
146
  <div
147
  className={cn(
148
  'flex size-5 shrink-0 items-center justify-center rounded',
149
+ !layer.hasContent && !isLocked ? 'text-muted-foreground/40' : 'text-muted-foreground',
 
 
150
  )}
151
  >
152
  {layer.icon === 'RAW' ? (
ui/components/panels/RenderControlsPanel.tsx CHANGED
@@ -1,7 +1,6 @@
1
  'use client'
2
 
3
- import { type ComponentType, useMemo } from 'react'
4
- import { useTranslation } from 'react-i18next'
5
  import {
6
  AlignCenterIcon,
7
  AlignLeftIcon,
@@ -12,33 +11,22 @@ import {
12
  PlusIcon,
13
  SquareIcon,
14
  } from 'lucide-react'
15
- import { useTextBlocks } from '@/hooks/useTextBlocks'
16
- import {
17
- RenderEffect,
18
- RenderStroke,
19
- RgbaColor,
20
- TextAlign,
21
- TextStyle,
22
- } from '@/types'
23
- import type { FontFaceInfo } from '@/lib/api/schemas'
24
  import { Button } from '@/components/ui/button'
25
  import { ColorPicker } from '@/components/ui/color-picker'
26
- import { Input } from '@/components/ui/input'
27
- import {
28
- Tooltip,
29
- TooltipContent,
30
- TooltipTrigger,
31
- } from '@/components/ui/tooltip'
32
  import { FontSelect } from '@/components/ui/font-select'
 
 
 
 
 
 
33
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
34
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
35
- import { useListFonts, useGetGoogleFontsCatalog } from '@/lib/api/system/system'
36
- import {
37
- getGetDocumentQueryKey,
38
- updateDocumentStyle,
39
- } from '@/lib/api/documents/documents'
40
- import { useQueryClient } from '@tanstack/react-query'
41
  import { cn } from '@/lib/utils'
 
42
 
43
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
44
  const DEFAULT_FONT_FACES: FontFaceInfo[] = [
@@ -62,16 +50,12 @@ const DEFAULT_STROKE_WIDTH = 1.6
62
  const MIN_STROKE_WIDTH = 0.2
63
  const MAX_STROKE_WIDTH = 24
64
  const STROKE_WIDTH_STEP = 0.1
65
- const LATIN_ONLY_PATTERN =
66
- /^[\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]*$/u
67
 
68
- const clampByte = (value: number) =>
69
- Math.max(0, Math.min(255, Math.round(value)))
70
 
71
  const clampStrokeWidth = (value: number) =>
72
- Number(
73
- Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, value)).toFixed(1),
74
- )
75
 
76
  const colorToHex = (color: RgbaColor) =>
77
  `#${color
@@ -129,8 +113,7 @@ const fallbackFontFace = (value?: string): FontFaceInfo | undefined => {
129
  }
130
  }
131
 
132
- const hasExplicitFontFamilies = (style?: TextStyle) =>
133
- (style?.fontFamilies?.length ?? 0) > 0
134
 
135
  const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
136
  italic: effect?.italic ?? false,
@@ -213,28 +196,20 @@ export function RenderControlsPanel() {
213
  const queryClient = useQueryClient()
214
  const { t } = useTranslation()
215
  const selectedBlock =
216
- selectedBlockIndex !== undefined
217
- ? textBlocks[selectedBlockIndex]
218
- : undefined
219
- const selectedBlockHasExplicitFont = hasExplicitFontFamilies(
220
- selectedBlock?.style,
221
- )
222
  const firstBlock = textBlocks[0]
223
  const hasBlocks = textBlocks.length > 0
224
  const fontCandidates = uniqueFontFaces(
225
  [
226
  ...sortedFonts,
227
  ...(documentFont ? [fallbackFontFace(documentFont)] : []),
228
- ...(selectedBlock?.style?.fontFamilies
229
- ?.slice(0, 1)
230
- ?.map(fallbackFontFace) ?? []),
231
- ...(firstBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ??
232
- []),
233
  ...DEFAULT_FONT_FACES,
234
  ].filter((value): value is FontFaceInfo => !!value),
235
  )
236
- const fallbackFontFaces =
237
- fontCandidates.length > 0 ? fontCandidates : DEFAULT_FONT_FACES
238
  const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
239
  const fontOptions = fontCandidates
240
  const currentFontCandidate =
@@ -243,18 +218,12 @@ export function RenderControlsPanel() {
243
  firstBlock?.style?.fontFamilies?.[0] ??
244
  (hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
245
  const currentFontFace =
246
- findFontFace(fontOptions, currentFontCandidate) ??
247
- fallbackFontFace(currentFontCandidate)
248
  const currentFont = currentFontFace?.postScriptName ?? ''
249
  const currentFontFamilyName = currentFontFace?.familyName
250
- const currentEffect = normalizeEffect(
251
- selectedBlock?.style?.effect ?? renderEffect,
252
- )
253
- const currentStroke = normalizeStroke(
254
- selectedBlock?.style?.stroke ?? renderStroke,
255
- )
256
- const currentColor =
257
- selectedBlock?.style?.color ?? (hasBlocks ? fallbackColor : DEFAULT_COLOR)
258
  const currentColorHex = colorToHex(currentColor)
259
  const currentStrokeColorHex = colorToHex(currentStroke.color)
260
  const currentStrokeWidth = currentStroke.widthPx ?? DEFAULT_STROKE_WIDTH
@@ -269,9 +238,7 @@ export function RenderControlsPanel() {
269
  const strokeColorLabel = t('render.strokeColorLabel')
270
  const strokeWidthLabel = t('render.strokeWidthLabel')
271
  const alignLabel = t('render.alignLabel')
272
- const currentTextAlign = resolveEffectiveTextAlign(
273
- selectedBlock ?? firstBlock,
274
- )
275
  const scopeLabel =
276
  selectedBlockIndex !== undefined
277
  ? t('render.fontScopeBlockIndex', {
@@ -319,14 +286,9 @@ export function RenderControlsPanel() {
319
  void updateTextBlocks(nextBlocks)
320
  }
321
 
322
- const mergeFontFamilies = (
323
- nextFont: string,
324
- current: string[] | undefined,
325
- ) => {
326
  const base = (
327
- current?.length
328
- ? current
329
- : fallbackFontFaces.map((font) => font.postScriptName)
330
  ).map((family) => normalizeFontValue(fontOptions, family) ?? family)
331
  return [nextFont, ...base.filter((family) => family !== nextFont)]
332
  }
@@ -406,10 +368,10 @@ export function RenderControlsPanel() {
406
  {/* Font + Color */}
407
  <div className='flex flex-col gap-0.5'>
408
  <div className='flex items-baseline justify-between'>
409
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
410
  {fontLabel}
411
  </span>
412
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
413
  {t('render.fontColorLabel')}
414
  </span>
415
  </div>
@@ -422,20 +384,14 @@ export function RenderControlsPanel() {
422
  disabled={fontOptions.length === 0}
423
  placeholder={t('render.fontPlaceholder')}
424
  triggerStyle={
425
- currentFontFamilyName
426
- ? { fontFamily: currentFontFamilyName }
427
- : undefined
428
  }
429
  onChange={(value) => {
430
- const nextFamilies = mergeFontFamilies(
431
- value,
432
- selectedBlock?.style?.fontFamilies,
433
- )
434
  // Only persist a block override when the block already has one.
435
  // Otherwise keep the block inheriting the document default.
436
  if (selectedBlockHasExplicitFont) {
437
- if (applyStyleToSelected({ fontFamilies: nextFamilies }))
438
- return
439
  }
440
  updateDocumentDefaultFont(value)
441
  }}
@@ -444,7 +400,7 @@ export function RenderControlsPanel() {
444
  {selectedBlockHasExplicitFont ? (
445
  <button
446
  type='button'
447
- className='text-muted-foreground hover:text-foreground text-[9px]'
448
  onClick={() => applyStyleToSelected({ fontFamilies: [] })}
449
  title='Reset to document default'
450
  >
@@ -471,17 +427,17 @@ export function RenderControlsPanel() {
471
 
472
  {/* Size / Effect / Align */}
473
  <div className='grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-end gap-x-2'>
474
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
475
  {fontSizeLabel}
476
  </span>
477
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
478
  {effectLabel}
479
  </span>
480
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
481
  {alignLabel}
482
  </span>
483
 
484
- <div className='border-input bg-background flex min-w-0 items-center rounded-md border shadow-xs'>
485
  <Button
486
  type='button'
487
  variant='ghost'
@@ -505,9 +461,7 @@ export function RenderControlsPanel() {
505
  className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
506
  data-testid='render-font-size'
507
  disabled={selectedBlockIndex === undefined}
508
- value={
509
- currentFontSize !== undefined ? Math.round(currentFontSize) : ''
510
- }
511
  placeholder='auto'
512
  onChange={(event) => {
513
  const parsed = Number.parseInt(event.target.value, 10)
@@ -523,10 +477,7 @@ export function RenderControlsPanel() {
523
  className='size-7 shrink-0 rounded-l-none border-l'
524
  disabled={selectedBlockIndex === undefined}
525
  onClick={() => {
526
- const next = Math.min(
527
- 300,
528
- Math.round((currentFontSize ?? 16) + 1),
529
- )
530
  applyStyleToSelected({ fontSize: next })
531
  }}
532
  >
@@ -549,7 +500,7 @@ export function RenderControlsPanel() {
549
  className={cn(
550
  'size-7 shrink-0',
551
  active &&
552
- 'bg-primary text-primary-foreground border-primary hover:bg-primary/90',
553
  )}
554
  onClick={() => {
555
  const nextEffect = {
@@ -588,11 +539,10 @@ export function RenderControlsPanel() {
588
  className={cn(
589
  'size-7 shrink-0',
590
  active &&
591
- 'bg-primary text-primary-foreground border-primary hover:bg-primary/90',
592
  )}
593
  onClick={() => {
594
- if (applyStyleToSelected({ textAlign: item.value }))
595
- return
596
  applyStyleToAll({ textAlign: item.value })
597
  }}
598
  >
@@ -610,7 +560,7 @@ export function RenderControlsPanel() {
610
 
611
  {/* Border / Stroke */}
612
  <div className='flex flex-col gap-0.5'>
613
- <span className='text-muted-foreground text-[10px] font-medium uppercase'>
614
  {strokeLabel}
615
  </span>
616
  <div className='flex min-w-0 items-center gap-1'>
@@ -624,7 +574,7 @@ export function RenderControlsPanel() {
624
  className={cn(
625
  'size-7 shrink-0',
626
  currentStroke.enabled &&
627
- 'bg-primary text-primary-foreground border-primary hover:bg-primary/90',
628
  )}
629
  onClick={() =>
630
  applyStrokeSetting({
@@ -667,16 +617,14 @@ export function RenderControlsPanel() {
667
  </TooltipContent>
668
  </Tooltip>
669
 
670
- <div className='border-input bg-background flex min-w-0 flex-1 items-center rounded-md border shadow-xs'>
671
  <Button
672
  type='button'
673
  variant='ghost'
674
  size='icon-sm'
675
  aria-label={`${strokeWidthLabel} -`}
676
  className='size-7 shrink-0 rounded-r-none border-r'
677
- onClick={() =>
678
- updateStrokeWidth(currentStrokeWidth - STROKE_WIDTH_STEP)
679
- }
680
  >
681
  <MinusIcon className='size-3' />
682
  </Button>
@@ -690,9 +638,7 @@ export function RenderControlsPanel() {
690
  className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
691
  data-testid='render-stroke-width'
692
  value={
693
- Number.isFinite(currentStrokeWidth)
694
- ? currentStrokeWidth
695
- : DEFAULT_STROKE_WIDTH
696
  }
697
  onChange={(event) => {
698
  const parsed = Number.parseFloat(event.target.value)
@@ -707,9 +653,7 @@ export function RenderControlsPanel() {
707
  size='icon-sm'
708
  aria-label={`${strokeWidthLabel} +`}
709
  className='size-7 shrink-0 rounded-l-none border-l'
710
- onClick={() =>
711
- updateStrokeWidth(currentStrokeWidth + STROKE_WIDTH_STEP)
712
- }
713
  >
714
  <PlusIcon className='size-3' />
715
  </Button>
 
1
  'use client'
2
 
3
+ import { useQueryClient } from '@tanstack/react-query'
 
4
  import {
5
  AlignCenterIcon,
6
  AlignLeftIcon,
 
11
  PlusIcon,
12
  SquareIcon,
13
  } from 'lucide-react'
14
+ import { type ComponentType, useMemo } from 'react'
15
+ import { useTranslation } from 'react-i18next'
16
+
 
 
 
 
 
 
17
  import { Button } from '@/components/ui/button'
18
  import { ColorPicker } from '@/components/ui/color-picker'
 
 
 
 
 
 
19
  import { FontSelect } from '@/components/ui/font-select'
20
+ import { Input } from '@/components/ui/input'
21
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
22
+ import { useTextBlocks } from '@/hooks/useTextBlocks'
23
+ import { getGetDocumentQueryKey, updateDocumentStyle } from '@/lib/api/documents/documents'
24
+ import type { FontFaceInfo } from '@/lib/api/schemas'
25
+ import { useListFonts, useGetGoogleFontsCatalog } from '@/lib/api/system/system'
26
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
27
  import { usePreferencesStore } from '@/lib/stores/preferencesStore'
 
 
 
 
 
 
28
  import { cn } from '@/lib/utils'
29
+ import { RenderEffect, RenderStroke, RgbaColor, TextAlign, TextStyle } from '@/types'
30
 
31
  const DEFAULT_COLOR: RgbaColor = [0, 0, 0, 255]
32
  const DEFAULT_FONT_FACES: FontFaceInfo[] = [
 
50
  const MIN_STROKE_WIDTH = 0.2
51
  const MAX_STROKE_WIDTH = 24
52
  const STROKE_WIDTH_STEP = 0.1
53
+ const LATIN_ONLY_PATTERN = /^[\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]*$/u
 
54
 
55
+ const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
 
56
 
57
  const clampStrokeWidth = (value: number) =>
58
+ Number(Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, value)).toFixed(1))
 
 
59
 
60
  const colorToHex = (color: RgbaColor) =>
61
  `#${color
 
113
  }
114
  }
115
 
116
+ const hasExplicitFontFamilies = (style?: TextStyle) => (style?.fontFamilies?.length ?? 0) > 0
 
117
 
118
  const normalizeEffect = (effect?: Partial<RenderEffect>): RenderEffect => ({
119
  italic: effect?.italic ?? false,
 
196
  const queryClient = useQueryClient()
197
  const { t } = useTranslation()
198
  const selectedBlock =
199
+ selectedBlockIndex !== undefined ? textBlocks[selectedBlockIndex] : undefined
200
+ const selectedBlockHasExplicitFont = hasExplicitFontFamilies(selectedBlock?.style)
 
 
 
 
201
  const firstBlock = textBlocks[0]
202
  const hasBlocks = textBlocks.length > 0
203
  const fontCandidates = uniqueFontFaces(
204
  [
205
  ...sortedFonts,
206
  ...(documentFont ? [fallbackFontFace(documentFont)] : []),
207
+ ...(selectedBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
208
+ ...(firstBlock?.style?.fontFamilies?.slice(0, 1)?.map(fallbackFontFace) ?? []),
 
 
 
209
  ...DEFAULT_FONT_FACES,
210
  ].filter((value): value is FontFaceInfo => !!value),
211
  )
212
+ const fallbackFontFaces = fontCandidates.length > 0 ? fontCandidates : DEFAULT_FONT_FACES
 
213
  const fallbackColor = firstBlock?.style?.color ?? DEFAULT_COLOR
214
  const fontOptions = fontCandidates
215
  const currentFontCandidate =
 
218
  firstBlock?.style?.fontFamilies?.[0] ??
219
  (hasBlocks ? fallbackFontFaces[0]?.postScriptName : '')
220
  const currentFontFace =
221
+ findFontFace(fontOptions, currentFontCandidate) ?? fallbackFontFace(currentFontCandidate)
 
222
  const currentFont = currentFontFace?.postScriptName ?? ''
223
  const currentFontFamilyName = currentFontFace?.familyName
224
+ const currentEffect = normalizeEffect(selectedBlock?.style?.effect ?? renderEffect)
225
+ const currentStroke = normalizeStroke(selectedBlock?.style?.stroke ?? renderStroke)
226
+ const currentColor = selectedBlock?.style?.color ?? (hasBlocks ? fallbackColor : DEFAULT_COLOR)
 
 
 
 
 
227
  const currentColorHex = colorToHex(currentColor)
228
  const currentStrokeColorHex = colorToHex(currentStroke.color)
229
  const currentStrokeWidth = currentStroke.widthPx ?? DEFAULT_STROKE_WIDTH
 
238
  const strokeColorLabel = t('render.strokeColorLabel')
239
  const strokeWidthLabel = t('render.strokeWidthLabel')
240
  const alignLabel = t('render.alignLabel')
241
+ const currentTextAlign = resolveEffectiveTextAlign(selectedBlock ?? firstBlock)
 
 
242
  const scopeLabel =
243
  selectedBlockIndex !== undefined
244
  ? t('render.fontScopeBlockIndex', {
 
286
  void updateTextBlocks(nextBlocks)
287
  }
288
 
289
+ const mergeFontFamilies = (nextFont: string, current: string[] | undefined) => {
 
 
 
290
  const base = (
291
+ current?.length ? current : fallbackFontFaces.map((font) => font.postScriptName)
 
 
292
  ).map((family) => normalizeFontValue(fontOptions, family) ?? family)
293
  return [nextFont, ...base.filter((family) => family !== nextFont)]
294
  }
 
368
  {/* Font + Color */}
369
  <div className='flex flex-col gap-0.5'>
370
  <div className='flex items-baseline justify-between'>
371
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
372
  {fontLabel}
373
  </span>
374
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
375
  {t('render.fontColorLabel')}
376
  </span>
377
  </div>
 
384
  disabled={fontOptions.length === 0}
385
  placeholder={t('render.fontPlaceholder')}
386
  triggerStyle={
387
+ currentFontFamilyName ? { fontFamily: currentFontFamilyName } : undefined
 
 
388
  }
389
  onChange={(value) => {
390
+ const nextFamilies = mergeFontFamilies(value, selectedBlock?.style?.fontFamilies)
 
 
 
391
  // Only persist a block override when the block already has one.
392
  // Otherwise keep the block inheriting the document default.
393
  if (selectedBlockHasExplicitFont) {
394
+ if (applyStyleToSelected({ fontFamilies: nextFamilies })) return
 
395
  }
396
  updateDocumentDefaultFont(value)
397
  }}
 
400
  {selectedBlockHasExplicitFont ? (
401
  <button
402
  type='button'
403
+ className='text-[9px] text-muted-foreground hover:text-foreground'
404
  onClick={() => applyStyleToSelected({ fontFamilies: [] })}
405
  title='Reset to document default'
406
  >
 
427
 
428
  {/* Size / Effect / Align */}
429
  <div className='grid w-full grid-cols-[minmax(0,1fr)_auto_auto] items-end gap-x-2'>
430
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
431
  {fontSizeLabel}
432
  </span>
433
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
434
  {effectLabel}
435
  </span>
436
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
437
  {alignLabel}
438
  </span>
439
 
440
+ <div className='flex min-w-0 items-center rounded-md border border-input bg-background shadow-xs'>
441
  <Button
442
  type='button'
443
  variant='ghost'
 
461
  className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
462
  data-testid='render-font-size'
463
  disabled={selectedBlockIndex === undefined}
464
+ value={currentFontSize !== undefined ? Math.round(currentFontSize) : ''}
 
 
465
  placeholder='auto'
466
  onChange={(event) => {
467
  const parsed = Number.parseInt(event.target.value, 10)
 
477
  className='size-7 shrink-0 rounded-l-none border-l'
478
  disabled={selectedBlockIndex === undefined}
479
  onClick={() => {
480
+ const next = Math.min(300, Math.round((currentFontSize ?? 16) + 1))
 
 
 
481
  applyStyleToSelected({ fontSize: next })
482
  }}
483
  >
 
500
  className={cn(
501
  'size-7 shrink-0',
502
  active &&
503
+ 'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
504
  )}
505
  onClick={() => {
506
  const nextEffect = {
 
539
  className={cn(
540
  'size-7 shrink-0',
541
  active &&
542
+ 'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
543
  )}
544
  onClick={() => {
545
+ if (applyStyleToSelected({ textAlign: item.value })) return
 
546
  applyStyleToAll({ textAlign: item.value })
547
  }}
548
  >
 
560
 
561
  {/* Border / Stroke */}
562
  <div className='flex flex-col gap-0.5'>
563
+ <span className='text-[10px] font-medium text-muted-foreground uppercase'>
564
  {strokeLabel}
565
  </span>
566
  <div className='flex min-w-0 items-center gap-1'>
 
574
  className={cn(
575
  'size-7 shrink-0',
576
  currentStroke.enabled &&
577
+ 'border-primary bg-primary text-primary-foreground hover:bg-primary/90',
578
  )}
579
  onClick={() =>
580
  applyStrokeSetting({
 
617
  </TooltipContent>
618
  </Tooltip>
619
 
620
+ <div className='flex min-w-0 flex-1 items-center rounded-md border border-input bg-background shadow-xs'>
621
  <Button
622
  type='button'
623
  variant='ghost'
624
  size='icon-sm'
625
  aria-label={`${strokeWidthLabel} -`}
626
  className='size-7 shrink-0 rounded-r-none border-r'
627
+ onClick={() => updateStrokeWidth(currentStrokeWidth - STROKE_WIDTH_STEP)}
 
 
628
  >
629
  <MinusIcon className='size-3' />
630
  </Button>
 
638
  className='h-7 min-w-0 flex-1 [appearance:textfield] rounded-none border-0 px-1 text-center text-xs shadow-none focus-visible:ring-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
639
  data-testid='render-stroke-width'
640
  value={
641
+ Number.isFinite(currentStrokeWidth) ? currentStrokeWidth : DEFAULT_STROKE_WIDTH
 
 
642
  }
643
  onChange={(event) => {
644
  const parsed = Number.parseFloat(event.target.value)
 
653
  size='icon-sm'
654
  aria-label={`${strokeWidthLabel} +`}
655
  className='size-7 shrink-0 rounded-l-none border-l'
656
+ onClick={() => updateStrokeWidth(currentStrokeWidth + STROKE_WIDTH_STEP)}
 
 
657
  >
658
  <PlusIcon className='size-3' />
659
  </Button>
ui/components/panels/TextBlocksPanel.tsx CHANGED
@@ -1,28 +1,25 @@
1
  'use client'
2
 
3
- import { useTranslation } from 'react-i18next'
4
- import { motion } from 'motion/react'
5
- import { TextBlock } from '@/types'
6
  import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
7
- import { useTextBlocks } from '@/hooks/useTextBlocks'
8
- import { useGetLlm } from '@/lib/api/llm/llm'
9
- import { useEditorUiStore } from '@/lib/stores/editorUiStore'
10
- import { usePreferencesStore } from '@/lib/stores/preferencesStore'
11
- import { useProcessing } from '@/lib/machines'
12
  import {
13
  Accordion,
14
  AccordionContent,
15
  AccordionItem,
16
  AccordionTrigger,
17
  } from '@/components/ui/accordion'
18
- import {
19
- Tooltip,
20
- TooltipContent,
21
- TooltipTrigger,
22
- } from '@/components/ui/tooltip'
23
  import { Button } from '@/components/ui/button'
24
  import { DraftTextarea } from '@/components/ui/draft-textarea'
25
  import { ScrollArea } from '@/components/ui/scroll-area'
 
 
 
 
 
 
 
26
 
27
  export function TextBlocksPanel() {
28
  const {
@@ -41,14 +38,13 @@ export function TextBlocksPanel() {
41
 
42
  if (!document) {
43
  return (
44
- <div className='text-muted-foreground flex flex-1 items-center justify-center text-xs'>
45
  {t('textBlocks.emptyPrompt')}
46
  </div>
47
  )
48
  }
49
 
50
- const accordionValue =
51
- selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
52
 
53
  const handleGenerate = (blockIndex: number) => {
54
  const documentId = useEditorUiStore.getState().currentDocumentId
@@ -78,11 +74,8 @@ export function TextBlocksPanel() {
78
  }
79
 
80
  return (
81
- <div
82
- className='flex min-h-0 flex-1 flex-col'
83
- data-testid='panels-textblocks'
84
- >
85
- <div className='border-border text-muted-foreground flex items-center justify-between border-b px-2 py-1.5 text-xs font-semibold tracking-wide uppercase'>
86
  <span data-testid='textblocks-count' data-count={textBlocks.length}>
87
  {t('textBlocks.title', { count: textBlocks.length })}
88
  </span>
@@ -94,7 +87,7 @@ export function TextBlocksPanel() {
94
  >
95
  <div className='p-2'>
96
  {textBlocks.length === 0 ? (
97
- <p className='border-border text-muted-foreground rounded border border-dashed p-2 text-xs'>
98
  {t('textBlocks.none')}
99
  </p>
100
  ) : (
@@ -172,9 +165,9 @@ function BlockCard({
172
  <AccordionItem
173
  value={index.toString()}
174
  data-selected={selected}
175
- className='bg-card/90 ring-border data-[selected=true]:ring-primary overflow-hidden rounded text-xs ring-1'
176
  >
177
- <AccordionTrigger className='data-[state=open]:bg-accent flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline [&>svg]:hidden'>
178
  <span
179
  className={`shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
180
  selected ? 'bg-primary' : 'bg-muted-foreground/60'
@@ -186,33 +179,27 @@ function BlockCard({
186
  <div className='flex min-w-0 flex-1 items-center gap-1'>
187
  <span
188
  className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
189
- hasOcr
190
- ? 'bg-rose-400/80 text-white'
191
- : 'bg-muted text-muted-foreground/50'
192
  }`}
193
  >
194
  {t('textBlocks.ocrBadge')}
195
  </span>
196
  <span
197
  className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
198
- hasTranslation
199
- ? 'bg-rose-400/80 text-white'
200
- : 'bg-muted text-muted-foreground/50'
201
  }`}
202
  >
203
  {t('textBlocks.translationBadge')}
204
  </span>
205
  {preview && (
206
- <p className='text-muted-foreground line-clamp-1 min-w-0 flex-1 text-xs'>
207
- {preview}
208
- </p>
209
  )}
210
  </div>
211
  </AccordionTrigger>
212
  <AccordionContent className='px-2 pt-1.5 pb-2 shadow-[inset_0_1px_0_0_var(--color-border)]'>
213
  <div className='space-y-1.5'>
214
  <div className='flex flex-col gap-0.5'>
215
- <span className='text-muted-foreground text-[10px] uppercase'>
216
  {t('textBlocks.ocrLabel')}
217
  </span>
218
  <DraftTextarea
@@ -226,7 +213,7 @@ function BlockCard({
226
  </div>
227
  <div className='flex flex-col gap-0.5'>
228
  <div className='flex items-center justify-between'>
229
- <span className='text-muted-foreground text-[10px] uppercase'>
230
  {t('textBlocks.translationLabel')}
231
  </span>
232
  <div className='flex items-center gap-0.5'>
 
1
  'use client'
2
 
 
 
 
3
  import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
4
+ import { motion } from 'motion/react'
5
+ import { useTranslation } from 'react-i18next'
6
+
 
 
7
  import {
8
  Accordion,
9
  AccordionContent,
10
  AccordionItem,
11
  AccordionTrigger,
12
  } from '@/components/ui/accordion'
 
 
 
 
 
13
  import { Button } from '@/components/ui/button'
14
  import { DraftTextarea } from '@/components/ui/draft-textarea'
15
  import { ScrollArea } from '@/components/ui/scroll-area'
16
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
17
+ import { useTextBlocks } from '@/hooks/useTextBlocks'
18
+ import { useGetLlm } from '@/lib/api/llm/llm'
19
+ import { useProcessing } from '@/lib/machines'
20
+ import { useEditorUiStore } from '@/lib/stores/editorUiStore'
21
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
22
+ import { TextBlock } from '@/types'
23
 
24
  export function TextBlocksPanel() {
25
  const {
 
38
 
39
  if (!document) {
40
  return (
41
+ <div className='flex flex-1 items-center justify-center text-xs text-muted-foreground'>
42
  {t('textBlocks.emptyPrompt')}
43
  </div>
44
  )
45
  }
46
 
47
+ const accordionValue = selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : ''
 
48
 
49
  const handleGenerate = (blockIndex: number) => {
50
  const documentId = useEditorUiStore.getState().currentDocumentId
 
74
  }
75
 
76
  return (
77
+ <div className='flex min-h-0 flex-1 flex-col' data-testid='panels-textblocks'>
78
+ <div className='flex items-center justify-between border-b border-border px-2 py-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase'>
 
 
 
79
  <span data-testid='textblocks-count' data-count={textBlocks.length}>
80
  {t('textBlocks.title', { count: textBlocks.length })}
81
  </span>
 
87
  >
88
  <div className='p-2'>
89
  {textBlocks.length === 0 ? (
90
+ <p className='rounded border border-dashed border-border p-2 text-xs text-muted-foreground'>
91
  {t('textBlocks.none')}
92
  </p>
93
  ) : (
 
165
  <AccordionItem
166
  value={index.toString()}
167
  data-selected={selected}
168
+ className='overflow-hidden rounded bg-card/90 text-xs ring-1 ring-border data-[selected=true]:ring-primary'
169
  >
170
+ <AccordionTrigger className='flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline data-[state=open]:bg-accent [&>svg]:hidden'>
171
  <span
172
  className={`shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
173
  selected ? 'bg-primary' : 'bg-muted-foreground/60'
 
179
  <div className='flex min-w-0 flex-1 items-center gap-1'>
180
  <span
181
  className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
182
+ hasOcr ? 'bg-rose-400/80 text-white' : 'bg-muted text-muted-foreground/50'
 
 
183
  }`}
184
  >
185
  {t('textBlocks.ocrBadge')}
186
  </span>
187
  <span
188
  className={`shrink-0 rounded px-1 py-0.5 text-[9px] font-medium uppercase ${
189
+ hasTranslation ? 'bg-rose-400/80 text-white' : 'bg-muted text-muted-foreground/50'
 
 
190
  }`}
191
  >
192
  {t('textBlocks.translationBadge')}
193
  </span>
194
  {preview && (
195
+ <p className='line-clamp-1 min-w-0 flex-1 text-xs text-muted-foreground'>{preview}</p>
 
 
196
  )}
197
  </div>
198
  </AccordionTrigger>
199
  <AccordionContent className='px-2 pt-1.5 pb-2 shadow-[inset_0_1px_0_0_var(--color-border)]'>
200
  <div className='space-y-1.5'>
201
  <div className='flex flex-col gap-0.5'>
202
+ <span className='text-[10px] text-muted-foreground uppercase'>
203
  {t('textBlocks.ocrLabel')}
204
  </span>
205
  <DraftTextarea
 
213
  </div>
214
  <div className='flex flex-col gap-0.5'>
215
  <div className='flex items-center justify-between'>
216
+ <span className='text-[10px] text-muted-foreground uppercase'>
217
  {t('textBlocks.translationLabel')}
218
  </span>
219
  <div className='flex items-center gap-0.5'>
ui/components/ui/accordion.tsx CHANGED
@@ -1,14 +1,12 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as AccordionPrimitive from '@radix-ui/react-accordion'
5
  import { ChevronDownIcon } from 'lucide-react'
 
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
- function Accordion({
10
- ...props
11
- }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
12
  return <AccordionPrimitive.Root data-slot='accordion' {...props} />
13
  }
14
 
@@ -35,13 +33,13 @@ function AccordionTrigger({
35
  <AccordionPrimitive.Trigger
36
  data-slot='accordion-trigger'
37
  className={cn(
38
- 'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
39
  className,
40
  )}
41
  {...props}
42
  >
43
  {children}
44
- <ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
45
  </AccordionPrimitive.Trigger>
46
  </AccordionPrimitive.Header>
47
  )
@@ -55,7 +53,7 @@ function AccordionContent({
55
  return (
56
  <AccordionPrimitive.Content
57
  data-slot='accordion-content'
58
- className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
59
  {...props}
60
  >
61
  <div className={cn('pt-0 pb-4', className)}>{children}</div>
 
1
  'use client'
2
 
 
3
  import * as AccordionPrimitive from '@radix-ui/react-accordion'
4
  import { ChevronDownIcon } from 'lucide-react'
5
+ import * as React from 'react'
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
+ function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
 
 
10
  return <AccordionPrimitive.Root data-slot='accordion' {...props} />
11
  }
12
 
 
33
  <AccordionPrimitive.Trigger
34
  data-slot='accordion-trigger'
35
  className={cn(
36
+ 'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
37
  className,
38
  )}
39
  {...props}
40
  >
41
  {children}
42
+ <ChevronDownIcon className='pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200' />
43
  </AccordionPrimitive.Trigger>
44
  </AccordionPrimitive.Header>
45
  )
 
53
  return (
54
  <AccordionPrimitive.Content
55
  data-slot='accordion-content'
56
+ className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down'
57
  {...props}
58
  >
59
  <div className={cn('pt-0 pb-4', className)}>{children}</div>
ui/components/ui/alert-dialog.tsx CHANGED
@@ -1,31 +1,23 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
 
5
 
6
- import { cn } from '@/lib/utils'
7
  import { buttonVariants } from '@/components/ui/button'
 
8
 
9
- function AlertDialog({
10
- ...props
11
- }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
12
  return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
13
  }
14
 
15
  function AlertDialogTrigger({
16
  ...props
17
  }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
18
- return (
19
- <AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
20
- )
21
  }
22
 
23
- function AlertDialogPortal({
24
- ...props
25
- }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
26
- return (
27
- <AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
28
- )
29
  }
30
 
31
  function AlertDialogOverlay({
@@ -36,7 +28,7 @@ function AlertDialogOverlay({
36
  <AlertDialogPrimitive.Overlay
37
  data-slot='alert-dialog-overlay'
38
  className={cn(
39
- 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40',
40
  className,
41
  )}
42
  {...props}
@@ -54,7 +46,7 @@ function AlertDialogContent({
54
  <AlertDialogPrimitive.Content
55
  data-slot='alert-dialog-content'
56
  className={cn(
57
- 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg',
58
  className,
59
  )}
60
  {...props}
@@ -70,7 +62,7 @@ function AlertDialogTitle({
70
  return (
71
  <AlertDialogPrimitive.Title
72
  data-slot='alert-dialog-title'
73
- className={cn('text-foreground text-sm font-semibold', className)}
74
  {...props}
75
  />
76
  )
@@ -83,7 +75,7 @@ function AlertDialogDescription({
83
  return (
84
  <AlertDialogPrimitive.Description
85
  data-slot='alert-dialog-description'
86
- className={cn('text-muted-foreground text-sm', className)}
87
  {...props}
88
  />
89
  )
@@ -93,12 +85,7 @@ function AlertDialogAction({
93
  className,
94
  ...props
95
  }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
96
- return (
97
- <AlertDialogPrimitive.Action
98
- className={cn(buttonVariants(), className)}
99
- {...props}
100
- />
101
- )
102
  }
103
 
104
  function AlertDialogCancel({
 
1
  'use client'
2
 
 
3
  import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
4
+ import * as React from 'react'
5
 
 
6
  import { buttonVariants } from '@/components/ui/button'
7
+ import { cn } from '@/lib/utils'
8
 
9
+ function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
 
 
10
  return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
11
  }
12
 
13
  function AlertDialogTrigger({
14
  ...props
15
  }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
16
+ return <AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
 
 
17
  }
18
 
19
+ function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
20
+ return <AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
 
 
 
 
21
  }
22
 
23
  function AlertDialogOverlay({
 
28
  <AlertDialogPrimitive.Overlay
29
  data-slot='alert-dialog-overlay'
30
  className={cn(
31
+ 'fixed inset-0 z-50 bg-black/40 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
32
  className,
33
  )}
34
  {...props}
 
46
  <AlertDialogPrimitive.Content
47
  data-slot='alert-dialog-content'
48
  className={cn(
49
+ 'fixed top-1/2 left-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
50
  className,
51
  )}
52
  {...props}
 
62
  return (
63
  <AlertDialogPrimitive.Title
64
  data-slot='alert-dialog-title'
65
+ className={cn('text-sm font-semibold text-foreground', className)}
66
  {...props}
67
  />
68
  )
 
75
  return (
76
  <AlertDialogPrimitive.Description
77
  data-slot='alert-dialog-description'
78
+ className={cn('text-sm text-muted-foreground', className)}
79
  {...props}
80
  />
81
  )
 
85
  className,
86
  ...props
87
  }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
88
+ return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
 
 
 
 
 
89
  }
90
 
91
  function AlertDialogCancel({
ui/components/ui/button.tsx CHANGED
@@ -1,29 +1,27 @@
1
- import * as React from 'react'
2
  import { Slot } from '@radix-ui/react-slot'
3
  import { cva, type VariantProps } from 'class-variance-authority'
 
4
 
5
  import { cn } from '@/lib/utils'
6
 
7
  const buttonVariants = cva(
8
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
  {
10
  variants: {
11
  variant: {
12
  default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13
  destructive:
14
- 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
15
  outline:
16
- 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
17
- secondary:
18
- 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19
- ghost:
20
- 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
21
  link: 'text-primary underline-offset-4 hover:underline',
22
  },
23
  size: {
24
  default: 'h-9 px-4 py-2 has-[>svg]:px-3',
25
  xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
26
- sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
27
  lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
28
  icon: 'size-9',
29
  'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
 
 
1
  import { Slot } from '@radix-ui/react-slot'
2
  import { cva, type VariantProps } from 'class-variance-authority'
3
+ import * as React from 'react'
4
 
5
  import { cn } from '@/lib/utils'
6
 
7
  const buttonVariants = cva(
8
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
  {
10
  variants: {
11
  variant: {
12
  default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13
  destructive:
14
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
15
  outline:
16
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
17
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
18
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
 
 
19
  link: 'text-primary underline-offset-4 hover:underline',
20
  },
21
  size: {
22
  default: 'h-9 px-4 py-2 has-[>svg]:px-3',
23
  xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
24
+ sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
25
  lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
26
  icon: 'size-9',
27
  'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
ui/components/ui/color-picker.tsx CHANGED
@@ -2,12 +2,9 @@
2
 
3
  import { useRef, useState, useCallback, useEffect } from 'react'
4
  import { HexColorInput, HexColorPicker } from 'react-colorful'
 
5
  import { Button } from '@/components/ui/button'
6
- import {
7
- Popover,
8
- PopoverContent,
9
- PopoverTrigger,
10
- } from '@/components/ui/popover'
11
  import { cn } from '@/lib/utils'
12
 
13
  type ColorPickerProps = {
@@ -60,8 +57,7 @@ export function ColorPicker({
60
  }, [localColor, onChange])
61
 
62
  const canUseEyeDropper =
63
- typeof window !== 'undefined' &&
64
- typeof (window as EyeDropperWindow).EyeDropper === 'function'
65
 
66
  const handlePickFromScreen = async () => {
67
  const EyeDropperCtor = (window as EyeDropperWindow).EyeDropper
@@ -87,7 +83,7 @@ export function ColorPicker({
87
  data-testid={triggerTestId}
88
  disabled={disabled}
89
  className={cn(
90
- 'border-input hover:border-border flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition disabled:cursor-not-allowed disabled:opacity-50',
91
  className,
92
  )}
93
  >
@@ -120,7 +116,7 @@ export function ColorPicker({
120
  spellCheck={false}
121
  disabled={disabled}
122
  aria-label='Hex color code'
123
- className='border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-8 min-w-0 flex-1 rounded-md border px-2 font-mono text-xs uppercase shadow-xs transition outline-none focus-visible:ring-[3px]'
124
  onChange={(color) => {
125
  const normalized = normalizeHex(color)
126
  setLocalColor(normalized)
 
2
 
3
  import { useRef, useState, useCallback, useEffect } from 'react'
4
  import { HexColorInput, HexColorPicker } from 'react-colorful'
5
+
6
  import { Button } from '@/components/ui/button'
7
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 
 
 
 
8
  import { cn } from '@/lib/utils'
9
 
10
  type ColorPickerProps = {
 
57
  }, [localColor, onChange])
58
 
59
  const canUseEyeDropper =
60
+ typeof window !== 'undefined' && typeof (window as EyeDropperWindow).EyeDropper === 'function'
 
61
 
62
  const handlePickFromScreen = async () => {
63
  const EyeDropperCtor = (window as EyeDropperWindow).EyeDropper
 
83
  data-testid={triggerTestId}
84
  disabled={disabled}
85
  className={cn(
86
+ 'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-input transition hover:border-border disabled:cursor-not-allowed disabled:opacity-50',
87
  className,
88
  )}
89
  >
 
116
  spellCheck={false}
117
  disabled={disabled}
118
  aria-label='Hex color code'
119
+ className='h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 font-mono text-xs uppercase shadow-xs transition outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
120
  onChange={(color) => {
121
  const normalized = normalizeHex(color)
122
  setLocalColor(normalized)
ui/components/ui/context-menu.tsx CHANGED
@@ -1,56 +1,37 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
5
  import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
 
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
- function ContextMenu({
10
- ...props
11
- }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
12
  return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />
13
  }
14
 
15
  function ContextMenuTrigger({
16
  ...props
17
  }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
18
- return (
19
- <ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
20
- )
21
  }
22
 
23
- function ContextMenuGroup({
24
- ...props
25
- }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
26
- return (
27
- <ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
28
- )
29
  }
30
 
31
- function ContextMenuPortal({
32
- ...props
33
- }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
34
- return (
35
- <ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
36
- )
37
  }
38
 
39
- function ContextMenuSub({
40
- ...props
41
- }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
42
  return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />
43
  }
44
 
45
  function ContextMenuRadioGroup({
46
  ...props
47
  }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
48
- return (
49
- <ContextMenuPrimitive.RadioGroup
50
- data-slot='context-menu-radio-group'
51
- {...props}
52
- />
53
- )
54
  }
55
 
56
  function ContextMenuSubTrigger({
@@ -66,7 +47,7 @@ function ContextMenuSubTrigger({
66
  data-slot='context-menu-sub-trigger'
67
  data-inset={inset}
68
  className={cn(
69
- "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
  className,
71
  )}
72
  {...props}
@@ -85,7 +66,7 @@ function ContextMenuSubContent({
85
  <ContextMenuPrimitive.SubContent
86
  data-slot='context-menu-sub-content'
87
  className={cn(
88
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
89
  className,
90
  )}
91
  {...props}
@@ -102,7 +83,7 @@ function ContextMenuContent({
102
  <ContextMenuPrimitive.Content
103
  data-slot='context-menu-content'
104
  className={cn(
105
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
106
  className,
107
  )}
108
  {...props}
@@ -126,7 +107,7 @@ function ContextMenuItem({
126
  data-inset={inset}
127
  data-variant={variant}
128
  className={cn(
129
- "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
130
  className,
131
  )}
132
  {...props}
@@ -144,7 +125,7 @@ function ContextMenuCheckboxItem({
144
  <ContextMenuPrimitive.CheckboxItem
145
  data-slot='context-menu-checkbox-item'
146
  className={cn(
147
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
148
  className,
149
  )}
150
  checked={checked}
@@ -169,7 +150,7 @@ function ContextMenuRadioItem({
169
  <ContextMenuPrimitive.RadioItem
170
  data-slot='context-menu-radio-item'
171
  className={cn(
172
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
173
  className,
174
  )}
175
  {...props}
@@ -195,10 +176,7 @@ function ContextMenuLabel({
195
  <ContextMenuPrimitive.Label
196
  data-slot='context-menu-label'
197
  data-inset={inset}
198
- className={cn(
199
- 'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
200
- className,
201
- )}
202
  {...props}
203
  />
204
  )
@@ -211,23 +189,17 @@ function ContextMenuSeparator({
211
  return (
212
  <ContextMenuPrimitive.Separator
213
  data-slot='context-menu-separator'
214
- className={cn('bg-border -mx-1 my-1 h-px', className)}
215
  {...props}
216
  />
217
  )
218
  }
219
 
220
- function ContextMenuShortcut({
221
- className,
222
- ...props
223
- }: React.ComponentProps<'span'>) {
224
  return (
225
  <span
226
  data-slot='context-menu-shortcut'
227
- className={cn(
228
- 'text-muted-foreground ml-auto text-xs tracking-widest',
229
- className,
230
- )}
231
  {...props}
232
  />
233
  )
 
1
  'use client'
2
 
 
3
  import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
4
  import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
5
+ import * as React from 'react'
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
+ function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
 
 
10
  return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />
11
  }
12
 
13
  function ContextMenuTrigger({
14
  ...props
15
  }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
16
+ return <ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
 
 
17
  }
18
 
19
+ function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
20
+ return <ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
 
 
 
 
21
  }
22
 
23
+ function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
24
+ return <ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
 
 
 
 
25
  }
26
 
27
+ function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
 
 
28
  return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />
29
  }
30
 
31
  function ContextMenuRadioGroup({
32
  ...props
33
  }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
34
+ return <ContextMenuPrimitive.RadioGroup data-slot='context-menu-radio-group' {...props} />
 
 
 
 
 
35
  }
36
 
37
  function ContextMenuSubTrigger({
 
47
  data-slot='context-menu-sub-trigger'
48
  data-inset={inset}
49
  className={cn(
50
+ "flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
51
  className,
52
  )}
53
  {...props}
 
66
  <ContextMenuPrimitive.SubContent
67
  data-slot='context-menu-sub-content'
68
  className={cn(
69
+ 'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
70
  className,
71
  )}
72
  {...props}
 
83
  <ContextMenuPrimitive.Content
84
  data-slot='context-menu-content'
85
  className={cn(
86
+ 'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
87
  className,
88
  )}
89
  {...props}
 
107
  data-inset={inset}
108
  data-variant={variant}
109
  className={cn(
110
+ "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
111
  className,
112
  )}
113
  {...props}
 
125
  <ContextMenuPrimitive.CheckboxItem
126
  data-slot='context-menu-checkbox-item'
127
  className={cn(
128
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
129
  className,
130
  )}
131
  checked={checked}
 
150
  <ContextMenuPrimitive.RadioItem
151
  data-slot='context-menu-radio-item'
152
  className={cn(
153
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
154
  className,
155
  )}
156
  {...props}
 
176
  <ContextMenuPrimitive.Label
177
  data-slot='context-menu-label'
178
  data-inset={inset}
179
+ className={cn('px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8', className)}
 
 
 
180
  {...props}
181
  />
182
  )
 
189
  return (
190
  <ContextMenuPrimitive.Separator
191
  data-slot='context-menu-separator'
192
+ className={cn('-mx-1 my-1 h-px bg-border', className)}
193
  {...props}
194
  />
195
  )
196
  }
197
 
198
+ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
 
 
 
199
  return (
200
  <span
201
  data-slot='context-menu-shortcut'
202
+ className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
 
 
 
203
  {...props}
204
  />
205
  )
ui/components/ui/dialog.tsx CHANGED
@@ -1,24 +1,19 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as DialogPrimitive from '@radix-ui/react-dialog'
 
 
5
  import { cn } from '@/lib/utils'
6
 
7
- function Dialog({
8
- ...props
9
- }: React.ComponentProps<typeof DialogPrimitive.Root>) {
10
  return <DialogPrimitive.Root data-slot='dialog' {...props} />
11
  }
12
 
13
- function DialogTrigger({
14
- ...props
15
- }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
16
  return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
17
  }
18
 
19
- function DialogPortal({
20
- ...props
21
- }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
22
  return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
23
  }
24
 
@@ -30,7 +25,7 @@ function DialogOverlay({
30
  <DialogPrimitive.Overlay
31
  data-slot='dialog-overlay'
32
  className={cn(
33
- 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40',
34
  className,
35
  )}
36
  {...props}
@@ -49,7 +44,7 @@ function DialogContent({
49
  <DialogPrimitive.Content
50
  data-slot='dialog-content'
51
  className={cn(
52
- 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-lg outline-none',
53
  className,
54
  )}
55
  {...props}
@@ -60,14 +55,11 @@ function DialogContent({
60
  )
61
  }
62
 
63
- function DialogTitle({
64
- className,
65
- ...props
66
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
67
  return (
68
  <DialogPrimitive.Title
69
  data-slot='dialog-title'
70
- className={cn('text-foreground text-sm font-semibold', className)}
71
  {...props}
72
  />
73
  )
@@ -80,7 +72,7 @@ function DialogDescription({
80
  return (
81
  <DialogPrimitive.Description
82
  data-slot='dialog-description'
83
- className={cn('text-muted-foreground text-xs', className)}
84
  {...props}
85
  />
86
  )
 
1
  'use client'
2
 
 
3
  import * as DialogPrimitive from '@radix-ui/react-dialog'
4
+ import * as React from 'react'
5
+
6
  import { cn } from '@/lib/utils'
7
 
8
+ function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
 
 
9
  return <DialogPrimitive.Root data-slot='dialog' {...props} />
10
  }
11
 
12
+ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
 
 
13
  return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
14
  }
15
 
16
+ function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
 
 
17
  return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
18
  }
19
 
 
25
  <DialogPrimitive.Overlay
26
  data-slot='dialog-overlay'
27
  className={cn(
28
+ 'fixed inset-0 z-50 bg-black/40 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
29
  className,
30
  )}
31
  {...props}
 
44
  <DialogPrimitive.Content
45
  data-slot='dialog-content'
46
  className={cn(
47
+ 'fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
48
  className,
49
  )}
50
  {...props}
 
55
  )
56
  }
57
 
58
+ function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
 
 
 
59
  return (
60
  <DialogPrimitive.Title
61
  data-slot='dialog-title'
62
+ className={cn('text-sm font-semibold text-foreground', className)}
63
  {...props}
64
  />
65
  )
 
72
  return (
73
  <DialogPrimitive.Description
74
  data-slot='dialog-description'
75
+ className={cn('text-xs text-muted-foreground', className)}
76
  {...props}
77
  />
78
  )
ui/components/ui/draft-textarea.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import * as React from 'react'
4
  import { useEffect, useRef, useState } from 'react'
 
5
  import { Textarea } from '@/components/ui/textarea'
6
 
7
  export type DraftTextareaProps = Omit<
 
2
 
3
  import * as React from 'react'
4
  import { useEffect, useRef, useState } from 'react'
5
+
6
  import { Textarea } from '@/components/ui/textarea'
7
 
8
  export type DraftTextareaProps = Omit<
ui/components/ui/font-select.tsx CHANGED
@@ -1,16 +1,13 @@
1
  'use client'
2
 
3
- import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
4
  import { useVirtualizer } from '@tanstack/react-virtual'
5
  import { CheckIcon, ChevronDownIcon } from 'lucide-react'
6
- import {
7
- Popover,
8
- PopoverContent,
9
- PopoverTrigger,
10
- } from '@/components/ui/popover'
11
  import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
12
- import { cn } from '@/lib/utils'
13
  import { fetchGoogleFont } from '@/lib/api/system/system'
 
14
 
15
  const ITEM_HEIGHT = 28
16
  const MAX_VISIBLE = 10
@@ -37,14 +34,8 @@ type FontSelectProps = {
37
  'data-testid'?: string
38
  }
39
 
40
- function useGoogleFontPreview(
41
- family: string,
42
- source: string,
43
- isVisible: boolean,
44
- ) {
45
- const [state, setState] = useState<FontLoadState>(
46
- source === 'system' ? 'ready' : 'idle',
47
- )
48
  const stateRef = useRef(state)
49
  stateRef.current = state
50
 
@@ -92,17 +83,13 @@ function FontRow({
92
  isVisible: boolean
93
  onClick: () => void
94
  }) {
95
- const loadState = useGoogleFontPreview(
96
- font.familyName,
97
- font.source,
98
- isVisible,
99
- )
100
 
101
  return (
102
  <button
103
  type='button'
104
  className={cn(
105
- 'hover:bg-accent hover:text-accent-foreground absolute left-0 flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 text-xs select-none',
106
  selected && 'bg-accent',
107
  )}
108
  style={{
@@ -116,7 +103,7 @@ function FontRow({
116
  </span>
117
  <span className='truncate'>{font.familyName}</span>
118
  {font.source === 'google' && (
119
- <span className='text-muted-foreground ml-auto shrink-0 text-[9px]'>
120
  {loadState === 'loading' ? '...' : 'G'}
121
  </span>
122
  )}
@@ -144,9 +131,7 @@ export function FontSelect({
144
  const filtered = useMemo(() => {
145
  let result = options
146
  if (categoryFilter) {
147
- result = result.filter(
148
- (f) => f.source === 'system' || f.category === categoryFilter,
149
- )
150
  }
151
  if (search) {
152
  const lower = search.toLowerCase()
@@ -190,7 +175,7 @@ export function FontSelect({
190
  disabled={disabled}
191
  data-testid={props['data-testid']}
192
  className={cn(
193
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50 flex h-7 w-full items-center justify-between gap-1.5 rounded-md border bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
194
  triggerClassName,
195
  )}
196
  style={triggerStyle}
@@ -211,49 +196,33 @@ export function FontSelect({
211
  value={search}
212
  onChange={(e) => setSearch(e.target.value)}
213
  placeholder='Search fonts…'
214
- className='placeholder:text-muted-foreground w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none'
215
  />
216
  <ScrollArea className='border-b'>
217
  <div className='flex gap-0.5 px-1.5 py-1'>
218
- {['all', 'hand', 'display', 'sans', 'serif', 'mono'].map(
219
- (cat, i) => {
220
- const full = [
221
- 'all',
222
- 'handwriting',
223
- 'display',
224
- 'sans-serif',
225
- 'serif',
226
- 'monospace',
227
- ][i]
228
- const active =
229
- cat === 'all' ? !categoryFilter : categoryFilter === full
230
- return (
231
- <button
232
- key={cat}
233
- type='button'
234
- className={cn(
235
- 'shrink-0 rounded-full px-1.5 py-px text-[9px]',
236
- active
237
- ? 'bg-primary text-primary-foreground'
238
- : 'bg-muted text-muted-foreground hover:bg-accent',
239
- )}
240
- onClick={() =>
241
- setCategoryFilter(cat === 'all' ? null : full)
242
- }
243
- >
244
- {cat.charAt(0).toUpperCase() + cat.slice(1)}
245
- </button>
246
- )
247
- },
248
- )}
249
  </div>
250
  <ScrollBar orientation='horizontal' />
251
  </ScrollArea>
252
- <ScrollArea
253
- className='relative'
254
- style={{ height: listHeight }}
255
- viewportRef={viewportRef}
256
- >
257
  <div
258
  style={{
259
  height: virtualizer.getTotalSize(),
@@ -262,8 +231,7 @@ export function FontSelect({
262
  >
263
  {virtualizer.getVirtualItems().map((vi) => {
264
  const font = filtered[vi.index]
265
- const selected =
266
- font.postScriptName === value || font.familyName === value
267
  return (
268
  <FontRow
269
  key={vi.key}
@@ -282,9 +250,7 @@ export function FontSelect({
282
  </div>
283
  </ScrollArea>
284
  {filtered.length === 0 && (
285
- <div className='text-muted-foreground px-2 py-4 text-center text-xs'>
286
- No fonts found
287
- </div>
288
  )}
289
  </PopoverContent>
290
  </Popover>
 
1
  'use client'
2
 
 
3
  import { useVirtualizer } from '@tanstack/react-virtual'
4
  import { CheckIcon, ChevronDownIcon } from 'lucide-react'
5
+ import { useRef, useState, useMemo, useCallback, useEffect } from 'react'
6
+
7
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 
 
8
  import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
 
9
  import { fetchGoogleFont } from '@/lib/api/system/system'
10
+ import { cn } from '@/lib/utils'
11
 
12
  const ITEM_HEIGHT = 28
13
  const MAX_VISIBLE = 10
 
34
  'data-testid'?: string
35
  }
36
 
37
+ function useGoogleFontPreview(family: string, source: string, isVisible: boolean) {
38
+ const [state, setState] = useState<FontLoadState>(source === 'system' ? 'ready' : 'idle')
 
 
 
 
 
 
39
  const stateRef = useRef(state)
40
  stateRef.current = state
41
 
 
83
  isVisible: boolean
84
  onClick: () => void
85
  }) {
86
+ const loadState = useGoogleFontPreview(font.familyName, font.source, isVisible)
 
 
 
 
87
 
88
  return (
89
  <button
90
  type='button'
91
  className={cn(
92
+ 'absolute left-0 flex w-full cursor-default items-center gap-1.5 rounded-sm px-2 text-xs select-none hover:bg-accent hover:text-accent-foreground',
93
  selected && 'bg-accent',
94
  )}
95
  style={{
 
103
  </span>
104
  <span className='truncate'>{font.familyName}</span>
105
  {font.source === 'google' && (
106
+ <span className='ml-auto shrink-0 text-[9px] text-muted-foreground'>
107
  {loadState === 'loading' ? '...' : 'G'}
108
  </span>
109
  )}
 
131
  const filtered = useMemo(() => {
132
  let result = options
133
  if (categoryFilter) {
134
+ result = result.filter((f) => f.source === 'system' || f.category === categoryFilter)
 
 
135
  }
136
  if (search) {
137
  const lower = search.toLowerCase()
 
175
  disabled={disabled}
176
  data-testid={props['data-testid']}
177
  className={cn(
178
+ "flex h-7 w-full items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground",
179
  triggerClassName,
180
  )}
181
  style={triggerStyle}
 
196
  value={search}
197
  onChange={(e) => setSearch(e.target.value)}
198
  placeholder='Search fonts…'
199
+ className='w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground'
200
  />
201
  <ScrollArea className='border-b'>
202
  <div className='flex gap-0.5 px-1.5 py-1'>
203
+ {['all', 'hand', 'display', 'sans', 'serif', 'mono'].map((cat, i) => {
204
+ const full = ['all', 'handwriting', 'display', 'sans-serif', 'serif', 'monospace'][i]
205
+ const active = cat === 'all' ? !categoryFilter : categoryFilter === full
206
+ return (
207
+ <button
208
+ key={cat}
209
+ type='button'
210
+ className={cn(
211
+ 'shrink-0 rounded-full px-1.5 py-px text-[9px]',
212
+ active
213
+ ? 'bg-primary text-primary-foreground'
214
+ : 'bg-muted text-muted-foreground hover:bg-accent',
215
+ )}
216
+ onClick={() => setCategoryFilter(cat === 'all' ? null : full)}
217
+ >
218
+ {cat.charAt(0).toUpperCase() + cat.slice(1)}
219
+ </button>
220
+ )
221
+ })}
 
 
 
 
 
 
 
 
 
 
 
 
222
  </div>
223
  <ScrollBar orientation='horizontal' />
224
  </ScrollArea>
225
+ <ScrollArea className='relative' style={{ height: listHeight }} viewportRef={viewportRef}>
 
 
 
 
226
  <div
227
  style={{
228
  height: virtualizer.getTotalSize(),
 
231
  >
232
  {virtualizer.getVirtualItems().map((vi) => {
233
  const font = filtered[vi.index]
234
+ const selected = font.postScriptName === value || font.familyName === value
 
235
  return (
236
  <FontRow
237
  key={vi.key}
 
250
  </div>
251
  </ScrollArea>
252
  {filtered.length === 0 && (
253
+ <div className='px-2 py-4 text-center text-xs text-muted-foreground'>No fonts found</div>
 
 
254
  )}
255
  </PopoverContent>
256
  </Popover>
ui/components/ui/input.tsx CHANGED
@@ -8,9 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
8
  type={type}
9
  data-slot='input'
10
  className={cn(
11
- 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
12
- 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
13
- 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
14
  className,
15
  )}
16
  {...props}
 
8
  type={type}
9
  data-slot='input'
10
  className={cn(
11
+ 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
12
+ 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
13
+ 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
14
  className,
15
  )}
16
  {...props}
ui/components/ui/label.tsx CHANGED
@@ -1,14 +1,11 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as LabelPrimitive from '@radix-ui/react-label'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
8
- function Label({
9
- className,
10
- ...props
11
- }: React.ComponentProps<typeof LabelPrimitive.Root>) {
12
  return (
13
  <LabelPrimitive.Root
14
  data-slot='label'
 
1
  'use client'
2
 
 
3
  import * as LabelPrimitive from '@radix-ui/react-label'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
8
+ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
 
 
 
9
  return (
10
  <LabelPrimitive.Root
11
  data-slot='label'
ui/components/ui/menubar.tsx CHANGED
@@ -1,20 +1,17 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as MenubarPrimitive from '@radix-ui/react-menubar'
5
  import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
 
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
- function Menubar({
10
- className,
11
- ...props
12
- }: React.ComponentProps<typeof MenubarPrimitive.Root>) {
13
  return (
14
  <MenubarPrimitive.Root
15
  data-slot='menubar'
16
  className={cn(
17
- 'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
18
  className,
19
  )}
20
  {...props}
@@ -22,30 +19,20 @@ function Menubar({
22
  )
23
  }
24
 
25
- function MenubarMenu({
26
- ...props
27
- }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
28
  return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />
29
  }
30
 
31
- function MenubarGroup({
32
- ...props
33
- }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
34
  return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />
35
  }
36
 
37
- function MenubarPortal({
38
- ...props
39
- }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
40
  return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />
41
  }
42
 
43
- function MenubarRadioGroup({
44
- ...props
45
- }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
46
- return (
47
- <MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
48
- )
49
  }
50
 
51
  function MenubarTrigger({
@@ -56,7 +43,7 @@ function MenubarTrigger({
56
  <MenubarPrimitive.Trigger
57
  data-slot='menubar-trigger'
58
  className={cn(
59
- 'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
60
  className,
61
  )}
62
  {...props}
@@ -79,7 +66,7 @@ function MenubarContent({
79
  alignOffset={alignOffset}
80
  sideOffset={sideOffset}
81
  className={cn(
82
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
83
  className,
84
  )}
85
  {...props}
@@ -103,7 +90,7 @@ function MenubarItem({
103
  data-inset={inset}
104
  data-variant={variant}
105
  className={cn(
106
- "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
107
  className,
108
  )}
109
  {...props}
@@ -121,7 +108,7 @@ function MenubarCheckboxItem({
121
  <MenubarPrimitive.CheckboxItem
122
  data-slot='menubar-checkbox-item'
123
  className={cn(
124
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
125
  className,
126
  )}
127
  checked={checked}
@@ -146,7 +133,7 @@ function MenubarRadioItem({
146
  <MenubarPrimitive.RadioItem
147
  data-slot='menubar-radio-item'
148
  className={cn(
149
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
150
  className,
151
  )}
152
  {...props}
@@ -172,10 +159,7 @@ function MenubarLabel({
172
  <MenubarPrimitive.Label
173
  data-slot='menubar-label'
174
  data-inset={inset}
175
- className={cn(
176
- 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
177
- className,
178
- )}
179
  {...props}
180
  />
181
  )
@@ -188,31 +172,23 @@ function MenubarSeparator({
188
  return (
189
  <MenubarPrimitive.Separator
190
  data-slot='menubar-separator'
191
- className={cn('bg-border -mx-1 my-1 h-px', className)}
192
  {...props}
193
  />
194
  )
195
  }
196
 
197
- function MenubarShortcut({
198
- className,
199
- ...props
200
- }: React.ComponentProps<'span'>) {
201
  return (
202
  <span
203
  data-slot='menubar-shortcut'
204
- className={cn(
205
- 'text-muted-foreground ml-auto text-xs tracking-widest',
206
- className,
207
- )}
208
  {...props}
209
  />
210
  )
211
  }
212
 
213
- function MenubarSub({
214
- ...props
215
- }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
216
  return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />
217
  }
218
 
@@ -229,7 +205,7 @@ function MenubarSubTrigger({
229
  data-slot='menubar-sub-trigger'
230
  data-inset={inset}
231
  className={cn(
232
- 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
233
  className,
234
  )}
235
  {...props}
@@ -248,7 +224,7 @@ function MenubarSubContent({
248
  <MenubarPrimitive.SubContent
249
  data-slot='menubar-sub-content'
250
  className={cn(
251
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
252
  className,
253
  )}
254
  {...props}
 
1
  'use client'
2
 
 
3
  import * as MenubarPrimitive from '@radix-ui/react-menubar'
4
  import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
5
+ import * as React from 'react'
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
+ function Menubar({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Root>) {
 
 
 
10
  return (
11
  <MenubarPrimitive.Root
12
  data-slot='menubar'
13
  className={cn(
14
+ 'flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs',
15
  className,
16
  )}
17
  {...props}
 
19
  )
20
  }
21
 
22
+ function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
 
 
23
  return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />
24
  }
25
 
26
+ function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
 
 
27
  return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />
28
  }
29
 
30
+ function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
 
 
31
  return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />
32
  }
33
 
34
+ function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
35
+ return <MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
 
 
 
 
36
  }
37
 
38
  function MenubarTrigger({
 
43
  <MenubarPrimitive.Trigger
44
  data-slot='menubar-trigger'
45
  className={cn(
46
+ 'flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
47
  className,
48
  )}
49
  {...props}
 
66
  alignOffset={alignOffset}
67
  sideOffset={sideOffset}
68
  className={cn(
69
+ 'z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
70
  className,
71
  )}
72
  {...props}
 
90
  data-inset={inset}
91
  data-variant={variant}
92
  className={cn(
93
+ "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:!text-destructive",
94
  className,
95
  )}
96
  {...props}
 
108
  <MenubarPrimitive.CheckboxItem
109
  data-slot='menubar-checkbox-item'
110
  className={cn(
111
+ "relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
112
  className,
113
  )}
114
  checked={checked}
 
133
  <MenubarPrimitive.RadioItem
134
  data-slot='menubar-radio-item'
135
  className={cn(
136
+ "relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
137
  className,
138
  )}
139
  {...props}
 
159
  <MenubarPrimitive.Label
160
  data-slot='menubar-label'
161
  data-inset={inset}
162
+ className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
 
 
 
163
  {...props}
164
  />
165
  )
 
172
  return (
173
  <MenubarPrimitive.Separator
174
  data-slot='menubar-separator'
175
+ className={cn('-mx-1 my-1 h-px bg-border', className)}
176
  {...props}
177
  />
178
  )
179
  }
180
 
181
+ function MenubarShortcut({ className, ...props }: React.ComponentProps<'span'>) {
 
 
 
182
  return (
183
  <span
184
  data-slot='menubar-shortcut'
185
+ className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
 
 
 
186
  {...props}
187
  />
188
  )
189
  }
190
 
191
+ function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
 
 
192
  return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />
193
  }
194
 
 
205
  data-slot='menubar-sub-trigger'
206
  data-inset={inset}
207
  className={cn(
208
+ 'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
209
  className,
210
  )}
211
  {...props}
 
224
  <MenubarPrimitive.SubContent
225
  data-slot='menubar-sub-content'
226
  className={cn(
227
+ 'z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
228
  className,
229
  )}
230
  {...props}
ui/components/ui/popover.tsx CHANGED
@@ -1,25 +1,19 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as PopoverPrimitive from '@radix-ui/react-popover'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
8
- function Popover({
9
- ...props
10
- }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
11
  return <PopoverPrimitive.Root data-slot='popover' {...props} />
12
  }
13
 
14
- function PopoverTrigger({
15
- ...props
16
- }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
17
  return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
18
  }
19
 
20
- function PopoverAnchor({
21
- ...props
22
- }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
23
  return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
24
  }
25
 
@@ -36,7 +30,7 @@ function PopoverContent({
36
  align={align}
37
  sideOffset={sideOffset}
38
  className={cn(
39
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-none',
40
  className,
41
  )}
42
  {...props}
 
1
  'use client'
2
 
 
3
  import * as PopoverPrimitive from '@radix-ui/react-popover'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
8
+ function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
 
 
9
  return <PopoverPrimitive.Root data-slot='popover' {...props} />
10
  }
11
 
12
+ function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
 
 
13
  return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
14
  }
15
 
16
+ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
 
 
17
  return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
18
  }
19
 
 
30
  align={align}
31
  sideOffset={sideOffset}
32
  className={cn(
33
+ 'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
34
  className,
35
  )}
36
  {...props}
ui/components/ui/progress.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as ProgressPrimitive from '@radix-ui/react-progress'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
@@ -13,15 +13,12 @@ function Progress({
13
  return (
14
  <ProgressPrimitive.Root
15
  data-slot='progress'
16
- className={cn(
17
- 'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
18
- className,
19
- )}
20
  {...props}
21
  >
22
  <ProgressPrimitive.Indicator
23
  data-slot='progress-indicator'
24
- className='bg-primary h-full w-full flex-1 rounded-full transition-transform duration-300 ease-in-out'
25
  style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
26
  />
27
  </ProgressPrimitive.Root>
 
1
  'use client'
2
 
 
3
  import * as ProgressPrimitive from '@radix-ui/react-progress'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
 
13
  return (
14
  <ProgressPrimitive.Root
15
  data-slot='progress'
16
+ className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
 
 
 
17
  {...props}
18
  >
19
  <ProgressPrimitive.Indicator
20
  data-slot='progress-indicator'
21
+ className='h-full w-full flex-1 rounded-full bg-primary transition-transform duration-300 ease-in-out'
22
  style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
23
  />
24
  </ProgressPrimitive.Root>
ui/components/ui/scroll-area.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
@@ -25,7 +25,7 @@ function ScrollArea({
25
  ref={viewportRef}
26
  data-slot='scroll-area-viewport'
27
  className={cn(
28
- 'focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1',
29
  viewportClassName,
30
  )}
31
  >
@@ -48,17 +48,15 @@ function ScrollBar({
48
  orientation={orientation}
49
  className={cn(
50
  'flex touch-none p-px transition-colors select-none',
51
- orientation === 'vertical' &&
52
- 'h-full w-2.5 border-l border-l-transparent',
53
- orientation === 'horizontal' &&
54
- 'h-2.5 flex-col border-t border-t-transparent',
55
  className,
56
  )}
57
  {...props}
58
  >
59
  <ScrollAreaPrimitive.ScrollAreaThumb
60
  data-slot='scroll-area-thumb'
61
- className='bg-border relative flex-1 rounded-full'
62
  />
63
  </ScrollAreaPrimitive.ScrollAreaScrollbar>
64
  )
 
1
  'use client'
2
 
 
3
  import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
 
25
  ref={viewportRef}
26
  data-slot='scroll-area-viewport'
27
  className={cn(
28
+ 'size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1',
29
  viewportClassName,
30
  )}
31
  >
 
48
  orientation={orientation}
49
  className={cn(
50
  'flex touch-none p-px transition-colors select-none',
51
+ orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
52
+ orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
 
 
53
  className,
54
  )}
55
  {...props}
56
  >
57
  <ScrollAreaPrimitive.ScrollAreaThumb
58
  data-slot='scroll-area-thumb'
59
+ className='relative flex-1 rounded-full bg-border'
60
  />
61
  </ScrollAreaPrimitive.ScrollAreaScrollbar>
62
  )
ui/components/ui/select.tsx CHANGED
@@ -1,26 +1,20 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as SelectPrimitive from '@radix-ui/react-select'
5
  import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
 
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
- function Select({
10
- ...props
11
- }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
  return <SelectPrimitive.Root data-slot='select' {...props} />
13
  }
14
 
15
- function SelectGroup({
16
- ...props
17
- }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
  return <SelectPrimitive.Group data-slot='select-group' {...props} />
19
  }
20
 
21
- function SelectValue({
22
- ...props
23
- }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
  return <SelectPrimitive.Value data-slot='select-value' {...props} />
25
  }
26
 
@@ -37,7 +31,7 @@ function SelectTrigger({
37
  data-slot='select-trigger'
38
  data-size={size}
39
  className={cn(
40
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-1.5 rounded-md border bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
41
  className,
42
  )}
43
  {...props}
@@ -62,7 +56,7 @@ function SelectContent({
62
  <SelectPrimitive.Content
63
  data-slot='select-content'
64
  className={cn(
65
- 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
66
  position === 'popper' &&
67
  'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
68
  className,
@@ -87,14 +81,11 @@ function SelectContent({
87
  )
88
  }
89
 
90
- function SelectLabel({
91
- className,
92
- ...props
93
- }: React.ComponentProps<typeof SelectPrimitive.Label>) {
94
  return (
95
  <SelectPrimitive.Label
96
  data-slot='select-label'
97
- className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
98
  {...props}
99
  />
100
  )
@@ -109,7 +100,7 @@ function SelectItem({
109
  <SelectPrimitive.Item
110
  data-slot='select-item'
111
  className={cn(
112
- "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-6 pl-1.5 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-1.5",
113
  className,
114
  )}
115
  {...props}
@@ -134,7 +125,7 @@ function SelectSeparator({
134
  return (
135
  <SelectPrimitive.Separator
136
  data-slot='select-separator'
137
- className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
138
  {...props}
139
  />
140
  )
@@ -147,10 +138,7 @@ function SelectScrollUpButton({
147
  return (
148
  <SelectPrimitive.ScrollUpButton
149
  data-slot='select-scroll-up-button'
150
- className={cn(
151
- 'flex cursor-default items-center justify-center py-1',
152
- className,
153
- )}
154
  {...props}
155
  >
156
  <ChevronUpIcon className='size-4' />
@@ -165,10 +153,7 @@ function SelectScrollDownButton({
165
  return (
166
  <SelectPrimitive.ScrollDownButton
167
  data-slot='select-scroll-down-button'
168
- className={cn(
169
- 'flex cursor-default items-center justify-center py-1',
170
- className,
171
- )}
172
  {...props}
173
  >
174
  <ChevronDownIcon className='size-4' />
 
1
  'use client'
2
 
 
3
  import * as SelectPrimitive from '@radix-ui/react-select'
4
  import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
5
+ import * as React from 'react'
6
 
7
  import { cn } from '@/lib/utils'
8
 
9
+ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
 
 
10
  return <SelectPrimitive.Root data-slot='select' {...props} />
11
  }
12
 
13
+ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
 
 
14
  return <SelectPrimitive.Group data-slot='select-group' {...props} />
15
  }
16
 
17
+ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
 
 
18
  return <SelectPrimitive.Value data-slot='select-value' {...props} />
19
  }
20
 
 
31
  data-slot='select-trigger'
32
  data-size={size}
33
  className={cn(
34
+ "flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground",
35
  className,
36
  )}
37
  {...props}
 
56
  <SelectPrimitive.Content
57
  data-slot='select-content'
58
  className={cn(
59
+ 'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
60
  position === 'popper' &&
61
  'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
62
  className,
 
81
  )
82
  }
83
 
84
+ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
 
 
 
85
  return (
86
  <SelectPrimitive.Label
87
  data-slot='select-label'
88
+ className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
89
  {...props}
90
  />
91
  )
 
100
  <SelectPrimitive.Item
101
  data-slot='select-item'
102
  className={cn(
103
+ "relative flex w-full cursor-default items-center gap-1.5 rounded-sm py-1 pr-6 pl-1.5 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-1.5",
104
  className,
105
  )}
106
  {...props}
 
125
  return (
126
  <SelectPrimitive.Separator
127
  data-slot='select-separator'
128
+ className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
129
  {...props}
130
  />
131
  )
 
138
  return (
139
  <SelectPrimitive.ScrollUpButton
140
  data-slot='select-scroll-up-button'
141
+ className={cn('flex cursor-default items-center justify-center py-1', className)}
 
 
 
142
  {...props}
143
  >
144
  <ChevronUpIcon className='size-4' />
 
153
  return (
154
  <SelectPrimitive.ScrollDownButton
155
  data-slot='select-scroll-down-button'
156
+ className={cn('flex cursor-default items-center justify-center py-1', className)}
 
 
 
157
  {...props}
158
  >
159
  <ChevronDownIcon className='size-4' />
ui/components/ui/separator.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as SeparatorPrimitive from '@radix-ui/react-separator'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
@@ -17,7 +17,7 @@ function Separator({
17
  decorative={decorative}
18
  orientation={orientation}
19
  className={cn(
20
- 'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
21
  className,
22
  )}
23
  {...props}
 
1
  'use client'
2
 
 
3
  import * as SeparatorPrimitive from '@radix-ui/react-separator'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
 
17
  decorative={decorative}
18
  orientation={orientation}
19
  className={cn(
20
+ 'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
21
  className,
22
  )}
23
  {...props}
ui/components/ui/slider.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client'
2
 
3
- import * as React from 'react'
4
  import * as SliderPrimitive from '@radix-ui/react-slider'
 
5
 
6
  import { cn } from '@/lib/utils'
7
 
@@ -14,12 +14,7 @@ function Slider({
14
  ...props
15
  }: React.ComponentProps<typeof SliderPrimitive.Root>) {
16
  const _values = React.useMemo(
17
- () =>
18
- Array.isArray(value)
19
- ? value
20
- : Array.isArray(defaultValue)
21
- ? defaultValue
22
- : [min, max],
23
  [value, defaultValue, min, max],
24
  )
25
 
@@ -39,13 +34,13 @@ function Slider({
39
  <SliderPrimitive.Track
40
  data-slot='slider-track'
41
  className={cn(
42
- 'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
43
  )}
44
  >
45
  <SliderPrimitive.Range
46
  data-slot='slider-range'
47
  className={cn(
48
- 'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
49
  )}
50
  />
51
  </SliderPrimitive.Track>
@@ -53,7 +48,7 @@ function Slider({
53
  <SliderPrimitive.Thumb
54
  data-slot='slider-thumb'
55
  key={index}
56
- className='border-primary ring-ring/50 bg-background block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50'
57
  />
58
  ))}
59
  </SliderPrimitive.Root>
 
1
  'use client'
2
 
 
3
  import * as SliderPrimitive from '@radix-ui/react-slider'
4
+ import * as React from 'react'
5
 
6
  import { cn } from '@/lib/utils'
7
 
 
14
  ...props
15
  }: React.ComponentProps<typeof SliderPrimitive.Root>) {
16
  const _values = React.useMemo(
17
+ () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
 
 
 
 
 
18
  [value, defaultValue, min, max],
19
  )
20
 
 
34
  <SliderPrimitive.Track
35
  data-slot='slider-track'
36
  className={cn(
37
+ 'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
38
  )}
39
  >
40
  <SliderPrimitive.Range
41
  data-slot='slider-range'
42
  className={cn(
43
+ 'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
44
  )}
45
  />
46
  </SliderPrimitive.Track>
 
48
  <SliderPrimitive.Thumb
49
  data-slot='slider-thumb'
50
  key={index}
51
+ className='block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50'
52
  />
53
  ))}
54
  </SliderPrimitive.Root>