From f2c6d525bc7e2358aa56ca5a38b1befa4e6334ce Mon Sep 17 00:00:00 2001
From: kev1n77
Date: Tue, 15 Sep 2026 00:09:43 +0800
Subject: [PATCH 1/6] refactor(ui): adopt Input in installer and standalone
markets
---
pnpm-lock.yaml | 6 ++
src/apps/miniapp-market-server/Dockerfile | 4 ++
.../Dockerfile.dockerignore | 3 +
src/apps/skin-market-server/Dockerfile | 4 ++
.../Dockerfile.dockerignore | 3 +
src/miniapp-market-web/package.json | 3 +-
src/miniapp-market-web/src/App.test.tsx | 58 +++++++++++++++++++
src/miniapp-market-web/src/App.tsx | 23 ++++----
src/miniapp-market-web/src/main.tsx | 6 +-
src/miniapp-market-web/src/styles.css | 31 +++++++---
src/skin-market-web/package.json | 3 +-
src/skin-market-web/src/CatalogPage.tsx | 3 +-
src/skin-market-web/src/main.tsx | 6 +-
src/skin-market-web/src/styles.css | 15 +++++
14 files changed, 145 insertions(+), 23 deletions(-)
create mode 100644 src/miniapp-market-web/src/App.test.tsx
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c4ca0fff2..bedb83e385 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -222,6 +222,9 @@ importers:
'@openbitfun/theme-openbitfun':
specifier: workspace:^
version: link:../../design-system/packages/theme-openbitfun
+ '@openbitfun/ui':
+ specifier: workspace:^
+ version: link:../../design-system/packages/ui
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -335,6 +338,9 @@ importers:
'@openbitfun/theme-openbitfun':
specifier: workspace:^
version: link:../../design-system/packages/theme-openbitfun
+ '@openbitfun/ui':
+ specifier: workspace:^
+ version: link:../../design-system/packages/ui
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
diff --git a/src/apps/miniapp-market-server/Dockerfile b/src/apps/miniapp-market-server/Dockerfile
index a3f7d936bb..22e6625132 100644
--- a/src/apps/miniapp-market-server/Dockerfile
+++ b/src/apps/miniapp-market-server/Dockerfile
@@ -8,6 +8,8 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY patches/ patches/
COPY design-system/LICENSE design-system/LICENSE
+COPY design-system/tsconfig.base.json design-system/tsconfig.base.json
+COPY design-system/packages/ui/package.json design-system/packages/ui/package.json
COPY design-system/tooling/token-engine/package.json design-system/tooling/token-engine/package.json
COPY design-system/packages/design-tokens/package.json design-system/packages/design-tokens/package.json
COPY design-system/packages/theme-openbitfun/package.json design-system/packages/theme-openbitfun/package.json
@@ -15,12 +17,14 @@ COPY src/miniapp-market-web/package.json src/miniapp-market-web/package.json
RUN --mount=type=cache,id=openbitfun-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-miniapp-market-web...
+COPY design-system/packages/ui/ design-system/packages/ui/
COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/
COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/
COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/
COPY src/miniapp-market-web/ src/miniapp-market-web/
RUN pnpm --dir design-system/packages/design-tokens build \
&& pnpm --dir design-system/packages/theme-openbitfun build \
+ && pnpm --dir design-system/packages/ui build \
&& pnpm --dir src/miniapp-market-web build
FROM rust:1.96-bookworm AS rust-builder
diff --git a/src/apps/miniapp-market-server/Dockerfile.dockerignore b/src/apps/miniapp-market-server/Dockerfile.dockerignore
index 9d77f42167..bfe9f881de 100644
--- a/src/apps/miniapp-market-server/Dockerfile.dockerignore
+++ b/src/apps/miniapp-market-server/Dockerfile.dockerignore
@@ -11,6 +11,9 @@
!design-system/
!design-system/LICENSE
+!design-system/tsconfig.base.json
+!design-system/packages/ui/
+!design-system/packages/ui/**
!design-system/packages/
!design-system/packages/design-tokens/
!design-system/packages/design-tokens/package.json
diff --git a/src/apps/skin-market-server/Dockerfile b/src/apps/skin-market-server/Dockerfile
index 108a00b790..50f91b23e3 100644
--- a/src/apps/skin-market-server/Dockerfile
+++ b/src/apps/skin-market-server/Dockerfile
@@ -8,6 +8,8 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY patches/ patches/
COPY design-system/LICENSE design-system/LICENSE
+COPY design-system/tsconfig.base.json design-system/tsconfig.base.json
+COPY design-system/packages/ui/package.json design-system/packages/ui/package.json
COPY design-system/tooling/token-engine/package.json design-system/tooling/token-engine/package.json
COPY design-system/packages/design-tokens/package.json design-system/packages/design-tokens/package.json
COPY design-system/packages/theme-openbitfun/package.json design-system/packages/theme-openbitfun/package.json
@@ -15,6 +17,7 @@ COPY src/skin-market-web/package.json src/skin-market-web/package.json
RUN --mount=type=cache,id=openbitfun-skin-market-pnpm,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile --ignore-scripts --filter openbitfun-skin-market-web...
+COPY design-system/packages/ui/ design-system/packages/ui/
COPY design-system/tooling/token-engine/ design-system/tooling/token-engine/
COPY design-system/packages/design-tokens/ design-system/packages/design-tokens/
COPY design-system/packages/theme-openbitfun/ design-system/packages/theme-openbitfun/
@@ -22,6 +25,7 @@ COPY src/skin-market-web/ src/skin-market-web/
COPY src/shared/appearance-market-contract-fixtures/ src/shared/appearance-market-contract-fixtures/
RUN pnpm --dir design-system/packages/design-tokens build \
&& pnpm --dir design-system/packages/theme-openbitfun build \
+ && pnpm --dir design-system/packages/ui build \
&& pnpm --dir src/skin-market-web build
FROM rust:1.96-bookworm AS rust-builder
diff --git a/src/apps/skin-market-server/Dockerfile.dockerignore b/src/apps/skin-market-server/Dockerfile.dockerignore
index 3e642b697d..97b27b0b3b 100644
--- a/src/apps/skin-market-server/Dockerfile.dockerignore
+++ b/src/apps/skin-market-server/Dockerfile.dockerignore
@@ -11,6 +11,9 @@
!design-system/
!design-system/LICENSE
+!design-system/tsconfig.base.json
+!design-system/packages/ui/
+!design-system/packages/ui/**
!design-system/packages/
!design-system/packages/design-tokens/
!design-system/packages/design-tokens/package.json
diff --git a/src/miniapp-market-web/package.json b/src/miniapp-market-web/package.json
index 9175dfda20..0a6f2f5972 100644
--- a/src/miniapp-market-web/package.json
+++ b/src/miniapp-market-web/package.json
@@ -15,7 +15,8 @@
"@phosphor-icons/react": "^2.1.10",
"lucide-react": "^0.541.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "@openbitfun/ui": "workspace:^"
},
"devDependencies": {
"@types/react": "^18.3.0",
diff --git a/src/miniapp-market-web/src/App.test.tsx b/src/miniapp-market-web/src/App.test.tsx
new file mode 100644
index 0000000000..43c1bf2569
--- /dev/null
+++ b/src/miniapp-market-web/src/App.test.tsx
@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import App from './App';
+
+const mocks = vi.hoisted(() => ({ config: vi.fn(), me: vi.fn() }));
+vi.mock('./api', async (original) => ({
+ ...await original(),
+ marketApi: mocks,
+}));
+
+let container: HTMLDivElement;
+let root: ReturnType;
+beforeEach(() => {
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
+ window.history.replaceState({}, '', '/miniapp/submit?listingId=listing-1&slug=my-app&release=2');
+ mocks.config.mockResolvedValue({ webSubmissionsEnabled: true, githubAuthConfigured: true });
+ mocks.me.mockResolvedValue({ user: { login: 'publisher', avatarUrl: '' }, isAdmin: false });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+it('preserves native submission fields and update constraints on the actual submit route', async () => {
+ await act(async () => root.render());
+ const form = container.querySelector('form');
+ expect(form).not.toBeNull();
+ const data = new FormData(form!);
+ expect(data.get('slug')).toBe('my-app');
+ expect(data.get('releaseNumber')).toBe('2');
+ expect(data.get('icon')).toBe('✦');
+ expect(data.get('licenseValue')).toBe('MIT');
+ const slug = form!.elements.namedItem('slug') as HTMLInputElement;
+ const release = form!.elements.namedItem('releaseNumber') as HTMLInputElement;
+ expect(slug.readOnly).toBe(true);
+ expect(slug.pattern).toBe('[a-z0-9][a-z0-9-]{2,62}');
+ expect(release.readOnly).toBe(true);
+ expect(release.min).toBe('1');
+ expect(slug.closest('[data-openbitfun-component="input"]')).not.toBeNull();
+ expect(form!.querySelectorAll('input[type="file"]')).toHaveLength(2);
+ expect((form!.elements.namedItem('screenshots') as HTMLInputElement).multiple).toBe(true);
+ expect(form!.querySelector('.submit-button')!.type).toBe('submit');
+ expect(form!.checkValidity()).toBe(false);
+});
+
+it('keeps web submissions unavailable when the server disables them', async () => {
+ mocks.config.mockResolvedValue({ webSubmissionsEnabled: false });
+ await act(async () => root.render());
+ expect(container.querySelector('form')).toBeNull();
+ expect(container.querySelector('input[type="file"]')).toBeNull();
+});
diff --git a/src/miniapp-market-web/src/App.tsx b/src/miniapp-market-web/src/App.tsx
index 84ebd40400..b135e204a1 100644
--- a/src/miniapp-market-web/src/App.tsx
+++ b/src/miniapp-market-web/src/App.tsx
@@ -1,3 +1,4 @@
+import { Input } from '@openbitfun/ui';
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CubeFocus as MarketLogo, GithubLogo, IconContext } from '@phosphor-icons/react';
import {
@@ -516,7 +517,7 @@ function CatalogPage({
) : null}
-
{moderationBusy === detail.listingId ? t('unpublishingListing') : t('unpublishListing')}
-
+
) : null}
@@ -424,7 +424,7 @@ function ReleaseItem({
{t('downloadVersion', { version: release.packageVersion })}
{isAdmin ? (
-
{moderationBusy === release.releaseId ? t('yankingRelease') : t('yankRelease')}
-
+
) : null}
) : null}
diff --git a/src/skin-market-web/src/SubmissionsPage.tsx b/src/skin-market-web/src/SubmissionsPage.tsx
index d7c30a434d..c5f22d22cd 100644
--- a/src/skin-market-web/src/SubmissionsPage.tsx
+++ b/src/skin-market-web/src/SubmissionsPage.tsx
@@ -1,3 +1,4 @@
+import { Button } from '@openbitfun/ui';
import { GithubLogo } from '@phosphor-icons/react';
import { RefreshCw as ArrowClockwise, Image, Package, CircleX as XCircle } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -114,10 +115,10 @@ export function SubmissionsPage({ account, accountResolved, locale, t }: Submiss
{t('submissionsTitle')}
{t('submissionsIntro')}
- void load()} disabled={loading}>
+ void load()} disabled={loading}>
{t('refresh')}
-
+
{error && (
@@ -167,14 +168,14 @@ export function SubmissionsPage({ account, accountResolved, locale, t }: Submiss
)}
{(submission.status === 'draft' || submission.status === 'submitted') && (
- void withdraw(submission)}
>
{actingId === submission.submissionId ? t('withdrawing') : t('withdraw')}
-
+
)}
))}
diff --git a/src/skin-market-web/src/styles.css b/src/skin-market-web/src/styles.css
index 79e38acec5..84d02dbaf1 100644
--- a/src/skin-market-web/src/styles.css
+++ b/src/skin-market-web/src/styles.css
@@ -2304,3 +2304,43 @@ img {
min-block-size: 0;
letter-spacing: inherit;
}
+
+/* Keep each surface's control geometry while using shared button semantics. */
+:where([data-openbitfun-component="button"]) {
+ display: inline-block;
+ block-size: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ font: inherit;
+ letter-spacing: inherit;
+ white-space: normal;
+}
+:where([data-openbitfun-component="icon-button"]) {
+ inline-size: auto;
+ block-size: auto;
+}
+[data-openbitfun-component="button"]::before,
+[data-openbitfun-component="icon-button"]::before {
+ content: none;
+}
+/* Rich labels (previews, review cards, icon + text) retain the root layout. */
+[data-openbitfun-component="button"] > [data-openbitfun-part="content"],
+[data-openbitfun-component="button"] > [data-openbitfun-part="content"] > [data-openbitfun-part="label"] {
+ display: contents;
+ white-space: inherit;
+}
+[data-openbitfun-component="icon-button"] > [data-openbitfun-part="icon"] {
+ position: static;
+ inline-size: auto;
+ block-size: auto;
+}
+[data-openbitfun-component="icon-button"] > [data-openbitfun-part="icon"] > svg {
+ inline-size: 18px;
+ block-size: 18px;
+}
+
+.header-actions > [data-openbitfun-component="icon-button"] > [data-openbitfun-part="icon"] > svg {
+ inline-size: 20px;
+ block-size: 20px;
+}
From f6401d679e8a98b201878805128758cabe88ab16 Mon Sep 17 00:00:00 2001
From: kev1n77
Date: Tue, 15 Sep 2026 00:19:23 +0800
Subject: [PATCH 4/6] refactor(ui): connect installer options to shared
Checkbox
---
OpenBitFun-Installer/AGENTS.md | 1 +
OpenBitFun-Installer/package.json | 7 +-
.../src/pages/Options.test.tsx | 99 +++++++++++++++++++
pnpm-lock.yaml | 6 ++
4 files changed, 111 insertions(+), 2 deletions(-)
create mode 100644 OpenBitFun-Installer/src/pages/Options.test.tsx
diff --git a/OpenBitFun-Installer/AGENTS.md b/OpenBitFun-Installer/AGENTS.md
index b3c58298fc..efd0908199 100644
--- a/OpenBitFun-Installer/AGENTS.md
+++ b/OpenBitFun-Installer/AGENTS.md
@@ -48,6 +48,7 @@ Use the smallest matching check:
pnpm run i18n:audit # resource-only i18n
pnpm run i18n:generate && pnpm run i18n:contract:test && pnpm run i18n:audit
pnpm --dir OpenBitFun-Installer run type-check # frontend i18n/runtime
+pnpm --dir OpenBitFun-Installer run test # frontend control interactions
cargo check --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml # Tauri/Rust changes
```
diff --git a/OpenBitFun-Installer/package.json b/OpenBitFun-Installer/package.json
index c11f11c7d8..667dfc0322 100644
--- a/OpenBitFun-Installer/package.json
+++ b/OpenBitFun-Installer/package.json
@@ -26,7 +26,8 @@
"installer:build:only": "node scripts/build-installer.cjs --skip-app-build",
"installer:build:only:fast": "node scripts/build-installer.cjs --skip-app-build --mode fast",
"installer:dev": "node scripts/build-installer.cjs --dev",
- "type-check": "tsc --noEmit"
+ "type-check": "tsc --noEmit",
+ "test": "vitest run src"
},
"dependencies": {
"@openbitfun/theme-openbitfun": "workspace:^",
@@ -46,6 +47,8 @@
"@vitejs/plugin-react": "^4.6.0",
"terser": "^5.46.0",
"typescript": "~5.8.3",
- "vite": "^7.0.4"
+ "vite": "^7.0.4",
+ "vitest": "^4.1.0",
+ "jsdom": "^29.0.1"
}
}
diff --git a/OpenBitFun-Installer/src/pages/Options.test.tsx b/OpenBitFun-Installer/src/pages/Options.test.tsx
new file mode 100644
index 0000000000..fa46f01abf
--- /dev/null
+++ b/OpenBitFun-Installer/src/pages/Options.test.tsx
@@ -0,0 +1,99 @@
+// @vitest-environment jsdom
+import { act, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { Options } from './Options';
+import { ThemeSetup } from './ThemeSetup';
+import { WindowControls } from '../components/WindowControls';
+import type { InstallOptions } from '../types/installer';
+
+const mocks = vi.hoisted(() => ({ minimize: vi.fn(), close: vi.fn() }));
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
+vi.mock('@tauri-apps/plugin-dialog', () => ({ open: vi.fn() }));
+vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => mocks }));
+
+const initial: InstallOptions = {
+ installPath: 'D:\\OpenBitFun', desktopShortcut: false, startMenu: true,
+ launchAfterInstall: false, appLanguage: 'en-US', themePreference: 'system', modelConfig: null,
+};
+let container: HTMLDivElement;
+let root: ReturnType;
+beforeEach(() => {
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+});
+
+it('updates an install option once per label or native input click and keeps the path editable', async () => {
+ const changed = vi.fn();
+ const clearError = vi.fn();
+ const refreshDiskSpace = vi.fn().mockResolvedValue(undefined);
+ function Page() {
+ const [options, setOptions] = useState(initial);
+ return { changed(value); setOptions(value); }}
+ diskSpace={null} error={null} refreshDiskSpace={refreshDiskSpace}
+ existingInstall={null} onLaunchRegisteredUninstaller={vi.fn()} onBack={vi.fn()}
+ onInstall={vi.fn()} isInstalling={false} clearInstallError={clearError} />;
+ }
+ await act(async () => root.render());
+ const label = container.querySelector('[data-openbitfun-component="checkbox"]')!;
+ const checkbox = label.querySelector('input')!;
+ expect(checkbox.checked).toBe(false);
+ act(() => label.click());
+ expect(checkbox.checked).toBe(true);
+ expect(changed).toHaveBeenCalledTimes(1);
+ act(() => checkbox.click());
+ expect(checkbox.checked).toBe(false);
+ expect(changed).toHaveBeenCalledTimes(2);
+ act(() => checkbox.focus());
+ expect(document.activeElement).toBe(checkbox);
+ expect(checkbox.tabIndex).toBe(0);
+
+ const path = container.querySelector('[data-openbitfun-component="input"] input')!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(path, 'D:\\Apps\\OpenBitFun');
+ path.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ expect(path.value).toBe('D:\\Apps\\OpenBitFun');
+ expect(clearError).toHaveBeenCalledTimes(1);
+ expect(refreshDiskSpace).toHaveBeenLastCalledWith('D:\\Apps\\OpenBitFun');
+});
+
+it('keeps path and install/navigation actions disabled during installation', async () => {
+ const install = vi.fn();
+ await act(async () => root.render());
+ expect(container.querySelector('input[type="text"]')!.disabled).toBe(true);
+ const buttons = [...container.querySelectorAll('button')];
+ expect(buttons).toHaveLength(3);
+ for (const button of buttons) {
+ expect(button.disabled).toBe(true);
+ expect(button.dataset.openbitfunComponent).toBe('button');
+ act(() => button.click());
+ }
+ expect(install).not.toHaveBeenCalled();
+});
+
+it('connects launch-after-install on the theme page and preserves window control handlers', async () => {
+ const changed = vi.fn();
+ await act(async () => root.render(<>>));
+ const label = container.querySelector('[data-openbitfun-component="checkbox"]')!;
+ act(() => label.click());
+ expect(changed).toHaveBeenCalledTimes(1);
+ expect(changed.mock.calls[0][0](initial).launchAfterInstall).toBe(true);
+ const buttons = [...container.querySelectorAll('[data-openbitfun-component="icon-button"]')];
+ act(() => buttons[0].click());
+ act(() => buttons[1].click());
+ expect(mocks.minimize).toHaveBeenCalledTimes(1);
+ expect(mocks.close).toHaveBeenCalledTimes(1);
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bedb83e385..5e405d8230 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,6 +99,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ jsdom:
+ specifier: ^29.0.1
+ version: 29.0.1(@noble/hashes@2.0.1)
terser:
specifier: ^5.46.0
version: 5.46.0
@@ -108,6 +111,9 @@ importers:
vite:
specifier: ^7.0.4
version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitest:
+ specifier: ^4.1.0
+ version: 4.1.0(@types/node@22.19.7)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
design-system: {}
From 01b5e5147a5ca6fd1ad90c77d9c794fd78709ef5 Mon Sep 17 00:00:00 2001
From: kev1n77
Date: Tue, 15 Sep 2026 00:19:23 +0800
Subject: [PATCH 5/6] refactor(ui): adopt native Disclosure in Skin market
---
src/miniapp-market-web/README.md | 1 +
src/skin-market-web/README.md | 1 +
src/skin-market-web/src/AdminPage.tsx | 4 ++--
src/skin-market-web/src/DetailPage.test.tsx | 17 +++++++++++++++++
src/skin-market-web/src/DetailPage.tsx | 7 +++----
5 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/src/miniapp-market-web/README.md b/src/miniapp-market-web/README.md
index d744df87d5..a5dc03f17f 100644
--- a/src/miniapp-market-web/README.md
+++ b/src/miniapp-market-web/README.md
@@ -115,6 +115,7 @@ cargo run -p openbitfun-miniapp-market-server
终端二启动 Vite:
```bash
+pnpm --dir design-system run build:packages # 首次运行或共享主题/UI 包变更后执行
pnpm run dev:miniapp-market
```
diff --git a/src/skin-market-web/README.md b/src/skin-market-web/README.md
index 98218762f0..fcba761d93 100644
--- a/src/skin-market-web/README.md
+++ b/src/skin-market-web/README.md
@@ -26,6 +26,7 @@ The server generates variants lazily, stores them beside the content-addressed
original, and serves public variants with immutable cache headers.
```bash
+pnpm --dir design-system run build:packages # First run or after shared theme/UI changes
pnpm --dir src/skin-market-web dev
pnpm --dir src/skin-market-web type-check
pnpm --dir src/skin-market-web test
diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx
index 1a0e3bf64b..70ff2befbb 100644
--- a/src/skin-market-web/src/AdminPage.tsx
+++ b/src/skin-market-web/src/AdminPage.tsx
@@ -1,4 +1,4 @@
-import { Textarea, Button } from '@openbitfun/ui';
+import { Textarea, Button, Disclosure } from '@openbitfun/ui';
import { GithubLogo } from '@phosphor-icons/react';
import { RefreshCw as ArrowClockwise, ShieldCheck, CircleAlert as WarningCircle } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -203,7 +203,7 @@ export function AdminPage({ account, accountResolved, locale, t }: AdminPageProp
{t('reviewPreviewHash')}{detail.previewSha256 || t('notDeclared')}
{t('reviewBundleHash')}{detail.reviewBundleHash || t('notDeclared')}
- {detail.manifest !== undefined && {t('reviewManifest')}
{JSON.stringify(detail.manifest, null, 2)} }
+ {detail.manifest !== undefined && {JSON.stringify(detail.manifest, null, 2)}}
{t('reviewReason')}
{older.length ? (
-
- {t('olderReleases', { count: older.length })}
+
{older.map((release) => (
))}
-
+
) : null}
);
From 0dfc62ff57af313105a7cb0150eb09a8eb7d4b4c Mon Sep 17 00:00:00 2001
From: kev1n77
Date: Tue, 15 Sep 2026 09:46:33 +0800
Subject: [PATCH 6/6] fix(installer): use shared Select and initialize
typography before render
---
OpenBitFun-Installer/index.html | 1 -
OpenBitFun-Installer/src/main.tsx | 1 +
.../src/pages/ModelSetup.test.tsx | 96 +++++++++++++++++++
OpenBitFun-Installer/src/pages/ModelSetup.tsx | 8 +-
4 files changed, 103 insertions(+), 3 deletions(-)
create mode 100644 OpenBitFun-Installer/src/pages/ModelSetup.test.tsx
diff --git a/OpenBitFun-Installer/index.html b/OpenBitFun-Installer/index.html
index 600ee6d262..eb57e70ac9 100644
--- a/OpenBitFun-Installer/index.html
+++ b/OpenBitFun-Installer/index.html
@@ -8,7 +8,6 @@
Install OpenBitFun
-
diff --git a/OpenBitFun-Installer/src/main.tsx b/OpenBitFun-Installer/src/main.tsx
index 22618acbea..3cbb53b265 100644
--- a/OpenBitFun-Installer/src/main.tsx
+++ b/OpenBitFun-Installer/src/main.tsx
@@ -1,3 +1,4 @@
+import './theme/installerThemeBootstrap';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './i18n';
diff --git a/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx b/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx
new file mode 100644
index 0000000000..61c50fab8e
--- /dev/null
+++ b/OpenBitFun-Installer/src/pages/ModelSetup.test.tsx
@@ -0,0 +1,96 @@
+// @vitest-environment jsdom
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { ModelSetup } from './ModelSetup';
+import { PROVIDER_TEMPLATES, createModelConfigFromTemplate } from '../data/modelProviders';
+import type { InstallOptions, RemoteModelInfo } from '../types/installer';
+
+const mocks = vi.hoisted(() => ({ invoke: vi.fn() }));
+vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue || key }),
+}));
+const initial: InstallOptions = {
+ installPath: 'D:\\OpenBitFun', desktopShortcut: false, startMenu: true,
+ launchAfterInstall: false, appLanguage: 'en-US', themePreference: 'system', modelConfig: null,
+};
+let container: HTMLDivElement;
+let root: ReturnType;
+let changed = vi.fn();
+beforeEach(() => {
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ changed = vi.fn();
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+});
+async function render(options = initial, previewOnly = false) {
+ await act(async () => root.render());
+}
+function trigger(label: string) {
+ return container.querySelector(`button[role="combobox"][aria-label="${label}"]`)!;
+}
+function option(label: string) {
+ return [...document.querySelectorAll('[role="option"]')]
+ .find(item => item.getAttribute('aria-label') === label)!;
+}
+
+it('uses the shared popup for provider selection and restores focus after Escape', async () => {
+ await render();
+ const provider = trigger('model.providerLabel');
+ expect(provider.closest('[data-openbitfun-component="select"]')).not.toBeNull();
+ await act(async () => provider.click());
+ expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull();
+ await act(async () => option('model.customProvider').click());
+ expect(trigger('model.form.provider')).not.toBeNull();
+ expect(changed.mock.lastCall.modelConfig.provider).toBe('custom');
+ await act(async () => trigger('model.form.provider').click());
+ await act(async () => option('model.formats.responsesApi').click());
+ expect(changed.mock.lastCall.modelConfig.format).toBe('responses');
+ await act(async () => provider.click());
+ await act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
+ expect(document.querySelector('[data-openbitfun-component="select-popup"]')).toBeNull();
+ expect(document.activeElement).toBe(provider);
+});
+
+it('retains endpoint notes and applies the selected endpoint format', async () => {
+ const template = Object.values(PROVIDER_TEMPLATES).find(item => (item.baseUrlOptions?.length ?? 0) > 1)!;
+ await render({ ...initial, modelConfig: createModelConfigFromTemplate(template, null) });
+ await act(async () => trigger('model.endpointPreset').click());
+ const endpoint = template.baseUrlOptions![1];
+ const label = endpoint.noteKey?.split('.').pop() || endpoint.format.toUpperCase();
+ const item = option(label);
+ expect(item.textContent).toContain(label);
+ await act(async () => item.click());
+ const config = changed.mock.lastCall.modelConfig;
+ expect(config.baseUrl).toBe(endpoint.url);
+ expect(config.format).toBe(endpoint.format);
+});
+
+it('keeps the model popup open while loading and lets the returned model be selected', async () => {
+ let resolve!: (models: RemoteModelInfo[]) => void;
+ mocks.invoke.mockReturnValue(new Promise(done => { resolve = done; }));
+ const modelConfig = { ...createModelConfigFromTemplate(PROVIDER_TEMPLATES.deepseek, null), apiKey: 'test-key' };
+ await render({ ...initial, modelConfig });
+ await act(async () => trigger('model.form.modelSelection').click());
+ expect(mocks.invoke).toHaveBeenCalledTimes(1);
+ expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull();
+ await act(async () => resolve([{ id: 'new-remote-model' }]));
+ await act(async () => option('new-remote-model').click());
+ expect(changed.mock.lastCall.modelConfig.modelName).toBe('new-remote-model');
+});
+
+it('keeps native preview from fetching remote models', async () => {
+ const modelConfig = { ...createModelConfigFromTemplate(PROVIDER_TEMPLATES.deepseek, null), apiKey: 'test-key' };
+ await render({ ...initial, modelConfig }, true);
+ await act(async () => trigger('model.form.modelSelection').click());
+ expect(document.querySelector('[data-openbitfun-component="select-popup"]')).not.toBeNull();
+ expect(mocks.invoke).not.toHaveBeenCalled();
+});
diff --git a/OpenBitFun-Installer/src/pages/ModelSetup.tsx b/OpenBitFun-Installer/src/pages/ModelSetup.tsx
index b8f3406e6f..f37cb7215a 100644
--- a/OpenBitFun-Installer/src/pages/ModelSetup.tsx
+++ b/OpenBitFun-Installer/src/pages/ModelSetup.tsx
@@ -297,6 +297,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti
value={selectedProviderId}
options={providerOptions}
placeholder={t('model.selectProvider')}
+ aria-label={t('model.providerLabel')}
onValueChange={(value) => handleProviderSelect(String(value))}
/>
@@ -340,6 +341,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti
value={template?.baseUrlOptions?.some((option) => option.url === effectiveBaseUrl) ? effectiveBaseUrl : ''}
options={baseUrlOptions}
placeholder={t('model.baseUrlPlaceholder')}
+ aria-label={t('model.endpointPreset')}
onValueChange={(value) => handleBaseUrlOptionSelect(String(value))}
/>
@@ -351,8 +353,9 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti
value={modelSelectionValue}
options={modelOptions}
placeholder={t('model.modelNameSelectPlaceholder')}
- disabled={isFetchingRemoteModels}
- onOpenChange={(open) => { if (open && !previewOnly) void fetchRemoteModels(); }}
+ aria-label={t('model.form.modelSelection')}
+ aria-busy={isFetchingRemoteModels}
+ onOpenChange={(open) => { if (open && !previewOnly && !isFetchingRemoteModels) void fetchRemoteModels(); }}
onValueChange={(value) => {
const next = String(value);
if (next === CUSTOM_MODEL_OPTION) {
@@ -413,6 +416,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti
value={isCustomProvider ? customFormat : apiFormat}
options={formatSelectOptions}
placeholder={t('model.form.providerPlaceholder')}
+ aria-label={t('model.form.provider')}
onValueChange={(value) => {
const format = value as RequestFormatValue;
if (isCustomProvider) setCustomFormat(format);