diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@egoist/tailwindcss-icons": "^1.9.2", + "@iconify-json/ri": "^1.2.10", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,12 @@ specifier: ^5.0.11 version: 5.0.11(@types/react@19.2.14)(react@19.2.4) devDependencies: + '@egoist/tailwindcss-icons': + specifier: ^1.9.2 + version: 1.9.2(tailwindcss@4.1.18) + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 '@tauri-apps/cli': specifier: ^2 version: 2.10.0 @@ -113,6 +119,9 @@ '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -337,6 +346,11 @@ cpu: [x64] os: [win32] + '@egoist/tailwindcss-icons@1.9.2': + resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==} + peerDependencies: + tailwindcss: '*' + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -501,6 +515,15 @@ peerDependenciesMeta: '@noble/hashes': optional: true + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1056,6 +1079,11 @@ '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1100,6 +1128,9 @@ chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1389,6 +1420,9 @@ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1413,6 +1447,9 @@ oxlint-tsgolint: optional: true + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -1425,6 +1462,9 @@ picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} @@ -1551,6 +1591,9 @@ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1696,6 +1739,11 @@ '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 '@asamuzakjp/css-color@4.1.2': dependencies: @@ -1976,6 +2024,11 @@ '@dprint/win32-x64@0.51.1': optional: true + '@egoist/tailwindcss-icons@1.9.2(tailwindcss@4.1.18)': + dependencies: + '@iconify/utils': 3.1.0 + tailwindcss: 4.1.18 + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -2055,6 +2108,18 @@ optional: true '@exodus/bytes@1.14.1': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -2506,6 +2571,8 @@ '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + acorn@8.16.0: {} + agent-base@7.1.4: {} ansi-regex@5.0.1: {} @@ -2543,6 +2610,8 @@ caniuse-lite@1.0.30001770: {} chai@6.2.2: {} + + confbox@0.1.8: {} convert-source-map@2.0.0: {} @@ -2818,6 +2887,13 @@ min-indent@1.0.1: {} + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + ms@2.1.3: {} nanoid@3.3.11: {} @@ -2848,6 +2924,8 @@ '@oxlint/binding-win32-ia32-msvc': 1.48.0 '@oxlint/binding-win32-x64-msvc': 1.48.0 + package-manager-detector@1.6.0: {} + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -2857,6 +2935,12 @@ picocolors@1.1.1: {} picomatch@4.0.3: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 postcss@8.5.6: dependencies: @@ -2981,6 +3065,8 @@ punycode: 2.3.1 typescript@5.8.3: {} + + ufo@1.6.3: {} undici-types@7.16.0: {} diff --git a/src/App.css b/src/App.css --- a/src/App.css +++ b/src/App.css @@ -1,296 +1,297 @@ @import "tailwindcss"; +@plugin "@egoist/tailwindcss-icons"; @theme { - /* Font families */ - --font-sans: - "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - sans-serif; - --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; - --font-mono: "IBM Plex Mono", "SF Mono", Monaco, "Cascadia Code", monospace; + /* Font families */ + --font-sans: + "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; + --font-mono: "IBM Plex Mono", "SF Mono", Monaco, "Cascadia Code", monospace; - /* Font weights */ - --font-weight-light: 300; - --font-weight-regular: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; + /* Font weights */ + --font-weight-light: 300; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; - /* Colors - Dark theme (default) */ - --color-bg-primary: #161616; - --color-bg-hover: #292929; - --color-bg-active: #393939; - --color-layer-01: #262626; - --color-layer-02: #393939; - --color-layer-03: #525252; - --color-layer-hover-01: #333333; - --color-layer-hover-02: #474747; - --color-layer-accent-01: #393939; - --color-layer-accent-02: #525252; - --color-field-01: #161616; - --color-field-02: #262626; - --color-field-hover-01: #292929; - --color-field-hover-02: #333333; - --color-border-subtle: #393939; - --color-border-strong: #6f6f6f; - --color-border-inverse: #f4f4f4; - --color-border-interactive: #4589ff; - --color-text-primary: #f4f4f4; - --color-text-secondary: #c6c6c6; - --color-text-placeholder: #6f6f6f; - --color-text-disabled: #6f6f6f; - --color-link-primary: #78a9ff; - --color-link-hover: #a8caff; - --color-icon-primary: #f4f4f4; - --color-icon-secondary: #c6c6c6; - --color-icon-disabled: #525252; - --color-support-error: #fa4d56; - --color-support-success: #42be65; - --color-support-warning: #f1c21b; - --color-support-info: #4589ff; - --color-accent-blue: #78a9ff; - --color-accent-cyan: #33b1ff; - --color-accent-green: #42be65; - --color-accent-magenta: #ff7eb6; - --color-accent-orange: #ff832b; - --color-accent-purple: #be95ff; - --color-accent-red: #fa4d56; - --color-accent-teal: #08bdba; - --color-accent-yellow: #f1c21b; + /* Colors - Dark theme (default) */ + --color-bg-primary: #161616; + --color-bg-hover: #292929; + --color-bg-active: #393939; + --color-layer-01: #262626; + --color-layer-02: #393939; + --color-layer-03: #525252; + --color-layer-hover-01: #333333; + --color-layer-hover-02: #474747; + --color-layer-accent-01: #393939; + --color-layer-accent-02: #525252; + --color-field-01: #161616; + --color-field-02: #262626; + --color-field-hover-01: #292929; + --color-field-hover-02: #333333; + --color-border-subtle: #393939; + --color-border-strong: #6f6f6f; + --color-border-inverse: #f4f4f4; + --color-border-interactive: #4589ff; + --color-text-primary: #f4f4f4; + --color-text-secondary: #c6c6c6; + --color-text-placeholder: #6f6f6f; + --color-text-disabled: #6f6f6f; + --color-link-primary: #78a9ff; + --color-link-hover: #a8caff; + --color-icon-primary: #f4f4f4; + --color-icon-secondary: #c6c6c6; + --color-icon-disabled: #525252; + --color-support-error: #fa4d56; + --color-support-success: #42be65; + --color-support-warning: #f1c21b; + --color-support-info: #4589ff; + --color-accent-blue: #78a9ff; + --color-accent-cyan: #33b1ff; + --color-accent-green: #42be65; + --color-accent-magenta: #ff7eb6; + --color-accent-orange: #ff832b; + --color-accent-purple: #be95ff; + --color-accent-red: #fa4d56; + --color-accent-teal: #08bdba; + --color-accent-yellow: #f1c21b; - /* Spacing */ - --spacing-sidebar: 280px; - --spacing-sidebar-collapsed: 48px; - --spacing-header: 48px; - --spacing-tab: 40px; + /* Spacing */ + --spacing-sidebar: 280px; + --spacing-sidebar-collapsed: 48px; + --spacing-header: 48px; + --spacing-tab: 40px; - /* Border radius */ - --radius-none: 0; - --radius-sm: 2px; - --radius-md: 4px; - --radius-lg: 6px; - --radius-xl: 8px; - --radius-2xl: 12px; - --radius-full: 9999px; + /* Border radius */ + --radius-none: 0; + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 6px; + --radius-xl: 8px; + --radius-2xl: 12px; + --radius-full: 9999px; - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.6); - --shadow-2xl: 0 16px 32px rgba(0, 0, 0, 0.7); - --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.4); + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.6); + --shadow-2xl: 0 16px 32px rgba(0, 0, 0, 0.7); + --shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.4); - /* Z-index */ - --z-dropdown: 1000; - --z-sticky: 1020; - --z-fixed: 1030; - --z-modal-backdrop: 1040; - --z-modal: 1050; - --z-popover: 1060; - --z-tooltip: 1070; - --z-toast: 1080; + /* Z-index */ + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-popover: 1060; + --z-tooltip: 1070; + --z-toast: 1080; } /* Scrollbar styles */ * { - scrollbar-width: thin; - scrollbar-color: #525252 #161616; + scrollbar-width: thin; + scrollbar-color: #525252 #161616; } *::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } *::-webkit-scrollbar-track { - background: #161616; + background: #161616; } *::-webkit-scrollbar-thumb { - background: #525252; - border-radius: 9999px; + background: #525252; + border-radius: 9999px; } *::-webkit-scrollbar-thumb:hover { - background: #6f6f6f; + background: #6f6f6f; } /* Focus visible */ :focus-visible { - outline: 2px solid #4589ff; - outline-offset: 2px; + outline: 2px solid #4589ff; + outline-offset: 2px; } /* Selection */ ::selection { - background: rgba(120, 169, 255, 0.3); - color: #f4f4f4; + background: rgba(120, 169, 255, 0.3); + color: #f4f4f4; } /* Light theme */ [data-theme="light"] { - color-scheme: light; - --color-bg-primary: #ffffff; - --color-bg-hover: #e8e8e8; - --color-bg-active: #c6c6c6; - --color-layer-01: #f4f4f4; - --color-layer-02: #ffffff; - --color-layer-03: #e8e8e8; - --color-layer-hover-01: #e8e8e8; - --color-layer-hover-02: #e8e8e8; - --color-layer-accent-01: #e0e0e0; - --color-layer-accent-02: #c6c6c6; - --color-field-01: #f4f4f4; - --color-field-02: #ffffff; - --color-field-hover-01: #e8e8e8; - --color-field-hover-02: #e8e8e8; - --color-border-subtle: #e0e0e0; - --color-border-strong: #c6c6c6; - --color-border-inverse: #161616; - --color-text-primary: #161616; - --color-text-secondary: #525252; - --color-text-placeholder: #a8a8a8; - --color-text-disabled: #c6c6c6; - --color-link-primary: #0f62fe; - --color-link-hover: #0353e9; - --color-icon-primary: #161616; - --color-icon-secondary: #525252; - --color-icon-disabled: #c6c6c6; - --color-support-error: #da1e28; - --color-support-success: #24a148; - --color-support-warning: #f1c21b; - --color-support-info: #0043ce; + color-scheme: light; + --color-bg-primary: #ffffff; + --color-bg-hover: #e8e8e8; + --color-bg-active: #c6c6c6; + --color-layer-01: #f4f4f4; + --color-layer-02: #ffffff; + --color-layer-03: #e8e8e8; + --color-layer-hover-01: #e8e8e8; + --color-layer-hover-02: #e8e8e8; + --color-layer-accent-01: #e0e0e0; + --color-layer-accent-02: #c6c6c6; + --color-field-01: #f4f4f4; + --color-field-02: #ffffff; + --color-field-hover-01: #e8e8e8; + --color-field-hover-02: #e8e8e8; + --color-border-subtle: #e0e0e0; + --color-border-strong: #c6c6c6; + --color-border-inverse: #161616; + --color-text-primary: #161616; + --color-text-secondary: #525252; + --color-text-placeholder: #a8a8a8; + --color-text-disabled: #c6c6c6; + --color-link-primary: #0f62fe; + --color-link-hover: #0353e9; + --color-icon-primary: #161616; + --color-icon-secondary: #525252; + --color-icon-disabled: #c6c6c6; + --color-support-error: #da1e28; + --color-support-success: #24a148; + --color-support-warning: #f1c21b; + --color-support-info: #0043ce; } [data-theme="light"] * { - scrollbar-color: #c6c6c6 #f4f4f4; + scrollbar-color: #c6c6c6 #f4f4f4; } [data-theme="light"] *::-webkit-scrollbar-track { - background: #f4f4f4; + background: #f4f4f4; } [data-theme="light"] *::-webkit-scrollbar-thumb { - background: #c6c6c6; + background: #c6c6c6; } [data-theme="light"] *::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; + background: #a8a8a8; } /* Markdown Preview Styles */ .preview-content { - max-width: 700px; - margin: 0 auto; - line-height: 1.6; + max-width: 700px; + margin: 0 auto; + line-height: 1.6; } .preview-content :is(h1, h2, h3, h4, h5, h6) { - color: var(--color-text-primary); - margin-top: 1.5em; - margin-bottom: 0.5em; - font-weight: 600; - line-height: 1.25; + color: var(--color-text-primary); + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; } .preview-content h1 { - font-size: 2em; - border-bottom: 1px solid var(--color-border-subtle); - padding-bottom: 0.3em; + font-size: 2em; + border-bottom: 1px solid var(--color-border-subtle); + padding-bottom: 0.3em; } .preview-content h2 { - font-size: 1.5em; - border-bottom: 1px solid var(--color-border-subtle); - padding-bottom: 0.3em; + font-size: 1.5em; + border-bottom: 1px solid var(--color-border-subtle); + padding-bottom: 0.3em; } .preview-content h3 { - font-size: 1.25em; + font-size: 1.25em; } .preview-content p { - margin: 0 0 1em 0; + margin: 0 0 1em 0; } .preview-content a { - color: var(--color-link-primary); - text-decoration: none; + color: var(--color-link-primary); + text-decoration: none; } .preview-content a:hover { - text-decoration: underline; + text-decoration: underline; } .preview-content code { - background: var(--color-layer-01); - padding: 0.2em 0.4em; - border-radius: 3px; - font-family: var(--font-mono); - font-size: 0.9em; + background: var(--color-layer-01); + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.9em; } .preview-content pre { - background: var(--color-layer-01); - padding: 1em; - border-radius: 6px; - overflow-x: auto; - margin: 0 0 1em 0; + background: var(--color-layer-01); + padding: 1em; + border-radius: 6px; + overflow-x: auto; + margin: 0 0 1em 0; } .preview-content pre code { - background: transparent; - padding: 0; + background: transparent; + padding: 0; } .preview-content blockquote { - border-left: 4px solid var(--color-border-subtle); - margin: 0 0 1em 0; - padding: 0 1em; - color: var(--color-text-secondary); + border-left: 4px solid var(--color-border-subtle); + margin: 0 0 1em 0; + padding: 0 1em; + color: var(--color-text-secondary); } .preview-content :is(ul, ol) { - margin: 0 0 1em 0; - padding-left: 2em; + margin: 0 0 1em 0; + padding-left: 2em; } .preview-content li { - margin: 0.25em 0; + margin: 0.25em 0; } .preview-content table { - width: 100%; - border-collapse: collapse; - margin: 0 0 1em 0; + width: 100%; + border-collapse: collapse; + margin: 0 0 1em 0; } .preview-content :is(th, td) { - border: 1px solid var(--color-border-subtle); - padding: 0.5em; - text-align: left; + border: 1px solid var(--color-border-subtle); + padding: 0.5em; + text-align: left; } .preview-content th { - background: var(--color-layer-01); - font-weight: 600; + background: var(--color-layer-01); + font-weight: 600; } .preview-content hr { - border: none; - border-top: 1px solid var(--color-border-subtle); - margin: 1.5em 0; + border: none; + border-top: 1px solid var(--color-border-subtle); + margin: 1.5em 0; } .preview-content img { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } .preview-content del { - text-decoration: line-through; + text-decoration: line-through; } .preview-content input[type="checkbox"] { - margin-right: 0.5em; + margin-right: 0.5em; } diff --git a/src/App.tsx b/src/App.tsx --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,13 @@ import "./App.css"; // TODO: make this recursive +function formatDraftDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}_${month}_${day}`; +} + function buildDraftRelPath(locationId: number, documents: DocMeta[], tabs: Tab[]): string { const usedPaths = new Set(); @@ -31,9 +38,10 @@ } } - let suffix = 1; + const base = `untitled_${formatDraftDate(new Date())}`; + let suffix = 0; while (true) { - const fileName = suffix === 1 ? "Untitled.md" : `Untitled ${suffix}.md`; + const fileName = suffix === 0 ? `${base}.md` : `${base}_${suffix}.md`; if (!usedPaths.has(fileName.toLowerCase())) { return fileName; } diff --git a/src/__tests__/Tooltip.test.tsx b/src/__tests__/Tooltip.test.tsx new file mode 100644 --- /dev/null +++ b/src/__tests__/Tooltip.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useCallback, useRef, useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Tooltip } from "../components/Tooltip"; + +function TooltipHarness( + { visible = true, placement = "bottom" as const, offset = 6, className }: { + visible?: boolean; + placement?: "top" | "bottom"; + offset?: number; + className?: string; + }, +) { + const anchorRef = useRef(null); + return ( +
+ + + Ctrl+S + +
+ ); +} + +function ToggleHarness() { + const [visible, setVisible] = useState(false); + const anchorRef = useRef(null); + const handleClick = useCallback(() => { + setVisible((v) => !v); + }, []); + + return ( +
+ + Ctrl+P +
+ ); +} + +describe("Tooltip", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("does not render when not visible", () => { + render(); + expect(screen.queryByText("Ctrl+S")).not.toBeInTheDocument(); + }); + + it("renders into the document body when visible", () => { + render(); + const tooltip = screen.getByText("Ctrl+S"); + expect(tooltip).toBeInTheDocument(); + expect(document.body).toContainElement(tooltip); + }); + + it("applies bottom placement with computed position", () => { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function mockRect(this: any) { + if (this instanceof HTMLButtonElement) { + return new DOMRect(10, 20, 40, 30); + } + return new DOMRect(); + }); + + render(); + const tooltip = screen.getByText("Ctrl+S"); + + expect(tooltip).toHaveClass("-translate-x-1/2"); + expect(tooltip).not.toHaveClass("-translate-y-full"); + expect(tooltip).toHaveStyle({ left: "30px", top: "58px" }); + }); + + it("applies top placement with computed position", () => { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function mockRect(this: any) { + if (this instanceof HTMLButtonElement) { + return new DOMRect(20, 100, 60, 24); + } + return new DOMRect(); + }); + + render(); + const tooltip = screen.getByText("Ctrl+S"); + + expect(tooltip).toHaveClass("-translate-x-1/2"); + expect(tooltip).toHaveClass("-translate-y-full"); + expect(tooltip).toHaveClass("custom-tooltip"); + expect(tooltip).toHaveStyle({ left: "50px", top: "90px" }); + }); + + it("updates position on viewport changes", async () => { + let left = 5; + let top = 15; + let width = 30; + let height = 20; + + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function mockRect(this: any) { + if (this instanceof HTMLButtonElement) { + return new DOMRect(left, top, width, height); + } + return new DOMRect(); + }); + + render(); + fireEvent.click(screen.getByText("Toggle")); + + await waitFor(() => { + const tooltip = screen.getByText("Ctrl+P"); + expect(tooltip).toHaveStyle({ left: "20px", top: "41px" }); + }); + + left = 50; + top = 70; + width = 50; + height = 30; + fireEvent(globalThis as unknown as Window, new Event("resize")); + + await waitFor(() => { + const tooltip = screen.getByText("Ctrl+P"); + expect(tooltip).toHaveStyle({ left: "75px", top: "106px" }); + }); + }); +}); diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -63,7 +63,7 @@ function HighlightLabel({ hit }: { hit: SearchHit }) { return (
- + {hit.title} Line {hit.line} @@ -119,14 +119,14 @@ {query ? ( <> - +

No results found

Try adjusting your search or filters

) : ( <> - +

Start typing to search

Search across all your documents

@@ -153,7 +153,7 @@ const SearchInput = ({ query, handleQueryChange, clearQuery }: SearchInputProps) => (
- + )}
@@ -200,7 +200,7 @@ const CloseButton = ({ onClose }: { onClose: () => void }) => ( ); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,7 +1,18 @@ import type { MouseEventHandler } from "react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { SaveStatus } from "../types"; -import { CheckIcon, EyeIcon, FocusIcon, RefreshIcon, SaveIcon, SettingsIcon, SplitViewIcon } from "./icons"; +import { + CheckIcon, + EyeIcon, + FocusIcon, + IconProps, + type IconSize, + RefreshIcon, + SaveIcon, + SettingsIcon, + SplitViewIcon, +} from "./icons"; +import { Tooltip } from "./Tooltip"; export type ToolbarProps = { saveStatus: SaveStatus; @@ -18,7 +29,7 @@ function ToolbarButton( { icon, label, isActive = false, onClick, disabled = false, shortcut }: { - icon: { Component: React.ComponentType<{ size: number }>; size: number }; + icon: { Component: React.ComponentType; size: IconSize }; label: string; isActive?: boolean; onClick: () => void; @@ -27,6 +38,7 @@ }, ) { const [showTooltip, setShowTooltip] = useState(false); + const buttonRef = useRef(null); const handleMouseEnter = useCallback(() => { setShowTooltip(true); @@ -49,39 +61,35 @@ }, []); return ( - + {label} + + {shortcut ? {shortcut} : null} + ); } const getStatusDisplay = (status: SaveStatus) => { switch (status) { case "Saving": { - return { icon: , text: "Saving...", color: "text-accent-cyan" }; + return { icon: , text: "Saving...", color: "text-accent-cyan" }; } case "Saved": { - return { icon: , text: "Saved", color: "text-accent-green" }; + return { icon: , text: "Saved", color: "text-accent-green" }; } case "Dirty": { return { icon: null, text: "Unsaved", color: "text-accent-yellow" }; @@ -121,14 +129,14 @@ onRefresh, }: ToolbarProps, ) { - const icons = useMemo( + const icons: Record; size: IconSize }> = useMemo( () => ({ - save: { Component: SaveIcon, size: 14 }, - refresh: { Component: RefreshIcon, size: 14 }, - splitView: { Component: SplitViewIcon, size: 14 }, - eye: { Component: EyeIcon, size: 14 }, - focus: { Component: FocusIcon, size: 14 }, - settings: { Component: SettingsIcon, size: 14 }, + save: { Component: SaveIcon, size: "sm" }, + refresh: { Component: RefreshIcon, size: "sm" }, + splitView: { Component: SplitViewIcon, size: "sm" }, + eye: { Component: EyeIcon, size: "sm" }, + focus: { Component: FocusIcon, size: "sm" }, + settings: { Component: SettingsIcon, size: "sm" }, }), [], ); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,70 @@ +import type { ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +type TooltipPlacement = "top" | "bottom"; + +export type TooltipProps = { + anchorRef: RefObject; + visible: boolean; + children: ReactNode; + placement?: TooltipPlacement; + offset?: number; + className?: string; +}; + +type TooltipPosition = { left: number; top: number }; + +export function Tooltip( + { anchorRef, visible, children, placement = "bottom", offset = 6, className = "" }: TooltipProps, +) { + const [position, setPosition] = useState(null); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current; + if (!anchor) { + return; + } + + const rect = anchor.getBoundingClientRect(); + const top = placement === "top" ? rect.top - offset : rect.bottom + offset; + setPosition({ left: rect.left + rect.width / 2, top }); + }, [anchorRef, offset, placement]); + + useEffect(() => { + if (!visible) { + return; + } + + updatePosition(); + + const handleViewportChange = () => updatePosition(); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [updatePosition, visible]); + + const placementClasses = useMemo( + () => placement === "top" ? "-translate-x-1/2 -translate-y-full" : "-translate-x-1/2", + [placement], + ); + + const style = useMemo(() => position ? { left: position.left, top: position.top } : undefined, [position]); + + if (!visible || !position || typeof document === "undefined") { + return null; + } + + return createPortal( +
+ {children} +
, + document.body, + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -5,278 +5,125 @@ * 16x16 viewBox, 2px stroke width, clean geometric shapes. */ -type IconProps = { size?: number; className?: string; style?: React.CSSProperties }; +export type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; -export const FolderIcon = ({ size = 16, className = "", style }: IconProps) => ( - +const sizeClass = (size: IconSize) => { + switch (size) { + case "xs": + return "text-xs"; + case "sm": + return "text-sm"; + case "md": + return "text-base"; + case "lg": + return "text-lg"; + case "xl": + return "text-xl"; + case "2xl": + return "text-2xl"; + } +}; + +export type IconProps = { size?: IconSize; className?: string; style?: React.CSSProperties }; + +export const FolderIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const FileTextIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const FileTextIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const SearchIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const SearchIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const PlusIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const PlusIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const XIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const XIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const ChevronRightIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const ChevronRightIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const ChevronDownIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const ChevronDownIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const SaveIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const SaveIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const CheckIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const CheckIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const MoreVerticalIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const MoreVerticalIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const SettingsIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const SettingsIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const SplitViewIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const SplitViewIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const EyeIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const EyeIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const FocusIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const FocusIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const LibraryIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const LibraryIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const TrashIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const TrashIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); -export const RefreshIcon = ({ size = 16, className = "", style }: IconProps) => ( - +export const RefreshIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + ); diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts --- a/src/hooks/useEditor.ts +++ b/src/hooks/useEditor.ts @@ -3,7 +3,7 @@ import { docOpen, docSave, none, runCmd } from "../ports"; import type { DocContent, DocRef, SaveStatus } from "../types"; -export interface EditorModel { +export type EditorModel = { docRef: DocRef | null; text: string; saveStatus: SaveStatus; @@ -13,7 +13,7 @@ selectionTo: number | null; isLoading: boolean; error: AppError | null; -} +}; export const initialEditorModel: EditorModel = { docRef: null, @@ -37,6 +37,10 @@ | { type: "DocOpenFinished"; success: boolean; error?: AppError } | { type: "CursorMoved"; line: number; column: number } | { type: "SelectionChanged"; from: number; to: number | null }; + +function isEditorMsg(value: unknown): value is EditorMsg { + return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; +} export function updateEditor(model: EditorModel, msg: EditorMsg): [EditorModel, Cmd] { switch (msg.type) { @@ -126,7 +130,26 @@ setModel((prevModel) => { const [newModel, cmd] = updateEditor(prevModel, msg); if (cmd.type !== "None") { - runCmd(cmd); + if (cmd.type === "Invoke") { + const wrappedCmd: Cmd = { + ...cmd, + onOk: (value) => { + const nextMsg = cmd.onOk(value); + if (isEditorMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + onErr: (error) => { + const nextMsg = cmd.onErr(error); + if (isEditorMsg(nextMsg)) { + dispatch(nextMsg); + } + }, + }; + runCmd(wrappedCmd); + } else { + runCmd(cmd); + } } return newModel; }); diff --git a/src/components/DocumentTabs/CloseTabButton.tsx b/src/components/DocumentTabs/CloseTabButton.tsx --- a/src/components/DocumentTabs/CloseTabButton.tsx +++ b/src/components/DocumentTabs/CloseTabButton.tsx @@ -8,6 +8,6 @@ onClick={handleCloseTabClick} className="tab-close-btn w-4 h-4 flex items-center justify-center bg-transparent border-none text-icon-secondary cursor-pointer rounded shrink-0 opacity-0 transition-all duration-150 group-hover:opacity-100 hover:text-icon-primary hover:bg-layer-hover-01" title="Close tab"> - + ); diff --git a/src/components/Sidebar/AddButton.tsx b/src/components/Sidebar/AddButton.tsx --- a/src/components/Sidebar/AddButton.tsx +++ b/src/components/Sidebar/AddButton.tsx @@ -14,6 +14,6 @@ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} title="Add Location"> - + ); diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx --- a/src/components/Sidebar/DocumentItem.tsx +++ b/src/components/Sidebar/DocumentItem.tsx @@ -12,7 +12,7 @@ id: number; }, ) { - const fileTextIcon = useMemo(() => ({ Component: FileTextIcon, size: 14 }), []); + const fileTextIcon = useMemo(() => ({ Component: FileTextIcon, size: "sm" as const }), []); const handleClick = useCallback(() => onSelectDocument(id, doc.rel_path), [id, onSelectDocument]); return ( void }) => (
- +

No locations added

diff --git a/src/components/Sidebar/SearchInput.tsx b/src/components/Sidebar/SearchInput.tsx --- a/src/components/Sidebar/SearchInput.tsx +++ b/src/components/Sidebar/SearchInput.tsx @@ -8,7 +8,7 @@ ) => (
- + - + ); diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -64,14 +64,14 @@ onToggle(location.id); }, [location.id, onToggle]); - const folderIcon = useMemo(() => ({ Component: FolderIcon, size: 16 }), []); + const folderIcon = useMemo(() => ({ Component: FolderIcon, size: "md" as const }), []); const LocationActions = useCallback(() => (
React.ReactNode; size: number }; + icon: { Component: (props: IconProps) => React.ReactNode; size: IconSize }; label: string; isSelected?: boolean; isExpanded?: boolean; @@ -86,7 +86,7 @@ onClick={handleButtonClick} className="bg-transparent border-none p-0.5 cursor-pointer text-icon-secondary flex items-center justify-center rounded transition-transform duration-150" style={buttonStyle}> - + ) : } diff --git a/src/components/layout/AppHeaderBar.tsx b/src/components/layout/AppHeaderBar.tsx --- a/src/components/layout/AppHeaderBar.tsx +++ b/src/components/layout/AppHeaderBar.tsx @@ -8,7 +8,7 @@ onClick={onToggleSidebar} className="w-8 h-8 flex items-center justify-center bg-transparent border-none text-icon-secondary cursor-pointer rounded" title="Toggle sidebar (Ctrl+B)"> - +

Writer

@@ -19,7 +19,7 @@ @@ -28,7 +28,7 @@ onClick={onToggleTopBars} className="flex items-center gap-1.5 px-2.5 py-1.5 bg-transparent border border-border-subtle rounded text-text-secondary text-[0.8125rem] cursor-pointer" title="Hide top bars (Ctrl+Shift+B)"> - + Hide Bars
diff --git a/src/components/layout/FocusModePanel.tsx b/src/components/layout/FocusModePanel.tsx --- a/src/components/layout/FocusModePanel.tsx +++ b/src/components/layout/FocusModePanel.tsx @@ -22,7 +22,7 @@ const FocusHeader = ({ onExit }: { onExit: () => void }) => (

- + Focus Mode