- Buzz Admin
+ {t("brand", lang)}
{content}
diff --git a/admin-web/src/i18n.ts b/admin-web/src/i18n.ts
new file mode 100644
index 0000000000..72d928c682
--- /dev/null
+++ b/admin-web/src/i18n.ts
@@ -0,0 +1,41 @@
+export type Lang = "en" | "zh";
+
+const dict = {
+ en: {
+ brand: "Buzz Admin",
+ nav_reports: "Reports",
+ nav_feedback: "Feedback",
+ loading: "Loading…",
+ access_denied: "Access denied",
+ load_failed: "Could not load data",
+ retry: "Retry",
+ lang: "Language",
+ },
+ zh: {
+ brand: "Buzz 管理台",
+ nav_reports: "举报",
+ nav_feedback: "反馈",
+ loading: "加载中…",
+ access_denied: "无权限",
+ load_failed: "无法加载数据",
+ retry: "重试",
+ lang: "语言",
+ },
+} as const;
+
+export type MsgKey = keyof (typeof dict)["en"];
+
+const STORAGE_KEY = "buzz-admin-lang";
+
+export function getLang(): Lang {
+ const v = localStorage.getItem(STORAGE_KEY);
+ return v === "zh" || v === "en" ? v : "zh";
+}
+
+export function setLang(lang: Lang) {
+ localStorage.setItem(STORAGE_KEY, lang);
+}
+
+export function t(key: MsgKey, lang: Lang = getLang()): string {
+ return dict[lang][key] ?? dict.en[key] ?? key;
+}
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index 93ebceeb14..a5b59c6389 100644
--- a/admin-web/src/styles.css
+++ b/admin-web/src/styles.css
@@ -1,9 +1,11 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700;800&display=swap");
+
:root {
- font-family:
- "Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
- color: #231e1e;
- background: #d7e7f6;
+ font-family: "Space Grotesk", "Segoe UI", "PingFang SC", "Helvetica Neue", sans-serif;
+ color: #1c1917;
+ background: #fff8f0;
font-synthesis: none;
+ letter-spacing: -0.01em;
}
* {
@@ -29,17 +31,18 @@ select {
}
button {
- color: white;
- background: #231e1e;
- border: 0;
- border-radius: 999px;
+ color: #fafaf9;
+ background: #1c1917;
+ border: 3px solid #1c1917;
+ border-radius: 0;
padding: 0.75rem 1.25rem;
cursor: pointer;
+ font-weight: 700;
}
.app {
min-height: 100vh;
- background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
+ background: #fff8f0;
}
.app-header {
@@ -72,8 +75,8 @@ button {
display: grid;
place-items: center;
border-radius: 0.7rem;
- color: #d7d72e;
- background: #231e1e;
+ color: #1c1917;
+ background: #fbbf24;
}
.brand-mark svg {
@@ -86,10 +89,9 @@ nav {
align-items: center;
gap: 0.35rem;
padding: 0.3rem;
- border: 1px solid rgb(35 30 30 / 10%);
- border-radius: 999px;
- background: rgb(255 255 255 / 42%);
- backdrop-filter: blur(12px);
+ border: 3px solid #1c1917;
+ border-radius: 0;
+ background: #ffffff;
}
.nav-link {
@@ -732,3 +734,20 @@ dd {
margin-top: 0.9rem;
}
}
+
+.lang-switch {
+ display: inline-flex;
+ gap: 0.25rem;
+ margin-left: 0.5rem;
+}
+.lang-switch button {
+ padding: 0.45rem 0.75rem;
+ background: #ffffff;
+ color: #1c1917;
+ border: 3px solid #1c1917;
+ border-radius: 0;
+ font-weight: 700;
+}
+.lang-switch button.on {
+ background: #fbbf24;
+}
diff --git a/desktop/index.html b/desktop/index.html
index 4b58bd4326..1aa179b596 100644
--- a/desktop/index.html
+++ b/desktop/index.html
@@ -54,6 +54,11 @@
}
})();
+
+
diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx
index 845c403b8c..17389d6943 100644
--- a/desktop/src/app/AppTopChrome.tsx
+++ b/desktop/src/app/AppTopChrome.tsx
@@ -6,6 +6,7 @@ import {
PanelLeftOpen,
} from "lucide-react";
+import { LanguageToggle, useI18n } from "@/shared/i18n";
import { isMacPlatform } from "@/shared/lib/platform";
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { Button } from "@/shared/ui/button";
@@ -36,10 +37,11 @@ function preventTopChromeWheel(event: WheelEvent) {
function TopChromeSidebarTrigger() {
const sidebar = useOptionalSidebar();
+ const { t } = useI18n();
return (
);
}
@@ -63,6 +65,7 @@ export function AppTopChrome({
onGoForward,
hasCommunityRail = false,
}: AppTopChromeProps) {
+ const { t } = useI18n();
const topChromeRef = React.useRef
(null);
const isFullscreen = useIsFullscreen();
// On macOS the traffic-light buttons overlay the chrome (see
@@ -106,10 +109,15 @@ export function AppTopChrome({
data-tauri-drag-region
data-testid="app-top-chrome"
>
-
);
}
diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx
index 67c4abb6e9..83011e63fa 100644
--- a/desktop/src/features/search/ui/TopbarSearch.tsx
+++ b/desktop/src/features/search/ui/TopbarSearch.tsx
@@ -14,6 +14,7 @@ import {
} from "@/features/search/ui/SearchResultItem";
import { SearchPromptPlaceholder } from "@/features/search/ui/SearchPromptPlaceholder";
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
+import { type MsgKey, useI18n } from "@/shared/i18n";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
@@ -183,7 +184,8 @@ function getSearchHitChannelName(
function getSearchHitContextLabel(
hit: SearchHit,
channelLookup: ReadonlyMap,
- channelLabels?: Record,
+ channelLabels: Record | undefined,
+ t: (key: MsgKey) => string,
): SearchHitContextLabel {
const channel = hit.channelId ? channelLookup.get(hit.channelId) : null;
const channelName = getSearchHitChannelName(
@@ -195,7 +197,7 @@ function getSearchHitContextLabel(
if (channel?.channelType === "dm") {
return {
channelLabel: null,
- text: "Direct message",
+ text: t("search.directMessage"),
};
}
@@ -204,10 +206,12 @@ function getSearchHitContextLabel(
return {
channelLabel: channelName,
text: channelName
- ? `${isThread ? "Thread" : "Message"} in`
+ ? isThread
+ ? t("search.threadIn")
+ : t("search.messageIn")
: isThread
- ? "Thread"
- : "Message",
+ ? t("search.thread")
+ : t("search.message"),
};
}
@@ -227,20 +231,23 @@ function getResultSectionKey(result: SearchResult): SearchResultSectionKey {
return "messages";
}
-function getSectionTitle(sectionKey: SearchResultSectionKey) {
+function getSectionTitle(
+ sectionKey: SearchResultSectionKey,
+ t: (key: MsgKey) => string,
+) {
switch (sectionKey) {
case "channels":
- return "Channels";
+ return t("search.channels");
case "direct-messages":
- return "Direct messages";
+ return t("search.directMessages");
case "people":
- return "People";
+ return t("search.users");
case "agents":
- return "Agents";
+ return t("search.agents");
case "messages":
- return "Most relevant";
+ return t("search.mostRelevant");
case "actions":
- return "Actions";
+ return t("search.actions");
}
}
@@ -268,7 +275,10 @@ function SearchHitContextLine({ label }: { label: SearchHitContextLabel }) {
);
}
-function groupSearchResults(results: SearchResult[]): SearchResultSection[] {
+function groupSearchResults(
+ results: SearchResult[],
+ t: (key: MsgKey) => string,
+): SearchResultSection[] {
const resultsBySection = new Map();
for (const result of results) {
@@ -289,7 +299,7 @@ function groupSearchResults(results: SearchResult[]): SearchResultSection[] {
{
key: sectionKey,
results: sectionResults,
- title: getSectionTitle(sectionKey),
+ title: getSectionTitle(sectionKey, t),
},
];
});
@@ -400,6 +410,7 @@ export function TopbarSearch({
suggestionChannels,
variant = "bar",
}: TopbarSearchProps) {
+ const { t } = useI18n();
const [isOpen, setIsOpen] = React.useState(false);
const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0);
const triggerRef = React.useRef(null);
@@ -434,7 +445,7 @@ export function TopbarSearch({
kind: "action",
action: {
id: "browse-channels",
- title: "Browse channels",
+ title: t("search.browseChannels"),
},
});
}
@@ -444,7 +455,7 @@ export function TopbarSearch({
kind: "action",
action: {
id: "create-channel",
- title: "Create a new channel",
+ title: t("search.createChannel"),
},
});
}
@@ -454,13 +465,13 @@ export function TopbarSearch({
kind: "action",
action: {
id: "create-agent",
- title: "Create a new agent",
+ title: t("search.createAgent"),
},
});
}
return actions;
- }, [onBrowseChannels, onCreateAgent, onCreateChannel]);
+ }, [onBrowseChannels, onCreateAgent, onCreateChannel, t]);
const suggestionResults = React.useMemo(
() => [...suggestedResults, ...suggestionActionResults],
[suggestedResults, suggestionActionResults],
@@ -478,8 +489,8 @@ export function TopbarSearch({
[currentPubkeyNormalized, results],
);
const searchResultSections = React.useMemo(
- () => groupSearchResults(searchableResults),
- [searchableResults],
+ () => groupSearchResults(searchableResults, t),
+ [searchableResults, t],
);
const groupedSearchResults = React.useMemo(
() => searchResultSections.flatMap((section) => section.results),
@@ -639,7 +650,12 @@ export function TopbarSearch({
: null;
const messageContextLabel =
result.kind === "message"
- ? getSearchHitContextLabel(result.hit, channelLookup, channelLabels)
+ ? getSearchHitContextLabel(
+ result.hit,
+ channelLookup,
+ channelLabels,
+ t,
+ )
: null;
const title =
result.kind === "channel"
@@ -771,11 +787,11 @@ export function TopbarSearch({
const searchResultContent = isShowingSuggestions ? (
suggestionResults.length === 0 ? (
-
No recent activity yet.
+
{t("search.noRecentActivity")}
) : (
@@ -787,7 +803,7 @@ export function TopbarSearch({
{suggestedResults.length > 0 ? (
- Recent activity
+ {t("search.recentActivity")}
{suggestedResults.map((result) =>
renderSearchResultRow(result, resultIndex++),
@@ -796,7 +812,9 @@ export function TopbarSearch({
) : null}
{suggestionActionResults.length > 0 ? (
-
Actions
+
+ {t("search.actions")}
+
{suggestionActionResults.map((result) =>
renderSearchResultRow(result, resultIndex++),
)}
@@ -815,7 +833,7 @@ export function TopbarSearch({
) : searchableResults.length === 0 ? (
- No matches for {trimmedQuery}.
+ {t("search.noMatchesFor", { query: trimmedQuery })}
) : (
@@ -827,7 +845,7 @@ export function TopbarSearch({
) : null}
- {visibleNavGroups.map((group) => (
-
- {group.label}
-
-
- {group.sections.map((entry) => (
-
- ))}
-
-
-
- ))}
+ {visibleNavGroups.map((group) => {
+ const groupLabel = t(group.labelKey);
+ return (
+
+ {groupLabel}
+
+
+ {group.sections.map((entry) => (
+
+ ))}
+
+
+
+ );
+ })}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 55f467f215..0b9574e1b2 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -1,6 +1,7 @@
// biome-ignore format: keep compact to stay within file size limit
import * as React from "react";
import { FeatureGate } from "@/shared/features";
+import { useI18n } from "@/shared/i18n";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import type { Community } from "@/features/communities/types";
@@ -237,6 +238,7 @@ export function AppSidebar({
onStarChannel,
onUnstarChannel,
}: AppSidebarProps) {
+ const { t } = useI18n();
const activeWorkingByChannelId = useActiveWorkingChannelsById();
const { status: updateStatus } = useUpdaterContext();
const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
@@ -647,7 +649,7 @@ export function AppSidebar({
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
selectedChannelId={selectedChannelId}
- title="Starred"
+ title={t("nav.starred")}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
@@ -743,7 +745,7 @@ export function AppSidebar({
}
actionsTestId="section-actions-channels"
listTestId="stream-list"
- quickCreateLabel="Browse channels"
+ quickCreateLabel={t("nav.browseChannels")}
onQuickCreateClick={() => onBrowseChannels?.()}
showQuickCreate
onMarkAllRead={onMarkAllChannelsRead}
@@ -752,7 +754,7 @@ export function AppSidebar({
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
- title="Channels"
+ title={t("nav.channels")}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
@@ -772,7 +774,7 @@ export function AppSidebar({
0}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
@@ -791,7 +793,7 @@ export function AppSidebar({
onSelectChannel={onSelectChannel}
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
- title="Forums"
+ title={t("nav.forums")}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
@@ -804,12 +806,12 @@ export function AppSidebar({
action={
- Inbox
+ {t("nav.inbox")}
{homeBadgeCount > 0 ? (
- Pulse
+ {t("nav.pulse")}
@@ -135,11 +137,11 @@ export function AppSidebarPrimaryMenu({
data-testid="open-projects-view"
isActive={selectedView === "projects"}
onClick={onSelectProjects}
- tooltip="Projects"
+ tooltip={t("nav.projects")}
type="button"
>
- Projects
+ {t("nav.projects")}
@@ -148,11 +150,11 @@ export function AppSidebarPrimaryMenu({
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
- tooltip="Agents"
+ tooltip={t("nav.agents")}
type="button"
>
- Agents
+ {t("nav.agents")}
@@ -161,11 +163,11 @@ export function AppSidebarPrimaryMenu({
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
- tooltip="Workflows"
+ tooltip={t("nav.workflows")}
type="button"
>
- Workflows
+ {t("nav.workflows")}
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index 890ef72226..ad181ffb74 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -8,6 +8,7 @@ import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";
import { CommunitiesProvider } from "@/features/communities/useCommunities";
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
+import { I18nProvider } from "@/shared/i18n";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
@@ -76,17 +77,19 @@ function renderApp() {
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/src/shared/i18n/I18nProvider.tsx b/desktop/src/shared/i18n/I18nProvider.tsx
new file mode 100644
index 0000000000..e4c1750fe5
--- /dev/null
+++ b/desktop/src/shared/i18n/I18nProvider.tsx
@@ -0,0 +1,84 @@
+import {
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ useEffect,
+} from "react";
+
+import {
+ DEFAULT_LANG,
+ type Lang,
+ type MsgKey,
+ loadStoredLang,
+ persistLang,
+ translate,
+} from "./messages";
+
+type I18nContextValue = {
+ lang: Lang;
+ setLang: (lang: Lang) => void;
+ t: (key: MsgKey, vars?: Record) => string;
+};
+
+const I18nContext = createContext(undefined);
+
+function applyDocumentLang(lang: Lang) {
+ document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
+}
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [lang, setLangState] = useState(() =>
+ loadStoredLang(window.localStorage),
+ );
+
+ useEffect(() => {
+ applyDocumentLang(lang);
+ }, [lang]);
+
+ const setLang = useCallback((next: Lang) => {
+ setLangState(next);
+ persistLang(window.localStorage, next);
+ applyDocumentLang(next);
+ }, []);
+
+ const t = useCallback(
+ (key: MsgKey, vars?: Record) =>
+ translate(key, lang, vars),
+ [lang],
+ );
+
+ const value = useMemo(
+ () => ({
+ lang,
+ setLang,
+ t,
+ }),
+ [lang, setLang, t],
+ );
+
+ return {children};
+}
+
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) {
+ throw new Error("useI18n must be used within I18nProvider");
+ }
+ return ctx;
+}
+
+/** Safe for optional trees (tests / isolated previews). Falls back to default zh. */
+export function useOptionalI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (ctx) {
+ return ctx;
+ }
+ return {
+ lang: DEFAULT_LANG,
+ setLang: () => {},
+ t: (key, vars) => translate(key, DEFAULT_LANG, vars),
+ };
+}
diff --git a/desktop/src/shared/i18n/LanguageToggle.tsx b/desktop/src/shared/i18n/LanguageToggle.tsx
new file mode 100644
index 0000000000..18b9dc1632
--- /dev/null
+++ b/desktop/src/shared/i18n/LanguageToggle.tsx
@@ -0,0 +1,83 @@
+import { cn } from "@/shared/lib/cn";
+import { useI18n } from "./I18nProvider";
+import type { Lang } from "./messages";
+
+type LanguageToggleProps = {
+ className?: string;
+ /** compact: top chrome EN|中文; full: settings card radios */
+ variant?: "compact" | "full";
+ testId?: string;
+};
+
+const OPTIONS: Array<{ value: Lang; compactKey: "chrome.lang.en" | "chrome.lang.zh"; fullKey: "appearance.language.en" | "appearance.language.zh" }> =
+ [
+ { value: "zh", compactKey: "chrome.lang.zh", fullKey: "appearance.language.zh" },
+ { value: "en", compactKey: "chrome.lang.en", fullKey: "appearance.language.en" },
+ ];
+
+export function LanguageToggle({
+ className,
+ variant = "compact",
+ testId = "language-toggle",
+}: LanguageToggleProps) {
+ const { lang, setLang, t } = useI18n();
+
+ if (variant === "full") {
+ return (
+
+ {OPTIONS.map((opt) => (
+ setLang(opt.value)}
+ >
+ {t(opt.fullKey)}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {OPTIONS.map((opt) => (
+ setLang(opt.value)}
+ >
+ {t(opt.compactKey)}
+
+ ))}
+
+ );
+}
diff --git a/desktop/src/shared/i18n/i18n.test.mjs b/desktop/src/shared/i18n/i18n.test.mjs
new file mode 100644
index 0000000000..8a5561cd0d
--- /dev/null
+++ b/desktop/src/shared/i18n/i18n.test.mjs
@@ -0,0 +1,79 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ DEFAULT_LANG,
+ LANG_STORAGE_KEY,
+ isLang,
+ loadStoredLang,
+ messages,
+ persistLang,
+ translate,
+} from "./messages.ts";
+
+describe("desktop i18n", () => {
+ it("defaults to Chinese", () => {
+ assert.equal(DEFAULT_LANG, "zh");
+ });
+
+ it("recognizes supported langs only", () => {
+ assert.equal(isLang("zh"), true);
+ assert.equal(isLang("en"), true);
+ assert.equal(isLang("fr"), false);
+ assert.equal(isLang(null), false);
+ });
+
+ it("has matching keys in en and zh", () => {
+ const enKeys = Object.keys(messages.en).sort();
+ const zhKeys = Object.keys(messages.zh).sort();
+ assert.deepEqual(enKeys, zhKeys);
+ });
+
+ it("translates main-path keys", () => {
+ assert.equal(translate("nav.inbox", "en"), "Inbox");
+ assert.equal(translate("nav.inbox", "zh"), "收件箱");
+ assert.equal(translate("nav.channels", "zh"), "频道");
+ assert.equal(translate("appearance.language.title", "zh"), "语言");
+ assert.equal(translate("settings.backToApp", "zh"), "返回应用");
+ assert.equal(translate("search.noRecentActivity", "en"), "No recent activity yet.");
+ assert.equal(
+ translate("search.noMatchesFor", "zh", { query: "foo" }),
+ "无匹配:foo。",
+ );
+ });
+
+ it("falls back to English for missing zh (defensive)", () => {
+ assert.equal(translate("common.save", "en"), "Save");
+ assert.equal(translate("common.save", "zh"), "保存");
+ });
+
+ it("loadStoredLang defaults when empty and reads valid values", () => {
+ const store = new Map();
+ const storage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => {
+ store.set(k, v);
+ },
+ };
+ assert.equal(loadStoredLang(storage), DEFAULT_LANG);
+ storage.setItem(LANG_STORAGE_KEY, "en");
+ assert.equal(loadStoredLang(storage), "en");
+ storage.setItem(LANG_STORAGE_KEY, "bogus");
+ assert.equal(loadStoredLang(storage), DEFAULT_LANG);
+ });
+
+ it("persistLang writes storage so a later load keeps the choice", () => {
+ const store = new Map();
+ const storage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => {
+ store.set(k, v);
+ },
+ };
+ assert.equal(persistLang(storage, "en"), true);
+ assert.equal(store.get(LANG_STORAGE_KEY), "en");
+ assert.equal(loadStoredLang(storage), "en");
+ assert.equal(persistLang(storage, "zh"), true);
+ assert.equal(loadStoredLang(storage), "zh");
+ });
+});
diff --git a/desktop/src/shared/i18n/index.ts b/desktop/src/shared/i18n/index.ts
new file mode 100644
index 0000000000..316416c244
--- /dev/null
+++ b/desktop/src/shared/i18n/index.ts
@@ -0,0 +1,13 @@
+export {
+ DEFAULT_LANG,
+ LANG_STORAGE_KEY,
+ type Lang,
+ type MsgKey,
+ isLang,
+ loadStoredLang,
+ messages,
+ persistLang,
+ translate,
+} from "./messages";
+export { I18nProvider, useI18n, useOptionalI18n } from "./I18nProvider";
+export { LanguageToggle } from "./LanguageToggle";
diff --git a/desktop/src/shared/i18n/messages.ts b/desktop/src/shared/i18n/messages.ts
new file mode 100644
index 0000000000..15803b6dff
--- /dev/null
+++ b/desktop/src/shared/i18n/messages.ts
@@ -0,0 +1,263 @@
+export type Lang = "en" | "zh";
+
+export const LANG_STORAGE_KEY = "buzz-desktop-lang";
+
+/** Default per product decision: Chinese. */
+export const DEFAULT_LANG: Lang = "zh";
+
+const en = {
+ // Top chrome
+ "chrome.toggleSidebar": "Toggle Sidebar",
+ "chrome.back": "Go back",
+ "chrome.forward": "Go forward",
+ "chrome.language": "Language",
+ "chrome.lang.en": "EN",
+ "chrome.lang.zh": "中文",
+
+ // Sidebar primary
+ "nav.inbox": "Inbox",
+ "nav.pulse": "Pulse",
+ "nav.projects": "Projects",
+ "nav.agents": "Agents",
+ "nav.workflows": "Workflows",
+ "nav.channels": "Channels",
+ "nav.forums": "Forums",
+ "nav.directMessages": "Direct messages",
+ "nav.starred": "Starred",
+ "nav.settings": "Settings",
+ "nav.browseChannels": "Browse channels",
+ "nav.newForum": "New forum",
+ "nav.newMessage": "New message",
+ "nav.createChannel": "Create channel",
+ "nav.sectionDirectMessages": "direct messages",
+
+ // Search
+ "search.placeholder": "Search",
+ "search.everything": "Search everything",
+ "search.channels": "Channels",
+ "search.directMessages": "Direct messages",
+ "search.users": "People",
+ "search.agents": "Agents",
+ "search.mostRelevant": "Most relevant",
+ "search.actions": "Actions",
+ "search.browseChannels": "Browse channels",
+ "search.createChannel": "Create a new channel",
+ "search.createAgent": "Create a new agent",
+ "search.directMessage": "Direct message",
+ "search.noResults": "No results",
+ "search.noMatches": "No matches for",
+ "search.noMatchesFor": "No matches for {query}.",
+ "search.noRecentActivity": "No recent activity yet.",
+ "search.recentActivity": "Recent activity",
+ "search.thread": "Thread",
+ "search.message": "Message",
+ "search.threadIn": "Thread in",
+ "search.messageIn": "Message in",
+
+ // Settings nav groups
+ "settings.group.personal": "Personal",
+ "settings.group.communities": "Communities",
+ "settings.group.app": "App",
+ "settings.back": "Back",
+ "settings.backToApp": "Back to app",
+ "settings.checkingInvitePermissions": "Checking invite permissions…",
+ "settings.inviteCheckFailed": "Invite settings could not be checked.",
+ "settings.tryAgain": "Try again",
+ "settings.inviteUnavailable":
+ "Invite settings are unavailable. Relay recovery may still be in progress.",
+ "settings.title": "Settings",
+
+ // Settings sections
+ "settings.section.appearance": "Appearance",
+ "settings.section.profile": "Profile",
+ "settings.section.notifications": "Notifications",
+ "settings.section.experimental": "Experiments",
+ "settings.section.agents": "Agents",
+ "settings.section.channel-templates": "Templates",
+ "settings.section.compute": "Compute",
+ "settings.section.shortcuts": "Shortcuts",
+ "settings.section.hosted-communities": "Hosted communities",
+ "settings.section.community-members": "Invites",
+ "settings.section.moderation": "Moderation",
+ "settings.section.custom-emoji": "Custom emoji",
+ "settings.section.local-archive": "Local archive",
+ "settings.section.mobile": "Mobile",
+ "settings.section.updates": "Updates",
+
+ // Appearance
+ "appearance.title": "Appearance",
+ "appearance.description": "Choose a theme for Buzz.",
+ "appearance.mode.system": "System",
+ "appearance.mode.light": "Light",
+ "appearance.mode.dark": "Dark",
+ "appearance.language.title": "Language",
+ "appearance.language.description":
+ "Interface language for the desktop app. Saved on this device.",
+ "appearance.language.zh": "中文",
+ "appearance.language.en": "English",
+
+ // Common actions
+ "common.retry": "Retry",
+ "common.cancel": "Cancel",
+ "common.save": "Save",
+ "common.close": "Close",
+ "common.create": "Create",
+ "common.delete": "Delete",
+ "common.loading": "Loading…",
+ "common.empty": "Nothing here yet",
+ "common.signOut": "Sign out",
+ "common.markAsRead": "Mark as read",
+ "common.error": "Something went wrong",
+} as const;
+
+export type MsgKey = keyof typeof en;
+
+const zh: Record = {
+ "chrome.toggleSidebar": "切换侧栏",
+ "chrome.back": "后退",
+ "chrome.forward": "前进",
+ "chrome.language": "语言",
+ "chrome.lang.en": "EN",
+ "chrome.lang.zh": "中文",
+
+ "nav.inbox": "收件箱",
+ "nav.pulse": "动态",
+ "nav.projects": "项目",
+ "nav.agents": "智能体",
+ "nav.workflows": "工作流",
+ "nav.channels": "频道",
+ "nav.forums": "论坛",
+ "nav.directMessages": "私信",
+ "nav.starred": "已加星标",
+ "nav.settings": "设置",
+ "nav.browseChannels": "浏览频道",
+ "nav.newForum": "新建论坛",
+ "nav.newMessage": "新消息",
+ "nav.createChannel": "创建频道",
+ "nav.sectionDirectMessages": "私信",
+
+ "search.placeholder": "搜索",
+ "search.everything": "搜索全部",
+ "search.channels": "频道",
+ "search.directMessages": "私信",
+ "search.users": "用户",
+ "search.agents": "智能体",
+ "search.mostRelevant": "最相关",
+ "search.actions": "操作",
+ "search.browseChannels": "浏览频道",
+ "search.createChannel": "创建新频道",
+ "search.createAgent": "创建新智能体",
+ "search.directMessage": "私信",
+ "search.noResults": "无结果",
+ "search.noMatches": "无匹配:",
+ "search.noMatchesFor": "无匹配:{query}。",
+ "search.noRecentActivity": "暂无最近活动。",
+ "search.recentActivity": "最近活动",
+ "search.thread": "帖子",
+ "search.message": "消息",
+ "search.threadIn": "帖子位于",
+ "search.messageIn": "消息位于",
+
+ "settings.group.personal": "个人",
+ "settings.group.communities": "社区",
+ "settings.group.app": "应用",
+ "settings.back": "返回",
+ "settings.backToApp": "返回应用",
+ "settings.checkingInvitePermissions": "正在检查邀请权限…",
+ "settings.inviteCheckFailed": "无法检查邀请设置。",
+ "settings.tryAgain": "重试",
+ "settings.inviteUnavailable": "邀请设置暂不可用。中继恢复可能仍在进行。",
+ "settings.title": "设置",
+
+ "settings.section.appearance": "外观",
+ "settings.section.profile": "个人资料",
+ "settings.section.notifications": "通知",
+ "settings.section.experimental": "实验功能",
+ "settings.section.agents": "智能体",
+ "settings.section.channel-templates": "模板",
+ "settings.section.compute": "算力",
+ "settings.section.shortcuts": "快捷键",
+ "settings.section.hosted-communities": "托管社区",
+ "settings.section.community-members": "邀请",
+ "settings.section.moderation": "审核",
+ "settings.section.custom-emoji": "自定义表情",
+ "settings.section.local-archive": "本地归档",
+ "settings.section.mobile": "移动端",
+ "settings.section.updates": "更新",
+
+ "appearance.title": "外观",
+ "appearance.description": "为 Buzz 选择主题。",
+ "appearance.mode.system": "跟随系统",
+ "appearance.mode.light": "浅色",
+ "appearance.mode.dark": "深色",
+ "appearance.language.title": "语言",
+ "appearance.language.description": "桌面端界面语言,保存在本机。",
+ "appearance.language.zh": "中文",
+ "appearance.language.en": "English",
+
+ "common.retry": "重试",
+ "common.cancel": "取消",
+ "common.save": "保存",
+ "common.close": "关闭",
+ "common.create": "创建",
+ "common.delete": "删除",
+ "common.loading": "加载中…",
+ "common.empty": "暂无内容",
+ "common.signOut": "退出登录",
+ "common.markAsRead": "标为已读",
+ "common.error": "出错了",
+};
+
+export const messages: Record> = {
+ en: en as Record,
+ zh,
+};
+
+export function isLang(value: unknown): value is Lang {
+ return value === "en" || value === "zh";
+}
+
+export function translate(
+ key: MsgKey,
+ lang: Lang,
+ vars?: Record,
+): string {
+ let text = messages[lang][key] ?? messages.en[key] ?? key;
+ if (vars) {
+ for (const [name, value] of Object.entries(vars)) {
+ text = text.replaceAll(`{${name}}`, String(value));
+ }
+ }
+ return text;
+}
+
+/** Read lang from a storage-like object (localStorage or test double). */
+export function loadStoredLang(
+ storage: Pick | null | undefined,
+): Lang {
+ try {
+ const stored = storage?.getItem(LANG_STORAGE_KEY);
+ if (isLang(stored)) {
+ return stored;
+ }
+ } catch {
+ // ignore
+ }
+ return DEFAULT_LANG;
+}
+
+/** Persist lang; returns false if storage write failed. */
+export function persistLang(
+ storage: Pick | null | undefined,
+ lang: Lang,
+): boolean {
+ if (!isLang(lang)) {
+ return false;
+ }
+ try {
+ storage?.setItem(LANG_STORAGE_KEY, lang);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index fd60621933..c3c09cfcb9 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1,3 +1,4 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700;800&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@import "./globals/scrollbars.css";
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index 0810a15f0a..42506d3d24 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -1,19 +1,19 @@
@layer base {
:root {
- /* Catppuccin Latte (mauve accent) */
- --radius: 0.625rem;
- --background: 220 23.08% 94.9%;
- --foreground: 234 16.02% 35.49%;
- --card: 220 23.08% 94.9%;
- --card-foreground: 234 16.02% 35.49%;
- --popover: 220 23.08% 94.9%;
- --popover-foreground: 234 16.02% 35.49%;
- --primary: 266 85.05% 58.04%;
- --primary-foreground: 220 23.08% 94.9%;
- --secondary: 223 15.91% 82.75%;
- --secondary-foreground: 234 16.02% 35.49%;
- --muted: 223 15.91% 82.75%;
- --muted-foreground: 233 12.8% 41.37%;
+ /* BC home_portal "raft" theme — cream paper + charcoal side + amber */
+ --radius: 0rem;
+ --background: 30 100% 97%;
+ --foreground: 24 10% 10%;
+ --card: 0 0% 100%;
+ --card-foreground: 24 10% 10%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 24 10% 10%;
+ --primary: 43 96% 56%;
+ --primary-foreground: 24 10% 10%;
+ --secondary: 30 25% 92%;
+ --secondary-foreground: 24 10% 10%;
+ --muted: 30 20% 90%;
+ --muted-foreground: 30 6% 32%;
--huddle-drawer-surface: 0 0% 0%;
--huddle-control-surface: 0 0% 20%;
--huddle-control-hover-surface: 0 0% 24%;
@@ -24,34 +24,34 @@
--huddle-popover-border: 0 0% 24%;
--huddle-tooltip-surface: 0 0% 20%;
--huddle-tooltip-foreground: 0 0% 98%;
- --accent: 223 15.91% 82.75%;
- --accent-foreground: 234 16.02% 35.49%;
- --destructive: 347 86.67% 44.12%;
- --destructive-foreground: 220 23.08% 94.9%;
- --border: 225 13.56% 76.86%;
- --input: 225 13.56% 76.86%;
- --ring: 234 16.02% 35.49%;
- --chart-1: 345 57.65% 74.12%;
- --chart-2: 185 37.25% 70.59%;
- --chart-3: 219 81.96% 78.43%;
- --chart-4: 35 61.18% 76.86%;
- --chart-5: 113 32.94% 73.33%;
- --sidebar: 218 25.1% 93.73%;
- --sidebar-background: 218 25.1% 93.73%;
- --sidebar-foreground: 234 16.02% 35.49%;
- --sidebar-primary: 266 85.05% 58.04%;
- --sidebar-primary-foreground: 220 23.08% 94.9%;
+ --accent: 43 96% 90%;
+ --accent-foreground: 24 10% 10%;
+ --destructive: 347 77% 50%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 24 10% 10%;
+ --input: 24 10% 10%;
+ --ring: 43 96% 56%;
+ --chart-1: 43 96% 56%;
+ --chart-2: 330 80% 75%;
+ --chart-3: 142 50% 40%;
+ --chart-4: 24 10% 30%;
+ --chart-5: 30 6% 45%;
+ --sidebar: 24 10% 15%;
+ --sidebar-background: 24 10% 15%;
+ --sidebar-foreground: 60 9% 98%;
+ --sidebar-primary: 43 96% 56%;
+ --sidebar-primary-foreground: 24 10% 10%;
--sidebar-active: var(--sidebar-primary);
--sidebar-active-foreground: var(--sidebar-primary-foreground);
- --sidebar-accent: 220 23.08% 94.9%;
- --sidebar-accent-foreground: 234 16.02% 35.49%;
- --sidebar-border: 225 13.56% 76.86%;
- --sidebar-ring: 225 13.56% 76.86%;
+ --sidebar-accent: 24 10% 20%;
+ --sidebar-accent-foreground: 60 9% 98%;
+ --sidebar-border: 24 6% 25%;
+ --sidebar-ring: 43 96% 56%;
/* Exact Buzz palette tokens; components consume these semantically. */
- --buzz-gradient-light-top: #e6e6b6;
- --buzz-gradient-light-bottom: #c4d0da;
- --buzz-gradient-dark-top: #4a4616;
- --buzz-gradient-dark-bottom: #0a1423;
+ --buzz-gradient-light-top: #fff8f0;
+ --buzz-gradient-light-bottom: #f5f0e8;
+ --buzz-gradient-dark-top: #292524;
+ --buzz-gradient-dark-bottom: #1c1917;
--buzz-content-dark: 0 0% 10.1960784314%;
/* Hosted-community onboarding intentionally keeps this light palette in
both app themes; semantic tokens keep component code color-agnostic. */
@@ -66,20 +66,20 @@
}
.dark {
- /* Catppuccin Macchiato (mauve accent) */
- --radius: 0.625rem;
- --background: 232 23.4% 18.43%;
- --foreground: 227 68.25% 87.65%;
- --card: 232 23.4% 18.43%;
- --card-foreground: 227 68.25% 87.65%;
- --popover: 232 23.4% 18.43%;
- --popover-foreground: 227 68.25% 87.65%;
- --primary: 267 82.69% 79.61%;
- --primary-foreground: 232 23.4% 18.43%;
- --secondary: 230 18.8% 26.08%;
- --secondary-foreground: 227 68.25% 87.65%;
- --muted: 230 18.8% 26.08%;
- --muted-foreground: 228 39.22% 80%;
+ /* BC home "ruff" dark — warm stone + amber */
+ --radius: 0rem;
+ --background: 24 10% 10%;
+ --foreground: 60 9% 98%;
+ --card: 24 10% 15%;
+ --card-foreground: 60 9% 98%;
+ --popover: 24 10% 15%;
+ --popover-foreground: 60 9% 98%;
+ --primary: 43 96% 56%;
+ --primary-foreground: 24 10% 10%;
+ --secondary: 24 6% 20%;
+ --secondary-foreground: 60 9% 98%;
+ --muted: 24 6% 20%;
+ --muted-foreground: 30 6% 65%;
--huddle-drawer-surface: 230 18.8% 26.08%;
--huddle-control-surface: 231 15.61% 33.92%;
--huddle-control-hover-surface: 231 15.61% 38%;
@@ -90,35 +90,40 @@
--huddle-popover-border: 231 15.61% 38%;
--huddle-tooltip-surface: 231 15.61% 33.92%;
--huddle-tooltip-foreground: 227 68.25% 87.65%;
- --accent: 230 18.8% 26.08%;
- --accent-foreground: 227 68.25% 87.65%;
- --destructive: 351 73.91% 72.94%;
- --destructive-foreground: 232 23.4% 18.43%;
- --border: 231 15.61% 33.92%;
- --input: 231 15.61% 33.92%;
- --ring: 227 68.25% 87.65%;
- --chart-1: 333 20.78% 38.04%;
- --chart-2: 193 19.61% 38.04%;
- --chart-3: 224 26.67% 41.18%;
- --chart-4: 30 8.24% 42.35%;
- --chart-5: 139 10.98% 38.82%;
- --sidebar: 234 22.75% 17.25%;
- --sidebar-background: 234 22.75% 17.25%;
- --sidebar-foreground: 227 68.25% 87.65%;
- --sidebar-primary: 267 82.69% 79.61%;
- --sidebar-primary-foreground: 232 23.4% 18.43%;
+ --accent: 24 6% 22%;
+ --accent-foreground: 60 9% 98%;
+ --destructive: 0 72% 55%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 24 6% 28%;
+ --input: 24 6% 28%;
+ --ring: 43 96% 56%;
+ --chart-1: 43 96% 56%;
+ --chart-2: 330 70% 65%;
+ --chart-3: 142 50% 45%;
+ --chart-4: 30 6% 50%;
+ --chart-5: 24 10% 40%;
+ --sidebar: 24 10% 12%;
+ --sidebar-background: 24 10% 12%;
+ --sidebar-foreground: 60 9% 98%;
+ --sidebar-primary: 43 96% 56%;
+ --sidebar-primary-foreground: 24 10% 10%;
--sidebar-active: var(--sidebar-primary);
--sidebar-active-foreground: var(--sidebar-primary-foreground);
- --sidebar-accent: 232 23.4% 18.43%;
- --sidebar-accent-foreground: 227 68.25% 87.65%;
- --sidebar-border: 231 15.61% 33.92%;
- --sidebar-ring: 231 15.61% 33.92%;
+ --sidebar-accent: 24 8% 18%;
+ --sidebar-accent-foreground: 60 9% 98%;
+ --sidebar-border: 24 6% 22%;
+ --sidebar-ring: 43 96% 56%;
}
* {
@apply border-border;
}
+ body {
+ font-family: "Space Grotesk", "Segoe UI", "PingFang SC", "Helvetica Neue", sans-serif;
+ letter-spacing: -0.01em;
+ }
+
html,
body,
#root {
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index d656815091..2e11c0fe57 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -12,6 +12,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { invokeTauri } from "@/shared/api/tauri";
import { isMacPlatform } from "@/shared/lib/platform";
import { createThemeVars, hexToHsl } from "./adaptive-theme";
+import { RAFT_ACCENT_HEX, getRaftShellVars } from "./raft-shell";
import {
SYNTAX_THEMES,
type SyntaxThemeName,
@@ -22,7 +23,8 @@ import {
} from "./theme-loader";
export const THEME_STORAGE_KEY = "buzz-theme";
-const CACHE_KEY = "buzz-theme-cache";
+/** Bumped when Buzz shell palette changes so FOUC cache cannot pin stale GitHub-derived chrome. */
+const CACHE_KEY = "buzz-theme-cache.v2-raft";
export const ACCENT_STORAGE_KEY = "buzz-accent-color";
export const NEUTRAL_ACCENT = "neutral";
const FOLLOW_SYSTEM_KEY = "buzz-follow-system";
@@ -212,9 +214,9 @@ function applyAccentColor(value: string) {
}
/**
- * The Buzz themes ship with a fixed neutral accent (the GitHub black/white
- * foreground) rather than a user-selectable accent color. When a Buzz theme is
- * active we force `NEUTRAL_ACCENT` regardless of the stored preference, and the
+ * The Buzz themes ship with a fixed Raft amber accent rather than a
+ * user-selectable accent color. When a Buzz theme is active we force
+ * {@link RAFT_ACCENT_HEX} regardless of the stored preference, and the
* appearance panel hides the accent picker. The user's chosen accent is left
* untouched in storage so it returns when they switch back to another theme.
*/
@@ -223,14 +225,14 @@ export function isBuzzTheme(themeName: string): boolean {
}
/**
- * Resolve the accent to actually apply for a theme: Buzz themes are pinned to
- * the neutral accent; every other theme uses the stored/selected accent.
+ * Resolve the accent to actually apply for a theme: Buzz (Raft) themes pin
+ * amber primary; every other theme uses the stored/selected accent.
*/
function resolveEffectiveAccent(
themeName: string,
accentColor: string,
): string {
- return isBuzzTheme(themeName) ? NEUTRAL_ACCENT : accentColor;
+ return isBuzzTheme(themeName) ? RAFT_ACCENT_HEX : accentColor;
}
/**
@@ -434,12 +436,20 @@ async function applyTheme(
if (requestToken !== themeApplyRequest) return null;
const info = extractThemeInfo(name, themeData);
- const { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
+ let { isDark, vars } = createThemeVars(info.bg, info.fg, info.comment, {
added: info.added,
deleted: info.deleted,
modified: info.modified,
});
+ // Buzz themes keep Shiki syntax colors from github-light/dark, but the app
+ // chrome must use Raft shell tokens (cream/charcoal/amber). Overlay after
+ // createThemeVars so inline styles do not pin the old GitHub-derived chrome.
+ if (isBuzzTheme(name)) {
+ isDark = name === "buzz-dark";
+ vars = { ...vars, ...getRaftShellVars(isDark) };
+ }
+
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
@@ -459,7 +469,7 @@ async function applyTheme(
// browser paints the new theme + accent together. Doing this in a later
// microtask (e.g. the caller's `.then`) let the previous accent flash on the
// new theme for a frame — the flicker seen when switching to Buzz. Buzz
- // themes resolve to the neutral accent regardless of the stored value.
+ // (Raft) themes resolve to amber primary regardless of the stored value.
applyAccentColor(
resolveEffectiveAccent(
name,
diff --git a/desktop/src/shared/theme/raft-shell.test.mjs b/desktop/src/shared/theme/raft-shell.test.mjs
new file mode 100644
index 0000000000..e871cf80e2
--- /dev/null
+++ b/desktop/src/shared/theme/raft-shell.test.mjs
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ RAFT_ACCENT_HEX,
+ RAFT_LIGHT_BACKGROUND,
+ RAFT_LIGHT_PRIMARY,
+ RAFT_LIGHT_SIDEBAR,
+ getRaftShellVars,
+} from "./raft-shell.ts";
+
+describe("raft shell tokens", () => {
+ it("exports amber accent for Buzz runtime primary", () => {
+ assert.equal(RAFT_ACCENT_HEX, "#fbbf24");
+ });
+
+ it("light shell matches cream paper / charcoal side / amber primary", () => {
+ const light = getRaftShellVars(false);
+ assert.equal(light["--background"], RAFT_LIGHT_BACKGROUND);
+ assert.equal(light["--primary"], RAFT_LIGHT_PRIMARY);
+ assert.equal(light["--sidebar-background"], RAFT_LIGHT_SIDEBAR);
+ assert.equal(light["--radius"], "0rem");
+ // Cream-ish warm background (hue ~30, high lightness)
+ assert.match(light["--background"], /^30 /);
+ // Charcoal sidebar (low lightness)
+ assert.match(light["--sidebar-background"], /^24 /);
+ // Amber primary
+ assert.equal(light["--primary"], "43 96% 56%");
+ });
+
+ it("dark shell keeps amber primary with warm stone surfaces", () => {
+ const dark = getRaftShellVars(true);
+ assert.equal(dark["--primary"], "43 96% 56%");
+ assert.equal(dark["--background"], "24 10% 10%");
+ assert.equal(dark["--sidebar-background"], "24 10% 12%");
+ });
+
+ it("returns independent copies (no shared mutation)", () => {
+ const a = getRaftShellVars(false);
+ const b = getRaftShellVars(false);
+ a["--primary"] = "0 0% 0%";
+ assert.equal(b["--primary"], "43 96% 56%");
+ });
+});
diff --git a/desktop/src/shared/theme/raft-shell.ts b/desktop/src/shared/theme/raft-shell.ts
new file mode 100644
index 0000000000..f974c2e530
--- /dev/null
+++ b/desktop/src/shared/theme/raft-shell.ts
@@ -0,0 +1,91 @@
+/**
+ * BC home_portal "Raft" shell tokens for the default Buzz themes.
+ *
+ * Syntax highlighting still loads via Shiki (github-light/dark), but the app
+ * chrome must use these values — ThemeProvider merges them after
+ * createThemeVars so inline styles do not wipe the Raft palette.
+ *
+ * Values mirror desktop/src/shared/styles/globals/theme.css (:root / .dark).
+ */
+
+export type RaftShellVars = Record;
+
+/** Amber primary used by Raft (approx #fbbf24). */
+export const RAFT_ACCENT_HEX = "#fbbf24";
+
+const RAFT_LIGHT: RaftShellVars = {
+ "--radius": "0rem",
+ "--background": "30 100% 97%",
+ "--foreground": "24 10% 10%",
+ "--card": "0 0% 100%",
+ "--card-foreground": "24 10% 10%",
+ "--popover": "0 0% 100%",
+ "--popover-foreground": "24 10% 10%",
+ "--primary": "43 96% 56%",
+ "--primary-foreground": "24 10% 10%",
+ "--secondary": "30 25% 92%",
+ "--secondary-foreground": "24 10% 10%",
+ "--muted": "30 20% 90%",
+ "--muted-foreground": "30 6% 32%",
+ "--accent": "43 96% 90%",
+ "--accent-foreground": "24 10% 10%",
+ "--destructive": "347 77% 50%",
+ "--destructive-foreground": "0 0% 100%",
+ "--border": "24 10% 10%",
+ "--input": "24 10% 10%",
+ "--ring": "43 96% 56%",
+ "--sidebar": "24 10% 15%",
+ "--sidebar-background": "24 10% 15%",
+ "--sidebar-foreground": "60 9% 98%",
+ "--sidebar-primary": "43 96% 56%",
+ "--sidebar-primary-foreground": "24 10% 10%",
+ "--sidebar-active": "43 96% 56%",
+ "--sidebar-active-foreground": "24 10% 10%",
+ "--sidebar-accent": "24 10% 20%",
+ "--sidebar-accent-foreground": "60 9% 98%",
+ "--sidebar-border": "24 6% 25%",
+ "--sidebar-ring": "43 96% 56%",
+};
+
+const RAFT_DARK: RaftShellVars = {
+ "--radius": "0rem",
+ "--background": "24 10% 10%",
+ "--foreground": "60 9% 98%",
+ "--card": "24 10% 15%",
+ "--card-foreground": "60 9% 98%",
+ "--popover": "24 10% 15%",
+ "--popover-foreground": "60 9% 98%",
+ "--primary": "43 96% 56%",
+ "--primary-foreground": "24 10% 10%",
+ "--secondary": "24 6% 20%",
+ "--secondary-foreground": "60 9% 98%",
+ "--muted": "24 6% 20%",
+ "--muted-foreground": "30 6% 65%",
+ "--accent": "24 6% 22%",
+ "--accent-foreground": "60 9% 98%",
+ "--destructive": "0 72% 55%",
+ "--destructive-foreground": "0 0% 100%",
+ "--border": "24 6% 28%",
+ "--input": "24 6% 28%",
+ "--ring": "43 96% 56%",
+ "--sidebar": "24 10% 12%",
+ "--sidebar-background": "24 10% 12%",
+ "--sidebar-foreground": "60 9% 98%",
+ "--sidebar-primary": "43 96% 56%",
+ "--sidebar-primary-foreground": "24 10% 10%",
+ "--sidebar-active": "43 96% 56%",
+ "--sidebar-active-foreground": "24 10% 10%",
+ "--sidebar-accent": "24 8% 18%",
+ "--sidebar-accent-foreground": "60 9% 98%",
+ "--sidebar-border": "24 6% 22%",
+ "--sidebar-ring": "43 96% 56%",
+};
+
+export function getRaftShellVars(isDark: boolean): RaftShellVars {
+ return isDark ? { ...RAFT_DARK } : { ...RAFT_LIGHT };
+}
+
+/** Expected computed-style samples for QA / unit checks (HSL channel triples). */
+export const RAFT_LIGHT_PRIMARY = RAFT_LIGHT["--primary"];
+export const RAFT_LIGHT_BACKGROUND = RAFT_LIGHT["--background"];
+export const RAFT_LIGHT_SIDEBAR = RAFT_LIGHT["--sidebar-background"];
diff --git a/desktop/src/shared/theme/useThemePreviewVars.ts b/desktop/src/shared/theme/useThemePreviewVars.ts
index 2d2ab35652..67f7d88d52 100644
--- a/desktop/src/shared/theme/useThemePreviewVars.ts
+++ b/desktop/src/shared/theme/useThemePreviewVars.ts
@@ -14,6 +14,7 @@ import {
} from "./ThemePreviewFrame";
import { NEUTRAL_ACCENT } from "./ThemeProvider";
import { hexToHsl } from "./adaptive-theme";
+import { getRaftShellVars } from "./raft-shell";
export type ThemePreviewVarsByTheme = Partial<
Record
@@ -25,11 +26,14 @@ let themePreviewVarsPromise: Promise | null = null;
async function loadThemePreviewVars(name: SyntaxThemeName) {
const themeData = await loadThemeData(name);
const info = extractThemeInfo(name, themeData);
- const { vars } = createThemeVars(info.bg, info.fg, info.comment, {
+ let { vars } = createThemeVars(info.bg, info.fg, info.comment, {
added: info.added,
deleted: info.deleted,
modified: info.modified,
});
+ if (name === "buzz" || name === "buzz-dark") {
+ vars = { ...vars, ...getRaftShellVars(name === "buzz-dark") };
+ }
return [name, vars] as const;
}
@@ -86,6 +90,9 @@ export function useThemePreviewVars() {
}
export function getThemeFallbackPreviewVars(name: SyntaxThemeName) {
+ if (name === "buzz" || name === "buzz-dark") {
+ return getRaftShellVars(name === "buzz-dark");
+ }
return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS;
}
diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx
index 70e2c0a045..036cbb7d62 100644
--- a/desktop/src/shared/ui/button.tsx
+++ b/desktop/src/shared/ui/button.tsx
@@ -10,12 +10,13 @@ const buttonVariants = cva(
variants: {
variant: {
default:
- "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ "border-2 border-foreground/90 bg-primary text-primary-foreground shadow-none hover:bg-primary/90",
destructive:
- "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
- outline: "border border-input/40 bg-background hover:bg-muted/70",
+ "border-2 border-destructive bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
+ outline:
+ "border-2 border-foreground/80 bg-background hover:bg-muted/70",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ "border-2 border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
diff --git a/desktop/src/shared/ui/input.tsx b/desktop/src/shared/ui/input.tsx
index 3983bc149f..a5236f45d0 100644
--- a/desktop/src/shared/ui/input.tsx
+++ b/desktop/src/shared/ui/input.tsx
@@ -11,7 +11,7 @@ const Input = React.forwardRef>(
spellCheck={false}
type={type}
className={cn(
- "flex h-9 w-full rounded-lg border border-input/40 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "flex h-9 w-full rounded-lg border-2 border-input/70 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 8905fc16ce..da14de5b68 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -39,9 +39,10 @@ export default {
},
fontFamily: {
sans: [
+ '"Space Grotesk"',
'"Inter Variable"',
"Inter",
- '"Avenir Next"',
+ '"PingFang SC"',
'"Segoe UI"',
"sans-serif",
],
diff --git a/preview-ui/index.html b/preview-ui/index.html
new file mode 100644
index 0000000000..2f03866124
--- /dev/null
+++ b/preview-ui/index.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+ Buzz UI Preview · BC home / Raft theme
+
+
+
+
+
+
+
+
+
+
+ Preview only — theme tokens from BC home_portal (raft). Not production :3000 yet.
+
+
+
+
+
+
@Cindy
+
Theme aligned to BC home: cream paper, charcoal sidebar, amber accent, hard borders.
+
+
+
You
+
Also want Chinese / English switch.
+
+
+
@Cindy
+
Language toggle is live in this preview (top-right EN / 中文).
+
+
+
+
+ Send
+
+
+
+
+
+
+
+
+
diff --git a/web/index.html b/web/index.html
index 0b9f358282..ecf5c60314 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,9 @@
Buzz
-
+
+
+
diff --git a/web/src/shared/styles/globals.css b/web/src/shared/styles/globals.css
index e7255a9cb7..1de832b32c 100644
--- a/web/src/shared/styles/globals.css
+++ b/web/src/shared/styles/globals.css
@@ -1,83 +1,84 @@
@import "tailwindcss";
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700;800&display=swap");
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@config "../../../tailwind.config.js";
@layer base {
:root {
- /* Catppuccin Latte (mauve accent) */
- --radius: 0.625rem;
- --background: 220 23.08% 94.9%;
- --foreground: 234 16.02% 35.49%;
- --card: 220 23.08% 94.9%;
- --card-foreground: 234 16.02% 35.49%;
- --popover: 220 23.08% 94.9%;
- --popover-foreground: 234 16.02% 35.49%;
- --primary: 266 85.05% 58.04%;
- --primary-foreground: 220 23.08% 94.9%;
- --secondary: 223 15.91% 82.75%;
- --secondary-foreground: 234 16.02% 35.49%;
- --muted: 223 15.91% 82.75%;
- --muted-foreground: 233 12.8% 41.37%;
- --accent: 223 15.91% 82.75%;
- --accent-foreground: 234 16.02% 35.49%;
- --destructive: 347 86.67% 44.12%;
- --destructive-foreground: 220 23.08% 94.9%;
- --border: 225 13.56% 76.86%;
- --input: 225 13.56% 76.86%;
- --ring: 234 16.02% 35.49%;
- --chart-1: 345 57.65% 74.12%;
- --chart-2: 185 37.25% 70.59%;
- --chart-3: 219 81.96% 78.43%;
- --chart-4: 35 61.18% 76.86%;
- --chart-5: 113 32.94% 73.33%;
- --sidebar: 218 25.1% 93.73%;
- --sidebar-background: 218 25.1% 93.73%;
- --sidebar-foreground: 234 16.02% 35.49%;
- --sidebar-primary: 266 85.05% 58.04%;
- --sidebar-primary-foreground: 220 23.08% 94.9%;
- --sidebar-accent: 220 23.08% 94.9%;
- --sidebar-accent-foreground: 234 16.02% 35.49%;
- --sidebar-border: 225 13.56% 76.86%;
- --sidebar-ring: 225 13.56% 76.86%;
+ /* BC home_portal "raft" — cream + charcoal + amber */
+ --radius: 0rem;
+ --background: 30 100% 97%;
+ --foreground: 24 10% 10%;
+ --card: 0 0% 100%;
+ --card-foreground: 24 10% 10%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 24 10% 10%;
+ --primary: 43 96% 56%;
+ --primary-foreground: 24 10% 10%;
+ --secondary: 30 25% 92%;
+ --secondary-foreground: 24 10% 10%;
+ --muted: 30 20% 90%;
+ --muted-foreground: 30 6% 32%;
+ --accent: 43 96% 90%;
+ --accent-foreground: 24 10% 10%;
+ --destructive: 347 77% 50%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 24 10% 10%;
+ --input: 24 10% 10%;
+ --ring: 43 96% 56%;
+ --chart-1: 43 96% 56%;
+ --chart-2: 330 80% 75%;
+ --chart-3: 142 50% 40%;
+ --chart-4: 24 10% 30%;
+ --chart-5: 30 6% 45%;
+ --sidebar: 24 10% 15%;
+ --sidebar-background: 24 10% 15%;
+ --sidebar-foreground: 60 9% 98%;
+ --sidebar-primary: 43 96% 56%;
+ --sidebar-primary-foreground: 24 10% 10%;
+ --sidebar-accent: 24 10% 20%;
+ --sidebar-accent-foreground: 60 9% 98%;
+ --sidebar-border: 24 6% 25%;
+ --sidebar-ring: 43 96% 56%;
}
.dark {
- /* Catppuccin Macchiato (mauve accent) */
- --radius: 0.625rem;
- --background: 232 23.4% 18.43%;
- --foreground: 227 68.25% 87.65%;
- --card: 232 23.4% 18.43%;
- --card-foreground: 227 68.25% 87.65%;
- --popover: 232 23.4% 18.43%;
- --popover-foreground: 227 68.25% 87.65%;
- --primary: 267 82.69% 79.61%;
- --primary-foreground: 232 23.4% 18.43%;
- --secondary: 230 18.8% 26.08%;
- --secondary-foreground: 227 68.25% 87.65%;
- --muted: 230 18.8% 26.08%;
- --muted-foreground: 228 39.22% 80%;
- --accent: 230 18.8% 26.08%;
- --accent-foreground: 227 68.25% 87.65%;
- --destructive: 351 73.91% 72.94%;
- --destructive-foreground: 232 23.4% 18.43%;
- --border: 231 15.61% 33.92%;
- --input: 231 15.61% 33.92%;
- --ring: 227 68.25% 87.65%;
- --chart-1: 333 20.78% 38.04%;
- --chart-2: 193 19.61% 38.04%;
- --chart-3: 224 26.67% 41.18%;
- --chart-4: 30 8.24% 42.35%;
- --chart-5: 139 10.98% 38.82%;
- --sidebar: 234 22.75% 17.25%;
- --sidebar-background: 234 22.75% 17.25%;
- --sidebar-foreground: 227 68.25% 87.65%;
- --sidebar-primary: 267 82.69% 79.61%;
- --sidebar-primary-foreground: 232 23.4% 18.43%;
- --sidebar-accent: 232 23.4% 18.43%;
- --sidebar-accent-foreground: 227 68.25% 87.65%;
- --sidebar-border: 231 15.61% 33.92%;
- --sidebar-ring: 231 15.61% 33.92%;
+ /* BC home ruff dark */
+ --radius: 0rem;
+ --background: 24 10% 10%;
+ --foreground: 60 9% 98%;
+ --card: 24 10% 15%;
+ --card-foreground: 60 9% 98%;
+ --popover: 24 10% 15%;
+ --popover-foreground: 60 9% 98%;
+ --primary: 43 96% 56%;
+ --primary-foreground: 24 10% 10%;
+ --secondary: 24 6% 20%;
+ --secondary-foreground: 60 9% 98%;
+ --muted: 24 6% 20%;
+ --muted-foreground: 30 6% 65%;
+ --accent: 24 6% 22%;
+ --accent-foreground: 60 9% 98%;
+ --destructive: 0 72% 55%;
+ --destructive-foreground: 0 0% 100%;
+ --border: 24 6% 28%;
+ --input: 24 6% 28%;
+ --ring: 43 96% 56%;
+ --chart-1: 43 96% 56%;
+ --chart-2: 330 70% 65%;
+ --chart-3: 142 50% 45%;
+ --chart-4: 30 6% 50%;
+ --chart-5: 24 10% 40%;
+ --sidebar: 24 10% 12%;
+ --sidebar-background: 24 10% 12%;
+ --sidebar-foreground: 60 9% 98%;
+ --sidebar-primary: 43 96% 56%;
+ --sidebar-primary-foreground: 24 10% 10%;
+ --sidebar-accent: 24 8% 18%;
+ --sidebar-accent-foreground: 60 9% 98%;
+ --sidebar-border: 24 6% 22%;
+ --sidebar-ring: 43 96% 56%;
}
* {