From 98c43c508c1bad56dba63e659756fceb8b467e7c Mon Sep 17 00:00:00 2001 From: GCWing Date: Wed, 2 Sep 2026 00:49:52 +0800 Subject: [PATCH] refactor(ui): consolidate fonts, icons, and surface controls --- THIRD_PARTY_NOTICES.md | 15 + .../design-tokens/src/system.tokens.json | 6 +- .../design-tokens/tests/contract.test.mjs | 12 +- .../performance/04-startup-and-bundle-size.md | 23 +- package.json | 2 - scripts/audit-typography-tokens.mjs | 46 + scripts/audit-typography-tokens.test.mjs | 32 + scripts/desktop-tauri-build.mjs | 18 + scripts/desktop-tauri-build.test.mjs | 37 + scripts/iconography/generate.mjs | 291 ----- scripts/web-font-profile.mjs | 196 +++ scripts/web-font-profile.test.mjs | 144 +++ .../generated/default_appearance_style.html | 4 +- src/shared/iconography/manifest.json | 226 ---- .../iconography/svg/harness-creative.svg | 4 - .../navigation-extensions-compatibility.svg | 4 - .../iconography/svg/navigation-mini-app.svg | 5 - .../svg/navigation-session-context-add.svg | 4 - .../svg/navigation-session-view-all.svg | 4 - .../svg/navigation-session-view-grouped.svg | 3 - .../svg/session-group-assistant-selected.svg | 4 - .../svg/session-group-assistant.svg | 5 - .../svg/session-group-global-task.svg | 5 - ...ession-group-remote-workspace-selected.svg | 5 - .../svg/session-group-remote-workspace.svg | 5 - .../svg/session-group-workspace-selected.svg | 3 - .../svg/session-group-workspace.svg | 3 - src/web-ui/index.html | 4 +- src/web-ui/public/fonts/Noto_Sans_SC/OFL.txt | 93 -- .../public/fonts/Noto_Sans_SC/README.txt | 33 - .../fonts/Noto_Sans_SC/variable/LICENSE.txt | 93 -- .../noto-sans-sc-100-wght-normal.woff2 | Bin 64548 -> 0 bytes .../noto-sans-sc-101-wght-normal.woff2 | Bin 58204 -> 0 bytes .../noto-sans-sc-102-wght-normal.woff2 | Bin 60708 -> 0 bytes .../noto-sans-sc-103-wght-normal.woff2 | Bin 63864 -> 0 bytes .../noto-sans-sc-104-wght-normal.woff2 | Bin 61588 -> 0 bytes .../noto-sans-sc-105-wght-normal.woff2 | Bin 61828 -> 0 bytes .../noto-sans-sc-106-wght-normal.woff2 | Bin 62636 -> 0 bytes .../noto-sans-sc-107-wght-normal.woff2 | Bin 61076 -> 0 bytes .../noto-sans-sc-108-wght-normal.woff2 | Bin 64552 -> 0 bytes .../noto-sans-sc-109-wght-normal.woff2 | Bin 60372 -> 0 bytes .../noto-sans-sc-110-wght-normal.woff2 | Bin 61472 -> 0 bytes .../noto-sans-sc-111-wght-normal.woff2 | Bin 62664 -> 0 bytes .../noto-sans-sc-112-wght-normal.woff2 | Bin 57648 -> 0 bytes .../noto-sans-sc-113-wght-normal.woff2 | Bin 58648 -> 0 bytes .../noto-sans-sc-114-wght-normal.woff2 | Bin 57964 -> 0 bytes .../noto-sans-sc-115-wght-normal.woff2 | Bin 56508 -> 0 bytes .../noto-sans-sc-116-wght-normal.woff2 | Bin 52984 -> 0 bytes .../noto-sans-sc-117-wght-normal.woff2 | Bin 52464 -> 0 bytes .../noto-sans-sc-118-wght-normal.woff2 | Bin 46192 -> 0 bytes .../noto-sans-sc-119-wght-normal.woff2 | Bin 76800 -> 0 bytes .../noto-sans-sc-21-wght-normal.woff2 | Bin 11356 -> 0 bytes .../noto-sans-sc-22-wght-normal.woff2 | Bin 65384 -> 0 bytes .../noto-sans-sc-23-wght-normal.woff2 | Bin 56792 -> 0 bytes .../noto-sans-sc-24-wght-normal.woff2 | Bin 62580 -> 0 bytes .../noto-sans-sc-25-wght-normal.woff2 | Bin 58320 -> 0 bytes .../noto-sans-sc-26-wght-normal.woff2 | Bin 50888 -> 0 bytes .../noto-sans-sc-27-wght-normal.woff2 | Bin 47488 -> 0 bytes .../noto-sans-sc-28-wght-normal.woff2 | Bin 50200 -> 0 bytes .../noto-sans-sc-29-wght-normal.woff2 | Bin 54008 -> 0 bytes .../noto-sans-sc-30-wght-normal.woff2 | Bin 54656 -> 0 bytes .../noto-sans-sc-31-wght-normal.woff2 | Bin 47220 -> 0 bytes .../noto-sans-sc-32-wght-normal.woff2 | Bin 52548 -> 0 bytes .../noto-sans-sc-33-wght-normal.woff2 | Bin 47452 -> 0 bytes .../noto-sans-sc-34-wght-normal.woff2 | Bin 46888 -> 0 bytes .../noto-sans-sc-35-wght-normal.woff2 | Bin 50688 -> 0 bytes .../noto-sans-sc-36-wght-normal.woff2 | Bin 41832 -> 0 bytes .../noto-sans-sc-37-wght-normal.woff2 | Bin 53528 -> 0 bytes .../noto-sans-sc-38-wght-normal.woff2 | Bin 54592 -> 0 bytes .../noto-sans-sc-39-wght-normal.woff2 | Bin 51784 -> 0 bytes .../variable/noto-sans-sc-4-wght-normal.woff2 | Bin 3796 -> 0 bytes .../noto-sans-sc-40-wght-normal.woff2 | Bin 46628 -> 0 bytes .../noto-sans-sc-41-wght-normal.woff2 | Bin 36416 -> 0 bytes .../noto-sans-sc-42-wght-normal.woff2 | Bin 45540 -> 0 bytes .../noto-sans-sc-43-wght-normal.woff2 | Bin 46624 -> 0 bytes .../noto-sans-sc-44-wght-normal.woff2 | Bin 54244 -> 0 bytes .../noto-sans-sc-45-wght-normal.woff2 | Bin 67620 -> 0 bytes .../noto-sans-sc-46-wght-normal.woff2 | Bin 57668 -> 0 bytes .../noto-sans-sc-47-wght-normal.woff2 | Bin 56156 -> 0 bytes .../noto-sans-sc-48-wght-normal.woff2 | Bin 50764 -> 0 bytes .../noto-sans-sc-49-wght-normal.woff2 | Bin 52844 -> 0 bytes .../variable/noto-sans-sc-5-wght-normal.woff2 | Bin 20176 -> 0 bytes .../noto-sans-sc-50-wght-normal.woff2 | Bin 45876 -> 0 bytes .../noto-sans-sc-51-wght-normal.woff2 | Bin 41616 -> 0 bytes .../noto-sans-sc-52-wght-normal.woff2 | Bin 51840 -> 0 bytes .../noto-sans-sc-53-wght-normal.woff2 | Bin 43528 -> 0 bytes .../noto-sans-sc-54-wght-normal.woff2 | Bin 52000 -> 0 bytes .../noto-sans-sc-55-wght-normal.woff2 | Bin 48132 -> 0 bytes .../noto-sans-sc-56-wght-normal.woff2 | Bin 53500 -> 0 bytes .../noto-sans-sc-57-wght-normal.woff2 | Bin 55496 -> 0 bytes .../noto-sans-sc-58-wght-normal.woff2 | Bin 57648 -> 0 bytes .../noto-sans-sc-59-wght-normal.woff2 | Bin 47676 -> 0 bytes .../variable/noto-sans-sc-6-wght-normal.woff2 | Bin 11464 -> 0 bytes .../noto-sans-sc-60-wght-normal.woff2 | Bin 41432 -> 0 bytes .../noto-sans-sc-61-wght-normal.woff2 | Bin 45276 -> 0 bytes .../noto-sans-sc-62-wght-normal.woff2 | Bin 59112 -> 0 bytes .../noto-sans-sc-63-wght-normal.woff2 | Bin 49180 -> 0 bytes .../noto-sans-sc-64-wght-normal.woff2 | Bin 41540 -> 0 bytes .../noto-sans-sc-65-wght-normal.woff2 | Bin 41608 -> 0 bytes .../noto-sans-sc-66-wght-normal.woff2 | Bin 52868 -> 0 bytes .../noto-sans-sc-67-wght-normal.woff2 | Bin 52584 -> 0 bytes .../noto-sans-sc-68-wght-normal.woff2 | Bin 41568 -> 0 bytes .../noto-sans-sc-69-wght-normal.woff2 | Bin 55604 -> 0 bytes .../noto-sans-sc-70-wght-normal.woff2 | Bin 42236 -> 0 bytes .../noto-sans-sc-71-wght-normal.woff2 | Bin 47904 -> 0 bytes .../noto-sans-sc-72-wght-normal.woff2 | Bin 47032 -> 0 bytes .../noto-sans-sc-73-wght-normal.woff2 | Bin 39312 -> 0 bytes .../noto-sans-sc-74-wght-normal.woff2 | Bin 56424 -> 0 bytes .../noto-sans-sc-75-wght-normal.woff2 | Bin 43940 -> 0 bytes .../noto-sans-sc-76-wght-normal.woff2 | Bin 48420 -> 0 bytes .../noto-sans-sc-77-wght-normal.woff2 | Bin 36788 -> 0 bytes .../noto-sans-sc-78-wght-normal.woff2 | Bin 49156 -> 0 bytes .../noto-sans-sc-79-wght-normal.woff2 | Bin 41232 -> 0 bytes .../noto-sans-sc-80-wght-normal.woff2 | Bin 32632 -> 0 bytes .../noto-sans-sc-81-wght-normal.woff2 | Bin 47548 -> 0 bytes .../noto-sans-sc-82-wght-normal.woff2 | Bin 46176 -> 0 bytes .../noto-sans-sc-83-wght-normal.woff2 | Bin 43504 -> 0 bytes .../noto-sans-sc-84-wght-normal.woff2 | Bin 32612 -> 0 bytes .../noto-sans-sc-85-wght-normal.woff2 | Bin 26900 -> 0 bytes .../noto-sans-sc-86-wght-normal.woff2 | Bin 33576 -> 0 bytes .../noto-sans-sc-87-wght-normal.woff2 | Bin 8012 -> 0 bytes .../noto-sans-sc-88-wght-normal.woff2 | Bin 7336 -> 0 bytes .../noto-sans-sc-89-wght-normal.woff2 | Bin 10984 -> 0 bytes .../noto-sans-sc-90-wght-normal.woff2 | Bin 9344 -> 0 bytes .../noto-sans-sc-91-wght-normal.woff2 | Bin 8704 -> 0 bytes .../noto-sans-sc-97-wght-normal.woff2 | Bin 2080 -> 0 bytes .../noto-sans-sc-98-wght-normal.woff2 | Bin 3416 -> 0 bytes .../noto-sans-sc-99-wght-normal.woff2 | Bin 9704 -> 0 bytes .../noto-sans-sc-cyrillic-wght-normal.woff2 | Bin 10008 -> 0 bytes .../noto-sans-sc-latin-ext-wght-normal.woff2 | Bin 8244 -> 0 bytes .../noto-sans-sc-latin-wght-normal.woff2 | Bin 25240 -> 0 bytes .../noto-sans-sc-vietnamese-wght-normal.woff2 | Bin 9096 -> 0 bytes src/web-ui/public/fonts/README.md | 71 -- src/web-ui/public/fonts/README.zh-CN.md | 71 -- src/web-ui/public/fonts/fonts.css | 1069 ----------------- .../src/app/components/NavPanel/MainNav.tsx | 45 +- .../src/app/components/NavPanel/NavPanel.scss | 121 +- .../NavPanel/NavPanelLayout.test.ts | 37 +- .../NavPanel/components/MiniAppEntry.test.ts | 1 - .../components/PersistentFooterActions.tsx | 2 +- .../NavPanel/components/SectionHeader.tsx | 85 +- .../WorkspaceSessionGroupingToggle.test.tsx | 18 +- .../WorkspaceSessionGroupingToggle.tsx | 14 +- .../navigationIconIntegration.test.ts | 60 +- .../sections/workspaces/WorkspaceItem.tsx | 17 +- .../WorkspaceListSectionLayout.test.ts | 8 +- .../NavPanel/unifiedSessionCreation.test.ts | 13 +- src/web-ui/src/app/icons/BitFunIcon.tsx | 24 - src/web-ui/src/app/icons/IconSystem.test.tsx | 133 -- .../app/icons/createBitFunIconComponent.tsx | 31 - .../icons/createBitFunSvgIconComponent.tsx | 73 -- .../icons/generated/iconRegistry.generated.ts | 613 ---------- src/web-ui/src/app/icons/index.ts | 24 - src/web-ui/src/app/icons/tokens.ts | 6 - src/web-ui/src/app/icons/types.ts | 45 - .../src/app/styles/textClipping.test.ts | 80 +- .../fonts/fira-code}/FiraCode-Medium.woff2 | Bin .../fonts/fira-code}/FiraCode-Regular.woff2 | Bin .../fonts/fira-code}/FiraCode-SemiBold.woff2 | Bin .../assets/fonts/fira-code}/FiraCode-VF.woff2 | Bin .../assets/fonts/fira-code}/LICENSE.txt | 0 .../assets/fonts/harmonyos-sans/LICENSE.txt | Bin 0 -> 32768 bytes .../assets/fonts/harmonyos-sans/NOTICE.txt | 7 + .../base/HarmonyOS_Sans_Bold.ttf | Bin 0 -> 145860 bytes .../base/HarmonyOS_Sans_Medium.ttf | Bin 0 -> 146164 bytes .../base/HarmonyOS_Sans_Regular.ttf | Bin 0 -> 146616 bytes .../sc/HarmonyOS_Sans_SC_Bold.ttf | Bin 0 -> 8158996 bytes .../sc/HarmonyOS_Sans_SC_Medium.ttf | Bin 0 -> 8227312 bytes .../sc/HarmonyOS_Sans_SC_Regular.ttf | Bin 0 -> 8261128 bytes .../components/ChatInputWorkspaceStrip.scss | 8 +- .../components/ModelSelector.appearance.ts | 2 + .../flow_chat/components/ModelSelector.scss | 26 +- .../flow_chat/components/ModelSelector.tsx | 532 ++++---- .../components/ModelSelectorExternal.test.tsx | 5 + .../ModelSelectorProviderLevels.test.tsx | 115 +- .../components/modern/ExportImageButton.tsx | 7 +- .../components/modern/FlowChatHeader.test.tsx | 9 +- .../components/modern/FlowChatHeader.tsx | 28 +- .../modern/ForkSessionButton.test.tsx | 7 +- .../components/modern/ForkSessionButton.tsx | 7 +- .../components/modern/ModelRoundItem.scss | 6 - .../components/modern/ModelRoundItem.tsx | 5 +- .../components/modern/SessionFilesBadge.scss | 60 +- .../modern/SessionFilesBadge.test.tsx | 4 + .../components/modern/SessionFilesBadge.tsx | 84 +- .../ModelThinkingDisplay.appearance.ts | 2 +- .../tool-cards/ModelThinkingDisplay.scss | 53 +- .../tool-cards/ModelThinkingDisplay.test.tsx | 11 + .../tool-cards/ModelThinkingDisplay.tsx | 13 +- src/web-ui/src/font-profiles/apple-system.css | 7 + .../src/font-profiles/harmony-bundled.css | 97 ++ .../IconUsageIntegration.test.tsx | 1 - src/web-ui/src/main.tsx | 4 +- src/web-ui/vite.config.ts | 77 +- tests/e2e/specs/l0-navigation.spec.ts | 46 +- .../l1-chat-input-contract-controls.spec.ts | 5 +- 196 files changed, 1427 insertions(+), 3791 deletions(-) delete mode 100644 scripts/iconography/generate.mjs create mode 100644 scripts/web-font-profile.mjs create mode 100644 scripts/web-font-profile.test.mjs delete mode 100644 src/shared/iconography/manifest.json delete mode 100644 src/shared/iconography/svg/harness-creative.svg delete mode 100644 src/shared/iconography/svg/navigation-extensions-compatibility.svg delete mode 100644 src/shared/iconography/svg/navigation-mini-app.svg delete mode 100644 src/shared/iconography/svg/navigation-session-context-add.svg delete mode 100644 src/shared/iconography/svg/navigation-session-view-all.svg delete mode 100644 src/shared/iconography/svg/navigation-session-view-grouped.svg delete mode 100644 src/shared/iconography/svg/session-group-assistant-selected.svg delete mode 100644 src/shared/iconography/svg/session-group-assistant.svg delete mode 100644 src/shared/iconography/svg/session-group-global-task.svg delete mode 100644 src/shared/iconography/svg/session-group-remote-workspace-selected.svg delete mode 100644 src/shared/iconography/svg/session-group-remote-workspace.svg delete mode 100644 src/shared/iconography/svg/session-group-workspace-selected.svg delete mode 100644 src/shared/iconography/svg/session-group-workspace.svg delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/OFL.txt delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/README.txt delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/LICENSE.txt delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-100-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-101-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-102-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-103-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-104-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-105-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-106-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-107-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-108-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-109-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-110-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-111-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-112-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-113-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-114-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-115-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-116-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-117-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-118-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-119-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-21-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-22-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-23-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-24-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-25-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-26-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-27-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-28-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-29-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-30-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-31-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-32-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-33-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-34-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-35-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-36-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-37-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-38-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-39-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-4-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-40-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-41-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-42-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-43-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-44-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-45-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-46-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-47-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-48-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-49-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-5-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-50-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-51-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-52-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-53-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-54-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-55-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-56-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-57-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-58-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-59-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-6-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-60-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-61-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-62-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-63-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-64-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-65-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-66-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-67-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-68-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-69-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-70-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-71-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-72-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-73-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-74-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-75-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-76-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-77-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-78-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-79-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-80-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-81-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-82-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-83-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-84-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-85-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-86-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-87-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-88-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-89-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-90-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-91-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-97-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-98-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-99-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-cyrillic-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-latin-ext-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-latin-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/Noto_Sans_SC/variable/noto-sans-sc-vietnamese-wght-normal.woff2 delete mode 100644 src/web-ui/public/fonts/README.md delete mode 100644 src/web-ui/public/fonts/README.zh-CN.md delete mode 100644 src/web-ui/public/fonts/fonts.css delete mode 100644 src/web-ui/src/app/icons/BitFunIcon.tsx delete mode 100644 src/web-ui/src/app/icons/IconSystem.test.tsx delete mode 100644 src/web-ui/src/app/icons/createBitFunIconComponent.tsx delete mode 100644 src/web-ui/src/app/icons/createBitFunSvgIconComponent.tsx delete mode 100644 src/web-ui/src/app/icons/generated/iconRegistry.generated.ts delete mode 100644 src/web-ui/src/app/icons/index.ts delete mode 100644 src/web-ui/src/app/icons/tokens.ts delete mode 100644 src/web-ui/src/app/icons/types.ts rename src/web-ui/{public/fonts/FiraCode => src/assets/fonts/fira-code}/FiraCode-Medium.woff2 (100%) rename src/web-ui/{public/fonts/FiraCode => src/assets/fonts/fira-code}/FiraCode-Regular.woff2 (100%) rename src/web-ui/{public/fonts/FiraCode => src/assets/fonts/fira-code}/FiraCode-SemiBold.woff2 (100%) rename src/web-ui/{public/fonts/FiraCode => src/assets/fonts/fira-code}/FiraCode-VF.woff2 (100%) rename src/web-ui/{public/fonts/FiraCode => src/assets/fonts/fira-code}/LICENSE.txt (100%) create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/LICENSE.txt create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/NOTICE.txt create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/base/HarmonyOS_Sans_Bold.ttf create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/base/HarmonyOS_Sans_Medium.ttf create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/base/HarmonyOS_Sans_Regular.ttf create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/sc/HarmonyOS_Sans_SC_Bold.ttf create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/sc/HarmonyOS_Sans_SC_Medium.ttf create mode 100644 src/web-ui/src/assets/fonts/harmonyos-sans/sc/HarmonyOS_Sans_SC_Regular.ttf create mode 100644 src/web-ui/src/font-profiles/apple-system.css create mode 100644 src/web-ui/src/font-profiles/harmony-bundled.css diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 284b7f2ba3..07824768fd 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,6 +4,21 @@ BitFun redistributes selected material and links libraries from the following third-party projects. These notices are included with BitFun's Desktop and CLI release packages. +## HarmonyOS Sans Fonts + +- Project: HarmonyOS Sans Fonts +- Provider: Huawei Device Co., Ltd. +- License: HarmonyOS Sans Fonts License Agreement +- Copyright: Copyright 2021 Huawei Device Co., Ltd. + +Non-Apple BitFun GUI distributions bundle unmodified copies of the Base and +Simplified Chinese Regular, Medium, and Bold fonts. They do not bundle the +Traditional Chinese font family. Apple GUI distributions and Traditional +Chinese UI text use platform system fonts instead. + +The complete license agreement and a font-specific notice are included only in +the non-Apple frontend artifact under `third-party/fonts/harmonyos-sans/`. + ## models.dev catalog data - Project: models.dev diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index aa5cb843e2..f215d75253 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -22,15 +22,15 @@ "family": { "sans": { "$type": "fontFamily", - "$value": "'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif" + "$value": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif" }, "control": { "$type": "fontFamily", - "$value": "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif" + "$value": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif" }, "mono": { "$type": "fontFamily", - "$value": "'JetBrains Mono', 'FiraCode', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace" + "$value": "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace" } }, "size": { diff --git a/design-system/packages/design-tokens/tests/contract.test.mjs b/design-system/packages/design-tokens/tests/contract.test.mjs index 05ba2fd57a..8e9b29d9cf 100644 --- a/design-system/packages/design-tokens/tests/contract.test.mjs +++ b/design-system/packages/design-tokens/tests/contract.test.mjs @@ -375,15 +375,13 @@ test("shared system scales preserve the migrated Web UI foundation contract", () }, ); const controlFontFamily = tokens["font.family.control"]; - assert.equal(controlFontFamily.startsWith("'SF Pro Text'"), true); + assert.equal(controlFontFamily.startsWith("system-ui"), true); + assert.equal(controlFontFamily.includes("-apple-system"), true); assert.equal(controlFontFamily.includes("'Segoe UI Variable Text'"), true); - assert.equal(controlFontFamily.includes("'Noto Sans SC'"), true); - assert.ok( - controlFontFamily.indexOf("'Segoe UI Variable Text'") - < controlFontFamily.indexOf("'Noto Sans SC'"), - ); - assert.equal(tokens["font.family.sans"].startsWith("'Noto Sans SC'"), true); + assert.equal(controlFontFamily.includes("'Noto Sans SC'"), false); + assert.equal(tokens["font.family.sans"].startsWith("system-ui"), true); assert.equal(tokens["font.family.mono"].startsWith("'JetBrains Mono'"), true); + assert.equal(tokens["font.family.mono"].includes("'Fira Code'"), true); assert.equal(tokens["font.size.micro"], "10px"); assert.equal(tokens["font.size.meta"], "11px"); assert.equal(tokens["font.size.xs"], "12px"); diff --git a/docs/performance/04-startup-and-bundle-size.md b/docs/performance/04-startup-and-bundle-size.md index c64a17fbc4..7975c99fab 100644 --- a/docs/performance/04-startup-and-bundle-size.md +++ b/docs/performance/04-startup-and-bundle-size.md @@ -4,6 +4,8 @@ - 审阅范围:Tauri 冷启动链路(src/apps/desktop)、前端首包与分包(src/web-ui)、静态资源(public/dist)、Rust release profile - 方法:只读源码分析 + 现成 dist 产物统计(未运行完整构建) +> 2026-09-02 更新:下文保留 2026-07-26 的体积基线;字体项已经按平台字体档案重新实现,当前状态见第 2 节。 + ## 产物体积基线(现成产物实测) | 产物 | 大小 | 说明 | @@ -12,7 +14,7 @@ | `dist/assets`(JS/CSS) | 19 MB(JS 13.6 MB / 731 个 chunk) | 入口 `index-DwMxiWtW.js` 单文件 **5.44 MB** | | `dist/agent-companion-pets` | **18 MB** | 10 个桌宠精灵图全量内置 | | `dist/monaco-editor` | **14 MB** | monaco `min/vs` 全量拷贝(含 9 种 NLS 语言、tsWorker 5.7 MB) | -| `dist/fonts` | **13 MB** | Noto Sans SC 3 个字重,各 ~4.2 MB | +| `dist/fonts` | **13 MB** | 审阅时的旧内置中文字体 3 个字重,各 ~4.2 MB | | `dist/` 根目录图片 | ~2.7 MB | BitFun-Logo.png 1.06 MB 等未压缩 PNG | | `target/release/bitfun-desktop.exe` | 217 MB(7-24 本地构建,profile 未知,可能为 release-fast) | 前端 dist 全部嵌入二进制 | | NSIS 安装包 | 0.2.11 → 81 MB;0.2.13 → **90.5 MB**(两个小版本 +9.4 MB) | `target/release/bundle/nsis/` | @@ -26,7 +28,7 @@ | # | 问题 | 维度 | 预期收益 | 风险 | |---|---|---|---|---| | 1 | Rust 端全部后端服务**串行初始化完成后才创建窗口**,init 期间无任何窗口 | 冷启动 | **高** | 中 | -| 2 | Noto Sans SC 三个静态字重共 12.7 MB,全部打入安装包 | 体积 | **高**(-8~9 MB) | 低 | +| 2 | 旧字体无条件进入所有平台安装包 | 体积/平台一致性 | **已解决**(2026-09-02) | — | | 3 | 10 个桌宠精灵图 18 MB 全量内置 | 体积 | **高**(-10~16 MB,视方案) | 中(产品决策) | | 4 | Monaco 全量 min 拷贝:未用 NLS 语言 1.4 MB + 未做裁剪 | 体积 | 中(-1.4 MB 起) | 低 | | 5 | 首包 5.44 MB:三语种 bootstrap 文案 ~793 KB 全部 eager 打入入口 | 冷启动+体积 | 中(首包 -0.5 MB+,减 JS parse) | 中 | @@ -60,13 +62,16 @@ 2. 把"窗口创建"前移:仅 `initialize_global_config`(主题/语言 bootstrap 需要)是窗口创建的真依赖;可在 config 就绪后立即创建并显示窗口(splash 已自带),其余服务初始化移入后台任务,前端已有 `initialize_workspace_startup_state` 命令兜底(`commands.rs:2024-2047` 注明了 fallback 路径)。需要给未就绪期间到达的 invoke 加"服务就绪门闩"(如全局 `OnceCell`/`Notify` gate)。 3. `prepare_workspace_startup_bootstrap_snapshot` 的 `block_in_place/block_on`(`lib.rs:708-716`)可设超时上限或改为异步注入(前端本就有命令回退路径),避免慢盘工作区把窗口创建拖住。 -### 2. 中文字体 12.7 MB【高收益/低风险】 +### 2. 平台字体档案【已解决】 + +2026-09-02 起,正式桌面构建按目标 triple 选择字体档案: -**证据**:`dist/fonts/Noto_Sans_SC/static/` 下 Regular/Medium/SemiBold 各 4.18~4.27 MB(woff2 已压缩,brotli 二次压缩几乎无收益,≈1:1 进入安装包);`public/fonts/fonts.css` 三个字重都被 `@font-face` 声明,UI 三个字重都会被实际请求。这一项约占 90 MB 安装包的 14%。 +- Apple 使用系统 `-apple-system` / SF Pro 与系统等宽字体,不输出任何产品文本字体文件。 +- Windows/Linux 使用 HarmonyOS Sans Base 与 SC 的 Regular、Medium、Bold 六个未修改 TTF;许可证不允许子集化或格式转换,因此这一档案优先保证授权合规和字形一致性,而不是最小体积。 +- `zh-TW` 明确使用系统字体,不内置 TC 字体。 +- 编辑器/终端字体与产品正文分离;Fira Code 只进入非 Apple 档案。 -**方案**(任选其一): -- 换用 Noto Sans SC **可变字体**单文件(wght 300-700 一个 woff2,约 4~5 MB),`fonts.css` 三个 `@font-face` 合并为一条 `font-weight: 400 600`,与 FiraCode-VF 的做法一致(FiraCode 已有 VF 文件)→ 净省 ~8.4 MB。 -- 或按 GB2312/常用字集做子集化(pyftsubset),三字重合计可降到 2~3 MB,但存在生僻字回退风险,需保留系统字体 fallback。 +`scripts/web-font-profile.mjs` 固化源文件长度和 SHA-256,并在构建时审计字体与法律文件白名单,防止旧字体、TC 字体或非批准格式重新进入产物。 ### 3. 桌宠资源 18 MB 全量内置【高收益/中风险(产品决策)】 @@ -128,7 +133,7 @@ | 任务 | 具体内容 | 涉及文件 | 预期收益 | 风险 | |---|---|---|---|---| -| T1 字体瘦身 | 将 Noto Sans SC 三个静态字重替换为单个可变字体 woff2,更新 `@font-face` 为 `font-weight: 400 600`;回归中英文界面渲染 | `src/web-ui/public/fonts/`(fonts.css + 字体文件) | 安装包 -8 MB | 低 | +| T1 平台字体档案(已完成) | Apple 使用系统字体且不携带产品字体;Windows/Linux 内置 HarmonyOS Sans Base + SC 六个未修改 TTF;`zh-TW` 使用系统字体 | `scripts/web-font-profile.mjs`、`src/web-ui/src/font-profiles/`、`src/web-ui/src/assets/fonts/` | Apple 移除产品字体;非 Apple 接受合规字体体积 | 已完成 | | T2 Monaco NLS 裁剪 | `copy-monaco` 后删除 `nls.messages.{ru,ja,ko,fr,it,es,de}.js`(保留 zh-cn/zh-tw);在 `verify-monaco-assets.cjs` 中加断言防回归 | 根 `package.json` scripts、`scripts/verify-monaco-assets.cjs` | dist -1.4 MB | 低 | | T3 图片压缩 | oxipng/pngquant 压缩 `public/` 根目录 6 个 PNG(Logo、panda 系列);`panda-pix/spritesheet.png` 转 webp;保持 `Logo-ICON-128.png` 透明度契约测试通过 | `src/web-ui/public/*.png`、`public/agent-companion-pets/panda-pix/` | dist -2.5~3 MB | 低 | | T4 桌宠按需分发 | 产品确认后:仅内置默认宠物,其余走现有 `import_agent_companion_pet_package` 通道按需获取 | `src/web-ui/public/agent-companion-pets/`、桌宠选择 UI | 安装包 -14 MB | 中(需产品确认) | @@ -140,4 +145,4 @@ | T10 高亮引擎统一 | 消除双 prismjs(InlineDiffPreview 改用 refractor 或懒加载),规划 highlight.js/react-syntax-highlighter 二选一 | `src/web-ui/src/flow_chat/components/InlineDiffPreview.tsx` 等 | JS -0.5~1 MB | 中 | | T11 体积基线监控 | CI 增加 dist 分目录与安装包体积基线对比(0.2.11→0.2.13 已 +9.4 MB 无告警) | `scripts/ci/` | 防回归 | 低 | -建议实施顺序:T1/T2/T3(纯资源,低风险速赢,合计约 -12 MB)→ T5 → T8 → T9 → T7 → T6(收益最大但需设计评审)→ T4/T10/T11。 +建议后续实施顺序:T2/T3(纯资源,低风险速赢)→ T5 → T8 → T9 → T7 → T6(收益最大但需设计评审)→ T4/T10/T11。 diff --git a/package.json b/package.json index 17c3b34d6a..76cb119ba4 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,6 @@ "capabilities:generate": "node scripts/generate-interactive-capabilities.mjs", "capabilities:check": "node scripts/generate-interactive-capabilities.mjs --check", "capabilities:test": "node --test scripts/interactive-capabilities.test.mjs", - "iconography:generate": "node scripts/iconography/generate.mjs", - "iconography:check": "node scripts/iconography/generate.mjs --check", "typography:audit": "node scripts/audit-typography-tokens.mjs", "typography:audit:test": "node --test scripts/audit-typography-tokens.test.mjs", "theme:color-audit": "node scripts/audit-frontend-colors.mjs --surface web-ui", diff --git a/scripts/audit-typography-tokens.mjs b/scripts/audit-typography-tokens.mjs index 5be71b9447..b3d8d927df 100644 --- a/scripts/audit-typography-tokens.mjs +++ b/scripts/audit-typography-tokens.mjs @@ -24,6 +24,12 @@ const SELF_AUDIT_FILES = new Set([ 'scripts/audit-typography-tokens.mjs', 'scripts/audit-typography-tokens.test.mjs', ]); +const WEB_FONT_PROFILE_STYLE_ROOT = 'src/web-ui/src/font-profiles/'; +const WEB_FONT_PROFILE_STACK_PROPERTIES = new Set([ + '--bf-font-family-sans', + '--bf-font-family-control', + '--bf-font-family-mono', +]); const TYPOGRAPHY_PROPERTIES = new Set([ 'font', @@ -353,6 +359,38 @@ function normalizeCssValue(value) { .trim(); } +function cssAtRuleRanges(source, atRulePattern) { + const ranges = []; + const pattern = new RegExp(`${atRulePattern}\\s*\\{`, 'gi'); + for (const match of source.matchAll(pattern)) { + const openBrace = (match.index ?? 0) + match[0].lastIndexOf('{'); + let depth = 1; + for (let index = openBrace + 1; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] === '}') depth -= 1; + if (depth === 0) { + ranges.push([openBrace, index]); + break; + } + } + } + return ranges; +} + +function isBuildOwnedWebFontDeclaration(relativePath, source, offset, property, customProperty) { + if (!normalizePath(relativePath).startsWith(WEB_FONT_PROFILE_STYLE_ROOT)) return false; + + if (customProperty) { + return property === 'fontFamily' + && WEB_FONT_PROFILE_STACK_PROPERTIES.has(customProperty.toLowerCase()); + } + + if (property !== 'fontFamily' && property !== 'fontWeight') return false; + return cssAtRuleRanges(source, '@font-face').some(([start, end]) => ( + offset >= start && offset <= end + )); +} + function isStructuralZero(property, value) { return (property === 'fontSize' || property === 'lineHeight') && /^0(?:\.0+)?$/.test(value); } @@ -383,6 +421,7 @@ function auditCssDeclarations(text, relativePath) { // are still caught because they contain no interpolation marker. if (value.includes('${')) continue; if (isCanonicalTypographyValue(property, value)) continue; + if (isBuildOwnedWebFontDeclaration(relativePath, source, match.index ?? 0, property)) continue; if (hasLocalTypographyException(lines, line)) continue; issues.push(createIssue( @@ -413,6 +452,13 @@ function auditCssDeclarations(text, relativePath) { if ( isCanonicalTypographyValue(property, value) || /^var\(--_[a-z0-9-]+\)$/i.test(normalizedValue) + || isBuildOwnedWebFontDeclaration( + relativePath, + source, + match.index ?? 0, + property, + customProperty, + ) || hasLocalTypographyException(lines, line) ) { continue; diff --git a/scripts/audit-typography-tokens.test.mjs b/scripts/audit-typography-tokens.test.mjs index 58845ee7be..acda53afe4 100644 --- a/scripts/audit-typography-tokens.test.mjs +++ b/scripts/audit-typography-tokens.test.mjs @@ -44,6 +44,38 @@ test('rejects raw CSS sizes, weights, line heights, tracking, and font stacks', assert.equal(codes.filter(code => code === 'raw-css-typography').length, 5); }); +test('accepts only build-owned font faces and stacks in Web font profiles', () => { + const issues = auditTypographyText(` + @font-face { + font-family: "BitFun HarmonyOS Sans"; + font-weight: 500; + } + @layer bf.tokens.system { + :where([data-bf-design-system-root]) { + --bf-font-family-sans: "BitFun HarmonyOS Sans", system-ui, sans-serif; + --bf-font-family-control: "BitFun HarmonyOS Sans", system-ui, sans-serif; + --bf-font-family-mono: "Fira Code", monospace; + } + } + `, 'src/web-ui/src/font-profiles/harmony-bundled.css'); + + assert.deepEqual(issues, []); +}); + +test('rejects unrelated raw typography inside Web font profiles', () => { + const codes = codesFor(` + .bad { + font-family: Inter, sans-serif; + font-size: 14px; + font-weight: 650; + --feature-font-family: Inter, sans-serif; + } + `, 'src/web-ui/src/font-profiles/harmony-bundled.css'); + + assert.equal(codes.filter(code => code === 'raw-css-typography').length, 3); + assert.equal(codes.filter(code => code === 'raw-private-typography-token').length, 1); +}); + test('rejects static TypeScript typography but accepts tokens, adapters, and dynamic previews', () => { const codes = codesFor(` const RAW_FONT_SIZE = 12; diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 5e66563483..0aef2abac7 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -18,6 +18,10 @@ import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; import { resolveReleaseChannel } from './release-channel.mjs'; +import { + WEB_FONT_PROFILE_ENV, + fontProfileForDesktopTarget, +} from './web-font-profile.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -43,6 +47,8 @@ async function main() { const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'desktop' }); Object.assign(process.env, productBuildEnvironment(resolution)); console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`); + const fontProfile = configureDesktopWebFontProfile(forward); + console.log(`[font-profile] ${fontProfile}`); const releaseChannel = resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); console.log(`[release] channel=${releaseChannel.channel}`); @@ -191,6 +197,18 @@ function optionValue(args, option) { return undefined; } +export function configureDesktopWebFontProfile( + args, + { env = process.env, platform = process.platform } = {}, +) { + const profile = fontProfileForDesktopTarget({ + target: optionValue(args, '--target'), + platform, + }); + env[WEB_FONT_PROFILE_ENV] = profile; + return profile; +} + export function prepareMacOSFlashgrepForSigning( flashgrepBinary, desktopDir, diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index a89c454637..9f3732a4dc 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -4,16 +4,53 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { + configureDesktopWebFontProfile, prepareMacOSFlashgrepForSigning, prepareTauriConfig, shouldRetryMacDmgBuild, } from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; +import { + APPLE_SYSTEM_FONT_PROFILE, + HARMONY_BUNDLED_FONT_PROFILE, + WEB_FONT_PROFILE_ENV, +} from './web-font-profile.mjs'; const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('Desktop packaging selects the Web font profile from its target triple', () => { + const appleEnv = { [WEB_FONT_PROFILE_ENV]: HARMONY_BUNDLED_FONT_PROFILE }; + assert.equal( + configureDesktopWebFontProfile( + ['--target', 'aarch64-apple-darwin'], + { env: appleEnv, platform: 'win32' }, + ), + APPLE_SYSTEM_FONT_PROFILE, + ); + assert.equal(appleEnv[WEB_FONT_PROFILE_ENV], APPLE_SYSTEM_FONT_PROFILE); + + const windowsEnv = { [WEB_FONT_PROFILE_ENV]: APPLE_SYSTEM_FONT_PROFILE }; + assert.equal( + configureDesktopWebFontProfile( + ['--target=x86_64-pc-windows-msvc'], + { env: windowsEnv, platform: 'darwin' }, + ), + HARMONY_BUNDLED_FONT_PROFILE, + ); + assert.equal(windowsEnv[WEB_FONT_PROFILE_ENV], HARMONY_BUNDLED_FONT_PROFILE); + + assert.equal( + configureDesktopWebFontProfile([], { env: {}, platform: 'darwin' }), + APPLE_SYSTEM_FONT_PROFILE, + ); + assert.equal( + configureDesktopWebFontProfile([], { env: {}, platform: 'linux' }), + HARMONY_BUNDLED_FONT_PROFILE, + ); +}); + test('release builds do not mutate DMGs after Tauri signs and notarizes them', () => { const source = readFileSync(join(ROOT, 'scripts', 'desktop-tauri-build.mjs'), 'utf8'); assert.doesNotMatch(source, /patchDmgExtras/); diff --git a/scripts/iconography/generate.mjs b/scripts/iconography/generate.mjs deleted file mode 100644 index 3d4e9d620c..0000000000 --- a/scripts/iconography/generate.mjs +++ /dev/null @@ -1,291 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); -const iconographyRoot = path.join(repositoryRoot, 'src/shared/iconography'); -const svgRoot = path.join(iconographyRoot, 'svg'); -const manifestPath = path.join(iconographyRoot, 'manifest.json'); -const outputPath = path.join( - repositoryRoot, - 'src/web-ui/src/app/icons/generated/iconRegistry.generated.ts', -); -const checkOnly = process.argv.includes('--check'); - -const fail = (message) => { - throw new Error(`Iconography manifest: ${message}`); -}; - -const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); - -function validateSvgSource(prefix, source) { - if (typeof source.path !== 'string' - || source.path.includes('\\') - || path.posix.normalize(source.path) !== source.path - || !/^svg\/[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/.test(source.path)) { - fail(`${prefix}.source.path must be a normalized svg/.svg path`); - } - if (!['bitfun-authored', 'bitfun-reference-redraw'].includes(source.origin)) { - fail(`${prefix}.source.origin must describe an approved BitFun source`); - } - if (!Number.isInteger(source.revision) || source.revision < 1) { - fail(`${prefix}.source.revision must be a positive integer`); - } - if (source.referenceId !== undefined - && (typeof source.referenceId !== 'string' - || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.referenceId))) { - fail(`${prefix}.source.referenceId must be kebab-case when present`); - } -} - -function validateManifest(value) { - if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.icons)) { - fail('expected schemaVersion 1 and an icons array'); - } - - const ids = new Set(); - const exports = new Set(); - const semantics = new Set(); - const ownedSourcePaths = new Set(); - - for (const [index, icon] of value.icons.entries()) { - const prefix = `icons[${index}]`; - if (!isRecord(icon)) fail(`${prefix} must be an object`); - if (typeof icon.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(icon.id)) { - fail(`${prefix}.id must be kebab-case`); - } - if (typeof icon.exportName !== 'string' || !/^[A-Z][A-Za-z0-9]*Icon$/.test(icon.exportName)) { - fail(`${prefix}.exportName must be PascalCase and end in Icon`); - } - if (typeof icon.semantic !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(icon.semantic)) { - fail(`${prefix}.semantic must use lowercase dotted or hyphenated segments`); - } - for (const key of ['category', 'displayName', 'description', 'license']) { - if (typeof icon[key] !== 'string' || icon[key].trim() === '') { - fail(`${prefix}.${key} must be a non-empty string`); - } - } - if (!isRecord(icon.source)) { - fail(`${prefix}.source must be an object`); - } - if (icon.source.type === 'lucide') { - if (typeof icon.source.icon !== 'string' || !/^[A-Z][A-Za-z0-9]*$/.test(icon.source.icon)) { - fail(`${prefix}.source.icon must be a Lucide PascalCase export`); - } - } else if (icon.source.type === 'bitfun-svg') { - validateSvgSource(prefix, icon.source); - if (ownedSourcePaths.has(icon.source.path)) { - fail(`duplicate BitFun SVG source path ${icon.source.path}`); - } - ownedSourcePaths.add(icon.source.path); - } else { - fail(`${prefix}.source.type must be lucide or bitfun-svg`); - } - if (!Array.isArray(icon.tags) || icon.tags.some(tag => typeof tag !== 'string' || tag.trim() === '')) { - fail(`${prefix}.tags must contain non-empty strings`); - } - if (ids.has(icon.id)) fail(`duplicate id ${icon.id}`); - if (exports.has(icon.exportName)) fail(`duplicate exportName ${icon.exportName}`); - if (semantics.has(icon.semantic)) fail(`duplicate semantic ${icon.semantic}`); - ids.add(icon.id); - exports.add(icon.exportName); - semantics.add(icon.semantic); - } - - return value.icons; -} - -function parseAttributes(rawAttributes, prefix) { - const attributes = new Map(); - const attributePattern = /([A-Za-z][A-Za-z0-9:-]*)="([^"]*)"/g; - let match; - while ((match = attributePattern.exec(rawAttributes)) !== null) { - if (attributes.has(match[1])) fail(`${prefix} contains duplicate ${match[1]} attributes`); - attributes.set(match[1], match[2]); - } - const unparsed = rawAttributes.replace(attributePattern, '').trim(); - if (unparsed !== '') fail(`${prefix} contains unsupported attribute syntax`); - return attributes; -} - -function parseOwnedSvg(icon, sourceText) { - const prefix = icon.source.path; - const normalized = sourceText.replace(/^\uFEFF/, '').trim(); - if (/<(?:script|style|foreignObject|image|use|text|filter|mask|pattern|linearGradient|radialGradient)\b/i.test(normalized) - || /\son[a-z]+=/i.test(normalized) - || /\s(?:href|style)=/i.test(normalized)) { - fail(`${prefix} contains forbidden active or presentation content`); - } - - const rootMatch = normalized.match(/^]*)>([\s\S]*)<\/svg>$/); - if (!rootMatch) fail(`${prefix} must contain one root svg element`); - const rootAttributes = parseAttributes(rootMatch[1], `${prefix} root`); - const allowedRootAttributes = new Set(['xmlns', 'viewBox', 'fill']); - for (const name of rootAttributes.keys()) { - if (!allowedRootAttributes.has(name)) fail(`${prefix} root attribute ${name} is not allowed`); - } - if (rootAttributes.get('viewBox') !== '0 0 24 24') { - fail(`${prefix} must use viewBox="0 0 24 24"`); - } - if (rootAttributes.get('fill') !== 'none') { - fail(`${prefix} root must use fill="none"`); - } - - const paths = []; - const pathPattern = /]*)\/>/g; - let pathMatch; - while ((pathMatch = pathPattern.exec(rootMatch[2])) !== null) { - const attributes = parseAttributes(pathMatch[1], `${prefix} path`); - const allowedPathAttributes = new Set([ - 'd', - 'fill', - 'fill-rule', - 'clip-rule', - 'stroke', - 'stroke-width', - 'stroke-linecap', - 'stroke-linejoin', - ]); - for (const name of attributes.keys()) { - if (!allowedPathAttributes.has(name)) fail(`${prefix} path attribute ${name} is not allowed`); - } - const d = attributes.get('d'); - if (!d || !/^[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]+$/.test(d)) { - fail(`${prefix} path data is missing or invalid`); - } - const fill = attributes.get('fill'); - const stroke = attributes.get('stroke'); - const strokeWidthText = attributes.get('stroke-width'); - const strokeLinecap = attributes.get('stroke-linecap'); - const strokeLinejoin = attributes.get('stroke-linejoin'); - const isFilledPath = fill === 'currentColor' && stroke === undefined; - const isStrokedPath = fill === 'none' && stroke === 'currentColor'; - if (!isFilledPath && !isStrokedPath) { - fail(`${prefix} paths must be currentColor fill paths or currentColor stroke paths`); - } - let strokeWidth; - if (isStrokedPath) { - if (!strokeWidthText || !/^(?:[1-2](?:\.\d+)?|3(?:\.0+)?)$/.test(strokeWidthText)) { - fail(`${prefix} stroke paths must use a numeric stroke-width from 1 through 3`); - } - strokeWidth = Number(strokeWidthText); - if (strokeLinecap !== undefined && !['butt', 'round', 'square'].includes(strokeLinecap)) { - fail(`${prefix} has an unsupported stroke-linecap`); - } - if (strokeLinejoin !== undefined && !['miter', 'round', 'bevel'].includes(strokeLinejoin)) { - fail(`${prefix} has an unsupported stroke-linejoin`); - } - } else if (strokeWidthText !== undefined || strokeLinecap !== undefined || strokeLinejoin !== undefined) { - fail(`${prefix} fill paths cannot declare stroke presentation attributes`); - } - const fillRule = attributes.get('fill-rule'); - const clipRule = attributes.get('clip-rule'); - if (fillRule !== undefined && !['evenodd', 'nonzero'].includes(fillRule)) { - fail(`${prefix} has an unsupported fill-rule`); - } - if (clipRule !== undefined && !['evenodd', 'nonzero'].includes(clipRule)) { - fail(`${prefix} has an unsupported clip-rule`); - } - paths.push({ - d, - fill, - ...(stroke ? { stroke } : {}), - ...(strokeWidth !== undefined ? { strokeWidth } : {}), - ...(strokeLinecap ? { strokeLinecap } : {}), - ...(strokeLinejoin ? { strokeLinejoin } : {}), - ...(fillRule ? { fillRule } : {}), - ...(clipRule ? { clipRule } : {}), - }); - } - if (paths.length === 0 || rootMatch[2].replace(pathPattern, '').trim() !== '') { - fail(`${prefix} may contain path elements only`); - } - return paths; -} - -async function loadOwnedSvgSources(icons) { - const entries = await Promise.all(icons - .filter(icon => icon.source.type === 'bitfun-svg') - .map(async (icon) => { - const absolutePath = path.resolve(iconographyRoot, ...icon.source.path.split('/')); - const relativeToSvgRoot = path.relative(svgRoot, absolutePath); - if (relativeToSvgRoot.startsWith('..') || path.isAbsolute(relativeToSvgRoot)) { - fail(`${icon.source.path} escapes the BitFun SVG source root`); - } - let sourceText; - try { - sourceText = await readFile(absolutePath, 'utf8'); - } catch { - fail(`missing BitFun SVG source ${icon.source.path}`); - } - return [icon.id, parseOwnedSvg(icon, sourceText)]; - })); - return new Map(entries); -} - -function renderGeneratedModule(icons, ownedSvgSources) { - const lucideImports = [...new Set(icons - .filter(icon => icon.source.type === 'lucide') - .map(icon => icon.source.icon))].sort(); - const imports = []; - if (lucideImports.length > 0) { - imports.push(`import { ${lucideImports.join(', ')} } from 'lucide-react';`); - imports.push("import { createBitFunIconComponent } from '../createBitFunIconComponent';"); - } - if (ownedSvgSources.size > 0) { - imports.push("import { createBitFunSvgIconComponent } from '../createBitFunSvgIconComponent';"); - } - imports.push("import type { BitFunIconMetadata } from '../types';"); - - const componentExports = icons.map((icon) => { - if (icon.source.type === 'lucide') { - return `export const ${icon.exportName} = /* @__PURE__ */ createBitFunIconComponent(\n` + - ` '${icon.id}',\n` + - ` ${icon.source.icon},\n` + - `);`; - } - const paths = JSON.stringify(ownedSvgSources.get(icon.id), null, 2) - .replace(/^/gm, ' ') - .trimStart(); - return `export const ${icon.exportName} = /* @__PURE__ */ createBitFunSvgIconComponent(\n` + - ` '${icon.id}',\n` + - ` ${paths},\n` + - `);`; - }).join('\n\n'); - const componentRegistry = icons.map(icon => ` '${icon.id}': ${icon.exportName},`).join('\n'); - const names = icons.map(icon => ` '${icon.id}',`).join('\n'); - const metadata = icons.map(icon => ( - ` '${icon.id}': ${JSON.stringify(icon, null, 2).replace(/^/gm, ' ').trimStart()},` - )).join('\n'); - - return `// This file is generated by scripts/iconography/generate.mjs.\n` + - `// Do not edit it by hand.\n\n` + - `${imports.join('\n')}\n\n` + - `${componentExports}\n\n` + - `export const bitFunIconComponents = {\n${componentRegistry}\n} as const;\n\n` + - `export type BitFunIconName = keyof typeof bitFunIconComponents;\n\n` + - `export const bitFunIconNames = [\n${names}\n] as const satisfies readonly BitFunIconName[];\n\n` + - `export const bitFunIconMetadata = {\n${metadata}\n} as const satisfies Record;\n`; -} - -const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); -const icons = validateManifest(manifest); -const ownedSvgSources = await loadOwnedSvgSources(icons); -const nextOutput = renderGeneratedModule(icons, ownedSvgSources); - -if (checkOnly) { - let currentOutput; - try { - currentOutput = await readFile(outputPath, 'utf8'); - } catch { - fail(`generated output is missing: ${path.relative(repositoryRoot, outputPath)}`); - } - if (currentOutput.replace(/\r\n/g, '\n') !== nextOutput) { - fail('generated output is stale; run pnpm run iconography:generate'); - } - process.stdout.write(`Iconography check passed (${icons.length} icons).\n`); -} else { - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, nextOutput, 'utf8'); - process.stdout.write(`Generated ${icons.length} icons (${ownedSvgSources.size} BitFun-owned).\n`); -} diff --git a/scripts/web-font-profile.mjs b/scripts/web-font-profile.mjs new file mode 100644 index 0000000000..bc32f8315b --- /dev/null +++ b/scripts/web-font-profile.mjs @@ -0,0 +1,196 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export const WEB_FONT_PROFILE_ENV = 'BITFUN_WEB_FONT_PROFILE'; +export const APPLE_SYSTEM_FONT_PROFILE = 'apple-system'; +export const HARMONY_BUNDLED_FONT_PROFILE = 'harmony-bundled'; + +export const HARMONY_FONT_ASSETS = Object.freeze([ + { + relativePath: 'base/HarmonyOS_Sans_Regular.ttf', + bytes: 146_616, + sha256: '4F00C7E80329238D0B6FC58E5C829C4086432BA9FA1A8C5CA3DA9A0442CE0452', + }, + { + relativePath: 'base/HarmonyOS_Sans_Medium.ttf', + bytes: 146_164, + sha256: 'F6B009D07D8D894D55EADEB7080B4916C3A2C83FF3EE60BBE851E6698D73BAFD', + }, + { + relativePath: 'base/HarmonyOS_Sans_Bold.ttf', + bytes: 145_860, + sha256: '7F973862C42353C9CC372DC2AE891D12C9EA5FE2A01B449ADAF1EADE9B469B47', + }, + { + relativePath: 'sc/HarmonyOS_Sans_SC_Regular.ttf', + bytes: 8_261_128, + sha256: '297B088424BE212207DF2CE8B98E335468B782AA6B96832AF0B8B773D711E2B1', + }, + { + relativePath: 'sc/HarmonyOS_Sans_SC_Medium.ttf', + bytes: 8_227_312, + sha256: '6ED1553EDCCDDC48EB27FF25D134A4A715CF54211238D4840B3038576CBA1944', + }, + { + relativePath: 'sc/HarmonyOS_Sans_SC_Bold.ttf', + bytes: 8_158_996, + sha256: '43A424B85E47FB53A17B3B32026A71801F86F8E022CA6798D186B47D39FA5F01', + }, + { + relativePath: 'LICENSE.txt', + bytes: 32_768, + sha256: 'B2FFEC0E6269EE41C3B5FC0345AB37600B46D66EBEA6C9C58FF37F517BDFA164', + }, +]); + +const HARMONY_FONT_STEMS = Object.freeze( + HARMONY_FONT_ASSETS + .filter(({ relativePath }) => relativePath.endsWith('.ttf')) + .map(({ relativePath }) => relativePath.split('/').at(-1).replace(/\.ttf$/, '')), +); + +const FIRA_FONT_STEMS = Object.freeze([ + 'FiraCode-Regular', + 'FiraCode-Medium', + 'FiraCode-SemiBold', + 'FiraCode-VF', +]); + +const PRODUCT_FONT_ASSET_PATTERN = /(?:HarmonyOS[_-]Sans|FiraCode-|Noto[_-]Sans[_-]SC)[^/]*\.(?:ttf|otf|woff2?)$/i; + +export function normalizeWebFontProfile(value) { + if (value === APPLE_SYSTEM_FONT_PROFILE || value === HARMONY_BUNDLED_FONT_PROFILE) { + return value; + } + throw new Error( + `Unsupported ${WEB_FONT_PROFILE_ENV} value: ${value}. ` + + `Expected ${APPLE_SYSTEM_FONT_PROFILE} or ${HARMONY_BUNDLED_FONT_PROFILE}.`, + ); +} + +export function resolveWebFontProfile({ requested, command = 'build', platform = process.platform } = {}) { + if (requested) return normalizeWebFontProfile(requested); + + // A portable Web build must carry the non-Apple face. Development follows + // the host so macOS never downloads product font packages merely to run HMR. + if (command === 'serve' && platform === 'darwin') { + return APPLE_SYSTEM_FONT_PROFILE; + } + return HARMONY_BUNDLED_FONT_PROFILE; +} + +export function fontProfileForDesktopTarget({ target, platform = process.platform } = {}) { + if (target) { + return /apple/i.test(target) + ? APPLE_SYSTEM_FONT_PROFILE + : HARMONY_BUNDLED_FONT_PROFILE; + } + return platform === 'darwin' + ? APPLE_SYSTEM_FONT_PROFILE + : HARMONY_BUNDLED_FONT_PROFILE; +} + +export function verifyHarmonyFontSources(assetRoot) { + for (const expected of HARMONY_FONT_ASSETS) { + const source = readFileSync(join(assetRoot, ...expected.relativePath.split('/'))); + const actualHash = createHash('sha256').update(source).digest('hex').toUpperCase(); + if (source.byteLength !== expected.bytes || actualHash !== expected.sha256) { + throw new Error( + `HarmonyOS Sans source changed: ${expected.relativePath}. ` + + `Expected ${expected.bytes} bytes / ${expected.sha256}, ` + + `received ${source.byteLength} bytes / ${actualHash}.`, + ); + } + } + + const expectedFontPaths = new Set( + HARMONY_FONT_ASSETS + .filter(({ relativePath }) => relativePath.endsWith('.ttf')) + .map(({ relativePath }) => relativePath.toLowerCase()), + ); + const unexpectedFonts = listFontFiles(assetRoot).filter( + (relativePath) => !expectedFontPaths.has(relativePath.toLowerCase()), + ); + if (unexpectedFonts.length > 0) { + throw new Error( + `HarmonyOS Sans source contains unapproved font files: ${unexpectedFonts.join(', ')}`, + ); + } +} + +export function assertWebFontProfileBundle(profile, bundleFileNames) { + const normalized = normalizeWebFontProfile(profile); + const names = [...bundleFileNames]; + const productFonts = names.filter((name) => PRODUCT_FONT_ASSET_PATTERN.test(name)); + + if (normalized === APPLE_SYSTEM_FONT_PROFILE) { + if (productFonts.length > 0) { + throw new Error( + `Apple Web bundle contains product text fonts: ${productFonts.join(', ')}`, + ); + } + return; + } + + const expectedStems = [...HARMONY_FONT_STEMS, ...FIRA_FONT_STEMS]; + const matchesByStem = new Map( + expectedStems.map((stem) => [ + stem, + productFonts.filter((name) => name.includes(stem)), + ]), + ); + const missing = expectedStems.filter((stem) => matchesByStem.get(stem).length === 0); + if (missing.length > 0) { + throw new Error(`Harmony Web bundle is missing font assets: ${missing.join(', ')}`); + } + + const duplicated = expectedStems.filter((stem) => matchesByStem.get(stem).length > 1); + if (duplicated.length > 0) { + throw new Error(`Harmony Web bundle contains duplicate font assets: ${duplicated.join(', ')}`); + } + + const unexpectedFonts = productFonts.filter( + (name) => !expectedStems.some((stem) => name.includes(stem)), + ); + if (unexpectedFonts.length > 0) { + throw new Error( + `Harmony Web bundle contains unapproved font assets: ${unexpectedFonts.join(', ')}`, + ); + } + + const wrongFormats = expectedStems.flatMap((stem) => { + const expectedExtension = HARMONY_FONT_STEMS.includes(stem) ? '.ttf' : '.woff2'; + return matchesByStem.get(stem).filter( + (name) => !name.toLowerCase().endsWith(expectedExtension), + ); + }); + if (wrongFormats.length > 0) { + throw new Error( + `Harmony Web bundle contains font assets in unapproved formats: ${wrongFormats.join(', ')}`, + ); + } + + for (const legalFile of [ + 'third-party/fonts/harmonyos-sans/LICENSE.txt', + 'third-party/fonts/harmonyos-sans/NOTICE.txt', + 'third-party/fonts/fira-code/LICENSE.txt', + ]) { + if (!names.includes(legalFile)) { + throw new Error(`Harmony Web bundle is missing legal asset: ${legalFile}`); + } + } +} + +function listFontFiles(root, current = root, prefix = '') { + const paths = []; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + paths.push(...listFontFiles(root, join(current, entry.name), relativePath)); + } else if (/\.(?:ttf|otf|woff2?)$/i.test(entry.name)) { + paths.push(relativePath); + } + } + return paths.sort(); +} diff --git a/scripts/web-font-profile.test.mjs b/scripts/web-font-profile.test.mjs new file mode 100644 index 0000000000..e51aeffe5b --- /dev/null +++ b/scripts/web-font-profile.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + APPLE_SYSTEM_FONT_PROFILE, + HARMONY_BUNDLED_FONT_PROFILE, + HARMONY_FONT_ASSETS, + assertWebFontProfileBundle, + fontProfileForDesktopTarget, + normalizeWebFontProfile, + resolveWebFontProfile, + verifyHarmonyFontSources, +} from './web-font-profile.mjs'; + +const ROOT = join(import.meta.dirname, '..'); +const harmonyBundle = [ + ...HARMONY_FONT_ASSETS + .filter(({ relativePath }) => relativePath.endsWith('.ttf')) + .map(({ relativePath }) => { + const stem = relativePath.split('/').at(-1).replace(/\.ttf$/, ''); + return `assets/${stem}-contenthash.ttf`; + }), + 'assets/FiraCode-Regular-contenthash.woff2', + 'assets/FiraCode-Medium-contenthash.woff2', + 'assets/FiraCode-SemiBold-contenthash.woff2', + 'assets/FiraCode-VF-contenthash.woff2', + 'third-party/fonts/harmonyos-sans/LICENSE.txt', + 'third-party/fonts/harmonyos-sans/NOTICE.txt', + 'third-party/fonts/fira-code/LICENSE.txt', + 'assets/KaTeX_Main-Regular-contenthash.woff2', + 'assets/codicon-contenthash.ttf', +]; + +test('Web font profiles resolve explicitly and use safe platform defaults', () => { + assert.equal(normalizeWebFontProfile(APPLE_SYSTEM_FONT_PROFILE), APPLE_SYSTEM_FONT_PROFILE); + assert.equal( + normalizeWebFontProfile(HARMONY_BUNDLED_FONT_PROFILE), + HARMONY_BUNDLED_FONT_PROFILE, + ); + assert.throws(() => normalizeWebFontProfile('legacy-fonts'), /Unsupported/); + + assert.equal(resolveWebFontProfile({ command: 'build', platform: 'darwin' }), HARMONY_BUNDLED_FONT_PROFILE); + assert.equal(resolveWebFontProfile({ command: 'serve', platform: 'darwin' }), APPLE_SYSTEM_FONT_PROFILE); + assert.equal(resolveWebFontProfile({ command: 'serve', platform: 'win32' }), HARMONY_BUNDLED_FONT_PROFILE); + assert.equal( + resolveWebFontProfile({ + requested: APPLE_SYSTEM_FONT_PROFILE, + command: 'build', + platform: 'win32', + }), + APPLE_SYSTEM_FONT_PROFILE, + ); +}); + +test('Desktop targets select Apple system fonts only for Apple triples', () => { + assert.equal( + fontProfileForDesktopTarget({ target: 'aarch64-apple-darwin', platform: 'win32' }), + APPLE_SYSTEM_FONT_PROFILE, + ); + assert.equal( + fontProfileForDesktopTarget({ target: 'x86_64-pc-windows-msvc', platform: 'darwin' }), + HARMONY_BUNDLED_FONT_PROFILE, + ); + assert.equal( + fontProfileForDesktopTarget({ target: 'x86_64-unknown-linux-gnu', platform: 'darwin' }), + HARMONY_BUNDLED_FONT_PROFILE, + ); + assert.equal(fontProfileForDesktopTarget({ platform: 'darwin' }), APPLE_SYSTEM_FONT_PROFILE); + assert.equal(fontProfileForDesktopTarget({ platform: 'linux' }), HARMONY_BUNDLED_FONT_PROFILE); +}); + +test('Harmony source profile contains only the six approved unmodified fonts', () => { + const fonts = HARMONY_FONT_ASSETS.filter(({ relativePath }) => relativePath.endsWith('.ttf')); + assert.equal(fonts.length, 6); + assert.equal(fonts.some(({ relativePath }) => /(?:^|\/)tc(?:\/|$)/i.test(relativePath)), false); + verifyHarmonyFontSources(join( + ROOT, + 'src', + 'web-ui', + 'src', + 'assets', + 'fonts', + 'harmonyos-sans', + )); +}); + +test('Apple bundles reject product text fonts but allow functional fonts', () => { + assert.doesNotThrow(() => assertWebFontProfileBundle(APPLE_SYSTEM_FONT_PROFILE, [ + 'index.html', + 'assets/KaTeX_Main-Regular-contenthash.woff2', + 'assets/codicon-contenthash.ttf', + ])); + + for (const forbidden of [ + 'assets/HarmonyOS_Sans_SC_Regular-contenthash.ttf', + 'assets/FiraCode-Regular-contenthash.woff2', + 'fonts/noto-sans-sc-latin-wght-normal.woff2', + ]) { + assert.throws( + () => assertWebFontProfileBundle(APPLE_SYSTEM_FONT_PROFILE, ['index.html', forbidden]), + /contains product text fonts/, + ); + } +}); + +test('Harmony bundles require the exact approved font and legal asset set', () => { + assert.doesNotThrow(() => assertWebFontProfileBundle(HARMONY_BUNDLED_FONT_PROFILE, harmonyBundle)); + + assert.throws( + () => assertWebFontProfileBundle( + HARMONY_BUNDLED_FONT_PROFILE, + harmonyBundle.filter((name) => !name.includes('HarmonyOS_Sans_SC_Bold')), + ), + /missing font assets: HarmonyOS_Sans_SC_Bold/, + ); + assert.throws( + () => assertWebFontProfileBundle(HARMONY_BUNDLED_FONT_PROFILE, [ + ...harmonyBundle, + 'assets/HarmonyOS_Sans_SC_Bold-copy.ttf', + ]), + /duplicate font assets: HarmonyOS_Sans_SC_Bold/, + ); + assert.throws( + () => assertWebFontProfileBundle(HARMONY_BUNDLED_FONT_PROFILE, [ + ...harmonyBundle, + 'assets/HarmonyOS_Sans_TC_Regular-contenthash.ttf', + ]), + /unapproved font assets/, + ); + assert.throws( + () => assertWebFontProfileBundle(HARMONY_BUNDLED_FONT_PROFILE, [ + ...harmonyBundle, + 'assets/noto-sans-sc-latin-wght-normal.woff2', + ]), + /unapproved font assets/, + ); + assert.throws( + () => assertWebFontProfileBundle( + HARMONY_BUNDLED_FONT_PROFILE, + harmonyBundle.filter((name) => !name.endsWith('/NOTICE.txt')), + ), + /missing legal asset/, + ); +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html index 7675d78d52..5758e1a8c2 100644 --- a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html +++ b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html @@ -4,8 +4,8 @@ background: transparent; --bitfun-radius: 8px; --bitfun-radius-lg: 12px; - --bitfun-font-sans: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; - --bitfun-font-mono: 'JetBrains Mono', 'FiraCode', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace; + --bitfun-font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --bitfun-font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace; --bitfun-bg: #0e0e10; --bitfun-bg-secondary: #1c1c1f; --bitfun-bg-tertiary: #0e0e10; diff --git a/src/shared/iconography/manifest.json b/src/shared/iconography/manifest.json deleted file mode 100644 index f06288a384..0000000000 --- a/src/shared/iconography/manifest.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "schemaVersion": 1, - "icons": [ - { - "id": "session-group-assistant", - "exportName": "SessionGroupAssistantIcon", - "semantic": "session.group.assistant", - "category": "navigation", - "displayName": "Assistant", - "description": "Assistant session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-assistant.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 3 - }, - "license": "BitFun Proprietary", - "tags": ["assistant", "session", "group"] - }, - { - "id": "session-group-remote-workspace", - "exportName": "SessionGroupRemoteWorkspaceIcon", - "semantic": "session.group.remote-workspace", - "category": "navigation", - "displayName": "Remote workspace", - "description": "Remote workspace session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-remote-workspace.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 3 - }, - "license": "BitFun Proprietary", - "tags": ["remote", "workspace", "session", "group"] - }, - { - "id": "session-group-workspace", - "exportName": "SessionGroupWorkspaceIcon", - "semantic": "session.group.workspace", - "category": "navigation", - "displayName": "Workspace", - "description": "Workspace session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-workspace.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 3 - }, - "license": "BitFun Proprietary", - "tags": ["workspace", "session", "group"] - }, - { - "id": "session-group-global-task", - "exportName": "SessionGroupGlobalTaskIcon", - "semantic": "session.group.aggregate", - "category": "navigation", - "displayName": "Global tasks", - "description": "Global task and all-session aggregate group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-global-task.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 3 - }, - "license": "BitFun Proprietary", - "tags": ["global", "task", "session", "group", "aggregate"] - }, - { - "id": "session-group-assistant-selected", - "exportName": "SessionGroupAssistantSelectedIcon", - "semantic": "session.group.assistant.selected", - "category": "navigation", - "displayName": "Selected assistant", - "description": "Selected assistant session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-assistant-selected.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["assistant", "session", "group", "selected", "filled"] - }, - { - "id": "session-group-remote-workspace-selected", - "exportName": "SessionGroupRemoteWorkspaceSelectedIcon", - "semantic": "session.group.remote-workspace.selected", - "category": "navigation", - "displayName": "Selected remote workspace", - "description": "Selected remote workspace session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-remote-workspace-selected.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["remote", "workspace", "session", "group", "selected", "filled"] - }, - { - "id": "session-group-workspace-selected", - "exportName": "SessionGroupWorkspaceSelectedIcon", - "semantic": "session.group.workspace.selected", - "category": "navigation", - "displayName": "Selected workspace", - "description": "Selected workspace session group", - "source": { - "type": "bitfun-svg", - "path": "svg/session-group-workspace-selected.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-group-icons-2026-08-16", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["workspace", "session", "group", "selected", "filled"] - }, - { - "id": "navigation-session-view-grouped", - "exportName": "NavigationSessionViewGroupedIcon", - "semantic": "navigation.session-view.grouped", - "category": "navigation", - "displayName": "Grouped sessions", - "description": "Sessions grouped by project and assistant", - "source": { - "type": "bitfun-svg", - "path": "svg/navigation-session-view-grouped.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-view-toggle-2026-08-16", - "revision": 2 - }, - "license": "BitFun Proprietary", - "tags": ["navigation", "session", "view", "grouped"] - }, - { - "id": "navigation-session-view-all", - "exportName": "NavigationSessionViewAllIcon", - "semantic": "navigation.session-view.all", - "category": "navigation", - "displayName": "All sessions", - "description": "All sessions shown without grouping", - "source": { - "type": "bitfun-svg", - "path": "svg/navigation-session-view-all.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-view-toggle-2026-08-16", - "revision": 2 - }, - "license": "BitFun Proprietary", - "tags": ["navigation", "session", "view", "all", "ungrouped", "slash"] - }, - { - "id": "navigation-session-context-add", - "exportName": "NavigationSessionContextAddIcon", - "semantic": "navigation.session-context.add", - "category": "navigation", - "displayName": "Add session context", - "description": "Add a project, assistant, or remote workspace to session navigation", - "source": { - "type": "bitfun-svg", - "path": "svg/navigation-session-context-add.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "session-context-add-folder-reference-2026-08-16", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["navigation", "session", "context", "folder", "add"] - }, - { - "id": "navigation-mini-app", - "exportName": "NavigationMiniAppIcon", - "semantic": "navigation.mini-app", - "category": "navigation", - "displayName": "Mini App", - "description": "Mini App navigation entry", - "source": { - "type": "bitfun-svg", - "path": "svg/navigation-mini-app.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "mini-app-navigation-2026-08-16", - "revision": 2 - }, - "license": "BitFun Proprietary", - "tags": ["mini-app", "navigation", "package", "create"] - }, - { - "id": "navigation-extensions-compatibility", - "exportName": "NavigationExtensionsCompatibilityIcon", - "semantic": "navigation.extensions-compatibility", - "category": "navigation", - "displayName": "Extensions & Compatibility", - "description": "Extensions and compatibility navigation group", - "source": { - "type": "bitfun-svg", - "path": "svg/navigation-extensions-compatibility.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "extensions-compatibility-navigation-2026-08-16", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["extensions", "compatibility", "navigation", "grid", "create"] - }, - { - "id": "harness-creative", - "exportName": "HarnessCreativeIcon", - "semantic": "harness.profile.creative", - "category": "harness", - "displayName": "Creative Harness", - "description": "Creative Harness profile represented by a modular grid and brush", - "source": { - "type": "bitfun-svg", - "path": "svg/harness-creative.svg", - "origin": "bitfun-reference-redraw", - "referenceId": "creative-harness-grid-brush-2026-08-17", - "revision": 1 - }, - "license": "BitFun Proprietary", - "tags": ["harness", "creative", "grid", "brush"] - } - ] -} diff --git a/src/shared/iconography/svg/harness-creative.svg b/src/shared/iconography/svg/harness-creative.svg deleted file mode 100644 index f78b79bff4..0000000000 --- a/src/shared/iconography/svg/harness-creative.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/iconography/svg/navigation-extensions-compatibility.svg b/src/shared/iconography/svg/navigation-extensions-compatibility.svg deleted file mode 100644 index e32494addb..0000000000 --- a/src/shared/iconography/svg/navigation-extensions-compatibility.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/iconography/svg/navigation-mini-app.svg b/src/shared/iconography/svg/navigation-mini-app.svg deleted file mode 100644 index e9dfd599c4..0000000000 --- a/src/shared/iconography/svg/navigation-mini-app.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/iconography/svg/navigation-session-context-add.svg b/src/shared/iconography/svg/navigation-session-context-add.svg deleted file mode 100644 index 0ebe843050..0000000000 --- a/src/shared/iconography/svg/navigation-session-context-add.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/iconography/svg/navigation-session-view-all.svg b/src/shared/iconography/svg/navigation-session-view-all.svg deleted file mode 100644 index bd6606741e..0000000000 --- a/src/shared/iconography/svg/navigation-session-view-all.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/iconography/svg/navigation-session-view-grouped.svg b/src/shared/iconography/svg/navigation-session-view-grouped.svg deleted file mode 100644 index 9a889d29e1..0000000000 --- a/src/shared/iconography/svg/navigation-session-view-grouped.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/shared/iconography/svg/session-group-assistant-selected.svg b/src/shared/iconography/svg/session-group-assistant-selected.svg deleted file mode 100644 index 67b8a85ca8..0000000000 --- a/src/shared/iconography/svg/session-group-assistant-selected.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/shared/iconography/svg/session-group-assistant.svg b/src/shared/iconography/svg/session-group-assistant.svg deleted file mode 100644 index c4356d2418..0000000000 --- a/src/shared/iconography/svg/session-group-assistant.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/iconography/svg/session-group-global-task.svg b/src/shared/iconography/svg/session-group-global-task.svg deleted file mode 100644 index ef4eb0b79c..0000000000 --- a/src/shared/iconography/svg/session-group-global-task.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/iconography/svg/session-group-remote-workspace-selected.svg b/src/shared/iconography/svg/session-group-remote-workspace-selected.svg deleted file mode 100644 index a761af16d3..0000000000 --- a/src/shared/iconography/svg/session-group-remote-workspace-selected.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/iconography/svg/session-group-remote-workspace.svg b/src/shared/iconography/svg/session-group-remote-workspace.svg deleted file mode 100644 index 6cb8c2ffa4..0000000000 --- a/src/shared/iconography/svg/session-group-remote-workspace.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/iconography/svg/session-group-workspace-selected.svg b/src/shared/iconography/svg/session-group-workspace-selected.svg deleted file mode 100644 index 2154b62c24..0000000000 --- a/src/shared/iconography/svg/session-group-workspace-selected.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/shared/iconography/svg/session-group-workspace.svg b/src/shared/iconography/svg/session-group-workspace.svg deleted file mode 100644 index 00f15ca273..0000000000 --- a/src/shared/iconography/svg/session-group-workspace.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/web-ui/index.html b/src/web-ui/index.html index 7bff0be6cc..6bb92b4e35 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -4,7 +4,6 @@ - BitFun - AI Code Assistant +