SPB Git

spb/zyquo-cloud-web

Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

phase4: full chat UI — shell, sidebar, transcript, model menu, input bar, settings, first-run; responsive + light/dark verified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Jul 31, 2026) parent 72bdedc Browse files

22 changed files +6,539 −69

modified docs/PLAN.md +19 −1
@@ -92,7 +92,25 @@ with WebCrypto AES-GCM and is honestly framed. Gate ran in a real browser with
92 a persistent profile: reload AND restart survival, exact export round-trip, and 92 a persistent profile: reload AND restart survival, exact export round-trip, and
93 vault behavior all green. 93 vault behavior all green.
94 94
95−## Phase 4 — Design system & UI — pending 95+## Phase 4 — Design system & UI
96+
97+- [x] 4.1 Light flagship theme via tokens (Phase 1 CSS vars) — light default, dark opt-in, 5 accents, reduced-motion respected
98+- [x] 4.2 Shell: sidebar (wordmark, search ⌘F, New Chat ⌘N, Pinned/Today/Yesterday/7 Days/Older groups, hover pin/delete, usage footer) + chat area (header 52px with editable title, centered model chip, compare/export/info actions)
99+- [x] 4.2.1 Model Menu: ⌘K command-palette style — fuzzy search, capability filter chips (Vision/Tools/Reasoning/JSON/Long context/Cheapest), Aliases + Favorites + Recents groups, keyed-providers-first sections, capability/context/price badges, star favorites, "Add key" affordance, keyboard nav (↑↓/↵/⌘↵ default/esc), footer hints
100+- [x] Transcript: centered 760px column, user right accent-subtle / assistant left surface+avatar, GFM markdown + tables + zebra rows + syntax-highlighted code (language label, copy, wrap), collapsible reasoning, citation chips, streaming caret, scroll-lock + jump-to-latest pill, hover metadata (time, tokens, cost) + actions (copy/edit-resend/regenerate/quote/branch/read-aloud/delete), variant switcher
101+- [x] Input bar: floating card, auto-grow, image+text-file attach (click/drag/paste), thumbnails, params popover (support-gated controls with enable checkboxes), circular send ⌘↩ / Stop, context-usage bar + token/cost HUD
102+- [x] Settings modal: Providers & Keys (masked, Test + latency + status dot, per-provider proxy URL, honesty notice), Models (catalog browser, favorites, set-default), Appearance (theme/accent/font-size/density + live preview), Data (export incl.-keys warning / import / confirm-twice clear), Advanced (default system prompt, passphrase lock, streaming toggle)
103+- [x] First-run honest welcome + root empty state (glyph, greeting, model chip, 4 suggestion cards — self-contained ones send immediately, input-expecting ones seed the draft)
104+- [x] Toasts with undo (conversation delete)
105+- [x] 4.3 Responsive: sidebar drawer <768px with scrim, full-width transcript, code blocks scroll internally, no horizontal overflow; motion tokens (80ms hover, 150ms message-in, blinking caret)
106+- [x] 4.4 Quality gate: screenshotted desktop+phone, light+dark, model menu, settings — reviewed and fixed (filter-chip clipping, cost precision, draft seeding)
107+
108+**Phase 4 summary.** The full chat UI is live and matches the family design:
109+tokens-only styling, light flagship default, dark derived, responsive down to
110+phone width with a drawer sidebar. The Model Menu is the headline surface —
111+fuzzy-searchable, filterable, priced, favorited, keyboard-driven. Verified
112+visually in headless Chromium at 1240px and 390px in both themes; found and
113+fixed three issues in the review pass. `npm run check` green.
96 114
97 ## Phase 5 — Brand assets — pending 115 ## Phase 5 — Brand assets — pending
98 116
modified package-lock.json +1599 −49
@@ -8,8 +8,12 @@
8 "name": "zyquo-cloud-web", 8 "name": "zyquo-cloud-web",
9 "version": "1.0.0", 9 "version": "1.0.0",
10 "dependencies": { 10 "dependencies": {
11+ "highlight.js": "^11.11.1",
11 "react": "^19.1.0", 12 "react": "^19.1.0",
12 "react-dom": "^19.1.0", 13 "react-dom": "^19.1.0",
14+ "react-markdown": "^10.1.0",
15+ "rehype-highlight": "^7.0.2",
16+ "remark-gfm": "^4.0.1",
13 "zustand": "^5.0.6" 17 "zustand": "^5.0.6"
14 }, 18 },
15 "devDependencies": { 19 "devDependencies": {
@@ -3011,13 +3015,39 @@
3011 "@babel/types": "^7.28.2" 3015 "@babel/types": "^7.28.2"
3012 } 3016 }
3013 }, 3017 },
3018+ "node_modules/@types/debug": {
3019+ "version": "4.1.13",
3020+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
3021+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
3022+ "license": "MIT",
3023+ "dependencies": {
3024+ "@types/ms": "*"
3025+ }
3026+ },
3014 "node_modules/@types/estree": { 3027 "node_modules/@types/estree": {
3015 "version": "1.0.9", 3028 "version": "1.0.9",
3016 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", 3029 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
3017 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", 3030 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
3018− "dev": true,
3019 "license": "MIT" 3031 "license": "MIT"
3020 }, 3032 },
3033+ "node_modules/@types/estree-jsx": {
3034+ "version": "1.0.5",
3035+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
3036+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
3037+ "license": "MIT",
3038+ "dependencies": {
3039+ "@types/estree": "*"
3040+ }
3041+ },
3042+ "node_modules/@types/hast": {
3043+ "version": "3.0.5",
3044+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
3045+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
3046+ "license": "MIT",
3047+ "dependencies": {
3048+ "@types/unist": "*"
3049+ }
3050+ },
3021 "node_modules/@types/json-schema": { 3051 "node_modules/@types/json-schema": {
3022 "version": "7.0.15", 3052 "version": "7.0.15",
3023 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 3053 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3025,11 +3055,25 @@
3025 "dev": true, 3055 "dev": true,
3026 "license": "MIT" 3056 "license": "MIT"
3027 }, 3057 },
3058+ "node_modules/@types/mdast": {
3059+ "version": "4.0.4",
3060+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
3061+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
3062+ "license": "MIT",
3063+ "dependencies": {
3064+ "@types/unist": "*"
3065+ }
3066+ },
3067+ "node_modules/@types/ms": {
3068+ "version": "2.1.0",
3069+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
3070+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
3071+ "license": "MIT"
3072+ },
3028 "node_modules/@types/react": { 3073 "node_modules/@types/react": {
3029 "version": "19.2.18", 3074 "version": "19.2.18",
3030 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", 3075 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
3031 "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", 3076 "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
3032− "devOptional": true,
3033 "license": "MIT", 3077 "license": "MIT",
3034 "dependencies": { 3078 "dependencies": {
3035 "csstype": "^3.2.2" 3079 "csstype": "^3.2.2"
@@ -3059,6 +3103,12 @@
3059 "dev": true, 3103 "dev": true,
3060 "license": "MIT" 3104 "license": "MIT"
3061 }, 3105 },
3106+ "node_modules/@types/unist": {
3107+ "version": "3.0.3",
3108+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
3109+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
3110+ "license": "MIT"
3111+ },
3062 "node_modules/@typescript-eslint/eslint-plugin": { 3112 "node_modules/@typescript-eslint/eslint-plugin": {
3063 "version": "8.65.0", 3113 "version": "8.65.0",
3064 "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", 3114 "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -3292,6 +3342,12 @@
3292 "url": "https://opencollective.com/eslint" 3342 "url": "https://opencollective.com/eslint"
3293 } 3343 }
3294 }, 3344 },
3345+ "node_modules/@ungap/structured-clone": {
3346+ "version": "1.3.3",
3347+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
3348+ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
3349+ "license": "ISC"
3350+ },
3295 "node_modules/@vitejs/plugin-react": { 3351 "node_modules/@vitejs/plugin-react": {
3296 "version": "4.7.0", 3352 "version": "4.7.0",
3297 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", 3353 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3510,6 +3566,16 @@
3510 "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" 3566 "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
3511 } 3567 }
3512 }, 3568 },
3569+ "node_modules/bail": {
3570+ "version": "2.0.2",
3571+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
3572+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
3573+ "license": "MIT",
3574+ "funding": {
3575+ "type": "github",
3576+ "url": "https://github.com/sponsors/wooorm"
3577+ }
3578+ },
3513 "node_modules/balanced-match": { 3579 "node_modules/balanced-match": {
3514 "version": "4.0.4", 3580 "version": "4.0.4",
3515 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", 3581 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3668,6 +3734,16 @@
3668 ], 3734 ],
3669 "license": "CC-BY-4.0" 3735 "license": "CC-BY-4.0"
3670 }, 3736 },
3737+ "node_modules/ccount": {
3738+ "version": "2.0.1",
3739+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
3740+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
3741+ "license": "MIT",
3742+ "funding": {
3743+ "type": "github",
3744+ "url": "https://github.com/sponsors/wooorm"
3745+ }
3746+ },
3671 "node_modules/chalk": { 3747 "node_modules/chalk": {
3672 "version": "4.1.2", 3748 "version": "4.1.2",
3673 "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 3749 "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3685,6 +3761,46 @@
3685 "url": "https://github.com/chalk/chalk?sponsor=1" 3761 "url": "https://github.com/chalk/chalk?sponsor=1"
3686 } 3762 }
3687 }, 3763 },
3764+ "node_modules/character-entities": {
3765+ "version": "2.0.2",
3766+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
3767+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
3768+ "license": "MIT",
3769+ "funding": {
3770+ "type": "github",
3771+ "url": "https://github.com/sponsors/wooorm"
3772+ }
3773+ },
3774+ "node_modules/character-entities-html4": {
3775+ "version": "2.1.0",
3776+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
3777+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
3778+ "license": "MIT",
3779+ "funding": {
3780+ "type": "github",
3781+ "url": "https://github.com/sponsors/wooorm"
3782+ }
3783+ },
3784+ "node_modules/character-entities-legacy": {
3785+ "version": "3.0.0",
3786+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
3787+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
3788+ "license": "MIT",
3789+ "funding": {
3790+ "type": "github",
3791+ "url": "https://github.com/sponsors/wooorm"
3792+ }
3793+ },
3794+ "node_modules/character-reference-invalid": {
3795+ "version": "2.0.1",
3796+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
3797+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
3798+ "license": "MIT",
3799+ "funding": {
3800+ "type": "github",
3801+ "url": "https://github.com/sponsors/wooorm"
3802+ }
3803+ },
3688 "node_modules/color-convert": { 3804 "node_modules/color-convert": {
3689 "version": "2.0.1", 3805 "version": "2.0.1",
3690 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 3806 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3705,6 +3821,16 @@
3705 "dev": true, 3821 "dev": true,
3706 "license": "MIT" 3822 "license": "MIT"
3707 }, 3823 },
3824+ "node_modules/comma-separated-tokens": {
3825+ "version": "2.0.3",
3826+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
3827+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
3828+ "license": "MIT",
3829+ "funding": {
3830+ "type": "github",
3831+ "url": "https://github.com/sponsors/wooorm"
3832+ }
3833+ },
3708 "node_modules/commander": { 3834 "node_modules/commander": {
3709 "version": "2.20.3", 3835 "version": "2.20.3",
3710 "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", 3836 "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3779,7 +3905,6 @@
3779 "version": "3.2.3", 3905 "version": "3.2.3",
3780 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 3906 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
3781 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", 3907 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
3782− "devOptional": true,
3783 "license": "MIT" 3908 "license": "MIT"
3784 }, 3909 },
3785 "node_modules/data-view-buffer": { 3910 "node_modules/data-view-buffer": {
@@ -3840,7 +3965,6 @@
3840 "version": "4.4.3", 3965 "version": "4.4.3",
3841 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 3966 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
3842 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 3967 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
3843− "dev": true,
3844 "license": "MIT", 3968 "license": "MIT",
3845 "dependencies": { 3969 "dependencies": {
3846 "ms": "^2.1.3" 3970 "ms": "^2.1.3"
@@ -3854,6 +3978,19 @@
3854 } 3978 }
3855 } 3979 }
3856 }, 3980 },
3981+ "node_modules/decode-named-character-reference": {
3982+ "version": "1.3.0",
3983+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
3984+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
3985+ "license": "MIT",
3986+ "dependencies": {
3987+ "character-entities": "^2.0.0"
3988+ },
3989+ "funding": {
3990+ "type": "github",
3991+ "url": "https://github.com/sponsors/wooorm"
3992+ }
3993+ },
3857 "node_modules/deep-is": { 3994 "node_modules/deep-is": {
3858 "version": "0.1.4", 3995 "version": "0.1.4",
3859 "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", 3996 "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3907,6 +4044,28 @@
3907 "url": "https://github.com/sponsors/ljharb" 4044 "url": "https://github.com/sponsors/ljharb"
3908 } 4045 }
3909 }, 4046 },
4047+ "node_modules/dequal": {
4048+ "version": "2.0.3",
4049+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
4050+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
4051+ "license": "MIT",
4052+ "engines": {
4053+ "node": ">=6"
4054+ }
4055+ },
4056+ "node_modules/devlop": {
4057+ "version": "1.1.0",
4058+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
4059+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
4060+ "license": "MIT",
4061+ "dependencies": {
4062+ "dequal": "^2.0.0"
4063+ },
4064+ "funding": {
4065+ "type": "github",
4066+ "url": "https://github.com/sponsors/wooorm"
4067+ }
4068+ },
3910 "node_modules/dunder-proto": { 4069 "node_modules/dunder-proto": {
3911 "version": "1.0.1", 4070 "version": "1.0.1",
3912 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 4071 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4392,6 +4551,16 @@
4392 "node": ">=4.0" 4551 "node": ">=4.0"
4393 } 4552 }
4394 }, 4553 },
4554+ "node_modules/estree-util-is-identifier-name": {
4555+ "version": "3.0.0",
4556+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
4557+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
4558+ "license": "MIT",
4559+ "funding": {
4560+ "type": "opencollective",
4561+ "url": "https://opencollective.com/unified"
4562+ }
4563+ },
4395 "node_modules/estree-walker": { 4564 "node_modules/estree-walker": {
4396 "version": "2.0.2", 4565 "version": "2.0.2",
4397 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", 4566 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -4422,6 +4591,12 @@
4422 "url": "https://github.com/bgub/eta?sponsor=1" 4591 "url": "https://github.com/bgub/eta?sponsor=1"
4423 } 4592 }
4424 }, 4593 },
4594+ "node_modules/extend": {
4595+ "version": "3.0.2",
4596+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
4597+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
4598+ "license": "MIT"
4599+ },
4425 "node_modules/fast-deep-equal": { 4600 "node_modules/fast-deep-equal": {
4426 "version": "3.1.3", 4601 "version": "3.1.3",
4427 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 4602 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4943,6 +5118,94 @@
4943 "node": ">= 0.4" 5118 "node": ">= 0.4"
4944 } 5119 }
4945 }, 5120 },
5121+ "node_modules/hast-util-is-element": {
5122+ "version": "3.0.0",
5123+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
5124+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
5125+ "license": "MIT",
5126+ "dependencies": {
5127+ "@types/hast": "^3.0.0"
5128+ },
5129+ "funding": {
5130+ "type": "opencollective",
5131+ "url": "https://opencollective.com/unified"
5132+ }
5133+ },
5134+ "node_modules/hast-util-to-jsx-runtime": {
5135+ "version": "2.3.6",
5136+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
5137+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
5138+ "license": "MIT",
5139+ "dependencies": {
5140+ "@types/estree": "^1.0.0",
5141+ "@types/hast": "^3.0.0",
5142+ "@types/unist": "^3.0.0",
5143+ "comma-separated-tokens": "^2.0.0",
5144+ "devlop": "^1.0.0",
5145+ "estree-util-is-identifier-name": "^3.0.0",
5146+ "hast-util-whitespace": "^3.0.0",
5147+ "mdast-util-mdx-expression": "^2.0.0",
5148+ "mdast-util-mdx-jsx": "^3.0.0",
5149+ "mdast-util-mdxjs-esm": "^2.0.0",
5150+ "property-information": "^7.0.0",
5151+ "space-separated-tokens": "^2.0.0",
5152+ "style-to-js": "^1.0.0",
5153+ "unist-util-position": "^5.0.0",
5154+ "vfile-message": "^4.0.0"
5155+ },
5156+ "funding": {
5157+ "type": "opencollective",
5158+ "url": "https://opencollective.com/unified"
5159+ }
5160+ },
5161+ "node_modules/hast-util-to-text": {
5162+ "version": "4.0.2",
5163+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
5164+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
5165+ "license": "MIT",
5166+ "dependencies": {
5167+ "@types/hast": "^3.0.0",
5168+ "@types/unist": "^3.0.0",
5169+ "hast-util-is-element": "^3.0.0",
5170+ "unist-util-find-after": "^5.0.0"
5171+ },
5172+ "funding": {
5173+ "type": "opencollective",
5174+ "url": "https://opencollective.com/unified"
5175+ }
5176+ },
5177+ "node_modules/hast-util-whitespace": {
5178+ "version": "3.0.0",
5179+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
5180+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
5181+ "license": "MIT",
5182+ "dependencies": {
5183+ "@types/hast": "^3.0.0"
5184+ },
5185+ "funding": {
5186+ "type": "opencollective",
5187+ "url": "https://opencollective.com/unified"
5188+ }
5189+ },
5190+ "node_modules/highlight.js": {
5191+ "version": "11.11.1",
5192+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
5193+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
5194+ "license": "BSD-3-Clause",
5195+ "engines": {
5196+ "node": ">=12.0.0"
5197+ }
5198+ },
5199+ "node_modules/html-url-attributes": {
5200+ "version": "3.0.1",
5201+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
5202+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
5203+ "license": "MIT",
5204+ "funding": {
5205+ "type": "opencollective",
5206+ "url": "https://opencollective.com/unified"
5207+ }
5208+ },
4946 "node_modules/idb": { 5209 "node_modules/idb": {
4947 "version": "7.1.1", 5210 "version": "7.1.1",
4948 "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", 5211 "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -4987,6 +5250,12 @@
4987 "node": ">=0.8.19" 5250 "node": ">=0.8.19"
4988 } 5251 }
4989 }, 5252 },
5253+ "node_modules/inline-style-parser": {
5254+ "version": "0.2.7",
5255+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
5256+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
5257+ "license": "MIT"
5258+ },
4990 "node_modules/internal-slot": { 5259 "node_modules/internal-slot": {
4991 "version": "1.1.0", 5260 "version": "1.1.0",
4992 "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", 5261 "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5002,6 +5271,30 @@
5002 "node": ">= 0.4" 5271 "node": ">= 0.4"
5003 } 5272 }
5004 }, 5273 },
5274+ "node_modules/is-alphabetical": {
5275+ "version": "2.0.1",
5276+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
5277+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
5278+ "license": "MIT",
5279+ "funding": {
5280+ "type": "github",
5281+ "url": "https://github.com/sponsors/wooorm"
5282+ }
5283+ },
5284+ "node_modules/is-alphanumerical": {
5285+ "version": "2.0.1",
5286+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
5287+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
5288+ "license": "MIT",
5289+ "dependencies": {
5290+ "is-alphabetical": "^2.0.0",
5291+ "is-decimal": "^2.0.0"
5292+ },
5293+ "funding": {
5294+ "type": "github",
5295+ "url": "https://github.com/sponsors/wooorm"
5296+ }
5297+ },
5005 "node_modules/is-array-buffer": { 5298 "node_modules/is-array-buffer": {
5006 "version": "3.0.5", 5299 "version": "3.0.5",
5007 "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", 5300 "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5137,6 +5430,16 @@
5137 "url": "https://github.com/sponsors/ljharb" 5430 "url": "https://github.com/sponsors/ljharb"
5138 } 5431 }
5139 }, 5432 },
5433+ "node_modules/is-decimal": {
5434+ "version": "2.0.1",
5435+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
5436+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
5437+ "license": "MIT",
5438+ "funding": {
5439+ "type": "github",
5440+ "url": "https://github.com/sponsors/wooorm"
5441+ }
5442+ },
5140 "node_modules/is-document.all": { 5443 "node_modules/is-document.all": {
5141 "version": "1.0.0", 5444 "version": "1.0.0",
5142 "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", 5445 "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
@@ -5212,6 +5515,16 @@
5212 "node": ">=0.10.0" 5515 "node": ">=0.10.0"
5213 } 5516 }
5214 }, 5517 },
5518+ "node_modules/is-hexadecimal": {
5519+ "version": "2.0.1",
5520+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
5521+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
5522+ "license": "MIT",
5523+ "funding": {
5524+ "type": "github",
5525+ "url": "https://github.com/sponsors/wooorm"
5526+ }
5527+ },
5215 "node_modules/is-map": { 5528 "node_modules/is-map": {
5216 "version": "2.0.3", 5529 "version": "2.0.3",
5217 "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", 5530 "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -5272,6 +5585,18 @@
5272 "node": ">=0.10.0" 5585 "node": ">=0.10.0"
5273 } 5586 }
5274 }, 5587 },
5588+ "node_modules/is-plain-obj": {
5589+ "version": "4.1.0",
5590+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
5591+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
5592+ "license": "MIT",
5593+ "engines": {
5594+ "node": ">=12"
5595+ },
5596+ "funding": {
5597+ "url": "https://github.com/sponsors/sindresorhus"
5598+ }
5599+ },
5275 "node_modules/is-regex": { 5600 "node_modules/is-regex": {
5276 "version": "1.2.1", 5601 "version": "1.2.1",
5277 "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", 5602 "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5652,6 +5977,31 @@
5652 "dev": true, 5977 "dev": true,
5653 "license": "MIT" 5978 "license": "MIT"
5654 }, 5979 },
5980+ "node_modules/longest-streak": {
5981+ "version": "3.1.0",
5982+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
5983+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
5984+ "license": "MIT",
5985+ "funding": {
5986+ "type": "github",
5987+ "url": "https://github.com/sponsors/wooorm"
5988+ }
5989+ },
5990+ "node_modules/lowlight": {
5991+ "version": "3.3.0",
5992+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
5993+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
5994+ "license": "MIT",
5995+ "dependencies": {
5996+ "@types/hast": "^3.0.0",
5997+ "devlop": "^1.0.0",
5998+ "highlight.js": "~11.11.0"
5999+ },
6000+ "funding": {
6001+ "type": "github",
6002+ "url": "https://github.com/sponsors/wooorm"
6003+ }
6004+ },
5655 "node_modules/lru-cache": { 6005 "node_modules/lru-cache": {
5656 "version": "5.1.1", 6006 "version": "5.1.1",
5657 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", 6007 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5672,6 +6022,16 @@
5672 "@jridgewell/sourcemap-codec": "^1.5.5" 6022 "@jridgewell/sourcemap-codec": "^1.5.5"
5673 } 6023 }
5674 }, 6024 },
6025+ "node_modules/markdown-table": {
6026+ "version": "3.0.4",
6027+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
6028+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
6029+ "license": "MIT",
6030+ "funding": {
6031+ "type": "github",
6032+ "url": "https://github.com/sponsors/wooorm"
6033+ }
6034+ },
5675 "node_modules/math-intrinsics": { 6035 "node_modules/math-intrinsics": {
5676 "version": "1.1.0", 6036 "version": "1.1.0",
5677 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 6037 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5682,63 +6042,907 @@
5682 "node": ">= 0.4" 6042 "node": ">= 0.4"
5683 } 6043 }
5684 }, 6044 },
5685− "node_modules/minimatch": { 6045+ "node_modules/mdast-util-find-and-replace": {
5686− "version": "10.2.6", 6046+ "version": "3.0.2",
5687− "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", 6047+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
5688− "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", 6048+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
5689− "dev": true, 6049+ "license": "MIT",
5690− "license": "BlueOak-1.0.0",
5691 "dependencies": { 6050 "dependencies": {
5692− "brace-expansion": "^5.0.8" 6051+ "@types/mdast": "^4.0.0",
5693− }, 6052+ "escape-string-regexp": "^5.0.0",
5694− "engines": { 6053+ "unist-util-is": "^6.0.0",
5695− "node": "18 || 20 || >=22" 6054+ "unist-util-visit-parents": "^6.0.0"
5696 }, 6055 },
5697 "funding": { 6056 "funding": {
5698− "url": "https://github.com/sponsors/isaacs" 6057+ "type": "opencollective",
6058+ "url": "https://opencollective.com/unified"
5699 } 6059 }
5700 }, 6060 },
5701− "node_modules/minipass": { 6061+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
5702− "version": "7.1.3", 6062+ "version": "5.0.0",
5703− "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", 6063+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
5704− "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", 6064+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
5705− "dev": true, 6065+ "license": "MIT",
5706− "license": "BlueOak-1.0.0",
5707 "engines": { 6066 "engines": {
5708− "node": ">=16 || 14 >=14.17" 6067+ "node": ">=12"
6068+ },
6069+ "funding": {
6070+ "url": "https://github.com/sponsors/sindresorhus"
5709 } 6071 }
5710 }, 6072 },
5711− "node_modules/ms": { 6073+ "node_modules/mdast-util-from-markdown": {
5712− "version": "2.1.3", 6074+ "version": "2.0.3",
5713− "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 6075+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
5714− "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 6076+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
5715− "dev": true,
5716− "license": "MIT"
5717− },
5718− "node_modules/nanoid": {
5719− "version": "3.3.16",
5720− "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
5721− "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
5722− "dev": true,
5723− "funding": [
5724− {
5725− "type": "github",
5726− "url": "https://github.com/sponsors/ai"
5727− }
5728− ],
5729 "license": "MIT", 6077 "license": "MIT",
5730− "bin": { 6078+ "dependencies": {
5731− "nanoid": "bin/nanoid.cjs" 6079+ "@types/mdast": "^4.0.0",
6080+ "@types/unist": "^3.0.0",
6081+ "decode-named-character-reference": "^1.0.0",
6082+ "devlop": "^1.0.0",
6083+ "mdast-util-to-string": "^4.0.0",
6084+ "micromark": "^4.0.0",
6085+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
6086+ "micromark-util-decode-string": "^2.0.0",
6087+ "micromark-util-normalize-identifier": "^2.0.0",
6088+ "micromark-util-symbol": "^2.0.0",
6089+ "micromark-util-types": "^2.0.0",
6090+ "unist-util-stringify-position": "^4.0.0"
5732 }, 6091 },
5733− "engines": { 6092+ "funding": {
5734− "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 6093+ "type": "opencollective",
6094+ "url": "https://opencollective.com/unified"
5735 } 6095 }
5736 }, 6096 },
5737− "node_modules/natural-compare": { 6097+ "node_modules/mdast-util-gfm": {
5738− "version": "1.4.0", 6098+ "version": "3.1.0",
5739− "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", 6099+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
5740− "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", 6100+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
5741− "dev": true, 6101+ "license": "MIT",
6102+ "dependencies": {
6103+ "mdast-util-from-markdown": "^2.0.0",
6104+ "mdast-util-gfm-autolink-literal": "^2.0.0",
6105+ "mdast-util-gfm-footnote": "^2.0.0",
6106+ "mdast-util-gfm-strikethrough": "^2.0.0",
6107+ "mdast-util-gfm-table": "^2.0.0",
6108+ "mdast-util-gfm-task-list-item": "^2.0.0",
6109+ "mdast-util-to-markdown": "^2.0.0"
6110+ },
6111+ "funding": {
6112+ "type": "opencollective",
6113+ "url": "https://opencollective.com/unified"
6114+ }
6115+ },
6116+ "node_modules/mdast-util-gfm-autolink-literal": {
6117+ "version": "2.0.1",
6118+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
6119+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
6120+ "license": "MIT",
6121+ "dependencies": {
6122+ "@types/mdast": "^4.0.0",
6123+ "ccount": "^2.0.0",
6124+ "devlop": "^1.0.0",
6125+ "mdast-util-find-and-replace": "^3.0.0",
6126+ "micromark-util-character": "^2.0.0"
6127+ },
6128+ "funding": {
6129+ "type": "opencollective",
6130+ "url": "https://opencollective.com/unified"
6131+ }
6132+ },
6133+ "node_modules/mdast-util-gfm-footnote": {
6134+ "version": "2.1.0",
6135+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
6136+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
6137+ "license": "MIT",
6138+ "dependencies": {
6139+ "@types/mdast": "^4.0.0",
6140+ "devlop": "^1.1.0",
6141+ "mdast-util-from-markdown": "^2.0.0",
6142+ "mdast-util-to-markdown": "^2.0.0",
6143+ "micromark-util-normalize-identifier": "^2.0.0"
6144+ },
6145+ "funding": {
6146+ "type": "opencollective",
6147+ "url": "https://opencollective.com/unified"
6148+ }
6149+ },
6150+ "node_modules/mdast-util-gfm-strikethrough": {
6151+ "version": "2.0.0",
6152+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
6153+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
6154+ "license": "MIT",
6155+ "dependencies": {
6156+ "@types/mdast": "^4.0.0",
6157+ "mdast-util-from-markdown": "^2.0.0",
6158+ "mdast-util-to-markdown": "^2.0.0"
6159+ },
6160+ "funding": {
6161+ "type": "opencollective",
6162+ "url": "https://opencollective.com/unified"
6163+ }
6164+ },
6165+ "node_modules/mdast-util-gfm-table": {
6166+ "version": "2.0.0",
6167+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
6168+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
6169+ "license": "MIT",
6170+ "dependencies": {
6171+ "@types/mdast": "^4.0.0",
6172+ "devlop": "^1.0.0",
6173+ "markdown-table": "^3.0.0",
6174+ "mdast-util-from-markdown": "^2.0.0",
6175+ "mdast-util-to-markdown": "^2.0.0"
6176+ },
6177+ "funding": {
6178+ "type": "opencollective",
6179+ "url": "https://opencollective.com/unified"
6180+ }
6181+ },
6182+ "node_modules/mdast-util-gfm-task-list-item": {
6183+ "version": "2.0.0",
6184+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
6185+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
6186+ "license": "MIT",
6187+ "dependencies": {
6188+ "@types/mdast": "^4.0.0",
6189+ "devlop": "^1.0.0",
6190+ "mdast-util-from-markdown": "^2.0.0",
6191+ "mdast-util-to-markdown": "^2.0.0"
6192+ },
6193+ "funding": {
6194+ "type": "opencollective",
6195+ "url": "https://opencollective.com/unified"
6196+ }
6197+ },
6198+ "node_modules/mdast-util-mdx-expression": {
6199+ "version": "2.0.1",
6200+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
6201+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
6202+ "license": "MIT",
6203+ "dependencies": {
6204+ "@types/estree-jsx": "^1.0.0",
6205+ "@types/hast": "^3.0.0",
6206+ "@types/mdast": "^4.0.0",
6207+ "devlop": "^1.0.0",
6208+ "mdast-util-from-markdown": "^2.0.0",
6209+ "mdast-util-to-markdown": "^2.0.0"
6210+ },
6211+ "funding": {
6212+ "type": "opencollective",
6213+ "url": "https://opencollective.com/unified"
6214+ }
6215+ },
6216+ "node_modules/mdast-util-mdx-jsx": {
6217+ "version": "3.2.0",
6218+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
6219+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
6220+ "license": "MIT",
6221+ "dependencies": {
6222+ "@types/estree-jsx": "^1.0.0",
6223+ "@types/hast": "^3.0.0",
6224+ "@types/mdast": "^4.0.0",
6225+ "@types/unist": "^3.0.0",
6226+ "ccount": "^2.0.0",
6227+ "devlop": "^1.1.0",
6228+ "mdast-util-from-markdown": "^2.0.0",
6229+ "mdast-util-to-markdown": "^2.0.0",
6230+ "parse-entities": "^4.0.0",
6231+ "stringify-entities": "^4.0.0",
6232+ "unist-util-stringify-position": "^4.0.0",
6233+ "vfile-message": "^4.0.0"
6234+ },
6235+ "funding": {
6236+ "type": "opencollective",
6237+ "url": "https://opencollective.com/unified"
6238+ }
6239+ },
6240+ "node_modules/mdast-util-mdxjs-esm": {
6241+ "version": "2.0.1",
6242+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
6243+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
6244+ "license": "MIT",
6245+ "dependencies": {
6246+ "@types/estree-jsx": "^1.0.0",
6247+ "@types/hast": "^3.0.0",
6248+ "@types/mdast": "^4.0.0",
6249+ "devlop": "^1.0.0",
6250+ "mdast-util-from-markdown": "^2.0.0",
6251+ "mdast-util-to-markdown": "^2.0.0"
6252+ },
6253+ "funding": {
6254+ "type": "opencollective",
6255+ "url": "https://opencollective.com/unified"
6256+ }
6257+ },
6258+ "node_modules/mdast-util-phrasing": {
6259+ "version": "4.1.0",
6260+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
6261+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
6262+ "license": "MIT",
6263+ "dependencies": {
6264+ "@types/mdast": "^4.0.0",
6265+ "unist-util-is": "^6.0.0"
6266+ },
6267+ "funding": {
6268+ "type": "opencollective",
6269+ "url": "https://opencollective.com/unified"
6270+ }
6271+ },
6272+ "node_modules/mdast-util-to-hast": {
6273+ "version": "13.2.1",
6274+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
6275+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
6276+ "license": "MIT",
6277+ "dependencies": {
6278+ "@types/hast": "^3.0.0",
6279+ "@types/mdast": "^4.0.0",
6280+ "@ungap/structured-clone": "^1.0.0",
6281+ "devlop": "^1.0.0",
6282+ "micromark-util-sanitize-uri": "^2.0.0",
6283+ "trim-lines": "^3.0.0",
6284+ "unist-util-position": "^5.0.0",
6285+ "unist-util-visit": "^5.0.0",
6286+ "vfile": "^6.0.0"
6287+ },
6288+ "funding": {
6289+ "type": "opencollective",
6290+ "url": "https://opencollective.com/unified"
6291+ }
6292+ },
6293+ "node_modules/mdast-util-to-markdown": {
6294+ "version": "2.1.2",
6295+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
6296+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
6297+ "license": "MIT",
6298+ "dependencies": {
6299+ "@types/mdast": "^4.0.0",
6300+ "@types/unist": "^3.0.0",
6301+ "longest-streak": "^3.0.0",
6302+ "mdast-util-phrasing": "^4.0.0",
6303+ "mdast-util-to-string": "^4.0.0",
6304+ "micromark-util-classify-character": "^2.0.0",
6305+ "micromark-util-decode-string": "^2.0.0",
6306+ "unist-util-visit": "^5.0.0",
6307+ "zwitch": "^2.0.0"
6308+ },
6309+ "funding": {
6310+ "type": "opencollective",
6311+ "url": "https://opencollective.com/unified"
6312+ }
6313+ },
6314+ "node_modules/mdast-util-to-string": {
6315+ "version": "4.0.0",
6316+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
6317+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
6318+ "license": "MIT",
6319+ "dependencies": {
6320+ "@types/mdast": "^4.0.0"
6321+ },
6322+ "funding": {
6323+ "type": "opencollective",
6324+ "url": "https://opencollective.com/unified"
6325+ }
6326+ },
6327+ "node_modules/micromark": {
6328+ "version": "4.0.2",
6329+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
6330+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
6331+ "funding": [
6332+ {
6333+ "type": "GitHub Sponsors",
6334+ "url": "https://github.com/sponsors/unifiedjs"
6335+ },
6336+ {
6337+ "type": "OpenCollective",
6338+ "url": "https://opencollective.com/unified"
6339+ }
6340+ ],
6341+ "license": "MIT",
6342+ "dependencies": {
6343+ "@types/debug": "^4.0.0",
6344+ "debug": "^4.0.0",
6345+ "decode-named-character-reference": "^1.0.0",
6346+ "devlop": "^1.0.0",
6347+ "micromark-core-commonmark": "^2.0.0",
6348+ "micromark-factory-space": "^2.0.0",
6349+ "micromark-util-character": "^2.0.0",
6350+ "micromark-util-chunked": "^2.0.0",
6351+ "micromark-util-combine-extensions": "^2.0.0",
6352+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
6353+ "micromark-util-encode": "^2.0.0",
6354+ "micromark-util-normalize-identifier": "^2.0.0",
6355+ "micromark-util-resolve-all": "^2.0.0",
6356+ "micromark-util-sanitize-uri": "^2.0.0",
6357+ "micromark-util-subtokenize": "^2.0.0",
6358+ "micromark-util-symbol": "^2.0.0",
6359+ "micromark-util-types": "^2.0.0"
6360+ }
6361+ },
6362+ "node_modules/micromark-core-commonmark": {
6363+ "version": "2.0.3",
6364+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
6365+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
6366+ "funding": [
6367+ {
6368+ "type": "GitHub Sponsors",
6369+ "url": "https://github.com/sponsors/unifiedjs"
6370+ },
6371+ {
6372+ "type": "OpenCollective",
6373+ "url": "https://opencollective.com/unified"
6374+ }
6375+ ],
6376+ "license": "MIT",
6377+ "dependencies": {
6378+ "decode-named-character-reference": "^1.0.0",
6379+ "devlop": "^1.0.0",
6380+ "micromark-factory-destination": "^2.0.0",
6381+ "micromark-factory-label": "^2.0.0",
6382+ "micromark-factory-space": "^2.0.0",
6383+ "micromark-factory-title": "^2.0.0",
6384+ "micromark-factory-whitespace": "^2.0.0",
6385+ "micromark-util-character": "^2.0.0",
6386+ "micromark-util-chunked": "^2.0.0",
6387+ "micromark-util-classify-character": "^2.0.0",
6388+ "micromark-util-html-tag-name": "^2.0.0",
6389+ "micromark-util-normalize-identifier": "^2.0.0",
6390+ "micromark-util-resolve-all": "^2.0.0",
6391+ "micromark-util-subtokenize": "^2.0.0",
6392+ "micromark-util-symbol": "^2.0.0",
6393+ "micromark-util-types": "^2.0.0"
6394+ }
6395+ },
6396+ "node_modules/micromark-extension-gfm": {
6397+ "version": "3.0.0",
6398+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
6399+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
6400+ "license": "MIT",
6401+ "dependencies": {
6402+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
6403+ "micromark-extension-gfm-footnote": "^2.0.0",
6404+ "micromark-extension-gfm-strikethrough": "^2.0.0",
6405+ "micromark-extension-gfm-table": "^2.0.0",
6406+ "micromark-extension-gfm-tagfilter": "^2.0.0",
6407+ "micromark-extension-gfm-task-list-item": "^2.0.0",
6408+ "micromark-util-combine-extensions": "^2.0.0",
6409+ "micromark-util-types": "^2.0.0"
6410+ },
6411+ "funding": {
6412+ "type": "opencollective",
6413+ "url": "https://opencollective.com/unified"
6414+ }
6415+ },
6416+ "node_modules/micromark-extension-gfm-autolink-literal": {
6417+ "version": "2.1.0",
6418+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
6419+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
6420+ "license": "MIT",
6421+ "dependencies": {
6422+ "micromark-util-character": "^2.0.0",
6423+ "micromark-util-sanitize-uri": "^2.0.0",
6424+ "micromark-util-symbol": "^2.0.0",
6425+ "micromark-util-types": "^2.0.0"
6426+ },
6427+ "funding": {
6428+ "type": "opencollective",
6429+ "url": "https://opencollective.com/unified"
6430+ }
6431+ },
6432+ "node_modules/micromark-extension-gfm-footnote": {
6433+ "version": "2.1.0",
6434+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
6435+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
6436+ "license": "MIT",
6437+ "dependencies": {
6438+ "devlop": "^1.0.0",
6439+ "micromark-core-commonmark": "^2.0.0",
6440+ "micromark-factory-space": "^2.0.0",
6441+ "micromark-util-character": "^2.0.0",
6442+ "micromark-util-normalize-identifier": "^2.0.0",
6443+ "micromark-util-sanitize-uri": "^2.0.0",
6444+ "micromark-util-symbol": "^2.0.0",
6445+ "micromark-util-types": "^2.0.0"
6446+ },
6447+ "funding": {
6448+ "type": "opencollective",
6449+ "url": "https://opencollective.com/unified"
6450+ }
6451+ },
6452+ "node_modules/micromark-extension-gfm-strikethrough": {
6453+ "version": "2.1.0",
6454+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
6455+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
6456+ "license": "MIT",
6457+ "dependencies": {
6458+ "devlop": "^1.0.0",
6459+ "micromark-util-chunked": "^2.0.0",
6460+ "micromark-util-classify-character": "^2.0.0",
6461+ "micromark-util-resolve-all": "^2.0.0",
6462+ "micromark-util-symbol": "^2.0.0",
6463+ "micromark-util-types": "^2.0.0"
6464+ },
6465+ "funding": {
6466+ "type": "opencollective",
6467+ "url": "https://opencollective.com/unified"
6468+ }
6469+ },
6470+ "node_modules/micromark-extension-gfm-table": {
6471+ "version": "2.1.1",
6472+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
6473+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
6474+ "license": "MIT",
6475+ "dependencies": {
6476+ "devlop": "^1.0.0",
6477+ "micromark-factory-space": "^2.0.0",
6478+ "micromark-util-character": "^2.0.0",
6479+ "micromark-util-symbol": "^2.0.0",
6480+ "micromark-util-types": "^2.0.0"
6481+ },
6482+ "funding": {
6483+ "type": "opencollective",
6484+ "url": "https://opencollective.com/unified"
6485+ }
6486+ },
6487+ "node_modules/micromark-extension-gfm-tagfilter": {
6488+ "version": "2.0.0",
6489+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
6490+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
6491+ "license": "MIT",
6492+ "dependencies": {
6493+ "micromark-util-types": "^2.0.0"
6494+ },
6495+ "funding": {
6496+ "type": "opencollective",
6497+ "url": "https://opencollective.com/unified"
6498+ }
6499+ },
6500+ "node_modules/micromark-extension-gfm-task-list-item": {
6501+ "version": "2.1.0",
6502+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
6503+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
6504+ "license": "MIT",
6505+ "dependencies": {
6506+ "devlop": "^1.0.0",
6507+ "micromark-factory-space": "^2.0.0",
6508+ "micromark-util-character": "^2.0.0",
6509+ "micromark-util-symbol": "^2.0.0",
6510+ "micromark-util-types": "^2.0.0"
6511+ },
6512+ "funding": {
6513+ "type": "opencollective",
6514+ "url": "https://opencollective.com/unified"
6515+ }
6516+ },
6517+ "node_modules/micromark-factory-destination": {
6518+ "version": "2.0.1",
6519+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
6520+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
6521+ "funding": [
6522+ {
6523+ "type": "GitHub Sponsors",
6524+ "url": "https://github.com/sponsors/unifiedjs"
6525+ },
6526+ {
6527+ "type": "OpenCollective",
6528+ "url": "https://opencollective.com/unified"
6529+ }
6530+ ],
6531+ "license": "MIT",
6532+ "dependencies": {
6533+ "micromark-util-character": "^2.0.0",
6534+ "micromark-util-symbol": "^2.0.0",
6535+ "micromark-util-types": "^2.0.0"
6536+ }
6537+ },
6538+ "node_modules/micromark-factory-label": {
6539+ "version": "2.0.1",
6540+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
6541+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
6542+ "funding": [
6543+ {
6544+ "type": "GitHub Sponsors",
6545+ "url": "https://github.com/sponsors/unifiedjs"
6546+ },
6547+ {
6548+ "type": "OpenCollective",
6549+ "url": "https://opencollective.com/unified"
6550+ }
6551+ ],
6552+ "license": "MIT",
6553+ "dependencies": {
6554+ "devlop": "^1.0.0",
6555+ "micromark-util-character": "^2.0.0",
6556+ "micromark-util-symbol": "^2.0.0",
6557+ "micromark-util-types": "^2.0.0"
6558+ }
6559+ },
6560+ "node_modules/micromark-factory-space": {
6561+ "version": "2.0.1",
6562+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
6563+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
6564+ "funding": [
6565+ {
6566+ "type": "GitHub Sponsors",
6567+ "url": "https://github.com/sponsors/unifiedjs"
6568+ },
6569+ {
6570+ "type": "OpenCollective",
6571+ "url": "https://opencollective.com/unified"
6572+ }
6573+ ],
6574+ "license": "MIT",
6575+ "dependencies": {
6576+ "micromark-util-character": "^2.0.0",
6577+ "micromark-util-types": "^2.0.0"
6578+ }
6579+ },
6580+ "node_modules/micromark-factory-title": {
6581+ "version": "2.0.1",
6582+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
6583+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
6584+ "funding": [
6585+ {
6586+ "type": "GitHub Sponsors",
6587+ "url": "https://github.com/sponsors/unifiedjs"
6588+ },
6589+ {
6590+ "type": "OpenCollective",
6591+ "url": "https://opencollective.com/unified"
6592+ }
6593+ ],
6594+ "license": "MIT",
6595+ "dependencies": {
6596+ "micromark-factory-space": "^2.0.0",
6597+ "micromark-util-character": "^2.0.0",
6598+ "micromark-util-symbol": "^2.0.0",
6599+ "micromark-util-types": "^2.0.0"
6600+ }
6601+ },
6602+ "node_modules/micromark-factory-whitespace": {
6603+ "version": "2.0.1",
6604+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
6605+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
6606+ "funding": [
6607+ {
6608+ "type": "GitHub Sponsors",
6609+ "url": "https://github.com/sponsors/unifiedjs"
6610+ },
6611+ {
6612+ "type": "OpenCollective",
6613+ "url": "https://opencollective.com/unified"
6614+ }
6615+ ],
6616+ "license": "MIT",
6617+ "dependencies": {
6618+ "micromark-factory-space": "^2.0.0",
6619+ "micromark-util-character": "^2.0.0",
6620+ "micromark-util-symbol": "^2.0.0",
6621+ "micromark-util-types": "^2.0.0"
6622+ }
6623+ },
6624+ "node_modules/micromark-util-character": {
6625+ "version": "2.1.1",
6626+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
6627+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
6628+ "funding": [
6629+ {
6630+ "type": "GitHub Sponsors",
6631+ "url": "https://github.com/sponsors/unifiedjs"
6632+ },
6633+ {
6634+ "type": "OpenCollective",
6635+ "url": "https://opencollective.com/unified"
6636+ }
6637+ ],
6638+ "license": "MIT",
6639+ "dependencies": {
6640+ "micromark-util-symbol": "^2.0.0",
6641+ "micromark-util-types": "^2.0.0"
6642+ }
6643+ },
6644+ "node_modules/micromark-util-chunked": {
6645+ "version": "2.0.1",
6646+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
6647+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
6648+ "funding": [
6649+ {
6650+ "type": "GitHub Sponsors",
6651+ "url": "https://github.com/sponsors/unifiedjs"
6652+ },
6653+ {
6654+ "type": "OpenCollective",
6655+ "url": "https://opencollective.com/unified"
6656+ }
6657+ ],
6658+ "license": "MIT",
6659+ "dependencies": {
6660+ "micromark-util-symbol": "^2.0.0"
6661+ }
6662+ },
6663+ "node_modules/micromark-util-classify-character": {
6664+ "version": "2.0.1",
6665+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
6666+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
6667+ "funding": [
6668+ {
6669+ "type": "GitHub Sponsors",
6670+ "url": "https://github.com/sponsors/unifiedjs"
6671+ },
6672+ {
6673+ "type": "OpenCollective",
6674+ "url": "https://opencollective.com/unified"
6675+ }
6676+ ],
6677+ "license": "MIT",
6678+ "dependencies": {
6679+ "micromark-util-character": "^2.0.0",
6680+ "micromark-util-symbol": "^2.0.0",
6681+ "micromark-util-types": "^2.0.0"
6682+ }
6683+ },
6684+ "node_modules/micromark-util-combine-extensions": {
6685+ "version": "2.0.1",
6686+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
6687+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
6688+ "funding": [
6689+ {
6690+ "type": "GitHub Sponsors",
6691+ "url": "https://github.com/sponsors/unifiedjs"
6692+ },
6693+ {
6694+ "type": "OpenCollective",
6695+ "url": "https://opencollective.com/unified"
6696+ }
6697+ ],
6698+ "license": "MIT",
6699+ "dependencies": {
6700+ "micromark-util-chunked": "^2.0.0",
6701+ "micromark-util-types": "^2.0.0"
6702+ }
6703+ },
6704+ "node_modules/micromark-util-decode-numeric-character-reference": {
6705+ "version": "2.0.2",
6706+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
6707+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
6708+ "funding": [
6709+ {
6710+ "type": "GitHub Sponsors",
6711+ "url": "https://github.com/sponsors/unifiedjs"
6712+ },
6713+ {
6714+ "type": "OpenCollective",
6715+ "url": "https://opencollective.com/unified"
6716+ }
6717+ ],
6718+ "license": "MIT",
6719+ "dependencies": {
6720+ "micromark-util-symbol": "^2.0.0"
6721+ }
6722+ },
6723+ "node_modules/micromark-util-decode-string": {
6724+ "version": "2.0.1",
6725+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
6726+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
6727+ "funding": [
6728+ {
6729+ "type": "GitHub Sponsors",
6730+ "url": "https://github.com/sponsors/unifiedjs"
6731+ },
6732+ {
6733+ "type": "OpenCollective",
6734+ "url": "https://opencollective.com/unified"
6735+ }
6736+ ],
6737+ "license": "MIT",
6738+ "dependencies": {
6739+ "decode-named-character-reference": "^1.0.0",
6740+ "micromark-util-character": "^2.0.0",
6741+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
6742+ "micromark-util-symbol": "^2.0.0"
6743+ }
6744+ },
6745+ "node_modules/micromark-util-encode": {
6746+ "version": "2.0.1",
6747+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
6748+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
6749+ "funding": [
6750+ {
6751+ "type": "GitHub Sponsors",
6752+ "url": "https://github.com/sponsors/unifiedjs"
6753+ },
6754+ {
6755+ "type": "OpenCollective",
6756+ "url": "https://opencollective.com/unified"
6757+ }
6758+ ],
6759+ "license": "MIT"
6760+ },
6761+ "node_modules/micromark-util-html-tag-name": {
6762+ "version": "2.0.1",
6763+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
6764+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
6765+ "funding": [
6766+ {
6767+ "type": "GitHub Sponsors",
6768+ "url": "https://github.com/sponsors/unifiedjs"
6769+ },
6770+ {
6771+ "type": "OpenCollective",
6772+ "url": "https://opencollective.com/unified"
6773+ }
6774+ ],
6775+ "license": "MIT"
6776+ },
6777+ "node_modules/micromark-util-normalize-identifier": {
6778+ "version": "2.0.1",
6779+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
6780+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
6781+ "funding": [
6782+ {
6783+ "type": "GitHub Sponsors",
6784+ "url": "https://github.com/sponsors/unifiedjs"
6785+ },
6786+ {
6787+ "type": "OpenCollective",
6788+ "url": "https://opencollective.com/unified"
6789+ }
6790+ ],
6791+ "license": "MIT",
6792+ "dependencies": {
6793+ "micromark-util-symbol": "^2.0.0"
6794+ }
6795+ },
6796+ "node_modules/micromark-util-resolve-all": {
6797+ "version": "2.0.1",
6798+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
6799+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
6800+ "funding": [
6801+ {
6802+ "type": "GitHub Sponsors",
6803+ "url": "https://github.com/sponsors/unifiedjs"
6804+ },
6805+ {
6806+ "type": "OpenCollective",
6807+ "url": "https://opencollective.com/unified"
6808+ }
6809+ ],
6810+ "license": "MIT",
6811+ "dependencies": {
6812+ "micromark-util-types": "^2.0.0"
6813+ }
6814+ },
6815+ "node_modules/micromark-util-sanitize-uri": {
6816+ "version": "2.0.1",
6817+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
6818+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
6819+ "funding": [
6820+ {
6821+ "type": "GitHub Sponsors",
6822+ "url": "https://github.com/sponsors/unifiedjs"
6823+ },
6824+ {
6825+ "type": "OpenCollective",
6826+ "url": "https://opencollective.com/unified"
6827+ }
6828+ ],
6829+ "license": "MIT",
6830+ "dependencies": {
6831+ "micromark-util-character": "^2.0.0",
6832+ "micromark-util-encode": "^2.0.0",
6833+ "micromark-util-symbol": "^2.0.0"
6834+ }
6835+ },
6836+ "node_modules/micromark-util-subtokenize": {
6837+ "version": "2.1.0",
6838+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
6839+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
6840+ "funding": [
6841+ {
6842+ "type": "GitHub Sponsors",
6843+ "url": "https://github.com/sponsors/unifiedjs"
6844+ },
6845+ {
6846+ "type": "OpenCollective",
6847+ "url": "https://opencollective.com/unified"
6848+ }
6849+ ],
6850+ "license": "MIT",
6851+ "dependencies": {
6852+ "devlop": "^1.0.0",
6853+ "micromark-util-chunked": "^2.0.0",
6854+ "micromark-util-symbol": "^2.0.0",
6855+ "micromark-util-types": "^2.0.0"
6856+ }
6857+ },
6858+ "node_modules/micromark-util-symbol": {
6859+ "version": "2.0.1",
6860+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
6861+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
6862+ "funding": [
6863+ {
6864+ "type": "GitHub Sponsors",
6865+ "url": "https://github.com/sponsors/unifiedjs"
6866+ },
6867+ {
6868+ "type": "OpenCollective",
6869+ "url": "https://opencollective.com/unified"
6870+ }
6871+ ],
6872+ "license": "MIT"
6873+ },
6874+ "node_modules/micromark-util-types": {
6875+ "version": "2.0.2",
6876+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
6877+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
6878+ "funding": [
6879+ {
6880+ "type": "GitHub Sponsors",
6881+ "url": "https://github.com/sponsors/unifiedjs"
6882+ },
6883+ {
6884+ "type": "OpenCollective",
6885+ "url": "https://opencollective.com/unified"
6886+ }
6887+ ],
6888+ "license": "MIT"
6889+ },
6890+ "node_modules/minimatch": {
6891+ "version": "10.2.6",
6892+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
6893+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
6894+ "dev": true,
6895+ "license": "BlueOak-1.0.0",
6896+ "dependencies": {
6897+ "brace-expansion": "^5.0.8"
6898+ },
6899+ "engines": {
6900+ "node": "18 || 20 || >=22"
6901+ },
6902+ "funding": {
6903+ "url": "https://github.com/sponsors/isaacs"
6904+ }
6905+ },
6906+ "node_modules/minipass": {
6907+ "version": "7.1.3",
6908+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
6909+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
6910+ "dev": true,
6911+ "license": "BlueOak-1.0.0",
6912+ "engines": {
6913+ "node": ">=16 || 14 >=14.17"
6914+ }
6915+ },
6916+ "node_modules/ms": {
6917+ "version": "2.1.3",
6918+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
6919+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
6920+ "license": "MIT"
6921+ },
6922+ "node_modules/nanoid": {
6923+ "version": "3.3.16",
6924+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
6925+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
6926+ "dev": true,
6927+ "funding": [
6928+ {
6929+ "type": "github",
6930+ "url": "https://github.com/sponsors/ai"
6931+ }
6932+ ],
6933+ "license": "MIT",
6934+ "bin": {
6935+ "nanoid": "bin/nanoid.cjs"
6936+ },
6937+ "engines": {
6938+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
6939+ }
6940+ },
6941+ "node_modules/natural-compare": {
6942+ "version": "1.4.0",
6943+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
6944+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
6945+ "dev": true,
5742 "license": "MIT" 6946 "license": "MIT"
5743 }, 6947 },
5744 "node_modules/node-releases": { 6948 "node_modules/node-releases": {
@@ -5884,6 +7088,31 @@
5884 "node": ">=6" 7088 "node": ">=6"
5885 } 7089 }
5886 }, 7090 },
7091+ "node_modules/parse-entities": {
7092+ "version": "4.0.2",
7093+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
7094+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
7095+ "license": "MIT",
7096+ "dependencies": {
7097+ "@types/unist": "^2.0.0",
7098+ "character-entities-legacy": "^3.0.0",
7099+ "character-reference-invalid": "^2.0.0",
7100+ "decode-named-character-reference": "^1.0.0",
7101+ "is-alphanumerical": "^2.0.0",
7102+ "is-decimal": "^2.0.0",
7103+ "is-hexadecimal": "^2.0.0"
7104+ },
7105+ "funding": {
7106+ "type": "github",
7107+ "url": "https://github.com/sponsors/wooorm"
7108+ }
7109+ },
7110+ "node_modules/parse-entities/node_modules/@types/unist": {
7111+ "version": "2.0.11",
7112+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
7113+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
7114+ "license": "MIT"
7115+ },
5887 "node_modules/path-exists": { 7116 "node_modules/path-exists": {
5888 "version": "4.0.0", 7117 "version": "4.0.0",
5889 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 7118 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6083,6 +7312,16 @@
6083 "url": "https://github.com/sponsors/sindresorhus" 7312 "url": "https://github.com/sponsors/sindresorhus"
6084 } 7313 }
6085 }, 7314 },
7315+ "node_modules/property-information": {
7316+ "version": "7.2.0",
7317+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
7318+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
7319+ "license": "MIT",
7320+ "funding": {
7321+ "type": "github",
7322+ "url": "https://github.com/sponsors/wooorm"
7323+ }
7324+ },
6086 "node_modules/punycode": { 7325 "node_modules/punycode": {
6087 "version": "2.3.1", 7326 "version": "2.3.1",
6088 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 7327 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6114,6 +7353,33 @@
6114 "react": "^19.2.8" 7353 "react": "^19.2.8"
6115 } 7354 }
6116 }, 7355 },
7356+ "node_modules/react-markdown": {
7357+ "version": "10.1.0",
7358+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
7359+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
7360+ "license": "MIT",
7361+ "dependencies": {
7362+ "@types/hast": "^3.0.0",
7363+ "@types/mdast": "^4.0.0",
7364+ "devlop": "^1.0.0",
7365+ "hast-util-to-jsx-runtime": "^2.0.0",
7366+ "html-url-attributes": "^3.0.0",
7367+ "mdast-util-to-hast": "^13.0.0",
7368+ "remark-parse": "^11.0.0",
7369+ "remark-rehype": "^11.0.0",
7370+ "unified": "^11.0.0",
7371+ "unist-util-visit": "^5.0.0",
7372+ "vfile": "^6.0.0"
7373+ },
7374+ "funding": {
7375+ "type": "opencollective",
7376+ "url": "https://opencollective.com/unified"
7377+ },
7378+ "peerDependencies": {
7379+ "@types/react": ">=18",
7380+ "react": ">=18"
7381+ }
7382+ },
6117 "node_modules/react-refresh": { 7383 "node_modules/react-refresh": {
6118 "version": "0.17.0", 7384 "version": "0.17.0",
6119 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", 7385 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -6226,6 +7492,89 @@
6226 "regjsparser": "bin/parser" 7492 "regjsparser": "bin/parser"
6227 } 7493 }
6228 }, 7494 },
7495+ "node_modules/rehype-highlight": {
7496+ "version": "7.0.2",
7497+ "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
7498+ "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
7499+ "license": "MIT",
7500+ "dependencies": {
7501+ "@types/hast": "^3.0.0",
7502+ "hast-util-to-text": "^4.0.0",
7503+ "lowlight": "^3.0.0",
7504+ "unist-util-visit": "^5.0.0",
7505+ "vfile": "^6.0.0"
7506+ },
7507+ "funding": {
7508+ "type": "opencollective",
7509+ "url": "https://opencollective.com/unified"
7510+ }
7511+ },
7512+ "node_modules/remark-gfm": {
7513+ "version": "4.0.1",
7514+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
7515+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
7516+ "license": "MIT",
7517+ "dependencies": {
7518+ "@types/mdast": "^4.0.0",
7519+ "mdast-util-gfm": "^3.0.0",
7520+ "micromark-extension-gfm": "^3.0.0",
7521+ "remark-parse": "^11.0.0",
7522+ "remark-stringify": "^11.0.0",
7523+ "unified": "^11.0.0"
7524+ },
7525+ "funding": {
7526+ "type": "opencollective",
7527+ "url": "https://opencollective.com/unified"
7528+ }
7529+ },
7530+ "node_modules/remark-parse": {
7531+ "version": "11.0.0",
7532+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
7533+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
7534+ "license": "MIT",
7535+ "dependencies": {
7536+ "@types/mdast": "^4.0.0",
7537+ "mdast-util-from-markdown": "^2.0.0",
7538+ "micromark-util-types": "^2.0.0",
7539+ "unified": "^11.0.0"
7540+ },
7541+ "funding": {
7542+ "type": "opencollective",
7543+ "url": "https://opencollective.com/unified"
7544+ }
7545+ },
7546+ "node_modules/remark-rehype": {
7547+ "version": "11.1.2",
7548+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
7549+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
7550+ "license": "MIT",
7551+ "dependencies": {
7552+ "@types/hast": "^3.0.0",
7553+ "@types/mdast": "^4.0.0",
7554+ "mdast-util-to-hast": "^13.0.0",
7555+ "unified": "^11.0.0",
7556+ "vfile": "^6.0.0"
7557+ },
7558+ "funding": {
7559+ "type": "opencollective",
7560+ "url": "https://opencollective.com/unified"
7561+ }
7562+ },
7563+ "node_modules/remark-stringify": {
7564+ "version": "11.0.0",
7565+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
7566+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
7567+ "license": "MIT",
7568+ "dependencies": {
7569+ "@types/mdast": "^4.0.0",
7570+ "mdast-util-to-markdown": "^2.0.0",
7571+ "unified": "^11.0.0"
7572+ },
7573+ "funding": {
7574+ "type": "opencollective",
7575+ "url": "https://opencollective.com/unified"
7576+ }
7577+ },
6229 "node_modules/require-from-string": { 7578 "node_modules/require-from-string": {
6230 "version": "2.0.2", 7579 "version": "2.0.2",
6231 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", 7580 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6609,6 +7958,16 @@
6609 "node": ">=0.10.0" 7958 "node": ">=0.10.0"
6610 } 7959 }
6611 }, 7960 },
7961+ "node_modules/space-separated-tokens": {
7962+ "version": "2.0.2",
7963+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
7964+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
7965+ "license": "MIT",
7966+ "funding": {
7967+ "type": "github",
7968+ "url": "https://github.com/sponsors/wooorm"
7969+ }
7970+ },
6612 "node_modules/stop-iteration-iterator": { 7971 "node_modules/stop-iteration-iterator": {
6613 "version": "1.1.0", 7972 "version": "1.1.0",
6614 "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", 7973 "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6711,6 +8070,20 @@
6711 "url": "https://github.com/sponsors/ljharb" 8070 "url": "https://github.com/sponsors/ljharb"
6712 } 8071 }
6713 }, 8072 },
8073+ "node_modules/stringify-entities": {
8074+ "version": "4.0.4",
8075+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
8076+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
8077+ "license": "MIT",
8078+ "dependencies": {
8079+ "character-entities-html4": "^2.0.0",
8080+ "character-entities-legacy": "^3.0.0"
8081+ },
8082+ "funding": {
8083+ "type": "github",
8084+ "url": "https://github.com/sponsors/wooorm"
8085+ }
8086+ },
6714 "node_modules/stringify-object": { 8087 "node_modules/stringify-object": {
6715 "version": "3.3.0", 8088 "version": "3.3.0",
6716 "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", 8089 "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
@@ -6749,6 +8122,24 @@
6749 "url": "https://github.com/sponsors/sindresorhus" 8122 "url": "https://github.com/sponsors/sindresorhus"
6750 } 8123 }
6751 }, 8124 },
8125+ "node_modules/style-to-js": {
8126+ "version": "1.1.21",
8127+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
8128+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
8129+ "license": "MIT",
8130+ "dependencies": {
8131+ "style-to-object": "1.0.14"
8132+ }
8133+ },
8134+ "node_modules/style-to-object": {
8135+ "version": "1.0.14",
8136+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
8137+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
8138+ "license": "MIT",
8139+ "dependencies": {
8140+ "inline-style-parser": "0.2.7"
8141+ }
8142+ },
6752 "node_modules/supports-color": { 8143 "node_modules/supports-color": {
6753 "version": "7.2.0", 8144 "version": "7.2.0",
6754 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 8145 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6840,6 +8231,26 @@
6840 "url": "https://github.com/sponsors/SuperchupuDev" 8231 "url": "https://github.com/sponsors/SuperchupuDev"
6841 } 8232 }
6842 }, 8233 },
8234+ "node_modules/trim-lines": {
8235+ "version": "3.0.1",
8236+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
8237+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
8238+ "license": "MIT",
8239+ "funding": {
8240+ "type": "github",
8241+ "url": "https://github.com/sponsors/wooorm"
8242+ }
8243+ },
8244+ "node_modules/trough": {
8245+ "version": "2.2.0",
8246+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
8247+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
8248+ "license": "MIT",
8249+ "funding": {
8250+ "type": "github",
8251+ "url": "https://github.com/sponsors/wooorm"
8252+ }
8253+ },
6843 "node_modules/ts-api-utils": { 8254 "node_modules/ts-api-utils": {
6844 "version": "2.5.0", 8255 "version": "2.5.0",
6845 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", 8256 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7034,6 +8445,25 @@
7034 "node": ">=4" 8445 "node": ">=4"
7035 } 8446 }
7036 }, 8447 },
8448+ "node_modules/unified": {
8449+ "version": "11.0.5",
8450+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
8451+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
8452+ "license": "MIT",
8453+ "dependencies": {
8454+ "@types/unist": "^3.0.0",
8455+ "bail": "^2.0.0",
8456+ "devlop": "^1.0.0",
8457+ "extend": "^3.0.0",
8458+ "is-plain-obj": "^4.0.0",
8459+ "trough": "^2.0.0",
8460+ "vfile": "^6.0.0"
8461+ },
8462+ "funding": {
8463+ "type": "opencollective",
8464+ "url": "https://opencollective.com/unified"
8465+ }
8466+ },
7037 "node_modules/unique-string": { 8467 "node_modules/unique-string": {
7038 "version": "2.0.0", 8468 "version": "2.0.0",
7039 "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", 8469 "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@@ -7047,6 +8477,88 @@
7047 "node": ">=8" 8477 "node": ">=8"
7048 } 8478 }
7049 }, 8479 },
8480+ "node_modules/unist-util-find-after": {
8481+ "version": "5.0.0",
8482+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
8483+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
8484+ "license": "MIT",
8485+ "dependencies": {
8486+ "@types/unist": "^3.0.0",
8487+ "unist-util-is": "^6.0.0"
8488+ },
8489+ "funding": {
8490+ "type": "opencollective",
8491+ "url": "https://opencollective.com/unified"
8492+ }
8493+ },
8494+ "node_modules/unist-util-is": {
8495+ "version": "6.0.1",
8496+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
8497+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
8498+ "license": "MIT",
8499+ "dependencies": {
8500+ "@types/unist": "^3.0.0"
8501+ },
8502+ "funding": {
8503+ "type": "opencollective",
8504+ "url": "https://opencollective.com/unified"
8505+ }
8506+ },
8507+ "node_modules/unist-util-position": {
8508+ "version": "5.0.0",
8509+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
8510+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
8511+ "license": "MIT",
8512+ "dependencies": {
8513+ "@types/unist": "^3.0.0"
8514+ },
8515+ "funding": {
8516+ "type": "opencollective",
8517+ "url": "https://opencollective.com/unified"
8518+ }
8519+ },
8520+ "node_modules/unist-util-stringify-position": {
8521+ "version": "4.0.0",
8522+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
8523+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
8524+ "license": "MIT",
8525+ "dependencies": {
8526+ "@types/unist": "^3.0.0"
8527+ },
8528+ "funding": {
8529+ "type": "opencollective",
8530+ "url": "https://opencollective.com/unified"
8531+ }
8532+ },
8533+ "node_modules/unist-util-visit": {
8534+ "version": "5.1.0",
8535+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
8536+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
8537+ "license": "MIT",
8538+ "dependencies": {
8539+ "@types/unist": "^3.0.0",
8540+ "unist-util-is": "^6.0.0",
8541+ "unist-util-visit-parents": "^6.0.0"
8542+ },
8543+ "funding": {
8544+ "type": "opencollective",
8545+ "url": "https://opencollective.com/unified"
8546+ }
8547+ },
8548+ "node_modules/unist-util-visit-parents": {
8549+ "version": "6.0.2",
8550+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
8551+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
8552+ "license": "MIT",
8553+ "dependencies": {
8554+ "@types/unist": "^3.0.0",
8555+ "unist-util-is": "^6.0.0"
8556+ },
8557+ "funding": {
8558+ "type": "opencollective",
8559+ "url": "https://opencollective.com/unified"
8560+ }
8561+ },
7050 "node_modules/universalify": { 8562 "node_modules/universalify": {
7051 "version": "2.0.1", 8563 "version": "2.0.1",
7052 "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", 8564 "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -7109,6 +8621,34 @@
7109 "punycode": "^2.1.0" 8621 "punycode": "^2.1.0"
7110 } 8622 }
7111 }, 8623 },
8624+ "node_modules/vfile": {
8625+ "version": "6.0.3",
8626+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
8627+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
8628+ "license": "MIT",
8629+ "dependencies": {
8630+ "@types/unist": "^3.0.0",
8631+ "vfile-message": "^4.0.0"
8632+ },
8633+ "funding": {
8634+ "type": "opencollective",
8635+ "url": "https://opencollective.com/unified"
8636+ }
8637+ },
8638+ "node_modules/vfile-message": {
8639+ "version": "4.0.3",
8640+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
8641+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
8642+ "license": "MIT",
8643+ "dependencies": {
8644+ "@types/unist": "^3.0.0",
8645+ "unist-util-stringify-position": "^4.0.0"
8646+ },
8647+ "funding": {
8648+ "type": "opencollective",
8649+ "url": "https://opencollective.com/unified"
8650+ }
8651+ },
7112 "node_modules/vite": { 8652 "node_modules/vite": {
7113 "version": "7.3.6", 8653 "version": "7.3.6",
7114 "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", 8654 "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -7639,6 +9179,16 @@
7639 "optional": true 9179 "optional": true
7640 } 9180 }
7641 } 9181 }
9182+ },
9183+ "node_modules/zwitch": {
9184+ "version": "2.0.4",
9185+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
9186+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
9187+ "license": "MIT",
9188+ "funding": {
9189+ "type": "github",
9190+ "url": "https://github.com/sponsors/wooorm"
9191+ }
7642 } 9192 }
7643 } 9193 }
7644 } 9194 }
modified package.json +4 −0
@@ -14,8 +14,12 @@
14 "check": "npm run typecheck && npm run lint && npm run build" 14 "check": "npm run typecheck && npm run lint && npm run build"
15 }, 15 },
16 "dependencies": { 16 "dependencies": {
17+ "highlight.js": "^11.11.1",
17 "react": "^19.1.0", 18 "react": "^19.1.0",
18 "react-dom": "^19.1.0", 19 "react-dom": "^19.1.0",
20+ "react-markdown": "^10.1.0",
21+ "rehype-highlight": "^7.0.2",
22+ "remark-gfm": "^4.0.1",
19 "zustand": "^5.0.6" 23 "zustand": "^5.0.6"
20 }, 24 },
21 "devDependencies": { 25 "devDependencies": {
modified src/App.tsx +122 −18
@@ -4,31 +4,135 @@
4 * 4 *
5 * Author: Simon-Pierre Boucher 5 * Author: Simon-Pierre Boucher
6 * Mail: contact@spboucher.ai 6 * Mail: contact@spboucher.ai
7+ *
8+ * The app shell: sidebar + chat area (drawer on phones), root empty state,
9+ * first-run notice, settings modal, toasts, and global keyboard shortcuts
10+ * (⌘N new chat, ⌘K model menu/palette, ⌘F search, ⌘, settings, ⌘. stop,
11+ * ⌘⇧E export).
7 */ 12 */
8 13
14+import { useEffect } from 'react'
15+import ChatView from './components/ChatView'
16+import FirstRun from './components/FirstRun'
17+import ModelMenu from './components/ModelMenu'
18+import SettingsModal from './components/SettingsModal'
19+import Sidebar from './components/Sidebar'
20+import SuggestionGrid from './components/SuggestionGrid'
21+import Toasts from './components/Toasts'
9 import StreamTestView from './features/StreamTestView' 22 import StreamTestView from './features/StreamTestView'
23+import { exportConversationMarkdown } from './features/exporters'
24+import { useStore } from './state/store'
25+import './design/components.css'
10 26
11−// Phase 1 placeholder shell — replaced by the real chat layout in Phase 4.
12−// #stream-test mounts the bare streaming view (Phase 2 gate; kept for Phase 7).
13 export default function App() { 27 export default function App() {
28+ const loaded = useStore((s) => s.loaded)
29+ const loadAll = useStore((s) => s.loadAll)
30+ const conversations = useStore((s) => s.conversations)
31+ const selectedID = useStore((s) => s.selectedID)
32+ const settings = useStore((s) => s.settings)
33+ const settingsOpen = useStore((s) => s.settingsOpen)
34+ const sidebarOpen = useStore((s) => s.sidebarOpen)
35+ const setSidebarOpen = useStore((s) => s.setSidebarOpen)
36+ const modelMenuOpen = useStore((s) => s.modelMenuOpen)
37+ const setModelMenuOpen = useStore((s) => s.setModelMenuOpen)
38+ const newConversation = useStore((s) => s.newConversation)
39+ const openSettings = useStore((s) => s.openSettings)
40+ const stop = useStore((s) => s.stop)
41+ const send = useStore((s) => s.send)
42+ const updateSettings = useStore((s) => s.updateSettings)
43+
44+ const selected = conversations.find((c) => c.id === selectedID) ?? null
45+
46+ useEffect(() => {
47+ void loadAll()
48+ }, [loadAll])
49+
50+ // Global shortcuts
51+ useEffect(() => {
52+ const onKeyDown = (e: KeyboardEvent) => {
53+ const mod = e.metaKey || e.ctrlKey
54+ if (!mod) return
55+ const state = useStore.getState()
56+ switch (e.key) {
57+ case 'n':
58+ e.preventDefault()
59+ state.newConversation()
60+ break
61+ case 'k':
62+ e.preventDefault()
63+ state.setModelMenuOpen(!state.modelMenuOpen)
64+ break
65+ case 'f': {
66+ e.preventDefault()
67+ const input = document.querySelector<HTMLInputElement>('[data-search-input]')
68+ input?.focus()
69+ break
70+ }
71+ case ',':
72+ e.preventDefault()
73+ state.openSettings()
74+ break
75+ case '.':
76+ e.preventDefault()
77+ if (state.selectedID) state.stop(state.selectedID)
78+ break
79+ case 'E':
80+ if (e.shiftKey) {
81+ e.preventDefault()
82+ const conversation = state.conversations.find((c) => c.id === state.selectedID)
83+ if (conversation) exportConversationMarkdown(conversation)
84+ }
85+ break
86+ }
87+ }
88+ window.addEventListener('keydown', onKeyDown)
89+ return () => window.removeEventListener('keydown', onKeyDown)
90+ }, [newConversation, openSettings, stop])
91+
14 if (window.location.hash === '#stream-test') return <StreamTestView /> 92 if (window.location.hash === '#stream-test') return <StreamTestView />
93+
94+ if (!loaded) {
95+ return (
96+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
97+ <span style={{ color: 'var(--z-text-tertiary)' }}>Loading…</span>
98+ </div>
99+ )
100+ }
101+
15 return ( 102 return (
16− <div 103+ <div className={`shell${settings.focusMode ? ' focus-mode' : ''}`}>
17− style={{ 104+ <Sidebar />
18− display: 'flex', 105+ {sidebarOpen && <div className="drawer-scrim" onClick={() => setSidebarOpen(false)} />}
19− alignItems: 'center', 106+ {selected ? (
20− justifyContent: 'center', 107+ <ChatView conversation={selected} />
21− height: '100%', 108+ ) : (
22− flexDirection: 'column', 109+ <div className="chat-area">
23− gap: 'var(--z-space-sm)', 110+ <SuggestionGrid
24− }} 111+ onPick={(prompt) => {
25− > 112+ const conversation = newConversation()
26− <h1 style={{ font: '600 var(--z-font-size-title) var(--z-font-sans)', margin: 0 }}> 113+ // Native behavior: self-contained suggestions send immediately;
27− Zyquo Cloud 114+ // ones expecting pasted input (trailing blank line) seed the draft.
28− </h1> 115+ if (prompt.endsWith('\n\n')) {
29− <p style={{ color: 'var(--z-text-secondary)', margin: 0 }}> 116+ useStore.getState().setDraftSeed(prompt)
30− Your keys, every cloud model, one beautiful chat. 117+ } else {
31− </p> 118+ void send(conversation.id, prompt, [])
119+ }
120+ }}
121+ />
122+ {modelMenuOpen && (
123+ <ModelMenu
124+ onPick={(model) => {
125+ updateSettings({ defaultModelID: model.id, defaultProvider: model.provider })
126+ newConversation({ model })
127+ }}
128+ onClose={() => setModelMenuOpen(false)}
129+ />
130+ )}
131+ </div>
132+ )}
133+ {settingsOpen && <SettingsModal />}
134+ {!settings.firstRunAcknowledged && <FirstRun />}
135+ <Toasts />
32 </div> 136 </div>
33 ) 137 )
34 } 138 }
added src/components/ChatView.tsx +277 −0
@@ -0,0 +1,277 @@
1+/*
2+ * ChatView.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * The chat area: header (editable title, centered model chip, actions),
9+ * transcript with scroll-lock + "jump to latest" pill, input bar, and the
10+ * parameters popover. Suggestions grid when the conversation is empty.
11+ */
12+
13+import { useEffect, useMemo, useRef, useState } from 'react'
14+import { findModel } from '../features/catalogHelpers'
15+import { useStore } from '../state/store'
16+import type { AIModel, Conversation } from '../types'
17+import InputBar from './InputBar'
18+import { MessageBubble } from './MessageBubble'
19+import ModelMenu from './ModelMenu'
20+import ParamsPanel from './ParamsPanel'
21+import SuggestionGrid from './SuggestionGrid'
22+import {
23+ IconArrowDown,
24+ IconChevronDown,
25+ IconColumns,
26+ IconDownload,
27+ IconInfo,
28+ IconMenu,
29+ ProviderGlyph,
30+} from './icons'
31+import { exportConversationMarkdown } from '../features/exporters'
32+
33+export default function ChatView({ conversation }: { conversation: Conversation }) {
34+ const updateConversation = useStore((s) => s.updateConversation)
35+ const streaming = useStore((s) => s.streaming[conversation.id])
36+ const setSidebarOpen = useStore((s) => s.setSidebarOpen)
37+ const setCompareOpen = useStore((s) => s.setCompareOpen)
38+ const updateSettings = useStore((s) => s.updateSettings)
39+ const modelMenuOpen = useStore((s) => s.modelMenuOpen)
40+ const setModelMenuOpen = useStore((s) => s.setModelMenuOpen)
41+ const density = useStore((s) => s.settings.density)
42+
43+ const [editingTitle, setEditingTitle] = useState(false)
44+ const [titleDraft, setTitleDraft] = useState(conversation.title)
45+ const [paramsOpen, setParamsOpen] = useState(false)
46+ const [infoOpen, setInfoOpen] = useState(false)
47+ const draftSeed = useStore((s) => s.draftSeed)
48+ const setDraftSeed = useStore((s) => s.setDraftSeed)
49+ const [draft, setDraft] = useState('')
50+ const [pinnedToBottom, setPinnedToBottom] = useState(true)
51+
52+ // Pick up a prompt seeded from the root empty state's suggestion cards.
53+ useEffect(() => {
54+ if (draftSeed !== null) {
55+ setDraft(draftSeed)
56+ setDraftSeed(null)
57+ document.querySelector<HTMLTextAreaElement>('[data-input-editor]')?.focus()
58+ }
59+ }, [draftSeed, setDraftSeed])
60+ const scrollRef = useRef<HTMLDivElement>(null)
61+
62+ const model = findModel(conversation.provider, conversation.modelID)
63+
64+ // Auto-scroll while pinned to bottom.
65+ const lastMessage = conversation.messages[conversation.messages.length - 1]
66+ const scrollFingerprint = `${conversation.messages.length}:${lastMessage?.text.length ?? 0}:${
67+ lastMessage?.reasoning?.length ?? 0
68+ }`
69+ useEffect(() => {
70+ if (pinnedToBottom && scrollRef.current) {
71+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
72+ }
73+ }, [scrollFingerprint, pinnedToBottom])
74+
75+ const onScroll = () => {
76+ const el = scrollRef.current
77+ if (!el) return
78+ setPinnedToBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60)
79+ }
80+
81+ const totals = useMemo(() => {
82+ let input = 0
83+ let output = 0
84+ let cost = 0
85+ for (const message of conversation.messages) {
86+ input += message.usage?.inputTokens ?? 0
87+ output += message.usage?.outputTokens ?? 0
88+ cost += message.estimatedCost ?? 0
89+ }
90+ return { input, output, cost }
91+ }, [conversation])
92+
93+ const pickModel = (picked: AIModel, scope: 'conversation' | 'default' | 'message') => {
94+ if (scope === 'default') {
95+ updateSettings({ defaultModelID: picked.id, defaultProvider: picked.provider })
96+ }
97+ updateConversation(conversation.id, { modelID: picked.id, provider: picked.provider })
98+ }
99+
100+ const quote = (text: string) => {
101+ const quoted = text
102+ .split('\n')
103+ .map((line) => `> ${line}`)
104+ .join('\n')
105+ setDraft((d) => `${quoted}\n\n${d}`)
106+ }
107+
108+ return (
109+ <div className={`chat-area density-${density}`}>
110+ <header className="chat-header">
111+ <div className="chat-header-left">
112+ <button
113+ className="icon-button menu-button"
114+ title="Conversations"
115+ onClick={() => setSidebarOpen(true)}
116+ >
117+ <IconMenu />
118+ </button>
119+ {editingTitle ? (
120+ <input
121+ className="chat-title-input"
122+ value={titleDraft}
123+ autoFocus
124+ onChange={(e) => setTitleDraft(e.target.value)}
125+ onBlur={() => {
126+ setEditingTitle(false)
127+ if (titleDraft.trim() !== '') {
128+ updateConversation(conversation.id, {
129+ title: titleDraft.trim(),
130+ hasAutoTitle: false,
131+ })
132+ }
133+ }}
134+ onKeyDown={(e) => {
135+ if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
136+ if (e.key === 'Escape') setEditingTitle(false)
137+ }}
138+ />
139+ ) : (
140+ <span
141+ className="chat-title"
142+ title="Double-click to rename"
143+ onDoubleClick={() => {
144+ setTitleDraft(conversation.title)
145+ setEditingTitle(true)
146+ }}
147+ >
148+ {conversation.title}
149+ </span>
150+ )}
151+ </div>
152+ <button className="model-chip" onClick={() => setModelMenuOpen(true)} data-model-chip>
153+ <span className="glyph">
154+ <ProviderGlyph provider={conversation.provider} />
155+ </span>
156+ {model?.displayName ?? conversation.modelID}
157+ <span className="chevron">
158+ <IconChevronDown />
159+ </span>
160+ </button>
161+ <div className="chat-header-right">
162+ <button className="icon-button" title="Compare models" onClick={() => setCompareOpen(true)}>
163+ <IconColumns />
164+ </button>
165+ <button
166+ className="icon-button"
167+ title="Export conversation (⌘⇧E)"
168+ onClick={() => exportConversationMarkdown(conversation)}
169+ >
170+ <IconDownload />
171+ </button>
172+ <button className="icon-button" title="Conversation info" onClick={() => setInfoOpen(true)}>
173+ <IconInfo />
174+ </button>
175+ </div>
176+ </header>
177+
178+ {conversation.messages.length === 0 ? (
179+ <SuggestionGrid
180+ onPick={(prompt) => setDraft(prompt)}
181+ modelChip={
182+ <button className="model-chip" onClick={() => setModelMenuOpen(true)}>
183+ <span className="glyph">
184+ <ProviderGlyph provider={conversation.provider} />
185+ </span>
186+ {model?.displayName ?? conversation.modelID}
187+ <span className="chevron">
188+ <IconChevronDown />
189+ </span>
190+ </button>
191+ }
192+ />
193+ ) : (
194+ <div className="transcript" ref={scrollRef} onScroll={onScroll}>
195+ <div className="transcript-column">
196+ {conversation.messages.map((message) => (
197+ <MessageBubble
198+ key={message.id}
199+ conversationID={conversation.id}
200+ message={message}
201+ streaming={streaming?.messageID === message.id}
202+ onQuote={quote}
203+ />
204+ ))}
205+ </div>
206+ {!pinnedToBottom && streaming && (
207+ <button
208+ className="jump-pill"
209+ onClick={() => {
210+ setPinnedToBottom(true)
211+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
212+ }}
213+ >
214+ <IconArrowDown /> Jump to latest
215+ </button>
216+ )}
217+ </div>
218+ )}
219+
220+ <InputBar
221+ conversation={conversation}
222+ draft={draft}
223+ setDraft={setDraft}
224+ onOpenParams={() => setParamsOpen(true)}
225+ />
226+
227+ {modelMenuOpen && (
228+ <ModelMenu
229+ currentID={conversation.modelID}
230+ onPick={pickModel}
231+ onClose={() => setModelMenuOpen(false)}
232+ />
233+ )}
234+ {paramsOpen && (
235+ <ParamsPanel conversation={conversation} onClose={() => setParamsOpen(false)} />
236+ )}
237+ {infoOpen && (
238+ <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setInfoOpen(false)}>
239+ <div className="panel" style={{ width: 400 }}>
240+ <div className="settings-content">
241+ <h3>Conversation</h3>
242+ <div className="settings-row">
243+ <span>Tokens</span>
244+ <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>
245+ {totals.input.toLocaleString()} in · {totals.output.toLocaleString()} out
246+ </span>
247+ </div>
248+ <div className="settings-row">
249+ <span>Estimated cost</span>
250+ <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>
251+ ~${totals.cost.toFixed(4)}
252+ </span>
253+ </div>
254+ <div className="settings-section" style={{ marginTop: 12 }}>
255+ <h3>System prompt</h3>
256+ <textarea
257+ className="text-input"
258+ rows={5}
259+ placeholder="No system prompt"
260+ value={conversation.systemPrompt ?? ''}
261+ onChange={(e) =>
262+ updateConversation(conversation.id, { systemPrompt: e.target.value })
263+ }
264+ />
265+ </div>
266+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
267+ <button className="button" onClick={() => setInfoOpen(false)}>
268+ Done
269+ </button>
270+ </div>
271+ </div>
272+ </div>
273+ </div>
274+ )}
275+ </div>
276+ )
277+}
added src/components/FirstRun.tsx +63 −0
@@ -0,0 +1,63 @@
1+/*
2+ * FirstRun.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * First-run welcome: clean, honest, transparent about local storage.
9+ */
10+
11+import { useStore } from '../state/store'
12+import ZyquoGlyph from './ZyquoGlyph'
13+
14+export default function FirstRun() {
15+ const updateSettings = useStore((s) => s.updateSettings)
16+ const openSettings = useStore((s) => s.openSettings)
17+
18+ return (
19+ <div className="overlay">
20+ <div className="panel" style={{ width: 520 }}>
21+ <div className="settings-content" style={{ padding: 24 }}>
22+ <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
23+ <ZyquoGlyph size={56} />
24+ </div>
25+ <h3 style={{ textAlign: 'center', fontSize: 18 }}>Welcome to Zyquo Cloud</h3>
26+ <p style={{ color: 'var(--z-text-secondary)', textAlign: 'center', marginTop: 4 }}>
27+ Every cloud model, one beautiful chat — running entirely in your browser.
28+ </p>
29+ <div className="first-run-note" style={{ marginTop: 16 }}>
30+ <span>
31+ <strong>No account. No backend. Nothing leaves this device.</strong>
32+ </span>
33+ <span>
34+ Your API keys and conversation history are stored <strong>in this browser</strong>
35+ {' '}(localStorage / IndexedDB) and are sent only to the AI provider you choose,
36+ directly. Keys in browser storage aren't a secure vault — anyone with access to this
37+ browser profile could read them. An optional passphrase lock is available in
38+ Settings → Advanced.
39+ </span>
40+ <span>Add a provider API key to begin.</span>
41+ </div>
42+ <div className="first-run-actions" style={{ marginTop: 16 }}>
43+ <button
44+ className="button"
45+ onClick={() => updateSettings({ firstRunAcknowledged: true })}
46+ >
47+ Explore first
48+ </button>
49+ <button
50+ className="button primary"
51+ onClick={() => {
52+ updateSettings({ firstRunAcknowledged: true })
53+ openSettings('providers')
54+ }}
55+ >
56+ Add a key
57+ </button>
58+ </div>
59+ </div>
60+ </div>
61+ </div>
62+ )
63+}
added src/components/InputBar.tsx +249 −0
@@ -0,0 +1,249 @@
1+/*
2+ * InputBar.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Floating input card: multiline auto-grow editor, image attach/paste for
9+ * vision models, text-file attach, drag-and-drop, params popover trigger,
10+ * circular accent send (⌘/Ctrl+Enter) that becomes Stop while streaming,
11+ * token/cost HUD with context-window usage bar.
12+ */
13+
14+import { useEffect, useMemo, useRef, useState } from 'react'
15+import { estimateTokens, findModel } from '../features/catalogHelpers'
16+import { useStore } from '../state/store'
17+import type { Attachment, Conversation } from '../types'
18+import { uid } from '../state/store'
19+import { IconPaperclip, IconSend, IconSliders, IconStop, IconX } from './icons'
20+
21+const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
22+const MAX_TEXT_FILE = 512 * 1024
23+
24+export default function InputBar({
25+ conversation,
26+ draft,
27+ setDraft,
28+ onOpenParams,
29+}: {
30+ conversation: Conversation
31+ draft: string
32+ setDraft: (text: string) => void
33+ onOpenParams: () => void
34+}) {
35+ const send = useStore((s) => s.send)
36+ const stop = useStore((s) => s.stop)
37+ const streaming = useStore((s) => s.streaming[conversation.id])
38+ const toast = useStore((s) => s.toast)
39+ const [attachments, setAttachments] = useState<Attachment[]>([])
40+ const [dragging, setDragging] = useState(false)
41+ const editorRef = useRef<HTMLTextAreaElement>(null)
42+ const fileRef = useRef<HTMLInputElement>(null)
43+
44+ const model = findModel(conversation.provider, conversation.modelID)
45+ const vision = model?.capabilities.vision ?? false
46+
47+ useEffect(() => {
48+ const editor = editorRef.current
49+ if (!editor) return
50+ editor.style.height = 'auto'
51+ editor.style.height = `${Math.min(editor.scrollHeight, 200)}px`
52+ }, [draft])
53+
54+ const contextUsage = useMemo(() => {
55+ if (!model) return 0
56+ let chars = (conversation.systemPrompt ?? '').length + draft.length
57+ for (const message of conversation.messages) chars += message.text.length
58+ return Math.min(1, estimateTokens('x'.repeat(chars)) / model.contextWindow)
59+ }, [conversation, draft, model])
60+
61+ const addFiles = async (files: FileList | File[]) => {
62+ for (const file of files) {
63+ if (IMAGE_TYPES.includes(file.type)) {
64+ if (!vision) {
65+ toast(`${model?.displayName ?? 'This model'} doesn't support images`, 'error')
66+ continue
67+ }
68+ const data = await fileToBase64(file)
69+ setAttachments((prev) => [
70+ ...prev,
71+ { id: uid(), kind: 'image', fileName: file.name, mimeType: file.type, data },
72+ ])
73+ } else if (file.size <= MAX_TEXT_FILE) {
74+ const text = await file.text()
75+ setAttachments((prev) => [
76+ ...prev,
77+ {
78+ id: uid(),
79+ kind: 'textFile',
80+ fileName: file.name,
81+ mimeType: file.type || 'text/plain',
82+ data: text,
83+ },
84+ ])
85+ } else {
86+ toast(`${file.name} is too large (max 512 KB for text files)`, 'error')
87+ }
88+ }
89+ }
90+
91+ const doSend = () => {
92+ const text = draft.trim()
93+ if (text === '' && attachments.length === 0) return
94+ setDraft('')
95+ const toSend = attachments
96+ setAttachments([])
97+ void send(conversation.id, text, toSend)
98+ }
99+
100+ const onKeyDown = (e: React.KeyboardEvent) => {
101+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
102+ e.preventDefault()
103+ doSend()
104+ }
105+ }
106+
107+ const onPaste = (e: React.ClipboardEvent) => {
108+ const files = [...e.clipboardData.files]
109+ if (files.length > 0) {
110+ e.preventDefault()
111+ void addFiles(files)
112+ }
113+ }
114+
115+ const totalTokens = useMemo(() => {
116+ let tokens = 0
117+ let cost = 0
118+ for (const message of conversation.messages) {
119+ tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)
120+ cost += message.estimatedCost ?? 0
121+ }
122+ return { tokens, cost }
123+ }, [conversation])
124+
125+ return (
126+ <div className="input-dock">
127+ <div
128+ className={`input-bar${dragging ? ' drag-target' : ''}`}
129+ onDragOver={(e) => {
130+ e.preventDefault()
131+ setDragging(true)
132+ }}
133+ onDragLeave={() => setDragging(false)}
134+ onDrop={(e) => {
135+ e.preventDefault()
136+ setDragging(false)
137+ void addFiles(e.dataTransfer.files)
138+ }}
139+ >
140+ {attachments.length > 0 && (
141+ <div className="attachment-strip">
142+ {attachments.map((attachment) => (
143+ <div key={attachment.id} className="attachment-thumb">
144+ {attachment.kind === 'image' ? (
145+ <img
146+ src={`data:${attachment.mimeType};base64,${attachment.data}`}
147+ alt={attachment.fileName}
148+ />
149+ ) : (
150+ <span>{attachment.fileName}</span>
151+ )}
152+ <button
153+ className="attachment-remove"
154+ onClick={() =>
155+ setAttachments((prev) => prev.filter((a) => a.id !== attachment.id))
156+ }
157+ >
158+ <IconX size={8} />
159+ </button>
160+ </div>
161+ ))}
162+ </div>
163+ )}
164+ <div className="input-row">
165+ <button
166+ className="icon-button"
167+ title={vision ? 'Attach image or file' : 'Attach text file'}
168+ onClick={() => fileRef.current?.click()}
169+ >
170+ <IconPaperclip />
171+ </button>
172+ <input
173+ ref={fileRef}
174+ type="file"
175+ multiple
176+ hidden
177+ onChange={(e) => {
178+ if (e.target.files) void addFiles(e.target.files)
179+ e.target.value = ''
180+ }}
181+ />
182+ <textarea
183+ ref={editorRef}
184+ className="input-editor"
185+ placeholder="Message…"
186+ rows={1}
187+ value={draft}
188+ data-input-editor
189+ onChange={(e) => setDraft(e.target.value)}
190+ onKeyDown={onKeyDown}
191+ onPaste={onPaste}
192+ />
193+ <button className="icon-button" title="Parameters" onClick={onOpenParams}>
194+ <IconSliders />
195+ </button>
196+ {streaming ? (
197+ <button
198+ className="send-button stop"
199+ title="Stop (⌘.)"
200+ onClick={() => stop(conversation.id)}
201+ >
202+ <IconStop />
203+ </button>
204+ ) : (
205+ <button
206+ className="send-button"
207+ title="Send (⌘↩)"
208+ disabled={draft.trim() === '' && attachments.length === 0}
209+ onClick={doSend}
210+ >
211+ <IconSend />
212+ </button>
213+ )}
214+ </div>
215+ <div className="input-hud">
216+ {model && (
217+ <>
218+ <div className="context-bar" title="Context window usage">
219+ <div
220+ className={`context-bar-fill${contextUsage > 0.8 ? ' warn' : ''}`}
221+ style={{ width: `${Math.max(2, contextUsage * 100)}%` }}
222+ />
223+ </div>
224+ <span>{Math.round(contextUsage * 100)}% ctx</span>
225+ </>
226+ )}
227+ {totalTokens.tokens > 0 && (
228+ <span>
229+ {totalTokens.tokens.toLocaleString()} tok
230+ {totalTokens.cost > 0 ? ` · ~$${totalTokens.cost.toFixed(4)}` : ''}
231+ </span>
232+ )}
233+ </div>
234+ </div>
235+ </div>
236+ )
237+}
238+
239+function fileToBase64(file: File): Promise<string> {
240+ return new Promise((resolve, reject) => {
241+ const reader = new FileReader()
242+ reader.onload = () => {
243+ const result = reader.result as string
244+ resolve(result.slice(result.indexOf(',') + 1))
245+ }
246+ reader.onerror = () => reject(reader.error ?? new Error('read failed'))
247+ reader.readAsDataURL(file)
248+ })
249+}
added src/components/Markdown.tsx +89 −0
@@ -0,0 +1,89 @@
1+/*
2+ * Markdown.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Assistant message rendering: GitHub-flavored Markdown with syntax-
9+ * highlighted code blocks (language label, copy, wrap toggle) mapped to the
10+ * Zyquo syntax tokens. Memoized — re-parsing only when text changes.
11+ */
12+
13+import { memo, useState, type ReactNode } from 'react'
14+import ReactMarkdown from 'react-markdown'
15+import remarkGfm from 'remark-gfm'
16+import rehypeHighlight from 'rehype-highlight'
17+import { IconCheck, IconCopy } from './icons'
18+
19+function extractText(node: ReactNode): string {
20+ if (typeof node === 'string') return node
21+ if (Array.isArray(node)) return node.map(extractText).join('')
22+ if (node && typeof node === 'object' && 'props' in node) {
23+ return extractText((node as { props: { children?: ReactNode } }).props.children)
24+ }
25+ return ''
26+}
27+
28+function CodeBlock({ className, children }: { className?: string; children?: ReactNode }) {
29+ const [copied, setCopied] = useState(false)
30+ const [wrap, setWrap] = useState(false)
31+ const language = /language-(\w+)/.exec(className ?? '')?.[1] ?? ''
32+
33+ const copy = () => {
34+ void navigator.clipboard.writeText(extractText(children).replace(/\n$/, ''))
35+ setCopied(true)
36+ setTimeout(() => setCopied(false), 1200)
37+ }
38+
39+ return (
40+ <div className="code-block">
41+ <div className="code-block-header">
42+ <span>{language || 'text'}</span>
43+ <span className={`code-block-actions${copied ? ' confirming' : ''}`}>
44+ <button className="code-block-copy" onClick={() => setWrap((w) => !w)}>
45+ {wrap ? 'unwrap' : 'wrap'}
46+ </button>
47+ <button className={`code-block-copy${copied ? ' ok' : ''}`} onClick={copy}>
48+ {copied ? <IconCheck size={10} /> : <IconCopy size={10} />} {copied ? 'copied' : 'copy'}
49+ </button>
50+ </span>
51+ </div>
52+ <pre className={wrap ? 'wrap' : ''}>
53+ <code className={className}>{children}</code>
54+ </pre>
55+ </div>
56+ )
57+}
58+
59+export const Markdown = memo(function Markdown({ text }: { text: string }) {
60+ return (
61+ <div className="markdown">
62+ <ReactMarkdown
63+ remarkPlugins={[remarkGfm]}
64+ rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
65+ components={{
66+ pre: ({ children }) => <>{children}</>,
67+ code: ({ className, children, ...props }) => {
68+ const inline = !className && typeof children === 'string' && !children.includes('\n')
69+ if (inline) {
70+ return (
71+ <code className={className} {...props}>
72+ {children}
73+ </code>
74+ )
75+ }
76+ return <CodeBlock className={className}>{children}</CodeBlock>
77+ },
78+ a: ({ href, children }) => (
79+ <a href={href} target="_blank" rel="noreferrer noopener">
80+ {children}
81+ </a>
82+ ),
83+ }}
84+ >
85+ {text}
86+ </ReactMarkdown>
87+ </div>
88+ )
89+})
added src/components/MessageBubble.tsx +305 −0
@@ -0,0 +1,305 @@
1+/*
2+ * MessageBubble.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * One chat turn: user right (accent-subtle) / assistant left (surface +
9+ * hairline + provider avatar). Markdown body, collapsible reasoning,
10+ * citation chips, attachments, error note, hover metadata + actions
11+ * (copy, edit & resend, regenerate, quote, branch, read-aloud, delete),
12+ * variant switcher, streaming caret.
13+ */
14+
15+import { memo, useState } from 'react'
16+import { costLabel } from '../features/catalogHelpers'
17+import { useStore } from '../state/store'
18+import type { Message } from '../types'
19+import { Markdown } from './Markdown'
20+import {
21+ IconBranch,
22+ IconCheck,
23+ IconChevronRight,
24+ IconCopy,
25+ IconEdit,
26+ IconQuote,
27+ IconRefresh,
28+ IconSpeaker,
29+ IconTrash,
30+ IconX,
31+ ProviderGlyph,
32+} from './icons'
33+
34+function timeLabel(timestamp: number): string {
35+ return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
36+}
37+
38+export const MessageBubble = memo(function MessageBubble({
39+ conversationID,
40+ message,
41+ streaming,
42+ onQuote,
43+}: {
44+ conversationID: string
45+ message: Message
46+ streaming: boolean
47+ onQuote: (text: string) => void
48+}) {
49+ const regenerate = useStore((s) => s.regenerate)
50+ const editAndResend = useStore((s) => s.editAndResend)
51+ const deleteMessage = useStore((s) => s.deleteMessage)
52+ const branchFrom = useStore((s) => s.branchFrom)
53+ const setActiveVariant = useStore((s) => s.setActiveVariant)
54+ const toast = useStore((s) => s.toast)
55+ const [reasoningOpen, setReasoningOpen] = useState(false)
56+ const [editing, setEditing] = useState(false)
57+ const [draft, setDraft] = useState(message.text)
58+ const [copied, setCopied] = useState(false)
59+
60+ const isUser = message.role === 'user'
61+ const variants = message.variants ?? []
62+ const activeVariant =
63+ message.activeVariant !== undefined ? variants[message.activeVariant] : undefined
64+ const shownText = activeVariant?.text ?? message.text
65+ const shownReasoning = activeVariant?.reasoning ?? message.reasoning
66+ const shownCitations = activeVariant?.citations ?? message.citations
67+ const shownUsage = activeVariant?.usage ?? message.usage
68+ const shownCost = activeVariant?.estimatedCost ?? message.estimatedCost
69+
70+ const copy = () => {
71+ void navigator.clipboard.writeText(shownText)
72+ setCopied(true)
73+ setTimeout(() => setCopied(false), 1200)
74+ }
75+
76+ const readAloud = () => {
77+ if (!('speechSynthesis' in window)) {
78+ toast('Speech synthesis not available in this browser', 'error')
79+ return
80+ }
81+ speechSynthesis.cancel()
82+ speechSynthesis.speak(new SpeechSynthesisUtterance(shownText))
83+ }
84+
85+ return (
86+ <div className={`turn ${isUser ? 'user' : 'assistant'}`}>
87+ {!isUser && (
88+ <div className="avatar">
89+ <ProviderGlyph provider={message.provider ?? 'custom'} size={12} />
90+ </div>
91+ )}
92+ <div className="turn-body">
93+ <div className="bubble">
94+ {(message.attachments ?? []).filter((a) => a.kind === 'image').length > 0 && (
95+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
96+ {(message.attachments ?? [])
97+ .filter((a) => a.kind === 'image')
98+ .map((a) => (
99+ <img
100+ key={a.id}
101+ src={`data:${a.mimeType};base64,${a.data}`}
102+ alt={a.fileName}
103+ style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, objectFit: 'cover' }}
104+ />
105+ ))}
106+ </div>
107+ )}
108+ {(message.attachments ?? []).filter((a) => a.kind === 'textFile').length > 0 && (
109+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
110+ {(message.attachments ?? [])
111+ .filter((a) => a.kind === 'textFile')
112+ .map((a) => (
113+ <span key={a.id} className="badge">
114+ {a.fileName}
115+ </span>
116+ ))}
117+ </div>
118+ )}
119+
120+ {shownReasoning !== undefined && shownReasoning !== '' && (
121+ <div className="reasoning-section">
122+ <button className="reasoning-toggle" onClick={() => setReasoningOpen((o) => !o)}>
123+ <span className={`chev${reasoningOpen ? ' open' : ''}`}>
124+ <IconChevronRight />
125+ </span>
126+ {streaming && shownText === '' ? 'Thinking…' : 'Thought process'}
127+ </button>
128+ {reasoningOpen && <div className="reasoning-body">{shownReasoning}</div>}
129+ </div>
130+ )}
131+
132+ {editing ? (
133+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
134+ <textarea
135+ className="text-input"
136+ rows={Math.min(8, Math.max(2, draft.split('\n').length))}
137+ value={draft}
138+ onChange={(e) => setDraft(e.target.value)}
139+ autoFocus
140+ />
141+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
142+ <button className="button" onClick={() => setEditing(false)}>
143+ Cancel
144+ </button>
145+ <button
146+ className="button primary"
147+ disabled={draft.trim() === ''}
148+ onClick={() => {
149+ setEditing(false)
150+ void editAndResend(conversationID, message.id, draft)
151+ }}
152+ >
153+ Resend
154+ </button>
155+ </div>
156+ </div>
157+ ) : isUser ? (
158+ shownText
159+ ) : (
160+ <>
161+ <Markdown text={shownText} />
162+ {streaming && <span className="streaming-caret" />}
163+ </>
164+ )}
165+
166+ {shownCitations && shownCitations.length > 0 && (
167+ <div className="citations">
168+ {shownCitations.map((citation) => (
169+ <a
170+ key={citation.index}
171+ className="citation-chip"
172+ href={citation.url}
173+ target="_blank"
174+ rel="noreferrer noopener"
175+ title={citation.url}
176+ >
177+ <span className="num">{citation.index}</span>
178+ <span className="title">
179+ {citation.title ?? new URL(citation.url).hostname}
180+ </span>
181+ </a>
182+ ))}
183+ </div>
184+ )}
185+
186+ {message.errorText && (
187+ <div className="error-note">
188+ <span>⚠</span>
189+ <span>{message.errorText}</span>
190+ </div>
191+ )}
192+ </div>
193+
194+ <div className={`meta-row${streaming ? ' always' : ''}`}>
195+ {!isUser && variants.length > 0 && (
196+ <span className="variant-switch">
197+ <button
198+ className="meta-action"
199+ title="Previous variant"
200+ onClick={() => {
201+ const current = message.activeVariant ?? -1
202+ setActiveVariant(conversationID, message.id, Math.max(-1, current - 1) as number)
203+ }}
204+ >
205+ ‹
206+ </button>
207+ {(message.activeVariant ?? -1) + 2}/{variants.length + 1}
208+ <button
209+ className="meta-action"
210+ title="Next variant"
211+ onClick={() => {
212+ const current = message.activeVariant ?? -1
213+ setActiveVariant(
214+ conversationID,
215+ message.id,
216+ Math.min(variants.length - 1, current + 1)
217+ )
218+ }}
219+ >
220+ ›
221+ </button>
222+ </span>
223+ )}
224+ {!isUser && (
225+ <>
226+ <button className="meta-action" title="Copy" onClick={copy}>
227+ {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
228+ </button>
229+ <button
230+ className="meta-action"
231+ title="Regenerate"
232+ onClick={() => void regenerate(conversationID, message.id)}
233+ >
234+ <IconRefresh size={10} />
235+ </button>
236+ <button className="meta-action" title="Quote reply" onClick={() => onQuote(shownText)}>
237+ <IconQuote size={10} />
238+ </button>
239+ <button
240+ className="meta-action"
241+ title="Branch from here"
242+ onClick={() => branchFrom(conversationID, message.id)}
243+ >
244+ <IconBranch size={10} />
245+ </button>
246+ <button className="meta-action" title="Read aloud" onClick={readAloud}>
247+ <IconSpeaker size={10} />
248+ </button>
249+ </>
250+ )}
251+ {isUser && (
252+ <>
253+ <button className="meta-action" title="Copy" onClick={copy}>
254+ {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
255+ </button>
256+ <button
257+ className="meta-action"
258+ title="Edit & resend"
259+ onClick={() => {
260+ setDraft(message.text)
261+ setEditing(true)
262+ }}
263+ >
264+ <IconEdit size={10} />
265+ </button>
266+ </>
267+ )}
268+ <button
269+ className="meta-action"
270+ title="Delete"
271+ onClick={() => deleteMessage(conversationID, message.id)}
272+ >
273+ <IconTrash size={10} />
274+ </button>
275+ <span>{timeLabel(message.createdAt)}</span>
276+ {shownUsage && (
277+ <span>
278+ {shownUsage.inputTokens}→{shownUsage.outputTokens} tok
279+ </span>
280+ )}
281+ {shownCost !== undefined && shownCost > 0 && <span>{costLabel(shownCost)}</span>}
282+ {streaming && <StopHint conversationID={conversationID} />}
283+ </div>
284+ </div>
285+ </div>
286+ )
287+})
288+
289+function StopHint({ conversationID }: { conversationID: string }) {
290+ const stop = useStore((s) => s.stop)
291+ const streaming = useStore((s) => s.streaming[conversationID])
292+ if (!streaming) return null
293+ const elapsed = (Date.now() - streaming.startedAt) / 1000
294+ const tps = elapsed > 0 ? Math.round(streaming.outputChars / 4 / elapsed) : 0
295+ return (
296+ <>
297+ <span>
298+ {elapsed.toFixed(0)}s{tps > 0 ? ` · ~${tps} tok/s` : ''}
299+ </span>
300+ <button className="meta-action" onClick={() => stop(conversationID)}>
301+ <IconX size={9} /> stop
302+ </button>
303+ </>
304+ )
305+}
added src/components/ModelMenu.tsx +331 −0
@@ -0,0 +1,331 @@
1+/*
2+ * ModelMenu.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * The Model Menu — command-palette-style picker (also opened with ⌘K via the
9+ * command palette's model section). Instant fuzzy search, capability filter
10+ * chips, Favorites + Recents groups, provider sections (keyed providers
11+ * first), capability/context/price badges, per-row star, "not configured"
12+ * affordance, keyboard navigation.
13+ */
14+
15+import { useEffect, useMemo, useRef, useState } from 'react'
16+import { CATALOG, priceBadge } from '../features/catalogHelpers'
17+import { PROVIDER_META } from '../providers/registry'
18+import { useStore } from '../state/store'
19+import { contextBadge, type AIModel, type Provider } from '../types'
20+import {
21+ IconBrain,
22+ IconEye,
23+ IconSearch,
24+ IconStar,
25+ IconWrench,
26+ ProviderGlyph,
27+} from './icons'
28+
29+const FILTERS = [
30+ { key: 'vision', label: 'Vision' },
31+ { key: 'tools', label: 'Tools' },
32+ { key: 'reasoning', label: 'Reasoning' },
33+ { key: 'jsonMode', label: 'JSON' },
34+ { key: 'long', label: 'Long context' },
35+ { key: 'cheap', label: 'Cheapest' },
36+] as const
37+
38+type FilterKey = (typeof FILTERS)[number]['key']
39+
40+/** Simple subsequence fuzzy match; returns a score (lower = better) or null. */
41+function fuzzyScore(query: string, target: string): number | null {
42+ const q = query.toLowerCase()
43+ const t = target.toLowerCase()
44+ if (q === '') return 0
45+ const direct = t.indexOf(q)
46+ if (direct !== -1) return direct
47+ let qi = 0
48+ let gaps = 0
49+ let last = -1
50+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
51+ if (t[ti] === q[qi]) {
52+ if (last !== -1) gaps += ti - last - 1
53+ last = ti
54+ qi++
55+ }
56+ }
57+ return qi === q.length ? 100 + gaps : null
58+}
59+
60+interface Row {
61+ model: AIModel
62+ section: string
63+}
64+
65+export default function ModelMenu({
66+ onPick,
67+ onClose,
68+ currentID,
69+ title = 'Switch model',
70+}: {
71+ onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void
72+ onClose: () => void
73+ currentID?: string
74+ title?: string
75+}) {
76+ const settings = useStore((s) => s.settings)
77+ const updateSettings = useStore((s) => s.updateSettings)
78+ const keyedProviders = useStore((s) => s.keyedProviders)
79+ const openSettings = useStore((s) => s.openSettings)
80+ const [query, setQuery] = useState('')
81+ const [filters, setFilters] = useState<Set<FilterKey>>(new Set())
82+ const [highlight, setHighlight] = useState(0)
83+ const inputRef = useRef<HTMLInputElement>(null)
84+ const listRef = useRef<HTMLDivElement>(null)
85+
86+ useEffect(() => inputRef.current?.focus(), [])
87+
88+ const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])
89+
90+ const rows: Row[] = useMemo(() => {
91+ let models = [...CATALOG]
92+ // Capability filter chips
93+ if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision)
94+ if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools)
95+ if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning)
96+ if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode)
97+ if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000)
98+ if (filters.has('cheap')) {
99+ models = models
100+ .filter((m) => m.pricing)
101+ .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0))
102+ .slice(0, 20)
103+ }
104+ // Fuzzy search over name + id + provider
105+ if (query !== '') {
106+ const scored = models
107+ .map((m) => {
108+ const scores = [
109+ fuzzyScore(query, m.displayName),
110+ fuzzyScore(query, m.id),
111+ fuzzyScore(query, PROVIDER_META[m.provider].displayName),
112+ ].filter((s): s is number => s !== null)
113+ return scores.length > 0 ? { m, score: Math.min(...scores) } : null
114+ })
115+ .filter((x): x is { m: AIModel; score: number } => x !== null)
116+ .sort((a, b) => a.score - b.score)
117+ return scored.map(({ m }) => ({ model: m, section: 'Results' }))
118+ }
119+ // Grouped: aliases → favorites → recents → providers (keyed first)
120+ const out: Row[] = []
121+ for (const [alias, ref] of Object.entries(settings.aliases)) {
122+ const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID)
123+ if (model) out.push({ model, section: `Alias: ${alias}` })
124+ }
125+ const favorites = models.filter((m) => favoriteSet.has(m.id))
126+ for (const model of favorites) out.push({ model, section: 'Favorites' })
127+ const recents = settings.recentModelIDs
128+ .map((id) => models.find((m) => m.id === id))
129+ .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id))
130+ .slice(0, 5)
131+ for (const model of recents) out.push({ model, section: 'Recents' })
132+ const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => {
133+ const ak = keyedProviders.includes(a) ? 0 : 1
134+ const bk = keyedProviders.includes(b) ? 0 : 1
135+ if (ak !== bk) return ak - bk
136+ return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName)
137+ })
138+ for (const provider of providerOrder) {
139+ const list = models
140+ .filter((m) => m.provider === provider)
141+ .sort((a, b) => rankOf(a) - rankOf(b))
142+ for (const model of list) {
143+ out.push({ model, section: PROVIDER_META[provider].displayName })
144+ }
145+ }
146+ return out
147+
148+ function rankOf(m: AIModel): number {
149+ if (favoriteSet.has(m.id)) return 0
150+ if (m.isRecommended) return 1
151+ if (m.isLegacy) return 3
152+ return 2
153+ }
154+ }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders])
155+
156+ useEffect(() => setHighlight(0), [query, filters])
157+
158+ const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => {
159+ updateSettings({
160+ recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice(
161+ 0,
162+ 8
163+ ),
164+ })
165+ onPick(model, scope)
166+ onClose()
167+ }
168+
169+ const onKeyDown = (e: React.KeyboardEvent) => {
170+ if (e.key === 'ArrowDown') {
171+ e.preventDefault()
172+ setHighlight((h) => Math.min(h + 1, rows.length - 1))
173+ scrollToHighlight(1)
174+ } else if (e.key === 'ArrowUp') {
175+ e.preventDefault()
176+ setHighlight((h) => Math.max(h - 1, 0))
177+ scrollToHighlight(-1)
178+ } else if (e.key === 'Enter') {
179+ e.preventDefault()
180+ const row = rows[highlight]
181+ if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')
182+ } else if (e.key === 'Escape') {
183+ onClose()
184+ }
185+ }
186+
187+ const scrollToHighlight = (dir: number) => {
188+ requestAnimationFrame(() => {
189+ const el = listRef.current?.querySelector('.model-row.highlighted')
190+ el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' })
191+ })
192+ }
193+
194+ const toggleFavorite = (e: React.MouseEvent, model: AIModel) => {
195+ e.stopPropagation()
196+ const next = favoriteSet.has(model.id)
197+ ? settings.favoriteModelIDs.filter((id) => id !== model.id)
198+ : [...settings.favoriteModelIDs, model.id]
199+ updateSettings({ favoriteModelIDs: next })
200+ }
201+
202+ let lastSection = ''
203+ let rowIndex = -1
204+
205+ return (
206+ <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
207+ <div className="panel" role="dialog" aria-label={title} onKeyDown={onKeyDown}>
208+ <div className="panel-search">
209+ <IconSearch size={14} />
210+ <input
211+ ref={inputRef}
212+ placeholder="Search models, providers, capabilities…"
213+ value={query}
214+ onChange={(e) => setQuery(e.target.value)}
215+ />
216+ </div>
217+ <div className="panel-filters">
218+ {FILTERS.map((f) => (
219+ <button
220+ key={f.key}
221+ className={`filter-chip${filters.has(f.key) ? ' active' : ''}`}
222+ onClick={() =>
223+ setFilters((prev) => {
224+ const next = new Set(prev)
225+ if (next.has(f.key)) next.delete(f.key)
226+ else next.add(f.key)
227+ return next
228+ })
229+ }
230+ >
231+ {f.label}
232+ </button>
233+ ))}
234+ </div>
235+ <div className="panel-list" ref={listRef}>
236+ {rows.length === 0 && <div className="panel-empty">No models match.</div>}
237+ {rows.map((row) => {
238+ rowIndex++
239+ const index = rowIndex
240+ const header =
241+ row.section !== lastSection ? (
242+ <div className="panel-section-header" key={`h-${row.section}`}>
243+ {row.section}
244+ </div>
245+ ) : null
246+ lastSection = row.section
247+ const model = row.model
248+ const keyed = keyedProviders.includes(model.provider)
249+ return (
250+ <div key={`${row.section}-${model.provider}-${model.id}`}>
251+ {header}
252+ <button
253+ className={`model-row${index === highlight ? ' highlighted' : ''}${
254+ model.id === currentID ? ' current' : ''
255+ }`}
256+ onClick={(e) =>
257+ pick(model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')
258+ }
259+ onMouseMove={() => setHighlight(index)}
260+ >
261+ <span className="glyph">
262+ <ProviderGlyph provider={model.provider} />
263+ </span>
264+ <span className="model-row-name">{model.displayName}</span>
265+ <span className="model-row-provider">
266+ {PROVIDER_META[model.provider].displayName}
267+ </span>
268+ {model.isRecommended && <span className="badge accent">Featured</span>}
269+ {model.isLegacy && <span className="badge">Legacy</span>}
270+ {!keyed && (
271+ <button
272+ className="badge warn"
273+ title="No API key configured — add one"
274+ onClick={(e) => {
275+ e.stopPropagation()
276+ onClose()
277+ openSettings('providers')
278+ }}
279+ >
280+ Add key
281+ </button>
282+ )}
283+ <span className="model-row-badges">
284+ {model.capabilities.vision && (
285+ <span className="capability-badge" title="Vision">
286+ <IconEye />
287+ </span>
288+ )}
289+ {model.capabilities.reasoning && (
290+ <span className="capability-badge" title="Reasoning">
291+ <IconBrain />
292+ </span>
293+ )}
294+ {model.capabilities.tools && (
295+ <span className="capability-badge" title="Tools">
296+ <IconWrench />
297+ </span>
298+ )}
299+ <span>{contextBadge(model)}</span>
300+ <span title="USD per 1M tokens in / out">{priceBadge(model)}</span>
301+ <button
302+ className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}
303+ title="Favorite"
304+ onClick={(e) => toggleFavorite(e, model)}
305+ >
306+ <IconStar filled={favoriteSet.has(model.id)} />
307+ </button>
308+ </span>
309+ </button>
310+ </div>
311+ )
312+ })}
313+ </div>
314+ <div className="panel-footer">
315+ <span>
316+ <kbd>↑↓</kbd> navigate
317+ </span>
318+ <span>
319+ <kbd>↵</kbd> use for this chat
320+ </span>
321+ <span>
322+ <kbd>⌘↵</kbd> set as default
323+ </span>
324+ <span>
325+ <kbd>esc</kbd> close
326+ </span>
327+ </div>
328+ </div>
329+ </div>
330+ )
331+}
added src/components/ParamsPanel.tsx +147 −0
@@ -0,0 +1,147 @@
1+/*
2+ * ParamsPanel.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Per-conversation parameter editor. Only shows controls the active model's
9+ * ParameterSupport allows; every control is gated by an enable checkbox —
10+ * unchecked means "provider default" and the key is omitted from requests
11+ * entirely (native behavior).
12+ */
13+
14+import { findModel } from '../features/catalogHelpers'
15+import { useStore } from '../state/store'
16+import type { ChatParameters, Conversation } from '../types'
17+
18+export default function ParamsPanel({
19+ conversation,
20+ onClose,
21+}: {
22+ conversation: Conversation
23+ onClose: () => void
24+}) {
25+ const updateConversation = useStore((s) => s.updateConversation)
26+ const model = findModel(conversation.provider, conversation.modelID)
27+ const support = model?.parameterSupport
28+ const params = conversation.parameters
29+
30+ const patch = (p: Partial<ChatParameters>) => {
31+ const merged = { ...params, ...p }
32+ for (const key of Object.keys(merged) as (keyof ChatParameters)[]) {
33+ if (merged[key] === undefined) delete merged[key]
34+ }
35+ updateConversation(conversation.id, { parameters: merged })
36+ }
37+
38+ const slider = (
39+ label: string,
40+ key: 'temperature' | 'topP' | 'frequencyPenalty' | 'presencePenalty',
41+ min: number,
42+ max: number,
43+ step: number,
44+ fallback: number
45+ ) => (
46+ <div className="settings-row" key={key}>
47+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, width: 150 }}>
48+ <input
49+ type="checkbox"
50+ checked={params[key] !== undefined}
51+ onChange={(e) => patch({ [key]: e.target.checked ? fallback : undefined })}
52+ />
53+ {label}
54+ </label>
55+ {params[key] !== undefined ? (
56+ <>
57+ <input
58+ type="range"
59+ min={min}
60+ max={max}
61+ step={step}
62+ value={params[key]}
63+ style={{ flex: 1, accentColor: 'var(--z-accent)' }}
64+ onChange={(e) => patch({ [key]: Number(e.target.value) })}
65+ />
66+ <span style={{ width: 40, textAlign: 'right', fontSize: 12 }}>
67+ {params[key]?.toFixed(2)}
68+ </span>
69+ </>
70+ ) : (
71+ <span className="settings-note" style={{ marginLeft: 'auto' }}>
72+ default
73+ </span>
74+ )}
75+ </div>
76+ )
77+
78+ return (
79+ <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
80+ <div className="panel" style={{ width: 420 }}>
81+ <div className="settings-content">
82+ <h3>Parameters — {model?.displayName ?? conversation.modelID}</h3>
83+ {support?.temperature && slider('Temperature', 'temperature', 0, 2, 0.05, 1)}
84+ {support?.topP && slider('Top P', 'topP', 0, 1, 0.01, 1)}
85+ {support?.frequencyPenalty &&
86+ slider('Frequency penalty', 'frequencyPenalty', -2, 2, 0.1, 0)}
87+ {support?.presencePenalty && slider('Presence penalty', 'presencePenalty', -2, 2, 0.1, 0)}
88+ <div className="settings-row">
89+ <span style={{ width: 150 }}>Max tokens</span>
90+ <input
91+ className="number-input"
92+ type="number"
93+ min={1}
94+ placeholder="default"
95+ style={{ width: 100 }}
96+ value={params.maxTokens ?? ''}
97+ onChange={(e) =>
98+ patch({ maxTokens: e.target.value === '' ? undefined : Number(e.target.value) })
99+ }
100+ />
101+ </div>
102+ {support?.reasoningEffort && (
103+ <div className="settings-row">
104+ <span style={{ width: 150 }}>Reasoning effort</span>
105+ <select
106+ className="select"
107+ value={params.reasoningEffort ?? ''}
108+ onChange={(e) =>
109+ patch({
110+ reasoningEffort:
111+ e.target.value === ''
112+ ? undefined
113+ : (e.target.value as 'low' | 'medium' | 'high'),
114+ })
115+ }
116+ >
117+ <option value="">Default</option>
118+ <option value="low">Low</option>
119+ <option value="medium">Medium</option>
120+ <option value="high">High</option>
121+ </select>
122+ </div>
123+ )}
124+ {support?.thinkingToggle && (
125+ <div className="settings-row">
126+ <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
127+ <input
128+ type="checkbox"
129+ checked={params.thinkingEnabled ?? false}
130+ onChange={(e) =>
131+ patch({ thinkingEnabled: e.target.checked ? true : undefined })
132+ }
133+ />
134+ Extended thinking
135+ </label>
136+ </div>
137+ )}
138+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
139+ <button className="button" onClick={onClose}>
140+ Done
141+ </button>
142+ </div>
143+ </div>
144+ </div>
145+ </div>
146+ )
147+}
added src/components/SettingsModal.tsx +596 −0
@@ -0,0 +1,596 @@
1+/*
2+ * SettingsModal.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Settings: Providers & Keys (masked fields, Test with status dot + latency,
9+ * proxy base URL, honesty notice), Models (catalog browser, favorites,
10+ * default), Appearance (light default / dark / system, accents, font size),
11+ * Data (export/import/clear), Advanced (passphrase lock, default system
12+ * prompt, streaming toggle).
13+ */
14+
15+import { useMemo, useRef, useState } from 'react'
16+import { CATALOG, priceBadge } from '../features/catalogHelpers'
17+import { downloadBackup } from '../features/exporters'
18+import { testKey } from '../providers/testKey'
19+import { PROVIDER_META } from '../providers/registry'
20+import { useStore } from '../state/store'
21+import { importAll, isExportedData, clearEverything } from '../storage/backup'
22+import { clearConversations } from '../storage/conversations'
23+import { getKey, maskedKey, removeKey, setKey } from '../storage/keys'
24+import { disableVault, enableVault, isVaultEnabled, persistVault } from '../storage/vault'
25+import { contextBadge, BUILT_IN_PROVIDERS, type Provider } from '../types'
26+import {
27+ IconBrain,
28+ IconEye,
29+ IconStar,
30+ IconWrench,
31+ IconX,
32+ ProviderGlyph,
33+} from './icons'
34+
35+const TABS = [
36+ { id: 'providers', label: 'Providers & Keys' },
37+ { id: 'models', label: 'Models' },
38+ { id: 'appearance', label: 'Appearance' },
39+ { id: 'data', label: 'Data' },
40+ { id: 'advanced', label: 'Advanced' },
41+]
42+
43+type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string }
44+
45+export default function SettingsModal() {
46+ const settingsTab = useStore((s) => s.settingsTab)
47+ const closeSettings = useStore((s) => s.closeSettings)
48+ const [tab, setTab] = useState(settingsTab)
49+
50+ return (
51+ <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && closeSettings()}>
52+ <div className="panel settings-panel" role="dialog" aria-label="Settings">
53+ <div className="panel-search" style={{ justifyContent: 'space-between' }}>
54+ <strong style={{ fontSize: 14 }}>Settings</strong>
55+ <button className="icon-button" onClick={closeSettings}>
56+ <IconX />
57+ </button>
58+ </div>
59+ <div className="settings-body">
60+ <nav className="settings-tabs">
61+ {TABS.map((t) => (
62+ <button
63+ key={t.id}
64+ className={`settings-tab${tab === t.id ? ' active' : ''}`}
65+ onClick={() => setTab(t.id)}
66+ >
67+ {t.label}
68+ </button>
69+ ))}
70+ </nav>
71+ <div className="settings-content">
72+ {tab === 'providers' && <ProvidersTab />}
73+ {tab === 'models' && <ModelsTab />}
74+ {tab === 'appearance' && <AppearanceTab />}
75+ {tab === 'data' && <DataTab />}
76+ {tab === 'advanced' && <AdvancedTab />}
77+ </div>
78+ </div>
79+ </div>
80+ </div>
81+ )
82+}
83+
84+function ProvidersTab() {
85+ const refreshKeyedProviders = useStore((s) => s.refreshKeyedProviders)
86+ const settings = useStore((s) => s.settings)
87+ const updateSettings = useStore((s) => s.updateSettings)
88+ const [drafts, setDrafts] = useState<Partial<Record<Provider, string>>>({})
89+ const [tests, setTests] = useState<Partial<Record<Provider, TestState>>>({})
90+ const [proxyFor, setProxyFor] = useState<Provider | null>(null)
91+ const [, forceRender] = useState(0)
92+
93+ const saveDraft = (provider: Provider) => {
94+ const draft = drafts[provider]
95+ if (draft !== undefined && draft !== '') {
96+ setKey(provider, draft.trim())
97+ setDrafts((d) => ({ ...d, [provider]: undefined }))
98+ refreshKeyedProviders()
99+ forceRender((n) => n + 1)
100+ }
101+ }
102+
103+ const runTest = async (provider: Provider) => {
104+ saveDraft(provider)
105+ const key = getKey(provider)
106+ if (!key) {
107+ setTests((t) => ({ ...t, [provider]: { status: 'fail', detail: 'No key' } }))
108+ return
109+ }
110+ setTests((t) => ({ ...t, [provider]: { status: 'testing' } }))
111+ try {
112+ const ms = await testKey(provider, key, settings.proxyBaseURLs[provider])
113+ setTests((t) => ({
114+ ...t,
115+ [provider]: { status: 'ok', detail: `Verified · ${Math.round(ms)} ms` },
116+ }))
117+ } catch (err) {
118+ setTests((t) => ({
119+ ...t,
120+ [provider]: { status: 'fail', detail: err instanceof Error ? err.message : String(err) },
121+ }))
122+ }
123+ }
124+
125+ return (
126+ <div>
127+ <div className="first-run-note" style={{ marginBottom: 16 }}>
128+ <span>
129+ <strong>Your keys stay in this browser.</strong> They're stored in localStorage on this
130+ device and sent only to the provider you call. Anyone with access to this browser
131+ profile (or any script that runs on this page) could read them — that's inherent to a
132+ backend-less app. Use the optional passphrase lock in Advanced for at-rest encryption.
133+ </span>
134+ </div>
135+ {BUILT_IN_PROVIDERS.map((provider) => {
136+ const saved = getKey(provider)
137+ const test = tests[provider] ?? { status: 'idle' }
138+ const proxy = settings.proxyBaseURLs[provider]
139+ return (
140+ <div className="settings-row" key={provider}>
141+ <span className="provider-name">
142+ <span style={{ color: 'var(--z-accent)', display: 'inline-flex' }}>
143+ <ProviderGlyph provider={provider} size={13} />
144+ </span>
145+ {PROVIDER_META[provider].displayName}
146+ <span
147+ className={`status-dot ${
148+ test.status === 'ok'
149+ ? 'ok'
150+ : test.status === 'fail'
151+ ? 'fail'
152+ : saved
153+ ? 'saved'
154+ : ''
155+ }`}
156+ />
157+ </span>
158+ <input
159+ className="key-input"
160+ type="password"
161+ placeholder={saved ? maskedKey(saved) : 'API key'}
162+ value={drafts[provider] ?? ''}
163+ onChange={(e) => setDrafts((d) => ({ ...d, [provider]: e.target.value }))}
164+ onBlur={() => saveDraft(provider)}
165+ />
166+ <button className="button" onClick={() => void runTest(provider)}>
167+ {test.status === 'testing' ? 'Testing…' : 'Test'}
168+ </button>
169+ <button
170+ className="button"
171+ title="Proxy / base URL override"
172+ onClick={() => setProxyFor(proxyFor === provider ? null : provider)}
173+ >
174+ Proxy{proxy ? ' •' : ''}
175+ </button>
176+ {saved && (
177+ <button
178+ className="icon-button danger"
179+ title="Remove key"
180+ onClick={() => {
181+ removeKey(provider)
182+ refreshKeyedProviders()
183+ setTests((t) => ({ ...t, [provider]: { status: 'idle' } }))
184+ forceRender((n) => n + 1)
185+ }}
186+ >
187+ <IconX size={10} />
188+ </button>
189+ )}
190+ {test.detail && (
191+ <span className={`settings-sub ${test.status === 'ok' ? 'ok' : 'fail'}`}>
192+ {test.detail}
193+ </span>
194+ )}
195+ {proxyFor === provider && (
196+ <div style={{ width: '100%', display: 'flex', gap: 8, alignItems: 'center' }}>
197+ <input
198+ className="text-input"
199+ style={{ flex: 1 }}
200+ placeholder={`Proxy base URL (e.g. http://localhost:8787) — default: ${PROVIDER_META[provider].defaultBaseURL}`}
201+ value={proxy ?? ''}
202+ onChange={(e) =>
203+ updateSettings({
204+ proxyBaseURLs: {
205+ ...settings.proxyBaseURLs,
206+ [provider]: e.target.value === '' ? undefined : e.target.value,
207+ },
208+ })
209+ }
210+ />
211+ </div>
212+ )}
213+ </div>
214+ )
215+ })}
216+ <p className="settings-note" style={{ marginTop: 12 }}>
217+ All 12 providers currently allow direct browser calls (verified — see docs/CORS-MATRIX.md).
218+ If a provider starts blocking CORS, set a proxy URL above or run Zyquo Router locally and
219+ point the override at it.
220+ </p>
221+ </div>
222+ )
223+}
224+
225+function ModelsTab() {
226+ const settings = useStore((s) => s.settings)
227+ const updateSettings = useStore((s) => s.updateSettings)
228+ const [provider, setProvider] = useState<Provider>('openai')
229+ const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])
230+ const models = useMemo(() => CATALOG.filter((m) => m.provider === provider), [provider])
231+
232+ return (
233+ <div>
234+ <div className="settings-row" style={{ borderBottom: 'none' }}>
235+ <select
236+ className="select"
237+ value={provider}
238+ onChange={(e) => setProvider(e.target.value as Provider)}
239+ >
240+ {BUILT_IN_PROVIDERS.map((p) => (
241+ <option key={p} value={p}>
242+ {PROVIDER_META[p].displayName}
243+ </option>
244+ ))}
245+ </select>
246+ <span className="settings-note">{models.length} models</span>
247+ </div>
248+ {models.map((model) => (
249+ <div className="settings-row" key={model.id}>
250+ <button
251+ className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}
252+ style={{ color: favoriteSet.has(model.id) ? 'var(--z-warning)' : 'var(--z-text-tertiary)' }}
253+ onClick={() =>
254+ updateSettings({
255+ favoriteModelIDs: favoriteSet.has(model.id)
256+ ? settings.favoriteModelIDs.filter((id) => id !== model.id)
257+ : [...settings.favoriteModelIDs, model.id],
258+ })
259+ }
260+ >
261+ <IconStar filled={favoriteSet.has(model.id)} />
262+ </button>
263+ <div style={{ minWidth: 0, flex: 1 }}>
264+ <div style={{ fontSize: 13, display: 'flex', gap: 6, alignItems: 'center' }}>
265+ {model.displayName}
266+ {model.isRecommended && <span className="badge accent">Featured</span>}
267+ {model.isLegacy && <span className="badge">Legacy</span>}
268+ {settings.defaultModelID === model.id && settings.defaultProvider === provider && (
269+ <span className="badge accent">Default</span>
270+ )}
271+ </div>
272+ <div
273+ style={{
274+ fontSize: 10,
275+ fontFamily: 'var(--z-font-mono)',
276+ color: 'var(--z-text-tertiary)',
277+ overflow: 'hidden',
278+ textOverflow: 'ellipsis',
279+ whiteSpace: 'nowrap',
280+ }}
281+ >
282+ {model.id}
283+ </div>
284+ </div>
285+ <span className="model-row-badges">
286+ {model.capabilities.vision && <IconEye />}
287+ {model.capabilities.reasoning && <IconBrain />}
288+ {model.capabilities.tools && <IconWrench />}
289+ <span>{contextBadge(model)}</span>
290+ <span>{priceBadge(model)}</span>
291+ </span>
292+ <button
293+ className="button"
294+ onClick={() => updateSettings({ defaultModelID: model.id, defaultProvider: provider })}
295+ >
296+ Set default
297+ </button>
298+ </div>
299+ ))}
300+ </div>
301+ )
302+}
303+
304+function AppearanceTab() {
305+ const settings = useStore((s) => s.settings)
306+ const updateSettings = useStore((s) => s.updateSettings)
307+ return (
308+ <div>
309+ <div className="settings-section">
310+ <h3>Theme</h3>
311+ <div style={{ display: 'flex', gap: 8 }}>
312+ {(['light', 'dark', 'system'] as const).map((theme) => (
313+ <button
314+ key={theme}
315+ className={`button${settings.theme === theme ? ' primary' : ''}`}
316+ onClick={() => updateSettings({ theme })}
317+ >
318+ {theme[0]?.toUpperCase() + theme.slice(1)}
319+ </button>
320+ ))}
321+ </div>
322+ <p className="settings-note" style={{ marginTop: 6 }}>
323+ Light is the Zyquo flagship theme and the default.
324+ </p>
325+ </div>
326+ <div className="settings-section">
327+ <h3>Accent</h3>
328+ <div style={{ display: 'flex', gap: 8 }}>
329+ {(
330+ [
331+ ['indigo', '#4E6AF0'],
332+ ['graphite', '#5B6270'],
333+ ['teal', '#1D9A8F'],
334+ ['amber', '#C77D1D'],
335+ ['rose', '#C94F7C'],
336+ ] as const
337+ ).map(([accent, color]) => (
338+ <button
339+ key={accent}
340+ title={accent}
341+ onClick={() => updateSettings({ accent })}
342+ style={{
343+ width: 28,
344+ height: 28,
345+ borderRadius: '50%',
346+ background: color,
347+ border:
348+ settings.accent === accent
349+ ? '2px solid var(--z-text-primary)'
350+ : '2px solid transparent',
351+ }}
352+ />
353+ ))}
354+ </div>
355+ </div>
356+ <div className="settings-section">
357+ <h3>Chat font size</h3>
358+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
359+ <input
360+ type="range"
361+ min={12}
362+ max={18}
363+ step={0.5}
364+ value={settings.chatFontSize}
365+ style={{ flex: 1, accentColor: 'var(--z-accent)' }}
366+ onChange={(e) => updateSettings({ chatFontSize: Number(e.target.value) })}
367+ />
368+ <span style={{ fontSize: 12 }}>{settings.chatFontSize.toFixed(1)} px</span>
369+ </div>
370+ <div
371+ style={{
372+ marginTop: 8,
373+ padding: 12,
374+ background: 'var(--z-surface)',
375+ border: 'var(--z-hairline) solid var(--z-border)',
376+ borderRadius: 10,
377+ fontSize: settings.chatFontSize,
378+ lineHeight: 1.45,
379+ }}
380+ >
381+ The quick brown fox jumps over the lazy dog.
382+ </div>
383+ </div>
384+ <div className="settings-section">
385+ <h3>Density</h3>
386+ <div style={{ display: 'flex', gap: 8 }}>
387+ {(['comfortable', 'compact'] as const).map((density) => (
388+ <button
389+ key={density}
390+ className={`button${settings.density === density ? ' primary' : ''}`}
391+ onClick={() => updateSettings({ density })}
392+ >
393+ {density[0]?.toUpperCase() + density.slice(1)}
394+ </button>
395+ ))}
396+ </div>
397+ </div>
398+ </div>
399+ )
400+}
401+
402+function DataTab() {
403+ const toast = useStore((s) => s.toast)
404+ const loadAll = useStore((s) => s.loadAll)
405+ const fileRef = useRef<HTMLInputElement>(null)
406+ const [includeKeys, setIncludeKeys] = useState(false)
407+ const [confirmClear, setConfirmClear] = useState<string | null>(null)
408+
409+ const doImport = async (file: File) => {
410+ try {
411+ const parsed: unknown = JSON.parse(await file.text())
412+ if (!isExportedData(parsed)) throw new Error('Not a Zyquo Cloud Web export file')
413+ const result = await importAll(parsed)
414+ await loadAll()
415+ toast(
416+ `Imported ${result.conversations} conversations${result.keys > 0 ? ` and ${result.keys} keys` : ''}`,
417+ 'success'
418+ )
419+ } catch (err) {
420+ toast(err instanceof Error ? err.message : 'Import failed', 'error')
421+ }
422+ }
423+
424+ return (
425+ <div>
426+ <div className="settings-section">
427+ <h3>Export</h3>
428+ <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>
429+ <input
430+ type="checkbox"
431+ checked={includeKeys}
432+ onChange={(e) => setIncludeKeys(e.target.checked)}
433+ />
434+ Include API keys
435+ </label>
436+ {includeKeys && (
437+ <p className="settings-note" style={{ color: 'var(--z-warning)' }}>
438+ ⚠ The exported file will contain your API keys in plain text. Store it safely.
439+ </p>
440+ )}
441+ <button
442+ className="button primary"
443+ style={{ marginTop: 8 }}
444+ onClick={() => void downloadBackup(includeKeys)}
445+ >
446+ Download backup (JSON)
447+ </button>
448+ </div>
449+ <div className="settings-section">
450+ <h3>Import</h3>
451+ <input
452+ ref={fileRef}
453+ type="file"
454+ accept="application/json"
455+ hidden
456+ onChange={(e) => {
457+ const file = e.target.files?.[0]
458+ if (file) void doImport(file)
459+ e.target.value = ''
460+ }}
461+ />
462+ <button className="button" onClick={() => fileRef.current?.click()}>
463+ Import backup file…
464+ </button>
465+ </div>
466+ <div className="settings-section">
467+ <h3>Clear data</h3>
468+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
469+ {(
470+ [
471+ ['conversations', 'Delete all conversations'],
472+ ['everything', 'Delete everything (keys, history, settings)'],
473+ ] as const
474+ ).map(([kind, label]) => (
475+ <button
476+ key={kind}
477+ className="button danger"
478+ onClick={async () => {
479+ if (confirmClear !== kind) {
480+ setConfirmClear(kind)
481+ setTimeout(() => setConfirmClear(null), 4000)
482+ return
483+ }
484+ if (kind === 'conversations') await clearConversations()
485+ else await clearEverything()
486+ await loadAll()
487+ setConfirmClear(null)
488+ toast('Cleared', 'success')
489+ if (kind === 'everything') window.location.reload()
490+ }}
491+ >
492+ {confirmClear === kind ? 'Click again to confirm' : label}
493+ </button>
494+ ))}
495+ </div>
496+ </div>
497+ </div>
498+ )
499+}
500+
501+function AdvancedTab() {
502+ const settings = useStore((s) => s.settings)
503+ const updateSettings = useStore((s) => s.updateSettings)
504+ const toast = useStore((s) => s.toast)
505+ const [passphrase, setPassphrase] = useState('')
506+ const vaulted = isVaultEnabled()
507+
508+ return (
509+ <div>
510+ <div className="settings-section">
511+ <h3>Default system prompt</h3>
512+ <textarea
513+ className="text-input"
514+ rows={4}
515+ placeholder="Applied to new conversations"
516+ value={settings.defaultSystemPrompt}
517+ onChange={(e) => updateSettings({ defaultSystemPrompt: e.target.value })}
518+ />
519+ </div>
520+ <div className="settings-section">
521+ <h3>Passphrase lock</h3>
522+ <p className="settings-note">
523+ Encrypts your stored keys with AES-GCM (key derived from your passphrase). Honest
524+ caveat: once unlocked, a compromised page can still read keys — this protects the data
525+ at rest, not a running session. Without a passphrase, keys are stored in plaintext.
526+ </p>
527+ <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
528+ <input
529+ className="text-input"
530+ type="password"
531+ placeholder="Passphrase"
532+ value={passphrase}
533+ onChange={(e) => setPassphrase(e.target.value)}
534+ />
535+ {!vaulted ? (
536+ <button
537+ className="button primary"
538+ disabled={passphrase.length < 4}
539+ onClick={async () => {
540+ await enableVault(passphrase)
541+ setPassphrase('')
542+ toast('Passphrase lock enabled', 'success')
543+ }}
544+ >
545+ Enable lock
546+ </button>
547+ ) : (
548+ <>
549+ <button
550+ className="button"
551+ disabled={passphrase.length < 4}
552+ onClick={async () => {
553+ try {
554+ await persistVault(passphrase)
555+ toast('Vault re-encrypted', 'success')
556+ } catch {
557+ toast('Wrong passphrase', 'error')
558+ }
559+ }}
560+ >
561+ Re-encrypt
562+ </button>
563+ <button
564+ className="button danger"
565+ disabled={passphrase.length < 4}
566+ onClick={async () => {
567+ try {
568+ await disableVault(passphrase)
569+ setPassphrase('')
570+ toast('Passphrase lock disabled', 'success')
571+ } catch {
572+ toast('Wrong passphrase', 'error')
573+ }
574+ }}
575+ >
576+ Disable lock
577+ </button>
578+ </>
579+ )}
580+ </div>
581+ {vaulted && <p className="settings-note" style={{ marginTop: 6 }}>Lock is enabled.</p>}
582+ </div>
583+ <div className="settings-section">
584+ <h3>Streaming</h3>
585+ <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>
586+ <input
587+ type="checkbox"
588+ checked={settings.streamingEnabled}
589+ onChange={(e) => updateSettings({ streamingEnabled: e.target.checked })}
590+ />
591+ Stream responses token-by-token
592+ </label>
593+ </div>
594+ </div>
595+ )
596+}
added src/components/Sidebar.tsx +179 −0
@@ -0,0 +1,179 @@
1+/*
2+ * Sidebar.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Conversation sidebar: wordmark, search (⌘F), New Chat, grouped rows
9+ * (Pinned / Today / Yesterday / Previous 7 Days / Older), footer with
10+ * Settings + usage summary. Collapses to a drawer on phones.
11+ */
12+
13+import { useMemo, useRef } from 'react'
14+import { useStore } from '../state/store'
15+import { matchesSearch } from '../storage/conversations'
16+import type { Conversation } from '../types'
17+import ZyquoGlyph from './ZyquoGlyph'
18+import { IconGear, IconPin, IconPlus, IconSearch, IconTrash } from './icons'
19+
20+const GROUP_ORDER = ['Pinned', 'Today', 'Yesterday', 'Previous 7 Days', 'Older'] as const
21+
22+function groupFor(conversation: Conversation, now: Date): (typeof GROUP_ORDER)[number] {
23+ if (conversation.pinned) return 'Pinned'
24+ const updated = new Date(conversation.updatedAt)
25+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
26+ if (updated.getTime() >= startOfToday) return 'Today'
27+ if (updated.getTime() >= startOfToday - 86_400_000) return 'Yesterday'
28+ if (updated.getTime() >= startOfToday - 7 * 86_400_000) return 'Previous 7 Days'
29+ return 'Older'
30+}
31+
32+function relativeTime(timestamp: number): string {
33+ const delta = Date.now() - timestamp
34+ const minutes = Math.floor(delta / 60_000)
35+ if (minutes < 1) return 'now'
36+ if (minutes < 60) return `${minutes}m`
37+ const hours = Math.floor(minutes / 60)
38+ if (hours < 24) return `${hours}h`
39+ const days = Math.floor(hours / 24)
40+ if (days < 7) return `${days}d`
41+ return new Date(timestamp).toLocaleDateString()
42+}
43+
44+export default function Sidebar() {
45+ const conversations = useStore((s) => s.conversations)
46+ const selectedID = useStore((s) => s.selectedID)
47+ const searchText = useStore((s) => s.searchText)
48+ const setSearchText = useStore((s) => s.setSearchText)
49+ const select = useStore((s) => s.select)
50+ const newConversation = useStore((s) => s.newConversation)
51+ const removeConversation = useStore((s) => s.removeConversation)
52+ const updateConversation = useStore((s) => s.updateConversation)
53+ const openSettings = useStore((s) => s.openSettings)
54+ const sidebarOpen = useStore((s) => s.sidebarOpen)
55+ const searchRef = useRef<HTMLInputElement>(null)
56+
57+ const groups = useMemo(() => {
58+ const now = new Date()
59+ const filtered = conversations.filter((c) => matchesSearch(c, searchText))
60+ const byGroup = new Map<string, Conversation[]>()
61+ for (const conversation of filtered) {
62+ const group = groupFor(conversation, now)
63+ const list = byGroup.get(group) ?? []
64+ list.push(conversation)
65+ byGroup.set(group, list)
66+ }
67+ return GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({
68+ name: g,
69+ items: byGroup.get(g) ?? [],
70+ }))
71+ }, [conversations, searchText])
72+
73+ const usage = useMemo(() => {
74+ let tokens = 0
75+ let cost = 0
76+ for (const conversation of conversations) {
77+ for (const message of conversation.messages) {
78+ tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)
79+ cost += message.estimatedCost ?? 0
80+ }
81+ }
82+ return { tokens, cost }
83+ }, [conversations])
84+
85+ return (
86+ <aside className={`sidebar${sidebarOpen ? ' open' : ''}`}>
87+ <div className="sidebar-wordmark">
88+ <ZyquoGlyph size={20} />
89+ <span>Zyquo Cloud</span>
90+ </div>
91+ <div className="sidebar-search">
92+ <IconSearch />
93+ <input
94+ ref={searchRef}
95+ data-search-input
96+ placeholder="Search"
97+ value={searchText}
98+ onChange={(e) => setSearchText(e.target.value)}
99+ />
100+ </div>
101+ <button className="new-chat-button" onClick={() => newConversation()}>
102+ <IconPlus size={12} /> New Chat
103+ </button>
104+ <div className="conversation-list">
105+ {groups.map((group) => (
106+ <div key={group.name}>
107+ <div className="conversation-group-header">{group.name}</div>
108+ {group.items.map((conversation) => (
109+ <div
110+ key={conversation.id}
111+ role="button"
112+ tabIndex={0}
113+ className={`conversation-row${conversation.id === selectedID ? ' selected' : ''}`}
114+ onClick={() => select(conversation.id)}
115+ onKeyDown={(e) => e.key === 'Enter' && select(conversation.id)}
116+ >
117+ <div className="conversation-row-main">
118+ <div className="conversation-row-title">{conversation.title}</div>
119+ <div className="conversation-row-sub">
120+ {conversation.modelID} · {relativeTime(conversation.updatedAt)}
121+ </div>
122+ </div>
123+ {conversation.pinned && (
124+ <span className="pin-indicator">
125+ <IconPin size={9} />
126+ </span>
127+ )}
128+ <span className="conversation-row-actions">
129+ <button
130+ className="icon-button"
131+ title={conversation.pinned ? 'Unpin' : 'Pin'}
132+ onClick={(e) => {
133+ e.stopPropagation()
134+ updateConversation(conversation.id, { pinned: !conversation.pinned })
135+ }}
136+ >
137+ <IconPin size={10} />
138+ </button>
139+ <button
140+ className="icon-button danger"
141+ title="Delete"
142+ onClick={(e) => {
143+ e.stopPropagation()
144+ removeConversation(conversation.id)
145+ }}
146+ >
147+ <IconTrash size={10} />
148+ </button>
149+ </span>
150+ </div>
151+ ))}
152+ </div>
153+ ))}
154+ {groups.length === 0 && (
155+ <div className="panel-empty">
156+ {searchText ? 'No conversations match.' : 'No conversations yet.'}
157+ </div>
158+ )}
159+ </div>
160+ <div className="sidebar-footer">
161+ <button className="icon-button" title="Settings (⌘,)" onClick={() => openSettings()}>
162+ <IconGear />
163+ </button>
164+ {usage.tokens > 0 && (
165+ <span>
166+ {usage.tokens >= 1_000_000
167+ ? `${(usage.tokens / 1_000_000).toFixed(1)}M tok`
168+ : `${(usage.tokens / 1_000).toFixed(1)}K tok`}
169+ {usage.cost >= 0.01
170+ ? ` · ~$${usage.cost.toFixed(2)}`
171+ : usage.cost > 0
172+ ? ' · <$0.01'
173+ : ''}
174+ </span>
175+ )}
176+ </div>
177+ </aside>
178+ )
179+}
added src/components/SuggestionGrid.tsx +68 −0
@@ -0,0 +1,68 @@
1+/*
2+ * SuggestionGrid.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Empty-conversation state: centered Zyquo mark, greeting, model chip, and
9+ * the four suggestion cards (matches the native EmptyStateView).
10+ */
11+
12+import type { ReactNode } from 'react'
13+import ZyquoGlyph from './ZyquoGlyph'
14+import { IconCode, IconDoc, IconGlobe, IconLightbulb } from './icons'
15+
16+const SUGGESTIONS = [
17+ {
18+ icon: <IconLightbulb />,
19+ title: 'Explain something',
20+ prompt: 'Explain how transformer attention works, with a concrete example.',
21+ },
22+ {
23+ icon: <IconCode />,
24+ title: 'Review my code',
25+ prompt: 'Review this code for bugs and suggest improvements:\n\n',
26+ },
27+ {
28+ icon: <IconDoc />,
29+ title: 'Summarize a document',
30+ prompt: 'Summarize the key points of the following document:\n\n',
31+ },
32+ {
33+ icon: <IconGlobe />,
34+ title: 'Translate',
35+ prompt: 'Translate the following text to French, keeping the tone natural:\n\n',
36+ },
37+]
38+
39+export default function SuggestionGrid({
40+ onPick,
41+ modelChip,
42+}: {
43+ onPick: (prompt: string) => void
44+ modelChip?: ReactNode
45+}) {
46+ return (
47+ <div className="empty-state">
48+ <ZyquoGlyph size={72} />
49+ <div style={{ textAlign: 'center' }}>
50+ <h1 className="empty-title">Welcome to Zyquo Cloud</h1>
51+ <p className="empty-subtitle">Your keys, every cloud model, one beautiful chat.</p>
52+ </div>
53+ {modelChip}
54+ <div className="suggestion-grid">
55+ {SUGGESTIONS.map((suggestion) => (
56+ <button
57+ key={suggestion.title}
58+ className="suggestion-card"
59+ onClick={() => onPick(suggestion.prompt)}
60+ >
61+ <span className="glyph">{suggestion.icon}</span>
62+ {suggestion.title}
63+ </button>
64+ ))}
65+ </div>
66+ </div>
67+ )
68+}
added src/components/Toasts.tsx +42 −0
@@ -0,0 +1,42 @@
1+/*
2+ * Toasts.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Toast stack: graceful human errors, success confirmations, and undo for
9+ * soft deletes.
10+ */
11+
12+import { useStore } from '../state/store'
13+import { IconX } from './icons'
14+
15+export default function Toasts() {
16+ const toasts = useStore((s) => s.toasts)
17+ const dismissToast = useStore((s) => s.dismissToast)
18+ if (toasts.length === 0) return null
19+ return (
20+ <div className="toasts">
21+ {toasts.map((toast) => (
22+ <div key={toast.id} className={`toast ${toast.kind}`}>
23+ <span style={{ flex: 1 }}>{toast.text}</span>
24+ {toast.undo && (
25+ <button
26+ className="undo"
27+ onClick={() => {
28+ toast.undo?.()
29+ dismissToast(toast.id)
30+ }}
31+ >
32+ Undo
33+ </button>
34+ )}
35+ <button className="icon-button" onClick={() => dismissToast(toast.id)}>
36+ <IconX size={9} />
37+ </button>
38+ </div>
39+ ))}
40+ </div>
41+ )
42+}
added src/components/ZyquoGlyph.tsx +40 −0
@@ -0,0 +1,40 @@
1+/*
2+ * ZyquoGlyph.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * The Zyquo brand mark — the chunky Z with the block caret, reproduced 1:1
9+ * from the native CloudZGlyph (1024 grid, tight box 503×509). Single-tint
10+ * via currentColor; defaults to the accent token.
11+ */
12+
13+export default function ZyquoGlyph({
14+ size = 22,
15+ color = 'var(--z-accent)',
16+}: {
17+ size?: number
18+ color?: string
19+}) {
20+ return (
21+ <svg
22+ width={size}
23+ height={size * 1.0119}
24+ viewBox="0 0 503 509"
25+ xmlns="http://www.w3.org/2000/svg"
26+ style={{ color }}
27+ aria-label="Zyquo"
28+ >
29+ <g transform="translate(-267,-257)" fill="none" stroke="currentColor">
30+ <path
31+ d="M320 310H704L320 700H556"
32+ strokeWidth="106"
33+ strokeLinecap="round"
34+ strokeLinejoin="round"
35+ />
36+ <rect x="653" y="585" width="104" height="168" rx="24" fill="currentColor" stroke="none" />
37+ </g>
38+ </svg>
39+ )
40+}
added src/components/icons.tsx +318 −0
@@ -0,0 +1,318 @@
1+/*
2+ * icons.tsx
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Minimal inline SVG icon set (stroke-based, currentColor) — no icon
9+ * dependency (every dependency is key-theft surface). 16×16 grid.
10+ */
11+
12+import type { ReactNode } from 'react'
13+
14+function I({ children, size = 14 }: { children: ReactNode; size?: number }) {
15+ return (
16+ <svg
17+ width={size}
18+ height={size}
19+ viewBox="0 0 16 16"
20+ fill="none"
21+ stroke="currentColor"
22+ strokeWidth="1.5"
23+ strokeLinecap="round"
24+ strokeLinejoin="round"
25+ aria-hidden="true"
26+ >
27+ {children}
28+ </svg>
29+ )
30+}
31+
32+export const IconSend = ({ size = 13 }: { size?: number }) => (
33+ <I size={size}>
34+ <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />
35+ </I>
36+)
37+export const IconStop = ({ size = 11 }: { size?: number }) => (
38+ <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
39+ <rect x="3" y="3" width="10" height="10" rx="2" />
40+ </svg>
41+)
42+export const IconPlus = ({ size = 13 }: { size?: number }) => (
43+ <I size={size}>
44+ <path d="M8 3v10M3 8h10" />
45+ </I>
46+)
47+export const IconSearch = ({ size = 12 }: { size?: number }) => (
48+ <I size={size}>
49+ <circle cx="7" cy="7" r="4.5" />
50+ <path d="m10.5 10.5 3 3" />
51+ </I>
52+)
53+export const IconGear = ({ size = 14 }: { size?: number }) => (
54+ <I size={size}>
55+ <circle cx="8" cy="8" r="2.2" />
56+ <path d="M8 1.8v2M8 12.2v2M1.8 8h2M12.2 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M12.4 3.6 11 5M5 11l-1.4 1.4" />
57+ </I>
58+)
59+export const IconTrash = ({ size = 12 }: { size?: number }) => (
60+ <I size={size}>
61+ <path d="M2.5 4.5h11M6.5 2.5h3M4 4.5 4.8 13.5h6.4L12 4.5M6.5 7v4M9.5 7v4" />
62+ </I>
63+)
64+export const IconPin = ({ size = 11 }: { size?: number }) => (
65+ <I size={size}>
66+ <path d="M9.5 2 14 6.5l-3 1-2 4-1.5-1.5L3 14.5 1.5 13l4.5-4.5L4.5 7l4-2z" />
67+ </I>
68+)
69+export const IconCopy = ({ size = 11 }: { size?: number }) => (
70+ <I size={size}>
71+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
72+ <path d="M10.5 3.5v-1h-8v8h1" />
73+ </I>
74+)
75+export const IconEdit = ({ size = 11 }: { size?: number }) => (
76+ <I size={size}>
77+ <path d="m2.5 13.5.6-3 8-8 2.4 2.4-8 8-3 .6zM9.5 4l2.5 2.5" />
78+ </I>
79+)
80+export const IconRefresh = ({ size = 11 }: { size?: number }) => (
81+ <I size={size}>
82+ <path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" />
83+ </I>
84+)
85+export const IconChevronDown = ({ size = 8 }: { size?: number }) => (
86+ <I size={size}>
87+ <path d="m3 5.5 5 5 5-5" strokeWidth="2.2" />
88+ </I>
89+)
90+export const IconChevronRight = ({ size = 8 }: { size?: number }) => (
91+ <I size={size}>
92+ <path d="m5.5 3 5 5-5 5" strokeWidth="2.2" />
93+ </I>
94+)
95+export const IconStar = ({ size = 11, filled = false }: { size?: number; filled?: boolean }) => (
96+ <svg
97+ width={size}
98+ height={size}
99+ viewBox="0 0 16 16"
100+ fill={filled ? 'currentColor' : 'none'}
101+ stroke="currentColor"
102+ strokeWidth="1.3"
103+ strokeLinejoin="round"
104+ aria-hidden="true"
105+ >
106+ <path d="M8 1.5 10 6l4.8.4-3.6 3.2 1 4.7L8 11.8l-4.2 2.5 1-4.7L1.2 6.4 6 6z" />
107+ </svg>
108+)
109+export const IconEye = ({ size = 10 }: { size?: number }) => (
110+ <I size={size}>
111+ <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
112+ <circle cx="8" cy="8" r="2" />
113+ </I>
114+)
115+export const IconBrain = ({ size = 10 }: { size?: number }) => (
116+ <I size={size}>
117+ <path d="M8 2.5c-3 0-5 2-5 4.5 0 1.6.8 3 2 3.8V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.2c1.2-.8 2-2.2 2-3.8 0-2.5-2-4.5-5-4.5z" />
118+ <path d="M6 7.5h4M8 7.5v3" />
119+ </I>
120+)
121+export const IconWrench = ({ size = 10 }: { size?: number }) => (
122+ <I size={size}>
123+ <path d="M9.5 6.5 13 3a3.5 3.5 0 0 1-4.6 4.6L4 12a1.4 1.4 0 0 1-2-2l4.4-4.4A3.5 3.5 0 0 1 11 1l-2.5 2.5 1 2 2-1z" />
124+ </I>
125+)
126+export const IconPaperclip = ({ size = 14 }: { size?: number }) => (
127+ <I size={size}>
128+ <path d="m13 7.5-5 5a3.2 3.2 0 0 1-4.5-4.5l5.5-5.5a2.2 2.2 0 0 1 3 3L6.7 10.8a1.1 1.1 0 0 1-1.5-1.5L10 4.5" />
129+ </I>
130+)
131+export const IconX = ({ size = 11 }: { size?: number }) => (
132+ <I size={size}>
133+ <path d="m3.5 3.5 9 9M12.5 3.5l-9 9" />
134+ </I>
135+)
136+export const IconMenu = ({ size = 15 }: { size?: number }) => (
137+ <I size={size}>
138+ <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
139+ </I>
140+)
141+export const IconSliders = ({ size = 13 }: { size?: number }) => (
142+ <I size={size}>
143+ <path d="M2.5 5h11M2.5 11h11" />
144+ <circle cx="6" cy="5" r="1.6" fill="var(--z-surface)" />
145+ <circle cx="10" cy="11" r="1.6" fill="var(--z-surface)" />
146+ </I>
147+)
148+export const IconQuote = ({ size = 11 }: { size?: number }) => (
149+ <I size={size}>
150+ <path d="M3 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3zM9 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3z" />
151+ </I>
152+)
153+export const IconBranch = ({ size = 11 }: { size?: number }) => (
154+ <I size={size}>
155+ <circle cx="4" cy="4" r="1.8" />
156+ <circle cx="4" cy="12" r="1.8" />
157+ <circle cx="12" cy="7" r="1.8" />
158+ <path d="M4 5.8v4.4M4 8c0-2 6-1 8-1" />
159+ </I>
160+)
161+export const IconCheck = ({ size = 11 }: { size?: number }) => (
162+ <I size={size}>
163+ <path d="m2.5 8.5 3.5 3.5 7.5-8" />
164+ </I>
165+)
166+export const IconDownload = ({ size = 12 }: { size?: number }) => (
167+ <I size={size}>
168+ <path d="M8 2.5v8M4.5 7 8 10.5 11.5 7M2.5 13.5h11" />
169+ </I>
170+)
171+export const IconColumns = ({ size = 12 }: { size?: number }) => (
172+ <I size={size}>
173+ <rect x="2" y="2.5" width="12" height="11" rx="1.5" />
174+ <path d="M8 2.5v11" />
175+ </I>
176+)
177+export const IconInfo = ({ size = 12 }: { size?: number }) => (
178+ <I size={size}>
179+ <circle cx="8" cy="8" r="6" />
180+ <path d="M8 7.5V11M8 5.2v.1" />
181+ </I>
182+)
183+export const IconArrowDown = ({ size = 10 }: { size?: number }) => (
184+ <I size={size}>
185+ <path d="M8 3v10M3.5 8.5 8 13l4.5-4.5" />
186+ </I>
187+)
188+export const IconLightbulb = ({ size = 14 }: { size?: number }) => (
189+ <I size={size}>
190+ <path d="M8 1.5a4.5 4.5 0 0 1 2.5 8.2c-.5.4-.5 1-.5 1.8h-4c0-.8 0-1.4-.5-1.8A4.5 4.5 0 0 1 8 1.5zM6.5 13.5h3" />
191+ </I>
192+)
193+export const IconCode = ({ size = 14 }: { size?: number }) => (
194+ <I size={size}>
195+ <path d="m5 4.5-4 3.5 4 3.5M11 4.5l4 3.5-4 3.5" />
196+ </I>
197+)
198+export const IconDoc = ({ size = 14 }: { size?: number }) => (
199+ <I size={size}>
200+ <path d="M4 1.5h5.5L13 5v9.5H4zM9.5 1.5V5H13" />
201+ <path d="M6 8h5M6 10.5h5" />
202+ </I>
203+)
204+export const IconGlobe = ({ size = 14 }: { size?: number }) => (
205+ <I size={size}>
206+ <circle cx="8" cy="8" r="6" />
207+ <path d="M2 8h12M8 2c1.8 1.8 2.5 3.8 2.5 6S9.8 12.2 8 14c-1.8-1.8-2.5-3.8-2.5-6S6.2 3.8 8 2z" />
208+ </I>
209+)
210+export const IconMic = ({ size = 13 }: { size?: number }) => (
211+ <I size={size}>
212+ <rect x="6" y="1.5" width="4" height="8" rx="2" />
213+ <path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" />
214+ </I>
215+)
216+export const IconSpeaker = ({ size = 11 }: { size?: number }) => (
217+ <I size={size}>
218+ <path d="M2.5 6v4h2.5L9 13V3L5 6zM11 5.5a3.5 3.5 0 0 1 0 5" />
219+ </I>
220+)
221+export const IconCommand = ({ size = 13 }: { size?: number }) => (
222+ <I size={size}>
223+ <path d="M5.5 5.5h5v5h-5zM5.5 5.5H4a2 2 0 1 1 2-2v1.5M10.5 5.5H12a2 2 0 1 0-2-2v1.5M5.5 10.5H4a2 2 0 1 0 2 2v-1.5M10.5 10.5H12a2 2 0 1 1-2 2v-1.5" />
224+ </I>
225+)
226+
227+/** Provider glyphs — simple distinctive marks per provider. */
228+export function ProviderGlyph({ provider, size = 11 }: { provider: string; size?: number }) {
229+ switch (provider) {
230+ case 'openai':
231+ return (
232+ <I size={size}>
233+ <circle cx="8" cy="8" r="5.5" />
234+ <path d="M8 2.5v11M3.2 5.2l9.6 5.6M3.2 10.8l9.6-5.6" />
235+ </I>
236+ )
237+ case 'anthropic':
238+ return (
239+ <I size={size}>
240+ <path d="M3 13 8 3l5 10M5.2 9.5h5.6" />
241+ </I>
242+ )
243+ case 'xai':
244+ return (
245+ <I size={size}>
246+ <path d="m3 3 10 10M13 3 3 13" />
247+ </I>
248+ )
249+ case 'mistral':
250+ return (
251+ <I size={size}>
252+ <path d="M2.5 12V5M8 12V3M13.5 12V5M2.5 5c2 0 3-2 5.5-2s3.5 2 5.5 2" />
253+ </I>
254+ )
255+ case 'gemini':
256+ return (
257+ <I size={size}>
258+ <path d="M8 1.5C8.5 5 11 7.5 14.5 8 11 8.5 8.5 11 8 14.5 7.5 11 5 8.5 1.5 8 5 7.5 7.5 5 8 1.5z" />
259+ </I>
260+ )
261+ case 'qwen':
262+ return (
263+ <I size={size}>
264+ <circle cx="8" cy="8" r="5.5" />
265+ <path d="m10.5 10.5 3 3" />
266+ </I>
267+ )
268+ case 'deepseek':
269+ return (
270+ <I size={size}>
271+ <path d="M2 9c1.5-4 4-6.5 8-6.5 2 0 3.5.8 4 2-1 .3-2 1-2.5 2.5C10.5 10 7.5 13 4 13c-1 0-1.7-.3-2-1z" />
272+ </I>
273+ )
274+ case 'kimi':
275+ return (
276+ <I size={size}>
277+ <path d="M12.5 10.5A5.5 5.5 0 1 1 5.5 3.5a4.5 4.5 0 0 0 7 7z" />
278+ </I>
279+ )
280+ case 'perplexity':
281+ return (
282+ <I size={size}>
283+ <circle cx="8" cy="8" r="5.5" />
284+ <path d="M6 6.5c0-2.5 4-2.5 4 0 0 1.5-2 1.5-2 3M8 11.5v.1" />
285+ </I>
286+ )
287+ case 'together':
288+ return (
289+ <I size={size}>
290+ <circle cx="5.5" cy="5.5" r="2.5" />
291+ <circle cx="10.5" cy="10.5" r="2.5" />
292+ <circle cx="10.5" cy="5.5" r="2.5" />
293+ <circle cx="5.5" cy="10.5" r="2.5" />
294+ </I>
295+ )
296+ case 'deepinfra':
297+ return (
298+ <I size={size}>
299+ <path d="M8 1.5 14 5v6l-6 3.5L2 11V5z" />
300+ <path d="M8 8v6.5M8 8 2 5M8 8l6-3" />
301+ </I>
302+ )
303+ case 'cerebras':
304+ return (
305+ <I size={size}>
306+ <circle cx="8" cy="8" r="5.5" />
307+ <path d="M8 4.5A3.5 3.5 0 1 0 11.5 8" />
308+ </I>
309+ )
310+ default:
311+ return (
312+ <I size={size}>
313+ <circle cx="8" cy="8" r="5.5" />
314+ <path d="M8 5v3.5" />
315+ </I>
316+ )
317+ }
318+}
added src/design/components.css +1373 −0
@@ -0,0 +1,1373 @@
1+/*
2+ * components.css
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Component styles — every value comes from tokens.css. Layout mirrors the
9+ * native app (sidebar 260 + centered 760 transcript column), responsive:
10+ * the sidebar becomes a drawer under 768px.
11+ */
12+
13+/* ---------- Shell ---------- */
14+
15+.shell {
16+ display: flex;
17+ height: 100%;
18+ background: var(--z-background);
19+}
20+
21+.chat-area {
22+ flex: 1;
23+ display: flex;
24+ flex-direction: column;
25+ min-width: 0;
26+ height: 100%;
27+}
28+
29+/* ---------- Sidebar ---------- */
30+
31+.sidebar {
32+ width: var(--z-sidebar-width);
33+ min-width: var(--z-sidebar-width);
34+ display: flex;
35+ flex-direction: column;
36+ border-right: var(--z-hairline) solid var(--z-border);
37+ background: var(--z-background);
38+ height: 100%;
39+}
40+
41+.sidebar-wordmark {
42+ display: flex;
43+ align-items: center;
44+ gap: var(--z-space-xs);
45+ padding: 12px var(--z-space-md) var(--z-space-xs);
46+ font-size: 14px;
47+ font-weight: 500;
48+ user-select: none;
49+}
50+
51+.sidebar-search {
52+ display: flex;
53+ align-items: center;
54+ gap: 6px;
55+ margin: 0 var(--z-space-sm) var(--z-space-xs);
56+ padding: 5px var(--z-space-xs);
57+ border-radius: var(--z-radius-small);
58+ background: color-mix(in srgb, var(--z-surface-secondary) 70%, transparent);
59+ color: var(--z-text-tertiary);
60+}
61+.sidebar-search input {
62+ flex: 1;
63+ border: none;
64+ outline: none;
65+ background: transparent;
66+ font-size: 12.5px;
67+ color: var(--z-text-primary);
68+ min-width: 0;
69+}
70+
71+.new-chat-button {
72+ display: flex;
73+ align-items: center;
74+ justify-content: center;
75+ gap: 6px;
76+ margin: 0 var(--z-space-sm) var(--z-space-xs);
77+ padding: 7px var(--z-space-sm);
78+ border-radius: var(--z-radius-small);
79+ background: var(--z-accent);
80+ color: var(--z-on-accent);
81+ font-size: 13px;
82+ font-weight: 500;
83+ transition: transform var(--z-motion-hover);
84+}
85+.new-chat-button:active {
86+ transform: scale(var(--z-motion-pressed-scale));
87+}
88+
89+.conversation-list {
90+ flex: 1;
91+ overflow-y: auto;
92+ padding: 0 var(--z-space-xs);
93+}
94+
95+.conversation-group-header {
96+ font-size: var(--z-font-size-caption);
97+ color: var(--z-text-tertiary);
98+ padding: 12px var(--z-space-xs) 2px;
99+ user-select: none;
100+}
101+
102+.conversation-row {
103+ display: flex;
104+ align-items: center;
105+ gap: 6px;
106+ width: 100%;
107+ text-align: left;
108+ padding: 5px var(--z-space-xs);
109+ border-radius: var(--z-radius-small);
110+ margin-bottom: 2px;
111+ transition: background var(--z-motion-hover);
112+ position: relative;
113+}
114+.conversation-row:hover {
115+ background: color-mix(in srgb, var(--z-surface-secondary) 60%, transparent);
116+}
117+.conversation-row.selected {
118+ background: var(--z-accent-subtle);
119+}
120+.conversation-row-main {
121+ flex: 1;
122+ min-width: 0;
123+}
124+.conversation-row-title {
125+ font-size: 13px;
126+ color: var(--z-text-primary);
127+ white-space: nowrap;
128+ overflow: hidden;
129+ text-overflow: ellipsis;
130+}
131+.conversation-row-sub {
132+ font-size: var(--z-font-size-caption);
133+ color: var(--z-text-tertiary);
134+ white-space: nowrap;
135+ overflow: hidden;
136+ text-overflow: ellipsis;
137+ margin-top: 1px;
138+}
139+.conversation-row-actions {
140+ display: none;
141+ gap: 4px;
142+ color: var(--z-text-tertiary);
143+}
144+.conversation-row:hover .conversation-row-actions {
145+ display: flex;
146+}
147+.conversation-row .pin-indicator {
148+ color: var(--z-text-tertiary);
149+ font-size: 9px;
150+}
151+.conversation-row:hover .pin-indicator {
152+ display: none;
153+}
154+
155+.sidebar-footer {
156+ display: flex;
157+ align-items: center;
158+ justify-content: space-between;
159+ padding: var(--z-space-xs) var(--z-space-md);
160+ border-top: var(--z-hairline) solid var(--z-border);
161+ color: var(--z-text-tertiary);
162+ font-size: var(--z-font-size-caption);
163+}
164+
165+.icon-button {
166+ display: inline-flex;
167+ align-items: center;
168+ justify-content: center;
169+ width: 26px;
170+ height: 26px;
171+ border-radius: var(--z-radius-small);
172+ color: var(--z-text-secondary);
173+ transition: background var(--z-motion-hover);
174+}
175+.icon-button:hover {
176+ background: var(--z-surface-secondary);
177+}
178+.icon-button.danger:hover {
179+ color: var(--z-danger);
180+}
181+
182+/* ---------- Chat header ---------- */
183+
184+.chat-header {
185+ height: var(--z-chat-header-height);
186+ min-height: var(--z-chat-header-height);
187+ display: grid;
188+ grid-template-columns: 1fr auto 1fr;
189+ align-items: center;
190+ padding: 0 var(--z-space-md);
191+ border-bottom: var(--z-hairline) solid var(--z-border);
192+ gap: var(--z-space-xs);
193+}
194+.chat-header-left {
195+ display: flex;
196+ align-items: center;
197+ gap: var(--z-space-xs);
198+ min-width: 0;
199+}
200+.chat-header-right {
201+ display: flex;
202+ align-items: center;
203+ gap: 2px;
204+ justify-content: flex-end;
205+}
206+.chat-title {
207+ font-size: 13px;
208+ font-weight: 500;
209+ max-width: 220px;
210+ white-space: nowrap;
211+ overflow: hidden;
212+ text-overflow: ellipsis;
213+ cursor: default;
214+}
215+.chat-title-input {
216+ font-size: 13px;
217+ font-weight: 500;
218+ border: none;
219+ outline: none;
220+ background: var(--z-surface-secondary);
221+ border-radius: var(--z-radius-small);
222+ padding: 2px 6px;
223+ max-width: 220px;
224+}
225+
226+/* ---------- Model chip ---------- */
227+
228+.model-chip {
229+ display: inline-flex;
230+ align-items: center;
231+ gap: 6px;
232+ padding: 4px var(--z-space-xs);
233+ border-radius: var(--z-radius-small);
234+ background: var(--z-surface-secondary);
235+ font-size: 12.5px;
236+ font-weight: 500;
237+ color: var(--z-text-primary);
238+ transition: transform var(--z-motion-hover);
239+ white-space: nowrap;
240+ max-width: 60vw;
241+ overflow: hidden;
242+ text-overflow: ellipsis;
243+}
244+.model-chip:active {
245+ transform: scale(var(--z-motion-pressed-scale));
246+}
247+.model-chip .glyph {
248+ color: var(--z-accent);
249+ display: inline-flex;
250+}
251+.model-chip .chevron {
252+ color: var(--z-text-tertiary);
253+ font-size: 8px;
254+}
255+
256+/* ---------- Model menu (command-palette style) ---------- */
257+
258+.overlay {
259+ position: fixed;
260+ inset: 0;
261+ background: rgba(10, 12, 20, 0.25);
262+ display: flex;
263+ align-items: flex-start;
264+ justify-content: center;
265+ padding: 8vh var(--z-space-md) var(--z-space-md);
266+ z-index: 100;
267+ animation: overlay-in 120ms ease-out;
268+}
269+@keyframes overlay-in {
270+ from {
271+ opacity: 0;
272+ }
273+ to {
274+ opacity: 1;
275+ }
276+}
277+
278+.panel {
279+ background: var(--z-surface);
280+ border: var(--z-hairline) solid var(--z-border);
281+ border-radius: var(--z-radius-large);
282+ box-shadow: var(--z-shadow-panel);
283+ width: 620px;
284+ max-width: 100%;
285+ max-height: 72vh;
286+ display: flex;
287+ flex-direction: column;
288+ overflow: hidden;
289+ animation: panel-in 150ms ease-out;
290+}
291+@keyframes panel-in {
292+ from {
293+ opacity: 0;
294+ transform: translateY(6px) scale(0.99);
295+ }
296+ to {
297+ opacity: 1;
298+ transform: none;
299+ }
300+}
301+
302+.panel-search {
303+ display: flex;
304+ align-items: center;
305+ gap: var(--z-space-xs);
306+ padding: var(--z-space-sm) var(--z-space-md);
307+ border-bottom: var(--z-hairline) solid var(--z-border);
308+ color: var(--z-text-tertiary);
309+ flex-shrink: 0;
310+}
311+.panel-search input {
312+ flex: 1;
313+ border: none;
314+ outline: none;
315+ background: transparent;
316+ font-size: 15px;
317+ color: var(--z-text-primary);
318+}
319+
320+.panel-filters {
321+ display: flex;
322+ gap: 6px;
323+ padding: var(--z-space-xs) var(--z-space-md);
324+ border-bottom: var(--z-hairline) solid var(--z-border);
325+ overflow-x: auto;
326+ flex-wrap: nowrap;
327+ flex-shrink: 0;
328+}
329+.panel-footer {
330+ flex-shrink: 0;
331+}
332+.filter-chip {
333+ font-size: var(--z-font-size-caption);
334+ padding: 3px 10px;
335+ border-radius: 999px;
336+ background: var(--z-surface-secondary);
337+ color: var(--z-text-secondary);
338+ white-space: nowrap;
339+ transition: background var(--z-motion-hover);
340+}
341+.filter-chip.active {
342+ background: var(--z-accent);
343+ color: var(--z-on-accent);
344+}
345+
346+.panel-list {
347+ flex: 1;
348+ overflow-y: auto;
349+ padding: var(--z-space-xs);
350+}
351+
352+.panel-section-header {
353+ font-size: var(--z-font-size-caption);
354+ color: var(--z-text-tertiary);
355+ padding: 10px var(--z-space-xs) 2px;
356+ display: flex;
357+ align-items: center;
358+ gap: 6px;
359+ user-select: none;
360+}
361+
362+.model-row {
363+ display: flex;
364+ align-items: center;
365+ gap: var(--z-space-xs);
366+ width: 100%;
367+ text-align: left;
368+ padding: 5px var(--z-space-xs);
369+ border-radius: var(--z-radius-small);
370+ transition: background var(--z-motion-hover);
371+}
372+.model-row:hover,
373+.model-row.highlighted {
374+ background: var(--z-surface-secondary);
375+}
376+.model-row.current {
377+ background: var(--z-accent-subtle);
378+}
379+.model-row .glyph {
380+ color: var(--z-accent);
381+ width: 16px;
382+ display: inline-flex;
383+ justify-content: center;
384+}
385+.model-row-name {
386+ font-size: 12.5px;
387+ color: var(--z-text-primary);
388+ white-space: nowrap;
389+ overflow: hidden;
390+ text-overflow: ellipsis;
391+}
392+.model-row-provider {
393+ font-size: var(--z-font-size-caption);
394+ color: var(--z-text-tertiary);
395+}
396+.model-row-badges {
397+ display: flex;
398+ align-items: center;
399+ gap: 6px;
400+ margin-left: auto;
401+ color: var(--z-text-tertiary);
402+ font-size: var(--z-font-size-caption);
403+ flex-shrink: 0;
404+}
405+.capability-badge {
406+ font-size: 10px;
407+}
408+.model-row .star {
409+ color: var(--z-text-tertiary);
410+ font-size: 11px;
411+}
412+.model-row .star.active {
413+ color: var(--z-warning);
414+}
415+.badge {
416+ font-size: 10px;
417+ padding: 1px 6px;
418+ border-radius: 999px;
419+ background: var(--z-surface-secondary);
420+ color: var(--z-text-secondary);
421+ white-space: nowrap;
422+}
423+.badge.accent {
424+ background: var(--z-accent-subtle);
425+ color: var(--z-accent);
426+}
427+.badge.warn {
428+ background: color-mix(in srgb, var(--z-warning) 12%, transparent);
429+ color: var(--z-warning);
430+}
431+
432+.panel-empty {
433+ padding: var(--z-space-xl);
434+ text-align: center;
435+ color: var(--z-text-tertiary);
436+ font-size: 12.5px;
437+}
438+
439+.panel-footer {
440+ display: flex;
441+ gap: var(--z-space-md);
442+ padding: var(--z-space-xs) var(--z-space-md);
443+ border-top: var(--z-hairline) solid var(--z-border);
444+ color: var(--z-text-tertiary);
445+ font-size: var(--z-font-size-caption);
446+}
447+.panel-footer kbd {
448+ background: var(--z-surface-secondary);
449+ border-radius: 4px;
450+ padding: 0 5px;
451+ font-size: 10px;
452+}
453+
454+/* ---------- Transcript ---------- */
455+
456+.transcript {
457+ flex: 1;
458+ overflow-y: auto;
459+ overflow-x: hidden;
460+ position: relative;
461+}
462+.transcript-column {
463+ max-width: var(--z-max-message-column);
464+ margin: 0 auto;
465+ padding: var(--z-space-md);
466+ display: flex;
467+ flex-direction: column;
468+ gap: var(--z-turn-rhythm);
469+}
470+.density-compact .transcript-column {
471+ gap: 10px;
472+}
473+
474+.turn {
475+ display: flex;
476+ gap: var(--z-space-xs);
477+ animation: message-in var(--z-motion-send);
478+}
479+@keyframes message-in {
480+ from {
481+ opacity: 0;
482+ transform: translateY(4px);
483+ }
484+ to {
485+ opacity: 1;
486+ transform: none;
487+ }
488+}
489+.turn.user {
490+ justify-content: flex-end;
491+ padding-left: 60px;
492+}
493+.turn.assistant {
494+ padding-right: 60px;
495+}
496+
497+.avatar {
498+ width: 26px;
499+ height: 26px;
500+ min-width: 26px;
501+ border-radius: 50%;
502+ background: var(--z-accent-subtle);
503+ color: var(--z-accent);
504+ display: flex;
505+ align-items: center;
506+ justify-content: center;
507+ margin-top: 2px;
508+}
509+
510+.turn-body {
511+ display: flex;
512+ flex-direction: column;
513+ gap: 4px;
514+ min-width: 0;
515+ max-width: 100%;
516+}
517+
518+.bubble {
519+ padding: 10px var(--z-space-sm);
520+ border-radius: var(--z-radius-medium);
521+ font-size: var(--z-chat-font-size);
522+ line-height: var(--z-line-height-body);
523+ overflow-wrap: break-word;
524+ min-width: 0;
525+}
526+.turn.user .bubble {
527+ background: var(--z-accent-subtle);
528+ white-space: pre-wrap;
529+}
530+.turn.assistant .bubble {
531+ background: var(--z-surface);
532+ border: var(--z-hairline) solid var(--z-border);
533+}
534+.bubble .error-note {
535+ display: flex;
536+ gap: 6px;
537+ align-items: flex-start;
538+ color: var(--z-danger);
539+ background: color-mix(in srgb, var(--z-danger) 8%, transparent);
540+ border-radius: var(--z-radius-small);
541+ padding: var(--z-space-xs);
542+ font-size: calc(var(--z-chat-font-size) - 1px);
543+ margin-top: 6px;
544+}
545+
546+.meta-row {
547+ display: flex;
548+ align-items: center;
549+ gap: var(--z-space-xs);
550+ font-size: var(--z-font-size-caption);
551+ color: var(--z-text-tertiary);
552+ opacity: 0;
553+ transition: opacity var(--z-motion-hover);
554+ min-height: 18px;
555+ flex-wrap: wrap;
556+}
557+.turn:hover .meta-row,
558+.meta-row.always {
559+ opacity: 1;
560+}
561+.turn.user .meta-row {
562+ justify-content: flex-end;
563+}
564+.meta-action {
565+ display: inline-flex;
566+ align-items: center;
567+ gap: 3px;
568+ color: var(--z-text-tertiary);
569+ border-radius: 4px;
570+ padding: 1px 4px;
571+ transition: background var(--z-motion-hover);
572+ font-size: var(--z-font-size-caption);
573+}
574+.meta-action:hover {
575+ background: var(--z-surface-secondary);
576+ color: var(--z-text-secondary);
577+}
578+
579+.reasoning-section {
580+ margin-bottom: 6px;
581+}
582+.reasoning-toggle {
583+ display: inline-flex;
584+ align-items: center;
585+ gap: 5px;
586+ font-size: var(--z-font-size-caption);
587+ color: var(--z-text-secondary);
588+}
589+.reasoning-toggle .chev {
590+ display: inline-block;
591+ transition: transform 120ms ease;
592+ font-size: 8px;
593+}
594+.reasoning-toggle .chev.open {
595+ transform: rotate(90deg);
596+}
597+.reasoning-body {
598+ margin-top: 6px;
599+ padding: var(--z-space-xs);
600+ background: var(--z-surface-secondary);
601+ border-radius: var(--z-radius-small);
602+ font-family: var(--z-font-mono);
603+ font-size: calc(var(--z-chat-font-size) - 2px);
604+ color: var(--z-text-secondary);
605+ white-space: pre-wrap;
606+ max-height: 320px;
607+ overflow-y: auto;
608+}
609+
610+.citations {
611+ display: flex;
612+ flex-wrap: wrap;
613+ gap: 4px;
614+ margin-top: 8px;
615+}
616+.citation-chip {
617+ display: inline-flex;
618+ align-items: center;
619+ gap: 5px;
620+ padding: 3px 8px;
621+ border-radius: 999px;
622+ background: var(--z-surface-secondary);
623+ font-size: var(--z-font-size-caption);
624+ color: var(--z-text-secondary);
625+ max-width: 220px;
626+}
627+.citation-chip:hover {
628+ text-decoration: none;
629+ background: var(--z-accent-subtle);
630+}
631+.citation-chip .num {
632+ width: 13px;
633+ height: 13px;
634+ min-width: 13px;
635+ border-radius: 50%;
636+ background: var(--z-accent);
637+ color: var(--z-on-accent);
638+ font-size: 9px;
639+ font-weight: 600;
640+ display: inline-flex;
641+ align-items: center;
642+ justify-content: center;
643+}
644+.citation-chip .title {
645+ white-space: nowrap;
646+ overflow: hidden;
647+ text-overflow: ellipsis;
648+}
649+
650+.streaming-caret {
651+ display: inline-block;
652+ width: 7px;
653+ height: 15px;
654+ border-radius: 3px;
655+ background: var(--z-accent);
656+ vertical-align: text-bottom;
657+ margin-left: 2px;
658+ animation: caret-blink 1.1s ease-in-out infinite;
659+}
660+@keyframes caret-blink {
661+ 0%,
662+ 100% {
663+ opacity: 1;
664+ }
665+ 50% {
666+ opacity: 0.15;
667+ }
668+}
669+
670+.jump-pill {
671+ position: absolute;
672+ bottom: var(--z-space-xs);
673+ left: 50%;
674+ transform: translateX(-50%);
675+ display: flex;
676+ align-items: center;
677+ gap: 5px;
678+ padding: 5px var(--z-space-sm);
679+ border-radius: 999px;
680+ background: var(--z-surface);
681+ border: var(--z-hairline) solid var(--z-border);
682+ box-shadow: var(--z-shadow-soft);
683+ font-size: var(--z-font-size-caption);
684+ color: var(--z-text-secondary);
685+ z-index: 5;
686+}
687+
688+.variant-switch {
689+ display: inline-flex;
690+ align-items: center;
691+ gap: 4px;
692+ font-size: var(--z-font-size-caption);
693+ color: var(--z-text-tertiary);
694+}
695+
696+/* ---------- Markdown ---------- */
697+
698+.markdown > *:first-child {
699+ margin-top: 0;
700+}
701+.markdown > *:last-child {
702+ margin-bottom: 0;
703+}
704+.markdown p,
705+.markdown ul,
706+.markdown ol,
707+.markdown blockquote,
708+.markdown table,
709+.markdown pre {
710+ margin: 0 0 12px;
711+}
712+.markdown h1,
713+.markdown h2,
714+.markdown h3,
715+.markdown h4,
716+.markdown h5,
717+.markdown h6 {
718+ font-weight: 600;
719+ margin: 16px 0 8px;
720+ line-height: 1.3;
721+}
722+.markdown h1 {
723+ font-size: calc(var(--z-chat-font-size) * 1.55);
724+}
725+.markdown h2 {
726+ font-size: calc(var(--z-chat-font-size) * 1.35);
727+}
728+.markdown h3 {
729+ font-size: calc(var(--z-chat-font-size) * 1.2);
730+}
731+.markdown h4,
732+.markdown h5,
733+.markdown h6 {
734+ font-size: calc(var(--z-chat-font-size) * 1.05);
735+}
736+.markdown blockquote {
737+ border-left: 3px solid var(--z-accent);
738+ border-radius: 2px;
739+ padding-left: 12px;
740+ color: var(--z-text-secondary);
741+}
742+.markdown ul,
743+.markdown ol {
744+ padding-left: 24px;
745+}
746+.markdown li {
747+ margin-bottom: 3px;
748+}
749+.markdown code:not(pre code) {
750+ font-size: calc(var(--z-chat-font-size) - 1px);
751+ background: var(--z-surface-secondary);
752+ border-radius: 4px;
753+ padding: 1px 5px;
754+}
755+.markdown hr {
756+ border: none;
757+ border-top: var(--z-hairline) solid var(--z-border);
758+ margin: 12px 0;
759+}
760+.markdown table {
761+ border-collapse: collapse;
762+ border: var(--z-hairline) solid var(--z-border);
763+ border-radius: var(--z-radius-small);
764+ overflow: hidden;
765+ display: block;
766+ max-width: 100%;
767+ overflow-x: auto;
768+ width: fit-content;
769+}
770+.markdown th,
771+.markdown td {
772+ padding: 8px 12px;
773+ border: var(--z-hairline) solid var(--z-border);
774+ font-size: calc(var(--z-chat-font-size) - 0.5px);
775+ text-align: left;
776+}
777+.markdown thead th {
778+ background: var(--z-surface-secondary);
779+ font-weight: 600;
780+}
781+.markdown tbody tr:nth-child(odd) {
782+ background: color-mix(in srgb, var(--z-surface-secondary) 40%, transparent);
783+}
784+.markdown img {
785+ max-width: 100%;
786+ border-radius: var(--z-radius-small);
787+}
788+.markdown input[type='checkbox'] {
789+ accent-color: var(--z-accent);
790+ margin-right: 4px;
791+}
792+
793+/* Code blocks */
794+.code-block {
795+ background: var(--z-surface-secondary);
796+ border-radius: var(--z-radius-medium);
797+ overflow: hidden;
798+}
799+.code-block-header {
800+ display: flex;
801+ align-items: center;
802+ justify-content: space-between;
803+ padding: 8px 12px 0;
804+ font-size: var(--z-font-size-caption);
805+ color: var(--z-text-secondary);
806+ text-transform: uppercase;
807+ letter-spacing: 0.04em;
808+}
809+.code-block-actions {
810+ display: flex;
811+ gap: 4px;
812+ opacity: 0;
813+ transition: opacity var(--z-motion-hover);
814+}
815+.code-block:hover .code-block-actions,
816+.code-block-actions.confirming {
817+ opacity: 1;
818+}
819+.code-block-copy {
820+ font-size: var(--z-font-size-caption);
821+ color: var(--z-text-secondary);
822+ text-transform: none;
823+ letter-spacing: normal;
824+ padding: 1px 6px;
825+ border-radius: 4px;
826+}
827+.code-block-copy:hover {
828+ background: color-mix(in srgb, var(--z-border) 50%, transparent);
829+}
830+.code-block-copy.ok {
831+ color: var(--z-success);
832+}
833+.code-block pre {
834+ margin: 0;
835+ padding: 4px 12px 8px;
836+ overflow-x: auto;
837+ font-size: calc(var(--z-chat-font-size) - 1px);
838+ line-height: 1.5;
839+}
840+.code-block pre.wrap {
841+ white-space: pre-wrap;
842+ overflow-wrap: break-word;
843+}
844+.code-block code {
845+ background: none;
846+ padding: 0;
847+ font-size: inherit;
848+}
849+
850+/* highlight.js theme mapped to Zyquo syntax tokens */
851+.hljs-keyword,
852+.hljs-selector-tag,
853+.hljs-built_in,
854+.hljs-tag {
855+ color: var(--z-code-keyword);
856+}
857+.hljs-string,
858+.hljs-regexp,
859+.hljs-addition {
860+ color: var(--z-code-string);
861+}
862+.hljs-comment,
863+.hljs-quote,
864+.hljs-deletion,
865+.hljs-meta {
866+ color: var(--z-code-comment);
867+}
868+.hljs-number,
869+.hljs-literal,
870+.hljs-symbol {
871+ color: var(--z-code-number);
872+}
873+.hljs-type,
874+.hljs-class .hljs-title,
875+.hljs-title.class_ {
876+ color: var(--z-code-type);
877+}
878+.hljs-function .hljs-title,
879+.hljs-title.function_ {
880+ color: var(--z-code-function);
881+}
882+.hljs-attr,
883+.hljs-property,
884+.hljs-variable {
885+ color: var(--z-code-property);
886+}
887+.hljs-attribute,
888+.hljs-name {
889+ color: var(--z-code-attribute);
890+}
891+
892+/* ---------- Input bar ---------- */
893+
894+.input-dock {
895+ padding: 0 var(--z-space-sm) var(--z-space-sm);
896+}
897+.input-bar {
898+ max-width: var(--z-max-message-column);
899+ margin: 0 auto;
900+ background: var(--z-surface);
901+ border: var(--z-hairline) solid var(--z-border);
902+ border-radius: var(--z-radius-large);
903+ box-shadow: var(--z-shadow-soft);
904+ padding: var(--z-space-sm);
905+ display: flex;
906+ flex-direction: column;
907+ gap: var(--z-space-xs);
908+}
909+.input-bar.drag-target {
910+ border: 1.5px dashed var(--z-accent);
911+}
912+.attachment-strip {
913+ display: flex;
914+ gap: var(--z-space-xs);
915+ overflow-x: auto;
916+}
917+.attachment-thumb {
918+ position: relative;
919+ width: var(--z-attachment-thumb);
920+ height: var(--z-attachment-thumb);
921+ min-width: var(--z-attachment-thumb);
922+ border-radius: var(--z-radius-small);
923+ overflow: hidden;
924+ background: var(--z-surface-secondary);
925+ display: flex;
926+ align-items: center;
927+ justify-content: center;
928+ font-size: 9px;
929+ color: var(--z-text-secondary);
930+ text-align: center;
931+ padding: 2px;
932+}
933+.attachment-thumb img {
934+ width: 100%;
935+ height: 100%;
936+ object-fit: cover;
937+}
938+.attachment-remove {
939+ position: absolute;
940+ top: 2px;
941+ right: 2px;
942+ width: 16px;
943+ height: 16px;
944+ border-radius: 50%;
945+ background: var(--z-text-primary);
946+ color: var(--z-background);
947+ font-size: 10px;
948+ line-height: 1;
949+ display: flex;
950+ align-items: center;
951+ justify-content: center;
952+}
953+.input-row {
954+ display: flex;
955+ align-items: flex-end;
956+ gap: var(--z-space-xs);
957+}
958+.input-editor {
959+ flex: 1;
960+ border: none;
961+ outline: none;
962+ background: transparent;
963+ resize: none;
964+ font-size: var(--z-chat-font-size);
965+ line-height: var(--z-line-height-body);
966+ max-height: 200px;
967+ min-height: 22px;
968+ padding: 2px 0;
969+ color: var(--z-text-primary);
970+}
971+.send-button {
972+ width: 28px;
973+ height: 28px;
974+ min-width: 28px;
975+ border-radius: 50%;
976+ background: var(--z-accent);
977+ color: var(--z-on-accent);
978+ display: flex;
979+ align-items: center;
980+ justify-content: center;
981+ transition: transform var(--z-motion-hover), background var(--z-motion-hover);
982+}
983+.send-button:disabled {
984+ background: var(--z-surface-secondary);
985+ color: var(--z-text-tertiary);
986+ cursor: default;
987+}
988+.send-button.stop {
989+ background: var(--z-danger);
990+}
991+.send-button:active:not(:disabled) {
992+ transform: scale(var(--z-motion-pressed-scale));
993+}
994+.input-hud {
995+ display: flex;
996+ align-items: center;
997+ gap: var(--z-space-xs);
998+ font-size: var(--z-font-size-caption);
999+ color: var(--z-text-tertiary);
1000+ flex-wrap: wrap;
1001+}
1002+.context-bar {
1003+ flex: 1;
1004+ min-width: 60px;
1005+ max-width: 160px;
1006+ height: 3px;
1007+ border-radius: 2px;
1008+ background: var(--z-surface-secondary);
1009+ overflow: hidden;
1010+}
1011+.context-bar-fill {
1012+ height: 100%;
1013+ background: var(--z-accent);
1014+ border-radius: 2px;
1015+ transition: width 300ms ease;
1016+}
1017+.context-bar-fill.warn {
1018+ background: var(--z-warning);
1019+}
1020+
1021+/* ---------- Empty state ---------- */
1022+
1023+.empty-state {
1024+ flex: 1;
1025+ display: flex;
1026+ flex-direction: column;
1027+ align-items: center;
1028+ justify-content: center;
1029+ gap: var(--z-space-lg);
1030+ padding: var(--z-space-xl);
1031+ overflow-y: auto;
1032+}
1033+.empty-title {
1034+ font-size: var(--z-font-size-title);
1035+ font-weight: 600;
1036+ margin: 0;
1037+}
1038+.empty-subtitle {
1039+ color: var(--z-text-secondary);
1040+ margin: 0;
1041+}
1042+.suggestion-grid {
1043+ display: grid;
1044+ grid-template-columns: 1fr 1fr;
1045+ gap: var(--z-space-sm);
1046+ max-width: 460px;
1047+ width: 100%;
1048+}
1049+.suggestion-card {
1050+ display: flex;
1051+ align-items: center;
1052+ gap: var(--z-space-xs);
1053+ text-align: left;
1054+ padding: var(--z-space-sm);
1055+ background: var(--z-surface);
1056+ border: var(--z-hairline) solid var(--z-border);
1057+ border-radius: var(--z-radius-medium);
1058+ font-size: 13px;
1059+ transition: transform var(--z-motion-hover), background var(--z-motion-hover);
1060+}
1061+.suggestion-card:hover {
1062+ background: var(--z-surface-secondary);
1063+}
1064+.suggestion-card:active {
1065+ transform: scale(var(--z-motion-pressed-scale));
1066+}
1067+.suggestion-card .glyph {
1068+ color: var(--z-accent);
1069+ width: 20px;
1070+ display: inline-flex;
1071+ justify-content: center;
1072+}
1073+
1074+/* ---------- First run ---------- */
1075+
1076+.first-run-note {
1077+ max-width: 560px;
1078+ margin: 0 auto;
1079+ background: var(--z-surface);
1080+ border: var(--z-hairline) solid var(--z-border);
1081+ border-radius: var(--z-radius-medium);
1082+ padding: var(--z-space-md);
1083+ font-size: 12.5px;
1084+ color: var(--z-text-secondary);
1085+ display: flex;
1086+ flex-direction: column;
1087+ gap: var(--z-space-xs);
1088+}
1089+.first-run-note strong {
1090+ color: var(--z-text-primary);
1091+}
1092+.first-run-actions {
1093+ display: flex;
1094+ gap: var(--z-space-xs);
1095+ justify-content: flex-end;
1096+}
1097+.button {
1098+ padding: 6px 14px;
1099+ border-radius: var(--z-radius-small);
1100+ font-size: 12.5px;
1101+ font-weight: 500;
1102+ background: var(--z-surface-secondary);
1103+ color: var(--z-text-primary);
1104+ transition: transform var(--z-motion-hover);
1105+}
1106+.button.primary {
1107+ background: var(--z-accent);
1108+ color: var(--z-on-accent);
1109+}
1110+.button.danger {
1111+ background: color-mix(in srgb, var(--z-danger) 10%, transparent);
1112+ color: var(--z-danger);
1113+}
1114+.button:active {
1115+ transform: scale(var(--z-motion-pressed-scale));
1116+}
1117+.button:disabled {
1118+ opacity: 0.5;
1119+ cursor: default;
1120+}
1121+
1122+/* ---------- Settings ---------- */
1123+
1124+.settings-panel {
1125+ width: 760px;
1126+ max-width: 100%;
1127+ height: 560px;
1128+ max-height: 85vh;
1129+}
1130+.settings-body {
1131+ display: flex;
1132+ flex: 1;
1133+ min-height: 0;
1134+}
1135+.settings-tabs {
1136+ width: 168px;
1137+ min-width: 168px;
1138+ border-right: var(--z-hairline) solid var(--z-border);
1139+ padding: var(--z-space-xs);
1140+ display: flex;
1141+ flex-direction: column;
1142+ gap: 2px;
1143+}
1144+.settings-tab {
1145+ text-align: left;
1146+ padding: 6px 10px;
1147+ border-radius: var(--z-radius-small);
1148+ font-size: 13px;
1149+ color: var(--z-text-secondary);
1150+ transition: background var(--z-motion-hover);
1151+}
1152+.settings-tab:hover {
1153+ background: var(--z-surface-secondary);
1154+}
1155+.settings-tab.active {
1156+ background: var(--z-accent-subtle);
1157+ color: var(--z-text-primary);
1158+}
1159+.settings-content {
1160+ flex: 1;
1161+ overflow-y: auto;
1162+ padding: var(--z-space-md);
1163+ min-width: 0;
1164+}
1165+.settings-content h3 {
1166+ margin: 0 0 var(--z-space-sm);
1167+ font-size: 14px;
1168+}
1169+.settings-row {
1170+ display: flex;
1171+ align-items: center;
1172+ gap: var(--z-space-xs);
1173+ padding: var(--z-space-xs) 0;
1174+ border-bottom: var(--z-hairline) solid var(--z-border);
1175+ flex-wrap: wrap;
1176+}
1177+.settings-row:last-child {
1178+ border-bottom: none;
1179+}
1180+.provider-name {
1181+ display: flex;
1182+ align-items: center;
1183+ gap: 6px;
1184+ font-size: 13px;
1185+ font-weight: 500;
1186+ width: 140px;
1187+ min-width: 140px;
1188+}
1189+.status-dot {
1190+ width: 8px;
1191+ height: 8px;
1192+ border-radius: 50%;
1193+ background: var(--z-border);
1194+}
1195+.status-dot.ok {
1196+ background: var(--z-success);
1197+}
1198+.status-dot.fail {
1199+ background: var(--z-danger);
1200+}
1201+.status-dot.saved {
1202+ background: var(--z-text-tertiary);
1203+}
1204+.key-input {
1205+ flex: 1;
1206+ min-width: 160px;
1207+ font-family: var(--z-font-mono);
1208+ font-size: 11px;
1209+ padding: 5px 8px;
1210+ border: var(--z-hairline) solid var(--z-border);
1211+ border-radius: var(--z-radius-small);
1212+ background: var(--z-background);
1213+ outline: none;
1214+}
1215+.key-input:focus {
1216+ border-color: var(--z-accent);
1217+}
1218+.settings-sub {
1219+ font-size: var(--z-font-size-caption);
1220+ color: var(--z-text-tertiary);
1221+ width: 100%;
1222+}
1223+.settings-sub.ok {
1224+ color: var(--z-success);
1225+}
1226+.settings-sub.fail {
1227+ color: var(--z-danger);
1228+}
1229+.select,
1230+.number-input,
1231+.text-input {
1232+ padding: 5px 8px;
1233+ border: var(--z-hairline) solid var(--z-border);
1234+ border-radius: var(--z-radius-small);
1235+ background: var(--z-background);
1236+ font-size: 12.5px;
1237+ outline: none;
1238+}
1239+.text-input:focus,
1240+.number-input:focus {
1241+ border-color: var(--z-accent);
1242+}
1243+.settings-section {
1244+ margin-bottom: var(--z-space-lg);
1245+}
1246+.settings-note {
1247+ font-size: var(--z-font-size-caption);
1248+ color: var(--z-text-tertiary);
1249+ line-height: 1.5;
1250+}
1251+textarea.text-input {
1252+ width: 100%;
1253+ resize: vertical;
1254+ font-family: inherit;
1255+}
1256+
1257+/* ---------- Toasts ---------- */
1258+
1259+.toasts {
1260+ position: fixed;
1261+ bottom: var(--z-space-md);
1262+ right: var(--z-space-md);
1263+ display: flex;
1264+ flex-direction: column;
1265+ gap: var(--z-space-xs);
1266+ z-index: 200;
1267+ max-width: min(380px, calc(100vw - 32px));
1268+}
1269+.toast {
1270+ display: flex;
1271+ align-items: center;
1272+ gap: var(--z-space-xs);
1273+ background: var(--z-surface);
1274+ border: var(--z-hairline) solid var(--z-border);
1275+ border-left: 3px solid var(--z-accent);
1276+ border-radius: var(--z-radius-medium);
1277+ box-shadow: var(--z-shadow-soft);
1278+ padding: var(--z-space-xs) var(--z-space-sm);
1279+ font-size: 12.5px;
1280+ animation: message-in var(--z-motion-send);
1281+}
1282+.toast.error {
1283+ border-left-color: var(--z-danger);
1284+}
1285+.toast.success {
1286+ border-left-color: var(--z-success);
1287+}
1288+.toast .undo {
1289+ color: var(--z-accent);
1290+ font-weight: 500;
1291+}
1292+
1293+/* ---------- Drawer / responsive ---------- */
1294+
1295+.drawer-scrim {
1296+ display: none;
1297+}
1298+.menu-button {
1299+ display: none;
1300+}
1301+
1302+@media (max-width: 768px) {
1303+ .sidebar {
1304+ position: fixed;
1305+ left: 0;
1306+ top: 0;
1307+ bottom: 0;
1308+ z-index: 60;
1309+ transform: translateX(-100%);
1310+ transition: transform 200ms ease;
1311+ box-shadow: none;
1312+ width: min(300px, 85vw);
1313+ min-width: 0;
1314+ }
1315+ .sidebar.open {
1316+ transform: none;
1317+ box-shadow: var(--z-shadow-panel);
1318+ }
1319+ .drawer-scrim {
1320+ display: block;
1321+ position: fixed;
1322+ inset: 0;
1323+ background: rgba(10, 12, 20, 0.3);
1324+ z-index: 50;
1325+ }
1326+ .menu-button {
1327+ display: inline-flex;
1328+ }
1329+ .turn.user {
1330+ padding-left: 24px;
1331+ }
1332+ .turn.assistant {
1333+ padding-right: 12px;
1334+ }
1335+ .avatar {
1336+ display: none;
1337+ }
1338+ .overlay {
1339+ padding: 4vh var(--z-space-xs) var(--z-space-xs);
1340+ }
1341+ .settings-body {
1342+ flex-direction: column;
1343+ }
1344+ .settings-tabs {
1345+ width: 100%;
1346+ min-width: 0;
1347+ flex-direction: row;
1348+ overflow-x: auto;
1349+ border-right: none;
1350+ border-bottom: var(--z-hairline) solid var(--z-border);
1351+ }
1352+ .settings-panel {
1353+ height: 90vh;
1354+ }
1355+ .icon-button,
1356+ .meta-action,
1357+ .conversation-row {
1358+ min-height: 32px;
1359+ }
1360+ .chat-title {
1361+ max-width: 120px;
1362+ }
1363+}
1364+
1365+/* Focus mode hides the sidebar on desktop */
1366+.focus-mode .sidebar {
1367+ display: none;
1368+}
1369+@media (max-width: 768px) {
1370+ .focus-mode .sidebar {
1371+ display: flex;
1372+ }
1373+}
added src/features/catalogHelpers.ts +38 −0
@@ -0,0 +1,38 @@
1+/*
2+ * catalogHelpers.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Catalog conveniences shared by the store and UI: re-exports of the ported
9+ * catalog queries plus cost/estimation helpers.
10+ */
11+
12+import { pricingCost, type AIModel, type TokenUsage } from '../types'
13+
14+export { CATALOG, cheapestModel, defaultModel, findModel, modelsFor, rankModel } from '../providers/catalog'
15+
16+/** Estimated USD cost of a usage record for a model (0 when unpriced). */
17+export function pricedCost(model: AIModel, usage: TokenUsage): number {
18+ if (!model.pricing) return 0
19+ return pricingCost(model.pricing, usage.inputTokens, usage.outputTokens)
20+}
21+
22+/** Rough token estimate for context-window accounting (≈4 chars/token). */
23+export function estimateTokens(text: string): number {
24+ return Math.ceil(text.length / 4)
25+}
26+
27+/** "$1.25 / $10" per-MTok badge, or "—" when unpriced. */
28+export function priceBadge(model: AIModel): string {
29+ if (!model.pricing) return '—'
30+ const fmt = (v: number) => (v >= 1 ? `$${v.toFixed(2).replace(/\.?0+$/, '')}` : `$${v}`)
31+ return `${fmt(model.pricing.inputPerMTok)} / ${fmt(model.pricing.outputPerMTok)}`
32+}
33+
34+/** "$0.0042" style cost display. */
35+export function costLabel(cost: number): string {
36+ if (cost <= 0) return ''
37+ return `~$${cost.toFixed(4)}`
38+}
added src/features/exporters.ts +60 −0
@@ -0,0 +1,60 @@
1+/*
2+ * exporters.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * Single-conversation exports (Markdown / JSON) and the all-data backup
9+ * download — client-side file downloads, no server.
10+ */
11+
12+import { exportAll } from '../storage/backup'
13+import type { Conversation } from '../types'
14+
15+function download(filename: string, content: string, type: string): void {
16+ const blob = new Blob([content], { type })
17+ const url = URL.createObjectURL(blob)
18+ const a = document.createElement('a')
19+ a.href = url
20+ a.download = filename
21+ a.click()
22+ URL.revokeObjectURL(url)
23+}
24+
25+function safeName(title: string): string {
26+ return title.replace(/[/\\:*?"<>|]/g, '-').slice(0, 80)
27+}
28+
29+export function exportConversationMarkdown(conversation: Conversation): void {
30+ const lines: string[] = [`# ${conversation.title}`, '']
31+ lines.push(`*Model: ${conversation.modelID} (${conversation.provider})*`, '')
32+ if (conversation.systemPrompt) {
33+ lines.push(`> **System:** ${conversation.systemPrompt}`, '')
34+ }
35+ for (const message of conversation.messages) {
36+ const label = message.role === 'user' ? '**You**' : `**Assistant** (${message.modelID ?? ''})`
37+ lines.push(`${label}:`, '', message.text, '')
38+ if (message.citations && message.citations.length > 0) {
39+ lines.push(
40+ ...message.citations.map((c) => `[${c.index}]: ${c.url}${c.title ? ` "${c.title}"` : ''}`),
41+ ''
42+ )
43+ }
44+ }
45+ download(`${safeName(conversation.title)}.md`, lines.join('\n'), 'text/markdown')
46+}
47+
48+export function exportConversationJSON(conversation: Conversation): void {
49+ download(
50+ `${safeName(conversation.title)}.json`,
51+ JSON.stringify(conversation, null, 2),
52+ 'application/json'
53+ )
54+}
55+
56+export async function downloadBackup(includeKeys: boolean): Promise<void> {
57+ const data = await exportAll(includeKeys)
58+ const stamp = new Date().toISOString().slice(0, 10)
59+ download(`zyquo-cloud-backup-${stamp}.json`, JSON.stringify(data, null, 2), 'application/json')
60+}
added src/state/store.ts +619 −0
@@ -0,0 +1,619 @@
1+/*
2+ * store.ts
3+ * Zyquo Cloud Web
4+ *
5+ * Author: Simon-Pierre Boucher
6+ * Mail: contact@spboucher.ai
7+ *
8+ * The app store (Zustand): settings, keys status, conversations, streaming
9+ * state, and UI state. All persistence goes through storage/; all networking
10+ * through providers/. Conversation behaviors ported from the native
11+ * ConversationStore (send/stream/stop/regenerate/edit-resend/auto-title),
12+ * extended with web-only variants + branching.
13+ */
14+
15+import { create } from 'zustand'
16+import { cheapestModel, defaultModel, findModel, pricedCost } from '../features/catalogHelpers'
17+import { clientFor } from '../providers/registry'
18+import { ProviderError } from '../providers/types'
19+import {
20+ deleteConversation as dbDeleteConversation,
21+ loadConversations,
22+ saveConversation,
23+} from '../storage/conversations'
24+import { configuredProviders, getKey } from '../storage/keys'
25+import { loadSettings, saveSettings } from '../storage/settings'
26+import { isLocked } from '../storage/vault'
27+import type {
28+ AIModel,
29+ Attachment,
30+ ChatParameters,
31+ Conversation,
32+ Message,
33+ MessageVariant,
34+ Provider,
35+ Settings,
36+} from '../types'
37+
38+export function uid(): string {
39+ return crypto.randomUUID()
40+}
41+
42+export interface Toast {
43+ id: string
44+ text: string
45+ kind: 'info' | 'error' | 'success'
46+ /** Optional undo action (soft deletes). */
47+ undo?: () => void
48+}
49+
50+interface StreamingState {
51+ /** Message id currently being streamed into. */
52+ messageID: string
53+ abort: AbortController
54+ startedAt: number
55+ outputChars: number
56+}
57+
58+export interface ZyquoState {
59+ // Settings
60+ settings: Settings
61+ updateSettings: (patch: Partial<Settings>) => void
62+
63+ // Keys (presence only — values read from storage on demand)
64+ keyedProviders: Provider[]
65+ refreshKeyedProviders: () => void
66+ vaultLocked: boolean
67+ setVaultLocked: (locked: boolean) => void
68+
69+ // Conversations
70+ conversations: Conversation[]
71+ selectedID: string | null
72+ searchText: string
73+ loaded: boolean
74+ loadAll: () => Promise<void>
75+ select: (id: string | null) => void
76+ setSearchText: (text: string) => void
77+ newConversation: (opts?: {
78+ model?: AIModel
79+ systemPrompt?: string
80+ personaID?: string
81+ parameters?: ChatParameters
82+ }) => Conversation
83+ updateConversation: (id: string, patch: Partial<Conversation>) => void
84+ removeConversation: (id: string) => void
85+ restoreConversation: (conversation: Conversation) => void
86+
87+ // Chat actions
88+ send: (conversationID: string, text: string, attachments: Attachment[], overrideModel?: AIModel) => Promise<void>
89+ regenerate: (conversationID: string, messageID: string, overrideModel?: AIModel) => Promise<void>
90+ editAndResend: (conversationID: string, messageID: string, newText: string) => Promise<void>
91+ deleteMessage: (conversationID: string, messageID: string) => void
92+ stop: (conversationID: string) => void
93+ continueGeneration: (conversationID: string) => Promise<void>
94+ branchFrom: (conversationID: string, messageID: string) => Conversation | null
95+ setActiveVariant: (conversationID: string, messageID: string, index: number) => void
96+
97+ // Streaming
98+ streaming: Record<string, StreamingState>
99+
100+ // UI
101+ /** Prompt text to preload into the next-opened conversation's input. */
102+ draftSeed: string | null
103+ setDraftSeed: (seed: string | null) => void
104+ sidebarOpen: boolean
105+ setSidebarOpen: (open: boolean) => void
106+ modelMenuOpen: boolean
107+ setModelMenuOpen: (open: boolean) => void
108+ settingsOpen: boolean
109+ settingsTab: string
110+ openSettings: (tab?: string) => void
111+ closeSettings: () => void
112+ paletteOpen: boolean
113+ setPaletteOpen: (open: boolean) => void
114+ compareOpen: boolean
115+ setCompareOpen: (open: boolean) => void
116+ toasts: Toast[]
117+ toast: (text: string, kind?: Toast['kind'], undo?: () => void) => void
118+ dismissToast: (id: string) => void
119+}
120+
121+function applyThemeToDocument(settings: Settings): void {
122+ const root = document.documentElement
123+ const dark =
124+ settings.theme === 'dark' ||
125+ (settings.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
126+ if (dark) root.setAttribute('data-theme', 'dark')
127+ else root.removeAttribute('data-theme')
128+ if (settings.accent !== 'indigo') root.setAttribute('data-accent', settings.accent)
129+ else root.removeAttribute('data-accent')
130+ root.style.setProperty('--z-chat-font-size', `${settings.chatFontSize}px`)
131+}
132+
133+export const useStore = create<ZyquoState>((set, get) => {
134+ const initialSettings = loadSettings()
135+ applyThemeToDocument(initialSettings)
136+
137+ const persist = (conversation: Conversation) => {
138+ void saveConversation(conversation).catch(() => {
139+ get().toast('Failed to save conversation locally', 'error')
140+ })
141+ }
142+
143+ const mutateConversation = (
144+ id: string,
145+ mutate: (c: Conversation) => Conversation,
146+ save = true
147+ ) => {
148+ set((state) => {
149+ const conversations = state.conversations.map((c) => {
150+ if (c.id !== id) return c
151+ const next = mutate(c)
152+ if (save) persist(next)
153+ return next
154+ })
155+ return { conversations }
156+ })
157+ }
158+
159+ const modelFor = (conversation: Conversation): AIModel | undefined =>
160+ findModel(conversation.provider, conversation.modelID)
161+
162+ /** Streams a reply into a fresh assistant message appended to the conversation. */
163+ const generateReply = async (conversationID: string, overrideModel?: AIModel) => {
164+ const state = get()
165+ const conversation = state.conversations.find((c) => c.id === conversationID)
166+ if (!conversation) return
167+ if (state.streaming[conversationID]) state.stop(conversationID)
168+
169+ const model = overrideModel ?? modelFor(conversation)
170+ if (!model) {
171+ state.toast(`Model ${conversation.modelID} not found in catalog`, 'error')
172+ return
173+ }
174+ const apiKey = getKey(model.provider)
175+ if (!apiKey) {
176+ state.toast(ProviderError.missingAPIKey(model.provider).message, 'error')
177+ get().openSettings('providers')
178+ return
179+ }
180+ if (overrideModel) {
181+ mutateConversation(conversationID, (c) => ({
182+ ...c,
183+ modelID: overrideModel.id,
184+ provider: overrideModel.provider,
185+ }))
186+ }
187+
188+ const placeholder: Message = {
189+ id: uid(),
190+ role: 'assistant',
191+ text: '',
192+ modelID: model.id,
193+ provider: model.provider,
194+ createdAt: Date.now(),
195+ }
196+ mutateConversation(
197+ conversationID,
198+ (c) => ({ ...c, messages: [...c.messages, placeholder], updatedAt: Date.now() }),
199+ false
200+ )
201+
202+ const abort = new AbortController()
203+ set((s) => ({
204+ streaming: {
205+ ...s.streaming,
206+ [conversationID]: {
207+ messageID: placeholder.id,
208+ abort,
209+ startedAt: Date.now(),
210+ outputChars: 0,
211+ },
212+ },
213+ }))
214+
215+ const source = get().conversations.find((c) => c.id === conversationID)
216+ if (!source) return
217+ // Request context: all messages except the placeholder and errored turns.
218+ const requestMessages = source.messages.filter(
219+ (m) => m.id !== placeholder.id && !m.errorText
220+ )
221+ const proxy = get().settings.proxyBaseURLs[model.provider]
222+
223+ const patchMessage = (patch: (m: Message) => Message, save = false) => {
224+ mutateConversation(
225+ conversationID,
226+ (c) => ({
227+ ...c,
228+ messages: c.messages.map((m) => (m.id === placeholder.id ? patch(m) : m)),
229+ }),
230+ save
231+ )
232+ }
233+
234+ try {
235+ const client = clientFor(model)
236+ const events = client.streamChat(
237+ {
238+ model,
239+ ...(source.systemPrompt ? { systemPrompt: source.systemPrompt } : {}),
240+ messages: requestMessages,
241+ parameters: source.parameters,
242+ stream: true,
243+ ...(proxy ? { baseURLOverride: proxy } : {}),
244+ ...(model.customBaseURL ? { baseURLOverride: model.customBaseURL } : {}),
245+ },
246+ apiKey,
247+ abort.signal
248+ )
249+ for await (const event of events) {
250+ switch (event.type) {
251+ case 'textDelta':
252+ set((s) => {
253+ const st = s.streaming[conversationID]
254+ return st
255+ ? {
256+ streaming: {
257+ ...s.streaming,
258+ [conversationID]: { ...st, outputChars: st.outputChars + event.text.length },
259+ },
260+ }
261+ : {}
262+ })
263+ patchMessage((m) => ({ ...m, text: m.text + event.text }))
264+ break
265+ case 'reasoningDelta':
266+ patchMessage((m) => ({ ...m, reasoning: (m.reasoning ?? '') + event.text }))
267+ break
268+ case 'citations':
269+ patchMessage((m) => ({ ...m, citations: event.citations }))
270+ break
271+ case 'usage':
272+ patchMessage((m) => ({
273+ ...m,
274+ usage: event.usage,
275+ ...(model.pricing
276+ ? { estimatedCost: pricedCost(model, event.usage) }
277+ : {}),
278+ }))
279+ break
280+ case 'finished':
281+ break
282+ }
283+ }
284+ patchMessage((m) => m, true) // final persist
285+ void autoTitleIfNeeded(conversationID)
286+ } catch (err) {
287+ const cancelled = err instanceof ProviderError && err.kind === 'cancelled'
288+ if (!cancelled) {
289+ const text = err instanceof Error ? err.message : String(err)
290+ patchMessage((m) => ({ ...m, errorText: text }), true)
291+ get().toast(text, 'error')
292+ } else {
293+ patchMessage((m) => m, true)
294+ }
295+ } finally {
296+ set((s) => {
297+ const streaming = { ...s.streaming }
298+ delete streaming[conversationID]
299+ return { streaming }
300+ })
301+ }
302+ }
303+
304+ /** Native auto-title behavior: after the first completed exchange. */
305+ const autoTitleIfNeeded = async (conversationID: string) => {
306+ const conversation = get().conversations.find((c) => c.id === conversationID)
307+ if (!conversation || !conversation.hasAutoTitle) return
308+ const assistantTurns = conversation.messages.filter(
309+ (m) => m.role === 'assistant' && m.text.trim() !== ''
310+ )
311+ if (assistantTurns.length !== 1) return
312+ const model = cheapestModel(conversation.provider)
313+ const apiKey = model ? getKey(model.provider) : undefined
314+ if (!model || !apiKey) return
315+ const firstUser = conversation.messages.find((m) => m.role === 'user')?.text ?? ''
316+ const lastAssistant = assistantTurns[assistantTurns.length - 1]?.text ?? ''
317+ try {
318+ const result = await clientFor(model).complete(
319+ {
320+ model,
321+ messages: [
322+ {
323+ id: uid(),
324+ role: 'user',
325+ text:
326+ 'Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.\n' +
327+ `User: ${firstUser.slice(0, 500)}\nAssistant: ${lastAssistant.slice(0, 500)}`,
328+ createdAt: Date.now(),
329+ },
330+ ],
331+ parameters: { maxTokens: 24 },
332+ stream: false,
333+ },
334+ apiKey
335+ )
336+ const title = result.text
337+ .trim()
338+ .replace(/^["'“”]+|["'“”]+$/g, '')
339+ .slice(0, 60)
340+ const current = get().conversations.find((c) => c.id === conversationID)
341+ if (title !== '' && current?.hasAutoTitle) {
342+ mutateConversation(conversationID, (c) => ({ ...c, title }))
343+ }
344+ } catch {
345+ // Silent failure — title stays "New Chat".
346+ }
347+ }
348+
349+ return {
350+ settings: initialSettings,
351+ updateSettings: (patch) => {
352+ const settings = { ...get().settings, ...patch }
353+ saveSettings(settings)
354+ applyThemeToDocument(settings)
355+ set({ settings })
356+ },
357+
358+ keyedProviders: [],
359+ refreshKeyedProviders: () => set({ keyedProviders: configuredProviders() }),
360+ vaultLocked: isLocked(),
361+ setVaultLocked: (locked) => set({ vaultLocked: locked }),
362+
363+ conversations: [],
364+ selectedID: null,
365+ searchText: '',
366+ loaded: false,
367+ loadAll: async () => {
368+ const conversations = await loadConversations()
369+ set({
370+ conversations,
371+ loaded: true,
372+ selectedID: conversations[0]?.id ?? null,
373+ keyedProviders: configuredProviders(),
374+ })
375+ },
376+ select: (id) => set({ selectedID: id, sidebarOpen: false }),
377+ setSearchText: (text) => set({ searchText: text }),
378+
379+ newConversation: (opts = {}) => {
380+ const settings = get().settings
381+ const model =
382+ opts.model ??
383+ findModel(settings.defaultProvider, settings.defaultModelID) ??
384+ defaultModel()
385+ const systemPrompt = opts.systemPrompt ?? settings.defaultSystemPrompt
386+ const conversation: Conversation = {
387+ id: uid(),
388+ title: 'New Chat',
389+ createdAt: Date.now(),
390+ updatedAt: Date.now(),
391+ modelID: model.id,
392+ provider: model.provider,
393+ ...(systemPrompt !== '' ? { systemPrompt } : {}),
394+ parameters: opts.parameters ?? { ...settings.defaultParameters },
395+ messages: [],
396+ pinned: false,
397+ hasAutoTitle: true,
398+ ...(opts.personaID ? { personaID: opts.personaID } : {}),
399+ }
400+ set((s) => ({
401+ conversations: [conversation, ...s.conversations],
402+ selectedID: conversation.id,
403+ sidebarOpen: false,
404+ }))
405+ persist(conversation)
406+ return conversation
407+ },
408+
409+ updateConversation: (id, patch) => {
410+ mutateConversation(id, (c) => ({ ...c, ...patch, updatedAt: Date.now() }))
411+ },
412+
413+ removeConversation: (id) => {
414+ const conversation = get().conversations.find((c) => c.id === id)
415+ get().stop(id)
416+ set((s) => {
417+ const conversations = s.conversations.filter((c) => c.id !== id)
418+ return {
419+ conversations,
420+ selectedID: s.selectedID === id ? (conversations[0]?.id ?? null) : s.selectedID,
421+ }
422+ })
423+ void dbDeleteConversation(id)
424+ if (conversation) {
425+ get().toast(`Deleted “${conversation.title}”`, 'info', () =>
426+ get().restoreConversation(conversation)
427+ )
428+ }
429+ },
430+
431+ restoreConversation: (conversation) => {
432+ set((s) => ({
433+ conversations: [conversation, ...s.conversations].sort(
434+ (a, b) => b.updatedAt - a.updatedAt
435+ ),
436+ selectedID: conversation.id,
437+ }))
438+ persist(conversation)
439+ },
440+
441+ send: async (conversationID, text, attachments, overrideModel) => {
442+ const conversation = get().conversations.find((c) => c.id === conversationID)
443+ if (!conversation) return
444+ const model = overrideModel ?? findModel(conversation.provider, conversation.modelID)
445+ const userMessage: Message = {
446+ id: uid(),
447+ role: 'user',
448+ text,
449+ ...(attachments.length > 0 ? { attachments } : {}),
450+ ...(model ? { modelID: model.id, provider: model.provider } : {}),
451+ createdAt: Date.now(),
452+ }
453+ mutateConversation(conversationID, (c) => ({
454+ ...c,
455+ messages: [...c.messages, userMessage],
456+ updatedAt: Date.now(),
457+ }))
458+ await generateReply(conversationID, overrideModel)
459+ },
460+
461+ regenerate: async (conversationID, messageID, overrideModel) => {
462+ const conversation = get().conversations.find((c) => c.id === conversationID)
463+ if (!conversation) return
464+ const index = conversation.messages.findIndex((m) => m.id === messageID)
465+ const message = conversation.messages[index]
466+ if (!message || message.role !== 'assistant') return
467+ // Web improvement over native: keep the old answer as a variant instead
468+ // of dropping it, unless it was empty/errored.
469+ const keepVariant = message.text.trim() !== '' && !message.errorText
470+ if (index === conversation.messages.length - 1) {
471+ mutateConversation(conversationID, (c) => {
472+ const messages = c.messages.slice(0, -1)
473+ return { ...c, messages }
474+ })
475+ const old: MessageVariant | null = keepVariant
476+ ? {
477+ text: message.text,
478+ ...(message.reasoning ? { reasoning: message.reasoning } : {}),
479+ ...(message.citations ? { citations: message.citations } : {}),
480+ ...(message.modelID ? { modelID: message.modelID } : {}),
481+ ...(message.provider ? { provider: message.provider } : {}),
482+ ...(message.usage ? { usage: message.usage } : {}),
483+ ...(message.estimatedCost !== undefined
484+ ? { estimatedCost: message.estimatedCost }
485+ : {}),
486+ createdAt: message.createdAt,
487+ }
488+ : null
489+ await generateReply(conversationID, overrideModel)
490+ if (old) {
491+ // Attach the previous answer as a variant of the fresh message.
492+ const fresh = get().conversations.find((c) => c.id === conversationID)
493+ const last = fresh?.messages[fresh.messages.length - 1]
494+ if (last && last.role === 'assistant') {
495+ mutateConversation(conversationID, (c) => ({
496+ ...c,
497+ messages: c.messages.map((m) =>
498+ m.id === last.id
499+ ? {
500+ ...m,
501+ variants: [...(message.variants ?? []), old],
502+ }
503+ : m
504+ ),
505+ }))
506+ }
507+ }
508+ }
509+ },
510+
511+ editAndResend: async (conversationID, messageID, newText) => {
512+ const conversation = get().conversations.find((c) => c.id === conversationID)
513+ if (!conversation) return
514+ const index = conversation.messages.findIndex((m) => m.id === messageID)
515+ if (index === -1) return
516+ mutateConversation(conversationID, (c) => ({
517+ ...c,
518+ messages: c.messages
519+ .slice(0, index + 1)
520+ .map((m) => (m.id === messageID ? { ...m, text: newText } : m)),
521+ updatedAt: Date.now(),
522+ }))
523+ await generateReply(conversationID)
524+ },
525+
526+ deleteMessage: (conversationID, messageID) => {
527+ mutateConversation(conversationID, (c) => ({
528+ ...c,
529+ messages: c.messages.filter((m) => m.id !== messageID),
530+ updatedAt: Date.now(),
531+ }))
532+ },
533+
534+ stop: (conversationID) => {
535+ const st = get().streaming[conversationID]
536+ if (st) st.abort.abort()
537+ },
538+
539+ continueGeneration: async (conversationID) => {
540+ const conversation = get().conversations.find((c) => c.id === conversationID)
541+ if (!conversation) return
542+ const continueMessage: Message = {
543+ id: uid(),
544+ role: 'user',
545+ text: 'Continue exactly where you left off.',
546+ createdAt: Date.now(),
547+ }
548+ mutateConversation(conversationID, (c) => ({
549+ ...c,
550+ messages: [...c.messages, continueMessage],
551+ updatedAt: Date.now(),
552+ }))
553+ await generateReply(conversationID)
554+ },
555+
556+ branchFrom: (conversationID, messageID) => {
557+ const conversation = get().conversations.find((c) => c.id === conversationID)
558+ if (!conversation) return null
559+ const index = conversation.messages.findIndex((m) => m.id === messageID)
560+ if (index === -1) return null
561+ const branch: Conversation = {
562+ ...conversation,
563+ id: uid(),
564+ title: `${conversation.title} (branch)`,
565+ createdAt: Date.now(),
566+ updatedAt: Date.now(),
567+ messages: conversation.messages.slice(0, index + 1).map((m) => ({ ...m, id: uid() })),
568+ pinned: false,
569+ hasAutoTitle: false,
570+ branchedFrom: { conversationID, messageID },
571+ }
572+ set((s) => ({
573+ conversations: [branch, ...s.conversations],
574+ selectedID: branch.id,
575+ }))
576+ persist(branch)
577+ return branch
578+ },
579+
580+ setActiveVariant: (conversationID, messageID, index) => {
581+ mutateConversation(conversationID, (c) => ({
582+ ...c,
583+ messages: c.messages.map((m) => {
584+ if (m.id !== messageID || !m.variants) return m
585+ const variants = m.variants
586+ const target = index === -1 ? null : variants[index]
587+ if (index === -1 || !target) return { ...m, activeVariant: undefined }
588+ // Swap: current content goes into the variant slot, variant becomes active.
589+ return { ...m, activeVariant: index }
590+ }),
591+ }))
592+ },
593+
594+ streaming: {},
595+
596+ draftSeed: null,
597+ setDraftSeed: (seed) => set({ draftSeed: seed }),
598+ sidebarOpen: false,
599+ setSidebarOpen: (open) => set({ sidebarOpen: open }),
600+ modelMenuOpen: false,
601+ setModelMenuOpen: (open) => set({ modelMenuOpen: open }),
602+ settingsOpen: false,
603+ settingsTab: 'providers',
604+ openSettings: (tab = 'providers') => set({ settingsOpen: true, settingsTab: tab }),
605+ closeSettings: () => set({ settingsOpen: false }),
606+ paletteOpen: false,
607+ setPaletteOpen: (open) => set({ paletteOpen: open }),
608+ compareOpen: false,
609+ setCompareOpen: (open) => set({ compareOpen: open }),
610+
611+ toasts: [],
612+ toast: (text, kind = 'info', undo) => {
613+ const id = uid()
614+ set((s) => ({ toasts: [...s.toasts, { id, text, kind, ...(undo ? { undo } : {}) }] }))
615+ setTimeout(() => get().dismissToast(id), 6000)
616+ },
617+ dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
618+ }
619+})
modified tsconfig.app.tsbuildinfo +1 −1
@@ -1 +1 @@
1−{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/features/devharness.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
1+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatview.tsx","./src/components/firstrun.tsx","./src/components/inputbar.tsx","./src/components/markdown.tsx","./src/components/messagebubble.tsx","./src/components/modelmenu.tsx","./src/components/paramspanel.tsx","./src/components/settingsmodal.tsx","./src/components/sidebar.tsx","./src/components/suggestiongrid.tsx","./src/components/toasts.tsx","./src/components/zyquoglyph.tsx","./src/components/icons.tsx","./src/features/streamtestview.tsx","./src/features/cataloghelpers.ts","./src/features/devharness.ts","./src/features/exporters.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/state/store.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
2 2
Menu

Theme