diff --git a/backend/app/templates/help_center/article.html b/backend/app/templates/help_center/article.html new file mode 100644 index 0000000..a2d6ab3 --- /dev/null +++ b/backend/app/templates/help_center/article.html @@ -0,0 +1,143 @@ +{# +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +A single help-center article: breadcrumb trail (which must stay in step with the +BreadcrumbList JSON-LD), rich Markdown body, "Was this helpful?" +feedback, and related articles. `article.body_html` is the ONLY value rendered +`| safe` — it is pre-sanitized to an HTML allowlist by help_center_content; +every other value stays autoescaped. +#}{% extends "help_center/base.html" %} + +{% block body %} +
+ + + + + +
+
+ + +

{{ article.question }}

+
+
{{ article.body_html | safe }}
+ {% if article.feedback_url %} + + {% endif %} +
+ + {% if article.related %} + + {% endif %} +
+
+
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/backend/app/templates/help_center/base.html b/backend/app/templates/help_center/base.html new file mode 100644 index 0000000..523cb75 --- /dev/null +++ b/backend/app/templates/help_center/base.html @@ -0,0 +1,420 @@ +{# +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Shared chrome for the public help center (list page + article page): head/meta, +all CSS, header, contact CTA, footer, and the chat-widget embed. Pages fill the +`hero`, `body` and `page_scripts` blocks. + +Every org/FAQ value is escaped by Jinja2 autoescaping. The ONE exception is the +article body, which is pre-sanitized to an HTML allowlist by +app.services.help_center_content and rendered with `| safe`; never mark any other +org content safe. Colors: only `--brand`/`--brand-ink` come from the server +(validated hex); every other tone is derived in CSS via color-mix. +#} + + + + + {{ title }} + + + {% if favicon_url %}{% endif %} + + + + + {% if og_site_name %}{% endif %} + {# og:image must be absolute (scrapers don't resolve relative URLs) — the + route absolutizes it. Without an image the card degrades to a text + summary rather than showing a broken thumbnail. #} + {% if og_image %} + + + + {% else %} + + {% endif %} + + + + + + + {% block extra_head %}{% endblock %} + + + +
+
+ + {% if logo_url %} + + {% else %} + + {% endif %} + {{ row.organization.name }} + Help Center + + +
+
+ + {% block hero %}{% endblock %} + + {% block body %}{% endblock %} + + + {% set cta_shown = row.cta_enabled and row.cta_text and row.cta_url %} + {% if widget_id or cta_shown %} +
+
+
+

Still need a hand?

+

{% if widget_id %}Our support assistant answers instantly, any time of day — and connects you to a human whenever you need one.{% else %}Reach out and our team will get back to you.{% endif %}

+
+ {% if widget_id %} + + {% elif cta_shown %} + {{ row.cta_text }} + {% endif %} +
+
+ {% endif %} + + + + + {% if json_ld %} + + {% endif %} + + + + + {% block page_scripts %}{% endblock %} + + {% if widget_id %} + + {% endif %} + + diff --git a/backend/app/templates/help_center/index.html b/backend/app/templates/help_center/index.html new file mode 100644 index 0000000..01b0c7b --- /dev/null +++ b/backend/app/templates/help_center/index.html @@ -0,0 +1,425 @@ +{# +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Help-center landing: hero + search, topic sidebar, and a list of FAQ cards that +link to their article pages (/a/{slug}). Fully server-rendered for SEO; the JS is +progressive enhancement only (topic filtering, client-side search, optional +Ask-AI fetch). Card previews/read-times are computed server-side. +#}{% extends "help_center/base.html" %} + +{% block hero %} +
+
+
+

How can we help?

+

Search our knowledge base, or pick a topic to browse.

+ + {% if groups %} + + {% endif %} +
+
+{% endblock %} + +{% block body %} + {% set first_cat = groups[0][0] if groups else '' %} +
+ + + + + +
+ + {% if ask_enabled %} + + {% endif %} + + + + + + + + +
+ {% for category, faqs in groups %} +
+
+
+ +
+
+

{{ category }}

+
{{ faqs | length }} {{ 'article' if faqs | length == 1 else 'articles' }}
+
+
+ +
+ {% endfor %} +
+ +
+
+ +
+

{% if groups %}No results{% else %}No published FAQs yet{% endif %}

+

{% if groups %}Try a different search{% if widget_id %}, or chat with us — our assistant can help right away{% endif %}.{% else %}Check back soon.{% endif %}

+ {% if widget_id %} + + {% endif %} +
+
+
+{% endblock %} + +{% block page_scripts %} + + + {% if ask_enabled %} + + {% endif %} +{% endblock %} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ebbd797 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + ChatterMate · 智能客服工作台 + + + + + + +
+ + + + + \ No newline at end of file diff --git a/frontend/src/assets/aiagent.svg b/frontend/src/assets/aiagent.svg new file mode 100644 index 0000000..2e92e9f --- /dev/null +++ b/frontend/src/assets/aiagent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/analytics.svg b/frontend/src/assets/analytics.svg new file mode 100644 index 0000000..cf24ccd --- /dev/null +++ b/frontend/src/assets/analytics.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/arrow-left.svg b/frontend/src/assets/arrow-left.svg new file mode 100644 index 0000000..0f429bb --- /dev/null +++ b/frontend/src/assets/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/avatar.svg b/frontend/src/assets/avatar.svg new file mode 100644 index 0000000..03515d6 --- /dev/null +++ b/frontend/src/assets/avatar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/avatars/avatar-01-asian-woman-1.png b/frontend/src/assets/avatars/avatar-01-asian-woman-1.png new file mode 100644 index 0000000..19349a4 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-01-asian-woman-1.png differ diff --git a/frontend/src/assets/avatars/avatar-02-black-man-1.png b/frontend/src/assets/avatars/avatar-02-black-man-1.png new file mode 100644 index 0000000..5100705 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-02-black-man-1.png differ diff --git a/frontend/src/assets/avatars/avatar-03-white-woman-glasses.png b/frontend/src/assets/avatars/avatar-03-white-woman-glasses.png new file mode 100644 index 0000000..338417f Binary files /dev/null and b/frontend/src/assets/avatars/avatar-03-white-woman-glasses.png differ diff --git a/frontend/src/assets/avatars/avatar-04-south-asian-man-glasses.png b/frontend/src/assets/avatars/avatar-04-south-asian-man-glasses.png new file mode 100644 index 0000000..e755478 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-04-south-asian-man-glasses.png differ diff --git a/frontend/src/assets/avatars/avatar-05-hijab-woman.png b/frontend/src/assets/avatars/avatar-05-hijab-woman.png new file mode 100644 index 0000000..b98176e Binary files /dev/null and b/frontend/src/assets/avatars/avatar-05-hijab-woman.png differ diff --git a/frontend/src/assets/avatars/avatar-06-latino-man.png b/frontend/src/assets/avatars/avatar-06-latino-man.png new file mode 100644 index 0000000..c4ee9ef Binary files /dev/null and b/frontend/src/assets/avatars/avatar-06-latino-man.png differ diff --git a/frontend/src/assets/avatars/avatar-07-asian-woman-2.png b/frontend/src/assets/avatars/avatar-07-asian-woman-2.png new file mode 100644 index 0000000..01cb609 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-07-asian-woman-2.png differ diff --git a/frontend/src/assets/avatars/avatar-08-black-woman-curly.png b/frontend/src/assets/avatars/avatar-08-black-woman-curly.png new file mode 100644 index 0000000..3c05fd9 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-08-black-woman-curly.png differ diff --git a/frontend/src/assets/avatars/avatar-09-white-man-glasses.png b/frontend/src/assets/avatars/avatar-09-white-man-glasses.png new file mode 100644 index 0000000..2062a86 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-09-white-man-glasses.png differ diff --git a/frontend/src/assets/avatars/avatar-10-south-asian-woman.png b/frontend/src/assets/avatars/avatar-10-south-asian-woman.png new file mode 100644 index 0000000..3f2f72a Binary files /dev/null and b/frontend/src/assets/avatars/avatar-10-south-asian-woman.png differ diff --git a/frontend/src/assets/avatars/avatar-11-latina-woman.png b/frontend/src/assets/avatars/avatar-11-latina-woman.png new file mode 100644 index 0000000..62761ce Binary files /dev/null and b/frontend/src/assets/avatars/avatar-11-latina-woman.png differ diff --git a/frontend/src/assets/avatars/avatar-12-east-asian-man.png b/frontend/src/assets/avatars/avatar-12-east-asian-man.png new file mode 100644 index 0000000..fa25a91 Binary files /dev/null and b/frontend/src/assets/avatars/avatar-12-east-asian-man.png differ diff --git a/frontend/src/assets/avatars/general-agent.svg b/frontend/src/assets/avatars/general-agent.svg new file mode 100644 index 0000000..046c536 --- /dev/null +++ b/frontend/src/assets/avatars/general-agent.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 0000000..7480eb9 --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,119 @@ +/* ChatterMate base — global resets & keyframes */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + --vt-c-indigo: #2c3e50; + --background-base-rgb: 11,12,16; + --background-soft-rgb: 14,15,20; + --background-dark-rgb: 0,0,0; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +html, body { + min-height: 100vh; + background: var(--bg); + color: var(--text); +} + +body { + line-height: 1.6; + font-family: var(--font-sans); + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Display headings */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + letter-spacing: var(--tracking-display); + color: var(--text); + line-height: 1.2; +} + +/* Inputs inherit font */ +input, textarea, select, button { + font-family: var(--font-sans); + color: var(--text); +} + +input::placeholder, textarea::placeholder { + color: var(--faint); +} + +/* Text selection */ +::selection { + background: #C9F24E; + color: #0B0C10; +} + +[data-theme="light"] ::selection { + background: #4C7A0B; + color: #ffffff; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--o10); + border-radius: 6px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--o16); +} + +/* Global focus ring */ +:focus-visible { + outline: none; + box-shadow: var(--ring-focus); + border-radius: var(--radius-sm); +} + +/* Keyframe animations */ +@keyframes cm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes cm-pulse { + 0%, 100% { opacity: .4; } + 50% { opacity: 1; } +} + +@keyframes cm-aurora { + 0% { transform: translate3d(-4%,-2%,0) scale(1); } + 50% { transform: translate3d(4%,3%,0) scale(1.12); } + 100% { transform: translate3d(-4%,-2%,0) scale(1); } +} + +@keyframes cm-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-12px); } +} + +@keyframes button-loading-spinner { + from { transform: rotate(0turn); } + to { transform: rotate(1turn); } +} diff --git a/frontend/src/assets/chat.svg b/frontend/src/assets/chat.svg new file mode 100644 index 0000000..1c834e1 --- /dev/null +++ b/frontend/src/assets/chat.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/cloudwatch-logo.svg b/frontend/src/assets/cloudwatch-logo.svg new file mode 100644 index 0000000..cc64d0e --- /dev/null +++ b/frontend/src/assets/cloudwatch-logo.svg @@ -0,0 +1 @@ +Amazon CloudWatch diff --git a/frontend/src/assets/config.svg b/frontend/src/assets/config.svg new file mode 100644 index 0000000..d4196a6 --- /dev/null +++ b/frontend/src/assets/config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/conversation.svg b/frontend/src/assets/conversation.svg new file mode 100644 index 0000000..c55ef85 --- /dev/null +++ b/frontend/src/assets/conversation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/delete.svg b/frontend/src/assets/delete.svg new file mode 100644 index 0000000..f801c53 --- /dev/null +++ b/frontend/src/assets/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/edit.svg b/frontend/src/assets/edit.svg new file mode 100644 index 0000000..a90f08c --- /dev/null +++ b/frontend/src/assets/edit.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/elasticsearch-logo.svg b/frontend/src/assets/elasticsearch-logo.svg new file mode 100644 index 0000000..1f7a47e --- /dev/null +++ b/frontend/src/assets/elasticsearch-logo.svg @@ -0,0 +1 @@ +Elasticsearch diff --git a/frontend/src/assets/email-logo.svg b/frontend/src/assets/email-logo.svg new file mode 100644 index 0000000..edd3250 --- /dev/null +++ b/frontend/src/assets/email-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/grafana-logo.svg b/frontend/src/assets/grafana-logo.svg new file mode 100644 index 0000000..7acee0e --- /dev/null +++ b/frontend/src/assets/grafana-logo.svg @@ -0,0 +1 @@ +Grafana diff --git a/frontend/src/assets/home.svg b/frontend/src/assets/home.svg new file mode 100644 index 0000000..42363bb --- /dev/null +++ b/frontend/src/assets/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/hubspot-logo.svg b/frontend/src/assets/hubspot-logo.svg new file mode 100644 index 0000000..c32350f --- /dev/null +++ b/frontend/src/assets/hubspot-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/humanagent.svg b/frontend/src/assets/humanagent.svg new file mode 100644 index 0000000..245ba95 --- /dev/null +++ b/frontend/src/assets/humanagent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/apple.svg b/frontend/src/assets/icons/apple.svg new file mode 100644 index 0000000..564db49 --- /dev/null +++ b/frontend/src/assets/icons/apple.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/check.svg b/frontend/src/assets/icons/check.svg new file mode 100644 index 0000000..e2971f0 --- /dev/null +++ b/frontend/src/assets/icons/check.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/google.svg b/frontend/src/assets/icons/google.svg new file mode 100644 index 0000000..01e91a1 --- /dev/null +++ b/frontend/src/assets/icons/google.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/instagram-logo.svg b/frontend/src/assets/instagram-logo.svg new file mode 100644 index 0000000..8202c25 --- /dev/null +++ b/frontend/src/assets/instagram-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/integrations.svg b/frontend/src/assets/integrations.svg new file mode 100644 index 0000000..3d1fd4a --- /dev/null +++ b/frontend/src/assets/integrations.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/jira-logo.svg b/frontend/src/assets/jira-logo.svg new file mode 100644 index 0000000..400b99a --- /dev/null +++ b/frontend/src/assets/jira-logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/knowledge.svg b/frontend/src/assets/knowledge.svg new file mode 100644 index 0000000..d730459 --- /dev/null +++ b/frontend/src/assets/knowledge.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/line-logo.svg b/frontend/src/assets/line-logo.svg new file mode 100644 index 0000000..e6b4212 --- /dev/null +++ b/frontend/src/assets/line-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/logo-signup.svg b/frontend/src/assets/logo-signup.svg new file mode 100644 index 0000000..2ac818d --- /dev/null +++ b/frontend/src/assets/logo-signup.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000..e536eef --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/messenger-logo.svg b/frontend/src/assets/messenger-logo.svg new file mode 100644 index 0000000..9a7a510 --- /dev/null +++ b/frontend/src/assets/messenger-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/notification.svg b/frontend/src/assets/notification.svg new file mode 100644 index 0000000..c170543 --- /dev/null +++ b/frontend/src/assets/notification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/organization.svg b/frontend/src/assets/organization.svg new file mode 100644 index 0000000..23ff259 --- /dev/null +++ b/frontend/src/assets/organization.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/pipedrive-logo.svg b/frontend/src/assets/pipedrive-logo.svg new file mode 100644 index 0000000..667c605 --- /dev/null +++ b/frontend/src/assets/pipedrive-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/robot.png b/frontend/src/assets/robot.png new file mode 100644 index 0000000..9902acc Binary files /dev/null and b/frontend/src/assets/robot.png differ diff --git a/frontend/src/assets/sendbutton.svg b/frontend/src/assets/sendbutton.svg new file mode 100644 index 0000000..1d39614 --- /dev/null +++ b/frontend/src/assets/sendbutton.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/sentry-logo.svg b/frontend/src/assets/sentry-logo.svg new file mode 100644 index 0000000..5b348c8 --- /dev/null +++ b/frontend/src/assets/sentry-logo.svg @@ -0,0 +1 @@ +Sentry diff --git a/frontend/src/assets/shopify-logo.svg b/frontend/src/assets/shopify-logo.svg new file mode 100644 index 0000000..426386e --- /dev/null +++ b/frontend/src/assets/shopify-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/signup_rightview.svg b/frontend/src/assets/signup_rightview.svg new file mode 100644 index 0000000..eb82bca --- /dev/null +++ b/frontend/src/assets/signup_rightview.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + Hello! How can I assist you today? + + + + I need help with my account + + + + I'll guide you through the process + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/slack-logo.svg b/frontend/src/assets/slack-logo.svg new file mode 100644 index 0000000..fb0fadf --- /dev/null +++ b/frontend/src/assets/slack-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/sms-logo.svg b/frontend/src/assets/sms-logo.svg new file mode 100644 index 0000000..99771df --- /dev/null +++ b/frontend/src/assets/sms-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/styles/base.css b/frontend/src/assets/styles/base.css new file mode 100644 index 0000000..7763d76 --- /dev/null +++ b/frontend/src/assets/styles/base.css @@ -0,0 +1,54 @@ +@import './design-tokens.css'; + +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; + font-weight: normal; +} + +/* Base Styles */ +body { + min-height: 100vh; + color: var(--text-color); + background: var(--background-color); + line-height: 1.6; + font-family: var(--font-family); + font-size: var(--text-base); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.2; +} + +h1 { font-size: var(--text-4xl); } +h2 { font-size: var(--text-3xl); } +h3 { font-size: var(--text-2xl); } +h4 { font-size: var(--text-xl); } +h5 { font-size: var(--text-lg); } +h6 { font-size: var(--text-base); } + +/* Links */ +a { + color: var(--primary-color); + text-decoration: none; + transition: var(--transition-fast); +} + +a:hover { + opacity: 0.8; +} + +/* Focus Styles */ +:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/components.css b/frontend/src/assets/styles/components.css new file mode 100644 index 0000000..0f8a4fb --- /dev/null +++ b/frontend/src/assets/styles/components.css @@ -0,0 +1,381 @@ +/* Buttons */ +.btn { + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-md); + transition: background-color var(--transition-fast), box-shadow var(--transition-fast), + transform var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast); + cursor: pointer; + border: 1px solid transparent; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + line-height: 1.4; + background: var(--accent-solid); + color: #0B0C10; +} + +.btn:hover { + opacity: 0.88; +} + +.btn:active { + transform: translateY(1px); +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--ring-focus); +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--accent-solid); + color: #0B0C10; + border-color: transparent; +} + +.btn-primary:hover { + opacity: 0.88; +} + +.btn-secondary { + background: var(--o06); + color: var(--text); + border: 1px solid var(--o14); +} + +.btn-secondary:hover { + background: var(--o10); + border-color: var(--o20); +} + +.btn-ghost { + background: transparent; + color: var(--text3); + border: 1px solid var(--o12); +} + +.btn-ghost:hover { + background: var(--o06); + color: var(--text); + border-color: var(--o20); +} + +.btn-danger { + background: transparent; + color: var(--c-coral); + border: 1px solid rgba(255,138,115,.3); +} + +.btn-danger:hover { + background: rgba(255,138,115,.08); + border-color: rgba(255,138,115,.5); +} + +/* Loading state */ +.btn.loading { + position: relative; + color: transparent !important; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + top: 50%; + left: 50%; + margin: -8px 0 0 -8px; + border: 2px solid rgba(11,12,16,.4); + border-right-color: #0B0C10; + border-radius: 50%; + animation: button-loading-spinner 0.75s linear infinite; +} + +/* Forms */ +.form-group { + margin-bottom: var(--space-lg); + width: 100%; +} + +.form-label { + display: block; + margin-bottom: 9px; + font-weight: var(--font-weight-medium); + color: var(--text3); + font-size: 13.5px; +} + +.form-input { + width: 100%; + padding: 14px 16px; + font-family: var(--font-sans); + font-size: var(--text-base); + background: var(--bg); + border: 1px solid var(--o12); + border-radius: 12px; + color: var(--text); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-input::placeholder { + color: var(--faint); +} + +.form-input:hover { + border-color: var(--o20); +} + +.form-input:focus, +.form-input:focus-visible { + border-color: var(--accent-ink); + box-shadow: var(--ring-focus); + outline: none; +} + +.form-input:disabled { + background: var(--o04); + color: var(--muted); + cursor: not-allowed; +} + +.form-input.error, +.form-input[aria-invalid='true'] { + border-color: var(--error-color); +} + +.form-input.error:focus, +.form-input[aria-invalid='true']:focus { + box-shadow: 0 0 0 3px var(--error-bg); +} + +/* Cards */ +.card { + background: var(--surface); + border-radius: 18px; + border: 1px solid var(--o08); + padding: var(--space-xl); + width: 100%; + max-width: 480px; + margin: 0 auto; +} + +.card-full { + max-width: none; +} + +/* Stepper */ +.progress-steps { + display: flex; + justify-content: center; + align-items: flex-start; + gap: var(--space-2xl); + margin-bottom: var(--space-2xl); +} + +.progress-step { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--space-md); + width: 140px; +} + +.step-indicator { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: var(--o05); + border: 2px solid var(--o14); + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--font-weight-semibold); + font-family: var(--font-display); + transition: background-color var(--transition-normal), border-color var(--transition-normal), + color var(--transition-normal); +} + +.step-indicator.active { + background: rgba(201,242,78,.1); + border-color: var(--accent-ink); + color: var(--accent-ink); +} + +.step-indicator.completed { + background: var(--accent-solid); + border-color: var(--accent-ink); + color: #0B0C10; +} + +.step-details h3 { + font-size: var(--text-base); + font-family: var(--font-display); + margin-bottom: var(--space-xs); + color: var(--text); +} + +.step-details p { + font-size: var(--text-sm); + color: var(--muted); +} + +@media (max-width: 768px) { + .progress-steps { + flex-direction: column; + align-items: flex-start; + gap: var(--space-lg); + } + + .progress-step { + flex-direction: row; + width: 100%; + text-align: left; + } + + .step-details { + flex: 1; + } +} + +/* Modal buttons. + Global rather than scoped because BaseModal takes its actions through a + slot, and slot content is styled by the parent — three modals were each + carrying a byte-identical private copy of these rules. + Distinct from .btn above, which is the app's louder primary action: these + are the quieter Cancel/Confirm pair a dialog footer wants. */ +.modal-btn { + padding: 9px 16px; + border-radius: var(--radius-btn, 8px); + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color); + background: var(--background-soft); + color: inherit; +} + +.modal-btn-primary { + background: var(--accent-solid); + color: var(--on-accent-solid); + border-color: transparent; +} + +.modal-btn-danger { + background: var(--c-danger, var(--danger)); + color: var(--on-accent-solid); + border-color: transparent; +} + +.modal-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Native checkboxes/radios follow the design accent instead of the browser + default (blue), so form controls match the theme app-wide. */ +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--accent-ink); +} + +/* Nav section heading — shared by the desktop sidebar and the mobile More + sheet so the two menus can't drift apart visually. */ +.nav-section-heading { + color: var(--faint); + font-family: var(--font-mono); + font-size: 11px; + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +/* ── Responsive table → cards ──────────────────────────────────────────── + Desktop keeps its own grid template; below 768px each row becomes a card: + the primary cell and a trailing badge share line 1, and the remaining + values wrap onto line 2 carrying their own labels (the table header is + hidden, so each value has to name itself). + + Markup: .rcards-head on the header row, .rcards-row on each row, + .rcards-primary / .rcards-badge on the line-1 cells, and .rcards-meta + wrapping a .rcards-label + .rcards-value pair on every other cell. + + The consuming component must scope its desktop grid to + @media (min-width: 769px) — a scoped SFC rule carries a [data-v-*] + attribute and would otherwise outrank these global classes. */ +.rcards-label { + display: none; +} + +@media (max-width: 768px) { + .rcards-head { + display: none; + } + + .rcards-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + } + + /* Zero-height full-width break: pushes the meta cells to their own line + without assuming how wide the badge renders */ + .rcards-row::after { + content: ''; + order: 3; + flex: 1 0 100%; + height: 0; + } + + .rcards-primary { + flex: 1 1 auto; + min-width: 0; + order: 1; + } + + .rcards-badge { + flex: 0 0 auto; + margin-left: auto; + order: 2; + } + + .rcards-meta { + order: 4; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + display: inline-flex; + align-items: baseline; + gap: 4px; + font-size: 12px; + color: var(--muted); + } + + .rcards-label { + display: inline; + font-size: 10.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--faint); + } + + /* text-overflow needs its own block box — it does nothing on a flex item */ + .rcards-value { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/frontend/src/assets/styles/design-tokens.css b/frontend/src/assets/styles/design-tokens.css new file mode 100644 index 0000000..93558dc --- /dev/null +++ b/frontend/src/assets/styles/design-tokens.css @@ -0,0 +1,340 @@ +/* ChatterMate Design Tokens — dark-first, lime accent */ +:root { + /* Surfaces */ + --bg: #0B0C10; + --bg2: #0E0F14; + --surface: #13151C; + --topbar: rgba(11,12,16,.8); + + /* Accent */ + --accent-ink: #C9F24E; /* accent for text / borders / icons (darkens in light mode) */ + --accent-solid: #C9F24E; /* accent for FILLED surfaces (stays lime in both themes) */ + --on-accent-solid: #0B0C10; /* text/icon on --accent-solid (dark in both themes) */ + --c-lime: #C9F24E; + --c-teal: #5FE3D6; + --c-purple: #9D8CFF; + --c-coral: #FF8A73; + + /* Contrast inks — text/icon placed on top of a fill */ + --on-accent: #0B0C10; /* on lime accent */ + --on-light: #0B0C10; /* on any light fill */ + --on-dark: #F5F6F8; /* on any dark/coloured fill (theme-invariant) */ + + /* Elevated surfaces / header gradient */ + --bg-elevated: #101119; + --bg-deep: #0A0B0E; + --header-gradient: linear-gradient(180deg, var(--bg-elevated), var(--bg)); + --scrim: rgba(6,7,10,.72); + + /* Mode pills (agent editor) */ + --mode-ai-bg: #3B82F6; + --mode-ai-fg: #FFFFFF; + --mode-wf-bg: #9D8CFF; + --mode-wf-fg: #0B0C10; + --pill-idle-bg: var(--o05); + --pill-idle-fg: var(--muted); + + /* Extended status / chart palette */ + --c-danger: #FF6B6B; + --c-warn: #E6A23C; + --c-info: #8FB8FF; + --c-positive: #7CE38B; + --c-neutral: #8A93A3; + --c-online: #8FEDE3; /* online label text */ + --c-pro: #C5BDFF; /* PRO badge text */ + + /* Theme-aware tints (derived from base palette, auto-adjust per theme) */ + --accent-bg-06: color-mix(in srgb, var(--accent-ink) 6%, transparent); + --accent-bg-08: color-mix(in srgb, var(--accent-ink) 8%, transparent); + --accent-bg-12: color-mix(in srgb, var(--accent-ink) 12%, transparent); + --accent-border: color-mix(in srgb, var(--accent-ink) 30%, transparent); + --teal-bg-10: color-mix(in srgb, var(--c-teal) 10%, transparent); + --teal-bg: color-mix(in srgb, var(--c-teal) 12%, transparent); + --teal-border: color-mix(in srgb, var(--c-teal) 22%, transparent); + --purple-bg: color-mix(in srgb, var(--c-purple) 15%, transparent); + --purple-border: color-mix(in srgb, var(--c-purple) 28%, transparent); + --coral-bg: color-mix(in srgb, var(--c-coral) 12%, transparent); + --coral-border: color-mix(in srgb, var(--c-coral) 30%, transparent); + + /* Controls */ + --toggle-knob: #FFFFFF; + --toggle-track-off: var(--o12); + --toggle-on-teal: var(--c-teal); + --toggle-on-accent: var(--accent-ink); + + /* Inbox message bubbles */ + --bubble-customer-bg: var(--o05); + --bubble-customer-fg: var(--text2); + --bubble-ai-bg: var(--teal-bg-10); + --bubble-ai-border: var(--teal-border); + --bubble-ai-fg: var(--text2); + --bubble-agent-bg: var(--accent-ink); + --bubble-agent-fg: var(--on-accent); + + /* Brand gradients / avatars */ + --grad-purple-teal: linear-gradient(135deg, var(--c-purple), var(--c-teal)); + --grad-lime-teal: linear-gradient(135deg, var(--accent-ink), var(--c-teal)); + --grad-generate: linear-gradient(135deg, var(--c-purple), #7C6AE6); + + /* Text scale */ + --text: #F5F6F8; + --text2: #E7EAF0; + --text3: #C7CCD6; + --muted: #9CA3B0; + /* Small text on every surface (down to #1A1C23) must reach WCAG AA 4.5:1. */ + --muted2: #868D9C; + --faint: #7F8696; + --code: #D7DBE3; + + /* White-opacity tokens — borders, dividers, overlays */ + --o03: rgba(255,255,255,.03); + --o04: rgba(255,255,255,.04); + --o05: rgba(255,255,255,.05); + --o06: rgba(255,255,255,.06); + --o07: rgba(255,255,255,.07); + --o08: rgba(255,255,255,.08); + --o10: rgba(255,255,255,.1); + --o12: rgba(255,255,255,.12); + --o14: rgba(255,255,255,.14); + --o16: rgba(255,255,255,.16); + --o20: rgba(255,255,255,.2); + --o25: rgba(255,255,255,.25); + --o30: rgba(255,255,255,.3); + + /* Semantic */ + --success-color: #0f9d6e; + --error-color: #DC2626; + --error-dark: #b91c1c; + --warning-color: #D97706; + --info-color: #2563EB; + --success-bg: rgba(15,157,110,.1); + --error-bg: rgba(220,38,38,.1); + --warning-bg: rgba(217,119,6,.1); + --info-bg: rgba(37,99,235,.1); + + /* Fonts */ + --font-family: 'Instrument Sans', system-ui, -apple-system, sans-serif; + --font-sans: 'Instrument Sans', system-ui, -apple-system, sans-serif; + --font-display: 'Space Grotesk', sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + /* Font weights */ + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Letter spacing */ + --tracking-display: -0.02em; + --tracking-tight: -0.01em; + --tracking-wide: 0.04em; + + /* Font sizes */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + --space-3xl: 4rem; + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-full: 9999px; + /* Design-spec radii */ + --radius-chip: 10px; + --radius-btn: 11px; + --radius-input: 12px; + --radius-card: 18px; + --radius-card-lg: 20px; + --radius-pill: 999px; + + /* Transitions */ + --transition-fast: 0.18s cubic-bezier(0.4,0,0.2,1); + --transition-normal: 0.25s cubic-bezier(0.4,0,0.2,1); + --transition-slow: 0.4s cubic-bezier(0.4,0,0.2,1); + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,.15); + --shadow-md: 0 4px 12px rgba(0,0,0,.2); + --shadow-lg: 0 20px 50px rgba(0,0,0,.5); + + /* Focus ring */ + --ring-focus: 0 0 0 3px rgba(201,242,78,.15); + + /* Icon filters (for SVG img elements on dark bg) */ + --icon-filter: brightness(0) invert(1); + --icon-opacity: 0.5; + --icon-active-opacity: 0.9; + + /* Mobile app shell */ + --bottom-nav-height: 56px; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + + /* Overlay stacking order — every fixed overlay must use one of these */ + --z-topbar: 50; + --z-bottom-nav: 900; + --z-drawer: 1000; /* notification drawer, sidebar overlay */ + --z-fullscreen-page: 1050; /* mobile chat-info "Details" page */ + --z-prompt: 1090; /* push/install prompts */ + --z-sheet: 1100; /* More bottom sheet */ + --z-support-chat: 1150; /* hosted support chat drawer (read from JS) */ + --z-modal: 2000; + + /* Breakpoints — keep in sync with useBreakpoint.ts (JS can't read these + at media-query time: --breakpoint-sm = MOBILE, --breakpoint-md = TABLET) */ + --breakpoint-xs: 480px; + --breakpoint-sm: 768px; + --breakpoint-md: 1024px; + --breakpoint-lg: 1280px; + --breakpoint-xl: 1366px; + --breakpoint-xxl: 1440px; + --breakpoint-xxxl: 1600px; + + /* Legacy aliases (keep for component compatibility) */ + --primary-color: var(--accent-ink); + --primary-dark: #b3d943; + --background-color: var(--bg); + --background-base: var(--surface); + --background-soft: var(--bg2); + --background-mute: var(--o08); + --hover-bg: var(--o05); + --border-color: var(--o10); + --border-color-hover: var(--o16); + --text-primary: var(--text); + --text-secondary: var(--text3); + --text-color: var(--text); + --text-heading: var(--text); + --text-muted: var(--muted); + --text-placeholder: var(--faint); + --code-bg: var(--surface); + --primary-soft: rgba(201,242,78,.1); + + /* Legacy aliases used by the agent editor (resolve via flipping tokens) */ + --accent-color: var(--accent-ink); + --background-alt: var(--bg2); + --background-muted: var(--o05); + --radius-xl: 20px; + --success: var(--success-color); + --success-dark: var(--success-color); + --success-light: color-mix(in srgb, var(--success-color) 14%, transparent); + --success-color-soft: color-mix(in srgb, var(--success-color) 12%, transparent); + --warning: var(--warning-color); + --warning-light: color-mix(in srgb, var(--warning-color) 14%, transparent); + --error-light: var(--coral-bg); + --error-soft: var(--coral-bg); + --error-color-soft: var(--coral-bg); + --primary-color-filter: none; +} + +/* Light theme override */ +[data-theme="light"] { + --bg: #EEF0F4; + --bg2: #FFFFFF; + --surface: #FFFFFF; + --topbar: rgba(255,255,255,.85); + + /* Accent and status inks are used as text on white and on their own pale + tints: darkened until they reach WCAG AA 4.5:1 on both. */ + --accent-ink: #44700A; + --c-lime: #44700A; + --c-teal: #097171; + --c-purple: #6454BF; + --c-coral: #AC4829; + --warning-color: #9C5604; + + --text: #171A21; + --text2: #222732; + --text3: #3C424E; + --muted: #5C6473; + /* Small text on every surface (down to #EEF0F4) must reach WCAG AA 4.5:1. */ + --muted2: #626977; + --faint: #646B79; + --code: #3C424E; + + --o03: rgba(17,21,30,.03); + --o04: rgba(17,21,30,.04); + --o05: rgba(17,21,30,.05); + --o06: rgba(17,21,30,.06); + --o07: rgba(17,21,30,.075); + --o08: rgba(17,21,30,.08); + --o10: rgba(17,21,30,.09); + --o12: rgba(17,21,30,.1); + --o14: rgba(17,21,30,.11); + --o16: rgba(17,21,30,.12); + --o20: rgba(17,21,30,.14); + --o25: rgba(17,21,30,.18); + --o30: rgba(17,21,30,.2); + + /* Icon filters for light theme — SVGs are already dark, no inversion needed */ + --icon-filter: brightness(1); + --icon-opacity: 0.6; + --icon-active-opacity: 0.85; + + --shadow-sm: 0 1px 2px rgba(17,21,30,.05); + --shadow-md: 0 4px 12px rgba(17,21,30,.08); + --shadow-lg: 0 20px 50px rgba(17,21,30,.15); + + --ring-focus: 0 0 0 3px rgba(76,122,11,.2); + --primary-soft: rgba(76,122,11,.08); + + /* Contrast inks — accent is dark-olive in light, so text on it must be white */ + --on-accent: #FFFFFF; + --on-light: #0B0C10; + --on-dark: #F5F6F8; + + /* Elevated surfaces / scrim (gradients & tints referencing base tokens auto-recompute) */ + --bg-elevated: #FFFFFF; + --bg-deep: #F4F6FA; + --scrim: rgba(17,21,30,.4); + + /* Mode pills — darker fills need light text in light theme */ + --mode-ai-bg: #2563EB; + --mode-ai-fg: #FFFFFF; + --mode-wf-bg: #6D5BD0; + --mode-wf-fg: #FFFFFF; + + /* Extended status / chart palette tuned for light bg: each reaches WCAG AA + 4.5:1 as text on white and on its own pale tint. */ + --c-danger: #B33F3F; + --c-warn: #8F5E18; + --c-info: #225BD8; + --c-positive: #187742; + --c-neutral: #606773; + --c-online: #097171; + --c-pro: #6454BF; + + /* Generate gradient — darker end stop for light */ + --grad-generate: linear-gradient(135deg, var(--c-purple), #5B49C9); + + /* Legacy aliases */ + --primary-color: var(--accent-ink); + --primary-dark: #3d6209; + --background-color: var(--bg); + --background-base: var(--surface); + --background-soft: var(--bg2); + --background-mute: var(--o08); + --hover-bg: var(--o05); + --border-color: var(--o10); + --border-color-hover: var(--o16); + --text-primary: var(--text); + --text-secondary: var(--text3); + --text-color: var(--text); + --text-heading: var(--text); + --text-muted: var(--muted); + --text-placeholder: var(--faint); + --code-bg: var(--surface); +} diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css new file mode 100644 index 0000000..37c2b18 --- /dev/null +++ b/frontend/src/assets/styles/main.css @@ -0,0 +1,9 @@ +@import './design-tokens.css'; +@import './base.css'; +@import './components.css'; +@import './utilities.css'; + +#app { + min-height: 100vh; + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/assets/styles/utilities.css b/frontend/src/assets/styles/utilities.css new file mode 100644 index 0000000..8ecfcd3 --- /dev/null +++ b/frontend/src/assets/styles/utilities.css @@ -0,0 +1,95 @@ +/* Layout */ +.container { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 0 var(--space-xl); + min-height: 100vh; +} + +.container-fluid { + width: 100%; + margin: 0 auto; + padding: 0 var(--space-xl); +} + +/* Responsive containers */ +@media (max-width: 1366px) { + .container, + .container-fluid { + max-width: 1200px; + padding: 0 var(--space-lg); + } +} + +@media (max-width: 1280px) { + .container, + .container-fluid { + padding: 0 var(--space-md); + } +} + +@media (max-width: 768px) { + .container, + .container-fluid { + padding: 0 var(--space-sm); + } +} + +/* Flexbox */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.gap-sm { gap: var(--space-sm); } +.gap-md { gap: var(--space-md); } +.gap-lg { gap: var(--space-lg); } + +/* Spacing */ +.m-0 { margin: 0; } +.mt-lg { margin-top: var(--space-lg); } +.mb-lg { margin-bottom: var(--space-lg); } +.mx-auto { margin-left: auto; margin-right: auto; } + +/* Text */ +.text-center { text-align: center; } +.text-sm { font-size: var(--text-sm); } +.text-lg { font-size: var(--text-lg); } +.font-bold { font-weight: 600; } +.opacity-80 { opacity: 0.8; } + +/* Responsive visibility utilities */ +.hidden { display: none !important; } +.visible { display: block !important; } + +/* Show/hide on specific screen sizes */ +@media (max-width: 1366px) { + .hidden-laptop-13 { display: none !important; } + .visible-laptop-13 { display: block !important; } +} + +@media (max-width: 1280px) { + .hidden-laptop-small { display: none !important; } + .visible-laptop-small { display: block !important; } +} + +@media (max-width: 1024px) { + .hidden-tablet { display: none !important; } + .visible-tablet { display: block !important; } +} + +@media (max-width: 768px) { + .hidden-mobile { display: none !important; } + .visible-mobile { display: block !important; } +} + +/* Responsive spacing utilities for 13-inch laptops */ +@media (max-width: 1366px) { + .p-responsive-13 { padding: var(--space-md) !important; } + .px-responsive-13 { padding-left: var(--space-md) !important; padding-right: var(--space-md) !important; } + .py-responsive-13 { padding-top: var(--space-md) !important; padding-bottom: var(--space-md) !important; } + .m-responsive-13 { margin: var(--space-md) !important; } + .mx-responsive-13 { margin-left: var(--space-md) !important; margin-right: var(--space-md) !important; } + .my-responsive-13 { margin-top: var(--space-md) !important; margin-bottom: var(--space-md) !important; } +} \ No newline at end of file diff --git a/frontend/src/assets/subscription.svg b/frontend/src/assets/subscription.svg new file mode 100644 index 0000000..02b39d1 --- /dev/null +++ b/frontend/src/assets/subscription.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/telegram-logo.svg b/frontend/src/assets/telegram-logo.svg new file mode 100644 index 0000000..fc78faa --- /dev/null +++ b/frontend/src/assets/telegram-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/assets/user.png b/frontend/src/assets/user.png new file mode 100644 index 0000000..af64069 Binary files /dev/null and b/frontend/src/assets/user.png differ diff --git a/frontend/src/assets/user.svg b/frontend/src/assets/user.svg new file mode 100644 index 0000000..c6894a3 --- /dev/null +++ b/frontend/src/assets/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/view.svg b/frontend/src/assets/view.svg new file mode 100644 index 0000000..9bb1fca --- /dev/null +++ b/frontend/src/assets/view.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/whatsapp-logo.svg b/frontend/src/assets/whatsapp-logo.svg new file mode 100644 index 0000000..023043f --- /dev/null +++ b/frontend/src/assets/whatsapp-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/widget-apps.svg b/frontend/src/assets/widget-apps.svg new file mode 100644 index 0000000..9b660b6 --- /dev/null +++ b/frontend/src/assets/widget-apps.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/widget_config.png b/frontend/src/assets/widget_config.png new file mode 100644 index 0000000..639172b Binary files /dev/null and b/frontend/src/assets/widget_config.png differ diff --git a/frontend/src/assets/zendesk-logo.svg b/frontend/src/assets/zendesk-logo.svg new file mode 100644 index 0000000..f82cc15 --- /dev/null +++ b/frontend/src/assets/zendesk-logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/pwa/InstallPrompt.vue b/frontend/src/components/pwa/InstallPrompt.vue new file mode 100644 index 0000000..a473eb7 --- /dev/null +++ b/frontend/src/components/pwa/InstallPrompt.vue @@ -0,0 +1,75 @@ + + + + + + + diff --git a/frontend/src/composables/usePWAInstall.ts b/frontend/src/composables/usePWAInstall.ts new file mode 100644 index 0000000..4fa2019 --- /dev/null +++ b/frontend/src/composables/usePWAInstall.ts @@ -0,0 +1,70 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { computed, ref } from 'vue' + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +// Module-level: beforeinstallprompt often fires before any component mounts, +// so the listener must be attached at import time. +const deferredPrompt = ref(null) +const installed = ref(false) + +if (typeof window !== 'undefined') { + window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault() + deferredPrompt.value = event as BeforeInstallPromptEvent + }) + window.addEventListener('appinstalled', () => { + installed.value = true + deferredPrompt.value = null + }) +} + +export function usePWAInstall() { + const isStandalone = computed( + () => + installed.value || + (typeof window !== 'undefined' && + (window.matchMedia?.('(display-mode: standalone)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true)) + ) + + const isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent) + + // Native prompt available (Chrome/Edge/Android) + const canInstall = computed(() => !isStandalone.value && !!deferredPrompt.value) + + // iOS never fires beforeinstallprompt — install is manual via the share sheet + const needsManualInstall = computed(() => !isStandalone.value && isIOS) + + const promptInstall = async (): Promise => { + const prompt = deferredPrompt.value + if (!prompt) return false + await prompt.prompt() + const choice = await prompt.userChoice + if (choice.outcome === 'accepted') { + deferredPrompt.value = null + return true + } + return false + } + + return { isStandalone, isIOS, canInstall, needsManualInstall, promptInstall } +} diff --git a/frontend/src/pwa/navigationDenylist.ts b/frontend/src/pwa/navigationDenylist.ts new file mode 100644 index 0000000..49795d0 --- /dev/null +++ b/frontend/src/pwa/navigationDenylist.ts @@ -0,0 +1,54 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Navigations the service worker must NOT answer with the SPA shell. + * + * Lives outside sw.ts so it can be tested without ServiceWorker globals. + * + * Workbox matches these against `url.pathname + url.search`, **not** the + * pathname alone (see workbox-routing/NavigationRoute `_match`). An anchored + * `$` therefore stops matching the moment a provider appends a query string — + * which is precisely the case that matters here, since OAuth providers always + * redirect back with `?code=…`. + */ +export const NAVIGATION_DENYLIST: RegExp[] = [ + /^\/api\//, + /^\/widget/, + /^\/webclient/, + /^\/shopify/, + // Anything carrying a file extension is a real file served from public/, + // never an SPA route — no route in the router has one. + // + // This is what keeps meta-oauth-callback.html working: Meta redirects the + // login popup there, and the page exists only to postMessage the code back + // to its opener. Answer it with index.html and the SPA boots instead, fails + // to route the path, lands on the dashboard, and the connect dies silently. + // Being precached does not save it — a precache lookup does not ignore + // `?code=&state=`, so the request misses the entry and reaches the fallback. + // + // `[^?]*` keeps the extension match on the path, so a dot inside a query + // string (`/settings?q=a.b`) is not mistaken for a filename. + /^[^?]*\.[a-z0-9]+(\?|$)/i, +] + +/** + * Whether this navigation should bypass the SPA shell. Mirrors workbox's own + * denylist check, so tests exercise the real matching semantics. + */ +export function isDeniedNavigation(pathnameAndSearch: string): boolean { + return NAVIGATION_DENYLIST.some((pattern) => pattern.test(pathnameAndSearch)) +} diff --git a/frontend/src/pwa/pushContract.ts b/frontend/src/pwa/pushContract.ts new file mode 100644 index 0000000..7effbdf --- /dev/null +++ b/frontend/src/pwa/pushContract.ts @@ -0,0 +1,33 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * The message contract between the service worker (src/sw.ts) and the page + * (src/composables/useNotifications.ts). Both sides import from here so a + * rename can never silently break background toasts or notification taps. + */ +export const SW_MESSAGE = { + /** SW → page: a background push arrived; page shows the in-app toast state */ + BACKGROUND_NOTIFICATION: 'BACKGROUND_NOTIFICATION', + /** SW → page: user tapped a notification the SW couldn't navigate() itself */ + OPEN_CONVERSATION: 'OPEN_CONVERSATION', +} as const + +/** The one place the conversation deep-link URL is spelled. */ +export const CONVERSATIONS_PATH = '/conversations' + +export const conversationSessionUrl = (sessionId?: string | null): string => + sessionId ? `${CONVERSATIONS_PATH}?session=${encodeURIComponent(sessionId)}` : '/' diff --git a/frontend/src/pwa/register.ts b/frontend/src/pwa/register.ts new file mode 100644 index 0000000..f0b1b92 --- /dev/null +++ b/frontend/src/pwa/register.ts @@ -0,0 +1,117 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { registerSW } from 'virtual:pwa-register' + +// Never register inside the Shopify admin iframe or on /shopify/* routes — +// the embedded app has its own lifecycle and must not install an app shell. +export const isShopifyEmbedded = (): boolean => { + try { + return window.top !== window.self || window.location.pathname.startsWith('/shopify') + } catch { + return true + } +} + +/** + * How long to wait for the new worker to take control before forcing a reload. + * Activation is normally well under a second; this only covers a worker that + * fails to activate or a browser that withholds controllerchange, so the user + * is never stranded on the old build. + */ +const UPDATE_TAKEOVER_TIMEOUT_MS = 4000 + +/** + * Offer the new build rather than forcing it: an agent mid-reply should not + * have the page reloaded under them. vue-sonner is imported lazily so the + * registration path stays off the startup critical path. + */ +async function promptForUpdate(reload: (reloadPage?: boolean) => Promise) { + try { + const { toast } = await import('vue-sonner') + const id = toast('ChatterMate 有新版本可用', { + description: '刷新页面即可使用最新版本。', + duration: Number.POSITIVE_INFINITY, + action: { + label: '刷新', + onClick: () => { + // Do NOT reload here. registerSW's updateSW ignores its reloadPage + // argument and only posts SKIP_WAITING, without awaiting activation + // — it resolves within a tick. The page refresh is done by the + // `controlling` listener registerSW arms just before it calls us. + // + // Reloading here raced that handshake and always won: the document + // came back under the OLD worker with the new one still in `waiting`, + // so the next load re-dispatched `waiting` and the prompt returned. + // That is why the toast reappeared on every release and no amount of + // clicking Reload cleared it. + toast.dismiss(id) + void reload() + window.setTimeout(() => window.location.reload(), UPDATE_TAKEOVER_TIMEOUT_MS) + }, + }, + }) + } catch (err) { + console.error('Failed to show update prompt:', err) + } +} + +export function setupPWA() { + if (!('serviceWorker' in navigator) || isShopifyEmbedded()) return + + // Clients from pre-PWA deployments still hold the Firebase-only worker that + // Firebase's getToken() self-registered; drop it so only one SW owns scope /. + navigator.serviceWorker + .getRegistrations() + .then((registrations) => { + registrations.forEach((registration) => { + const scriptUrl = + registration.active?.scriptURL || + registration.waiting?.scriptURL || + registration.installing?.scriptURL || + '' + if (scriptUrl.endsWith('firebase-messaging-sw.js')) { + registration.unregister() + } + }) + }) + .catch(() => {}) + + const updateSW = registerSW({ + immediate: true, + onNeedRefresh() { + promptForUpdate(updateSW) + }, + }) +} + +/** + * The single app SW registration — passed to Firebase's getToken() so it never + * self-registers a second worker. navigator.serviceWorker.ready never settles + * when no SW gets registered (vite dev, or a failed registration), so race it + * against a timeout instead of hanging callers forever. + */ +// Generous enough for a cold first install on low-end devices; a miss only +// delays token registration to the next visit. +const SW_READY_TIMEOUT_MS = 8000 + +export async function getSWRegistration(): Promise { + if (!('serviceWorker' in navigator) || isShopifyEmbedded()) return undefined + return Promise.race([ + navigator.serviceWorker.ready, + new Promise((resolve) => setTimeout(() => resolve(undefined), SW_READY_TIMEOUT_MS)), + ]) +} diff --git a/frontend/src/sw.ts b/frontend/src/sw.ts new file mode 100644 index 0000000..f51a0a8 --- /dev/null +++ b/frontend/src/sw.ts @@ -0,0 +1,165 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +/** + * Single app service worker: precaching/app-shell (installable PWA) plus + * Firebase Cloud Messaging background push. Replaces the old standalone + * public/firebase-messaging-sw.js — Firebase is bundled (the nginx CSP blocks + * importScripts from the gstatic CDN) and its config is inlined at build time + * (exactly what the generated swenv.js used to provide). + */ + +import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching' +import { NavigationRoute, registerRoute } from 'workbox-routing' +import { NetworkFirst, CacheFirst } from 'workbox-strategies' +import { clientsClaim } from 'workbox-core' +import { initializeApp } from 'firebase/app' +import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw' +import { SW_MESSAGE, conversationSessionUrl } from './pwa/pushContract' +import { NAVIGATION_DENYLIST } from './pwa/navigationDenylist' + +declare let self: ServiceWorkerGlobalScope + +// A new worker waits until the page asks it to take over, so an agent is +// never reloaded mid-reply. The page prompts and then posts SKIP_WAITING. +// Calling skipWaiting() unconditionally here activated the new worker while +// the open tab kept running the previous bundle's CSS and JS — the app looked +// stale until someone happened to reload. +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) +clientsClaim() + +precacheAndRoute(self.__WB_MANIFEST) +cleanupOutdatedCaches() + +// SPA navigation fallback. What it must not swallow lives in +// ./pwa/navigationDenylist, which is testable without ServiceWorker globals. +registerRoute( + new NavigationRoute(createHandlerBoundToURL('index.html'), { + denylist: NAVIGATION_DENYLIST, + }), +) + +// Runtime config is substituted per-environment at container start, so it must +// never be precached. NetworkFirst (not StaleWhileRevalidate): online clients +// must always boot on the CURRENT environment's config — a stale cached copy +// could keep credentialed traffic pointed at a rotated-away endpoint. The +// cache only serves offline boots. +registerRoute( + ({ url }) => url.origin === self.location.origin && url.pathname === '/config.js', + new NetworkFirst({ cacheName: 'runtime-config', networkTimeoutSeconds: 3 }), +) + +registerRoute( + ({ url }) => url.origin === 'https://fonts.googleapis.com' || url.origin === 'https://fonts.gstatic.com', + new CacheFirst({ cacheName: 'google-fonts' }), +) + +// ---- Firebase Cloud Messaging (background push) ---- + +// Pull in the same runtime config the page uses. Published images are built +// once and configured per deployment at container start, so import.meta.env is +// undefined there — without this, self-hosted installs get no background push. +// config.js assigns to `self`, which exists in worker scope; importScripts is +// synchronous and bypasses the SW's own fetch handling. +try { + self.importScripts('/config.js') +} catch { + // Absent in `vite dev` (config.js is served but not always present) and on + // any deployment that bakes its config at build time — the env fallbacks + // below cover both. +} + +const runtimeConfig = (self as unknown as { APP_CONFIG?: Record }).APP_CONFIG ?? {} + +const firebaseConfig = { + apiKey: runtimeConfig.FIREBASE_API_KEY || import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: runtimeConfig.FIREBASE_AUTH_DOMAIN || import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: runtimeConfig.FIREBASE_PROJECT_ID || import.meta.env.VITE_FIREBASE_PROJECT_ID, + messagingSenderId: + runtimeConfig.FIREBASE_MESSAGING_SENDER_ID || import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: runtimeConfig.FIREBASE_APP_ID || import.meta.env.VITE_FIREBASE_APP_ID, + storageBucket: + runtimeConfig.FIREBASE_STORAGE_BUCKET || import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + measurementId: + runtimeConfig.FIREBASE_MEASUREMENT_ID || import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, +} + +if (firebaseConfig.apiKey) { + const messaging = getMessaging(initializeApp(firebaseConfig)) + + onBackgroundMessage(messaging, (payload) => { + // Messages are data-only (title/body in data): a notification payload + // would make the Firebase SDK auto-display a duplicate copy that also + // swallows clicks. The notification?. fallbacks keep older senders working. + const title = payload.data?.title || payload.notification?.title || 'ChatterMate' + const body = payload.data?.body || payload.notification?.body + const sessionId = payload.data?.session_id + + self.registration.showNotification(title, { + body, + // icon = the large icon in the expanded tray, which may be full colour. + // badge = the status-bar icon, which Android builds from the ALPHA CHANNEL + // alone: it throws the colours away and tints the silhouette. Pointing this + // at the colour PWA icon is why it rendered as a grey blob — the whole + // square is opaque, so the silhouette is a square. + icon: '/pwa-192x192.png', + badge: '/notification-badge-96.png', + // One stacked notification per conversation (or per notification otherwise) + tag: sessionId || payload.data?.notification_id || undefined, + data: payload.data, + }) + + // Let any open windows refresh their in-app notification state + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + clients.forEach((client) => + client.postMessage({ eventType: SW_MESSAGE.BACKGROUND_NOTIFICATION, data: payload.data }), + ) + }) + }) +} + +// Deep-link straight into the conversation on tap (default action) +self.addEventListener('notificationclick', (event) => { + event.notification.close() + + const url = conversationSessionUrl(event.notification.data?.session_id) + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => { + // Prefer the focused window so a background tab isn't yanked to the session + const client = clients.find((c) => c.focused) ?? clients[0] + if (client) { + await client.focus() + try { + // navigate() rejects for windows this SW doesn't control + // (hard-reloaded or first-visit tabs) — fall back to messaging the + // page so the in-app router performs the navigation. + await client.navigate(url) + } catch { + client.postMessage({ eventType: SW_MESSAGE.OPEN_CONVERSATION, url }) + } + return + } + return self.clients.openWindow(url) + }), + ) +}) diff --git a/frontend/src/utils/orb.ts b/frontend/src/utils/orb.ts new file mode 100644 index 0000000..2129497 --- /dev/null +++ b/frontend/src/utils/orb.ts @@ -0,0 +1,126 @@ +/* +Copyright 2024-2026 ChatterMate + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Procedural "aurora" gradient orbs used as an avatar alternative to an uploaded photo. +// Colors are hardcoded hex (not CSS vars) so the orb renders identically in the dashboard, +// the preview panel, and the isolated widget bundle (which has no access to app design tokens). + +interface OrbPalette { + stops: string + glow: string +} + +// Each entry is a visibly distinct colored orb the user can choose from. +const ORB_PALETTES: OrbPalette[] = [ + { stops: '#9D8CFF, #5FE3D6, #C9F24E', glow: 'rgba(157,140,255,0.45)' }, // aurora (default) + { stops: '#FF8A73, #9D8CFF, #5FE3D6', glow: 'rgba(255,138,115,0.40)' }, // coral + { stops: '#5FE3D6, #C9F24E, #9D8CFF', glow: 'rgba(95,227,214,0.40)' }, // teal + { stops: '#C9F24E, #5FE3D6, #FF8A73', glow: 'rgba(201,242,78,0.35)' }, // lime + { stops: '#6EA8FF, #9D8CFF, #5FE3D6', glow: 'rgba(110,168,255,0.42)' }, // blue + { stops: '#FF7AC6, #9D8CFF, #6EA8FF', glow: 'rgba(255,122,198,0.42)' }, // pink + { stops: '#FF8A73, #FFC857, #FF7AC6', glow: 'rgba(255,200,87,0.40)' }, // sunset + { stops: '#7C5CFF, #B388FF, #5FE3D6', glow: 'rgba(124,92,255,0.45)' }, // violet + { stops: '#0EA5A5, #5FE3D6, #C9F24E', glow: 'rgba(14,165,165,0.40)' }, // emerald + { stops: '#F34611, #FF8A73, #FFC857', glow: 'rgba(243,70,17,0.38)' }, // ember +] + +// Number of selectable orb variants (for rendering the picker grid). +export const ORB_PALETTE_COUNT = ORB_PALETTES.length + +// Stable palette index from a seed (agent name) so the same agent always gets the same orb +// when no variant has been explicitly chosen. +export const orbIndexForSeed = (seed: string): number => + (seed || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % ORB_PALETTES.length + +// Inline style object for a specific orb variant by index. +export const getOrbStyleAt = (index: number): Record => { + const p = ORB_PALETTES[((index % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length] + return { + background: ` + radial-gradient(circle at 32% 28%, rgba(255,255,255,0.22) 0%, transparent 42%), + radial-gradient(circle at 68% 72%, rgba(0,0,0,0.25) 0%, transparent 38%), + radial-gradient(ellipse at 50% 50%, ${p.stops}) + `.trim(), + boxShadow: `0 4px 28px ${p.glow}, inset 0 1px 0 rgba(255,255,255,0.15)`, + borderRadius: '50%', + } +} + +// Inline style for the orb, keyed by a seed (used as the stable default). +export const getOrbStyle = (seed: string): Record => getOrbStyleAt(orbIndexForSeed(seed)) + +// Resolve the orb style for an agent: an explicitly chosen variant wins, otherwise +// fall back to the seed-derived default. `variant` is whatever is stored in +// customization_metadata.orb_variant (may be undefined / non-numeric). +export const resolveOrbStyle = (seed: string, variant: unknown): Record => { + const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed) + return getOrbStyleAt(idx) +} + +// Render an orb variant as a self-contained SVG data URI. Storing this in +// `photo_url` lets every existing avatar render site (agent list, widget header, +// previews) show the orb with no orb-specific logic — they just render the image. +export const orbSvgDataUri = (seed: string, variant?: unknown): string => { + const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed) + const p = ORB_PALETTES[((idx % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length] + const [c1, c2, c3] = p.stops.split(',').map((s) => s.trim()) + const svg = + '' + + '' + + '' + + `` + + `` + + `` + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +// Terminal-style avatar mark: a monospace ">" prompt on a dark, FULL-BLEED tile tinted +// with the accent (matches the Terminal chat theme). Full-bleed so it fully covers the +// avatar container at every render site — circular (header/message) or square — with no +// transparent corners. Stored in `photo_url` exactly like the orb, so every avatar +// render site shows it with no mark-specific logic. +export const TERMINAL_MARK_ACCENT = '#C9F24E' +// Only accept simple, safe colour tokens (hex / rgb[a] / hsl[a] / named) before +// embedding into the SVG, so a stray quote in a stored value can't break out of the +// attribute. Anything else falls back to the Terminal lime. +const SAFE_COLOR = /^(#[0-9a-f]{3,8}|rgba?\([\d.,\s%]+\)|hsla?\([\d.,\s%]+\)|[a-z]+)$/i +export const terminalMarkSvgDataUri = (accent: string = TERMINAL_MARK_ACCENT): string => { + const c = accent && SAFE_COLOR.test(accent.trim()) ? accent.trim() : TERMINAL_MARK_ACCENT + const svg = + '' + + // Full-bleed dark base guarantees coverage in any avatar shape. + '' + + `` + + // Inset accent frame for the terminal look. + `` + + `>` + + '' + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} diff --git a/wordpress/chattermate-chat.zip b/wordpress/chattermate-chat.zip new file mode 100644 index 0000000..d935171 Binary files /dev/null and b/wordpress/chattermate-chat.zip differ