Merge branch 'feat/2.6.0-beta4' into feat/2.6.0

# Conflicts:
#	src/backend/bisheng/knowledge/domain/models/knowledge.py
This commit is contained in:
dolphin
2026-07-21 23:00:39 +08:00
25 changed files with 1261 additions and 207 deletions
@@ -1,17 +1,17 @@
from datetime import datetime
from typing import List, Optional, Tuple, Any
from typing import Any
from sqlalchemy import case, func, or_
from sqlmodel import select, col, update
from sqlmodel import col, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
from bisheng.channel.domain.models.channel import Channel, ChannelVisibilityEnum
from bisheng.channel.domain.repositories.interfaces.channel_repository import ChannelRepository
from bisheng.common.models.space_channel_member import (
SpaceChannelMember,
REJECTED_STATUS_DISPLAY_WINDOW,
BusinessTypeEnum,
MembershipStatusEnum,
REJECTED_STATUS_DISPLAY_WINDOW,
SpaceChannelMember,
)
from bisheng.common.repositories.implementations.base_repository_impl import BaseRepositoryImpl
@@ -22,7 +22,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
def __init__(self, session: AsyncSession):
super().__init__(session, Channel)
async def find_channels_by_ids(self, channel_ids: List[str]) -> List[Channel]:
async def find_channels_by_ids(self, channel_ids: list[str]) -> list[Channel]:
"""Find channels by a list of channel IDs."""
if not channel_ids:
return []
@@ -30,8 +30,9 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
result = await self.session.exec(query)
return list(result.all())
async def find_square_channels(self, user_id: int, keyword: Optional[str] = None,
page: int = 1, page_size: int = 20) -> List[Tuple[Any, ...]]:
async def find_square_channels(
self, user_id: int, keyword: str | None = None, page: int = 1, page_size: int = 20
) -> list[tuple[Any, ...]]:
"""
Find released channels for the channel square with subscription status and subscriber count.
Uses multi-table LEFT JOIN:
@@ -42,15 +43,15 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
"""
rejection_cutoff = datetime.now() - REJECTED_STATUS_DISPLAY_WINDOW
# Subquery: count subscribers (status=ACTIVE) per channel
# Subquery: count unique active subscribers per channel
subscriber_subq = (
select(
SpaceChannelMember.business_id,
func.count().label('subscriber_count')
func.count(func.distinct(SpaceChannelMember.user_id)).label("subscriber_count"),
)
.where(
SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL,
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE,
)
.group_by(SpaceChannelMember.business_id)
.subquery()
@@ -60,36 +61,26 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
query = (
select(
Channel,
SpaceChannelMember.status.label('user_subscription_status'),
SpaceChannelMember.update_time.label('user_subscription_update_time'),
func.coalesce(subscriber_subq.c.subscriber_count, 0).label('subscriber_count')
SpaceChannelMember.status.label("user_subscription_status"),
SpaceChannelMember.update_time.label("user_subscription_update_time"),
func.coalesce(subscriber_subq.c.subscriber_count, 0).label("subscriber_count"),
)
.outerjoin(
SpaceChannelMember,
(SpaceChannelMember.business_id == Channel.id) &
(SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL) &
(SpaceChannelMember.user_id == user_id)
)
.outerjoin(
subscriber_subq,
subscriber_subq.c.business_id == Channel.id
)
.where(
Channel.is_released == True,
Channel.visibility != ChannelVisibilityEnum.PRIVATE
(SpaceChannelMember.business_id == Channel.id)
& (SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL)
& (SpaceChannelMember.user_id == user_id),
)
.outerjoin(subscriber_subq, subscriber_subq.c.business_id == Channel.id)
.where(Channel.is_released == True, Channel.visibility != ChannelVisibilityEnum.PRIVATE)
)
# Apply keyword filter (fuzzy search on name and description)
if keyword:
like_pattern = f'%{keyword}%'
query = query.where(
or_(
Channel.name.like(like_pattern),
Channel.description.like(like_pattern)
)
)
like_pattern = f"%{keyword}%"
query = query.where(or_(Channel.name.like(like_pattern), Channel.description.like(like_pattern)))
subscriber_count = func.coalesce(subscriber_subq.c.subscriber_count, 0)
subscription_order = case(
(SpaceChannelMember.status.is_(None), 0),
(
@@ -97,11 +88,13 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
& (SpaceChannelMember.update_time < rejection_cutoff),
0,
),
else_=1
else_=1,
)
query = query.order_by(
subscription_order.asc(),
func.coalesce(Channel.update_time, Channel.create_time).desc()
subscriber_count.desc(),
func.coalesce(Channel.update_time, Channel.create_time).desc(),
Channel.id.asc(),
)
# Pagination
@@ -111,9 +104,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
result = await self.session.exec(query)
return list(result.all())
async def find_public_recommend_channels(
self, user_id: int, candidate_limit: int = 100
) -> List[Tuple[Any, ...]]:
async def find_public_recommend_channels(self, user_id: int, candidate_limit: int = 100) -> list[tuple[Any, ...]]:
"""
Find released PUBLIC channels for the home-page discovery carousel.
@@ -125,13 +116,10 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
pre-filter — newest channels first, bounded to keep the ES batch small.
"""
subscriber_subq = (
select(
SpaceChannelMember.business_id,
func.count().label('subscriber_count')
)
select(SpaceChannelMember.business_id, func.count().label("subscriber_count"))
.where(
SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL,
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE
SpaceChannelMember.status == MembershipStatusEnum.ACTIVE,
)
.group_by(SpaceChannelMember.business_id)
.subquery()
@@ -140,24 +128,18 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
query = (
select(
Channel,
SpaceChannelMember.status.label('user_subscription_status'),
SpaceChannelMember.update_time.label('user_subscription_update_time'),
func.coalesce(subscriber_subq.c.subscriber_count, 0).label('subscriber_count')
SpaceChannelMember.status.label("user_subscription_status"),
SpaceChannelMember.update_time.label("user_subscription_update_time"),
func.coalesce(subscriber_subq.c.subscriber_count, 0).label("subscriber_count"),
)
.outerjoin(
SpaceChannelMember,
(SpaceChannelMember.business_id == Channel.id) &
(SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL) &
(SpaceChannelMember.user_id == user_id)
)
.outerjoin(
subscriber_subq,
subscriber_subq.c.business_id == Channel.id
)
.where(
Channel.is_released == True,
Channel.visibility == ChannelVisibilityEnum.PUBLIC
(SpaceChannelMember.business_id == Channel.id)
& (SpaceChannelMember.business_type == BusinessTypeEnum.CHANNEL)
& (SpaceChannelMember.user_id == user_id),
)
.outerjoin(subscriber_subq, subscriber_subq.c.business_id == Channel.id)
.where(Channel.is_released == True, Channel.visibility == ChannelVisibilityEnum.PUBLIC)
.order_by(func.coalesce(Channel.update_time, Channel.create_time).desc())
.limit(candidate_limit)
)
@@ -165,25 +147,17 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
result = await self.session.exec(query)
return list(result.all())
async def count_square_channels(self, keyword: Optional[str] = None) -> int:
async def count_square_channels(self, keyword: str | None = None) -> int:
"""Count total released channels matching the keyword filter."""
query = (
select(func.count())
.select_from(Channel)
.where(
Channel.is_released == True,
Channel.visibility != ChannelVisibilityEnum.PRIVATE
)
.where(Channel.is_released == True, Channel.visibility != ChannelVisibilityEnum.PRIVATE)
)
if keyword:
like_pattern = f'%{keyword}%'
query = query.where(
or_(
Channel.name.like(like_pattern),
Channel.description.like(like_pattern)
)
)
like_pattern = f"%{keyword}%"
query = query.where(or_(Channel.name.like(like_pattern), Channel.description.like(like_pattern)))
result = await self.session.exec(query)
return result.one()
@@ -198,7 +172,7 @@ class ChannelRepositoryImpl(BaseRepositoryImpl[Channel, str], ChannelRepository)
referenced.update(source_list)
return referenced
def update_channel_latest_article_update_time(self, channles: List[Channel]) -> List[Channel]:
def update_channel_latest_article_update_time(self, channles: list[Channel]) -> list[Channel]:
for channel in channles:
stmt = (
update(Channel)
@@ -1240,7 +1240,7 @@ class ChannelService:
- Supports fuzzy search by channel name and description
- Unsubscribed/unapplied channels are shown first
- Subscribed/applied channels are shown last
- Within each group, sorted by update_time descending
- Within each group, sorted by unique active subscriber count descending
"""
# 1. Multi-table join query for channels with subscription info
rows = await self.channel_repository.find_square_channels(
@@ -841,11 +841,11 @@ class KnowledgeDao(KnowledgeBase):
kid_str = col(Knowledge.id).cast(String)
# Subquery: count subscribers (status=ACTIVE) per space
# Subquery: count unique active subscribers per space
subscriber_subq = (
select(
SpaceChannelMember.business_id,
func.count().label("subscriber_count"),
func.count(func.distinct(SpaceChannelMember.user_id)).label("subscriber_count"),
)
.where(
SpaceChannelMember.business_type == BusinessTypeEnum.SPACE,
@@ -890,7 +890,10 @@ class KnowledgeDao(KnowledgeBase):
)
)
# Sort: not-subscribed first, then by update_time DESC
subscriber_count = func.coalesce(subscriber_subq.c.subscriber_count, 0)
# Sort by lightweight membership facts only. Permission evaluation remains
# a response-layer concern and does not affect SQL pagination.
subscription_order = case(
(SpaceChannelMember.status.is_(None), 0),
(
@@ -902,7 +905,9 @@ class KnowledgeDao(KnowledgeBase):
)
query = query.order_by(
subscription_order.asc(),
subscriber_count.desc(),
func.coalesce(Knowledge.update_time, Knowledge.create_time).desc(),
Knowledge.id.asc(),
)
# Pagination
@@ -428,7 +428,28 @@ class DepartmentKnowledgeSpaceService:
(await DepartmentKnowledgeSpaceDao.aget_department_ids_by_space_ids(member_space_ids)).keys()
)
space_ids = department_space_ids | member_bound_space_ids
# A user can also gain access to a department space through an ad-hoc
# authorization (viewer/editor/manager granted on the permission panel).
# That path writes only an FGA relation — no membership row, and the
# user need not be a member of the bound department — so neither branch
# above catches it and the space would only surface under "我加入的".
# Intersect the user's FGA-readable spaces with the department-bound set
# so an individually-authorized user sees it under 部门知识空间 instead.
accessible_ids = await PermissionService.list_accessible_ids(
user_id=login_user.user_id,
relation="can_read",
object_type="knowledge_space",
login_user=login_user,
)
authorized_bound_space_ids: set[int] = set()
if accessible_ids:
accessible_int_ids = [int(sid) for sid in accessible_ids if str(sid).isdigit()]
if accessible_int_ids:
authorized_bound_space_ids = set(
(await DepartmentKnowledgeSpaceDao.aget_department_ids_by_space_ids(accessible_int_ids)).keys()
)
space_ids = department_space_ids | member_bound_space_ids | authorized_bound_space_ids
if not space_ids:
return []
@@ -2106,7 +2106,7 @@ class KnowledgeSpaceService(KnowledgeUtils):
Return PUBLIC/APPROVAL spaces for the Knowledge Square with pagination, sorted by:
1. Not-joined first (easier to explore)
2. Already-joined or pending last
3. Within each group: sorted by update_time DESC
3. Within each group: unique active subscriber count descending
Sorting and pagination are handled at the SQL level for efficiency.
Returns: {"total": int, "page": int, "page_size": int, "data": List[KnowledgeSpaceInfoResp]}
"""
+4
View File
@@ -528,6 +528,10 @@ async def list_user(
group_dict = {}
for one, avatar_url in zip(users, avatar_urls):
one_data = one.model_dump()
# Never expose sensitive/internal User columns in the list response — the
# ORM dump carries the full row; the frontend uses none of these fields.
for sensitive_field in ("password", "password_update_time", "token_version"):
one_data.pop(sensitive_field, None)
primary_dept_id = primary_dept_by_user.get(int(one.user_id)) if one.user_id is not None else None
one_data["department_id"] = primary_dept_id
if with_department_path:
@@ -0,0 +1,106 @@
from __future__ import annotations
from datetime import datetime
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel.ext.asyncio.session import AsyncSession
from bisheng.channel.domain.models.channel import Channel, ChannelVisibilityEnum
from bisheng.channel.domain.repositories.implementations.channel_repository_impl import (
ChannelRepositoryImpl,
)
from bisheng.common.models.space_channel_member import (
BusinessTypeEnum,
MembershipStatusEnum,
SpaceChannelMember,
UserRoleEnum,
)
from bisheng.core.context.tenant import bypass_tenant_filter
def _channel(channel_id: str, update_time: datetime) -> Channel:
return Channel(
id=channel_id,
name=channel_id,
description="",
source_list=[],
visibility=ChannelVisibilityEnum.PUBLIC,
filter_rules=[],
user_id=99,
is_released=True,
create_time=update_time,
update_time=update_time,
)
def _member(
channel_id: str,
user_id: int,
status: MembershipStatusEnum = MembershipStatusEnum.ACTIVE,
) -> SpaceChannelMember:
return SpaceChannelMember(
business_id=channel_id,
business_type=BusinessTypeEnum.CHANNEL,
user_id=user_id,
user_role=UserRoleEnum.MEMBER,
status=status,
update_time=datetime(2026, 1, 1),
)
@pytest.mark.asyncio
async def test_square_orders_unsubscribed_before_applied_then_by_unique_subscriber_count():
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as connection:
await connection.run_sync(Channel.__table__.create)
await connection.run_sync(SpaceChannelMember.__table__.create)
now = datetime(2026, 1, 1)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all(
[
_channel("00-not-member-popular", now),
_channel("01-pending-popular", now),
_channel("02-not-member-less-popular", now),
_channel("03-member-popular", now),
_channel("04-member-less-popular", now),
_member("00-not-member-popular", 14),
_member("00-not-member-popular", 14),
_member("00-not-member-popular", 15),
_member("01-pending-popular", 7, MembershipStatusEnum.PENDING),
_member("01-pending-popular", 10),
_member("01-pending-popular", 10),
_member("01-pending-popular", 11),
_member("02-not-member-less-popular", 12),
_member("03-member-popular", 7),
_member("03-member-popular", 13),
_member("03-member-popular", 13),
_member("04-member-less-popular", 7),
]
)
await session.commit()
with bypass_tenant_filter():
rows = await ChannelRepositoryImpl(session).find_square_channels(
user_id=7,
page=1,
page_size=20,
)
await engine.dispose()
assert [row[0].id for row in rows] == [
"00-not-member-popular",
"02-not-member-less-popular",
"01-pending-popular",
"03-member-popular",
"04-member-less-popular",
]
assert [row[3] for row in rows] == [2, 1, 2, 2, 1]
assert rows[2][1] == MembershipStatusEnum.PENDING
@@ -0,0 +1,108 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel.ext.asyncio.session import AsyncSession
from bisheng.common.models.space_channel_member import (
BusinessTypeEnum,
MembershipStatusEnum,
SpaceChannelMember,
UserRoleEnum,
)
from bisheng.core.context.tenant import bypass_tenant_filter
from bisheng.knowledge.domain.models import knowledge as knowledge_module
from bisheng.knowledge.domain.models.knowledge import (
AuthTypeEnum,
Knowledge,
KnowledgeDao,
KnowledgeTypeEnum,
)
def _space(space_id: int, update_time: datetime) -> Knowledge:
return Knowledge(
id=space_id,
user_id=99,
name=f"space-{space_id}",
type=KnowledgeTypeEnum.SPACE.value,
description="",
is_released=True,
auth_type=AuthTypeEnum.PUBLIC,
create_time=update_time,
update_time=update_time,
)
def _member(
space_id: int,
user_id: int,
status: MembershipStatusEnum = MembershipStatusEnum.ACTIVE,
) -> SpaceChannelMember:
return SpaceChannelMember(
business_id=str(space_id),
business_type=BusinessTypeEnum.SPACE,
user_id=user_id,
user_role=UserRoleEnum.MEMBER,
status=status,
update_time=datetime(2026, 1, 1),
)
@pytest.mark.asyncio
async def test_square_orders_unsubscribed_before_applied_then_by_unique_subscriber_count(monkeypatch):
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as connection:
await connection.run_sync(Knowledge.__table__.create)
await connection.run_sync(SpaceChannelMember.__table__.create)
now = datetime(2026, 1, 1)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all(
[
_space(1, now),
_space(2, now),
_space(3, now),
_space(4, now),
_space(5, now),
_member(1, 7, MembershipStatusEnum.PENDING),
_member(1, 10),
_member(1, 10),
_member(1, 11),
_member(2, 12),
_member(3, 7),
_member(3, 13),
_member(3, 13),
_member(4, 7),
_member(5, 14),
_member(5, 14),
_member(5, 15),
]
)
await session.commit()
@asynccontextmanager
async def _get_test_session():
yield session
monkeypatch.setattr(knowledge_module, "get_async_db_session", _get_test_session)
with bypass_tenant_filter():
rows = await KnowledgeDao.async_get_public_spaces_paginated(
user_id=7,
page=1,
page_size=20,
)
await engine.dispose()
assert [row[0].id for row in rows] == [5, 2, 1, 3, 4]
assert [row[3] for row in rows] == [2, 1, 2, 2, 1]
assert rows[2][1] == MembershipStatusEnum.PENDING
@@ -0,0 +1,124 @@
/**
* rspack pre-loader for the docs site: strip internal working sections from
* the spec markdown (docs-ui-refactor/*.md) BEFORE the MDX compiler sees the
* source.
*
* Why a loader and not a remark plugin: rspress extracts the page TOC (and
* search index) before user remarkPlugins run, so a remark-level strip left
* ghost entries in the right-hand outline. Text-level stripping up front
* keeps body / TOC / search consistent.
*
* The spec files are living working documents — migration ledgers, scan
* archives, change logs and Claude-window handoff notes live next to the
* reader-facing spec. The site's audience (PM / devs / new designers) should
* only see the spec itself, while the md stays the single source of truth.
*
* Rules (see also docs-ui-refactor/00-总纲.md §五):
* 1. A heading matching HEADING_PATTERNS is dropped together with everything
* until the next heading of the same or higher level.
* 2. The blockquote directly below the document's first H1 is dropped — by
* convention it's working-doc meta (version stamp, 与总纲配套, notes to the
* next Claude window), not reader material.
* 2b. Standalone `---` section separators are dropped entirely: the rspress
* theme already draws a divider before every h2, so the source separators
* render as doubled lines — and stripping sections leaves orphaned ones.
* 3. Explicit markers for per-file curation:
* <!-- site-hide --> hides the next heading's whole section
* <!-- site-hide:start --> hides everything until
* <!-- site-hide:end -->
*/
const HEADING_PATTERNS = [
/改动记录/, // change logs (every spec doc)
/关键结论/, // “先读这个” digests addressed at incoming Claude windows
/^附录/, // appendices (scan archives, usage ledgers)
/^附[:]/, // “附:已迁出本文的内容” style
/落地记录/, // implementation logs
/给实现窗口/, // sections addressed to the implementing Claude window
/待决策清单/, // open-decision checklists for the designer
/代码锚点/, // code anchor lists
/扫描存档/, // scan archives outside 附录 headings
];
const HIDE_ONE = /^\s*<!--\s*site-hide\s*-->\s*$/;
const HIDE_START = /^\s*<!--\s*site-hide:start\s*-->\s*$/;
const HIDE_END = /^\s*<!--\s*site-hide:end\s*-->\s*$/;
const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
const FENCE = /^\s*(```|~~~)/;
module.exports = function stripInternalSections(source) {
const lines = source.split('\n');
const out = [];
let inFence = false;
/** Depth of the heading whose section is being hidden, or null. */
let sectionDepth = null;
let inRange = false;
let hideNextHeading = false;
/** 'before-h1' → 'after-h1' (drop a leading meta blockquote) → 'done'. */
let metaQuoteState = 'before-h1';
for (const line of lines) {
if (FENCE.test(line)) {
// Fences inside hidden regions still toggle so a closing ``` inside a
// hidden section doesn't leak fence state to the visible remainder.
inFence = !inFence;
if (sectionDepth === null && !inRange) out.push(line);
continue;
}
if (inFence) {
if (sectionDepth === null && !inRange) out.push(line);
continue;
}
if (HIDE_START.test(line)) {
inRange = true;
continue;
}
if (HIDE_END.test(line)) {
inRange = false;
continue;
}
if (HIDE_ONE.test(line)) {
hideNextHeading = true;
continue;
}
if (inRange) continue;
// Rule 2b: drop every standalone thematic break (see header).
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) continue;
// Drop the meta blockquote that sits directly under the first H1.
if (metaQuoteState === 'after-h1') {
if (line.trim() === '') {
out.push(line);
continue;
}
if (line.trimStart().startsWith('>')) {
continue; // swallow the whole leading blockquote
}
metaQuoteState = 'done'; // first real content — stop looking
}
const m = line.match(HEADING);
if (m) {
const depth = m[1].length;
if (metaQuoteState === 'before-h1' && depth === 1) metaQuoteState = 'after-h1';
if (sectionDepth !== null && depth <= sectionDepth) sectionDepth = null;
const isInternal =
hideNextHeading || HEADING_PATTERNS.some((p) => p.test(m[2]));
hideNextHeading = false;
if (sectionDepth === null && isInternal) {
sectionDepth = depth;
continue;
}
if (sectionDepth !== null) continue;
out.push(line);
continue;
}
if (sectionDepth !== null) continue;
out.push(line);
}
return out.join('\n');
};
+83 -16
View File
@@ -1,6 +1,8 @@
import * as path from 'path';
import { defineConfig } from 'rspress/config';
import { pluginPreview } from '@rspress/plugin-preview';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
/**
* Component-library docs site (rspress).
@@ -20,6 +22,14 @@ export default defineConfig({
title: 'BiSheng 组件库',
description: 'BiSheng client 设计规范 + 组件库',
lang: 'zh', // single language — i18n intentionally not enabled (req 5)
// No SSG: demos import real app components, whose dependency tree reaches
// browser/node-conditional packages (@dicebear/converter resolves its `node`
// build under SSR and then needs the native `sharp` / `@resvg/resvg-js`,
// which we don't install). `rspress dev` never hit this because it only
// client-renders; `rspress build` prerenders and failed. Client-side
// rendering is fine for an internal docs site, and it keeps every future
// demo immune to the same class of SSR-only resolution breakage.
ssg: false,
// Point straight at the app's stylesheet (tailwind directives + all design
// tokens). A wrapper css with `@import` breaks rspack's cssExtractLoader.
globalStyles: path.join(clientSrc, 'style.css'),
@@ -27,26 +37,40 @@ export default defineConfig({
// req 1: live component preview
plugins: [pluginPreview({ previewMode: 'internal' })],
route: {
// 00-总纲 is the Claude-window working charter, not reader material —
// keep it out of the site entirely (routes AND search index).
exclude: ['**/00-总纲.md'],
},
themeConfig: {
// req 6: two top-level sections — 文档 (specs) / 组件 (demos)
// NOTE: the demos dir is ASCII (`components/`) on purpose — rspress v1's
// client router fails to match nested routes with non-ASCII dir names.
nav: [
{ text: '文档', link: '/00-总纲' },
{ text: '组件', link: '/components/button' },
// activeMatch drives the selected state: 组件 owns /components/*,
// 文档 owns every other doc route (home included).
{ text: '文档', link: '/基础-字体规范', activeMatch: '^/(?!components/)' },
{ text: '组件', link: '/components/button', activeMatch: '^/components/' },
],
sidebar: {
// 组件 section — component demos
'/components/': [
{ text: '组件总览', link: '/components/index' },
{ text: 'Typography 字体', link: '/components/typography' },
{ text: 'Color 色彩', link: '/components/color' },
{ text: 'Button 按钮', link: '/components/button' },
{ text: 'Modal 弹窗', link: '/components/modal' },
{ text: 'Confirm 二次确认', link: '/components/confirm' },
{ text: 'Feedback 点赞点踩', link: '/components/feedback' },
{ text: 'Icon 图标', link: '/components/icon' },
{ text: 'Illustration 插画', link: '/components/illustration' },
],
// 文档 section — the existing design-spec markdown (kept flat, not moved)
'/': [
{
text: '设计规范',
items: [
{ text: '总纲', link: '/00-总纲' },
{ text: '字体 Typography', link: '/基础-字体规范' },
{ text: '色彩 Color', link: '/基础-色彩规范' },
{ text: '多端适配', link: '/基础-多端适配原则' },
@@ -68,6 +92,14 @@ export default defineConfig({
builderConfig: {
source: {
// The app entry (src/main.jsx) imports this too — react-speech-recognition
// (reached via the ~/hooks barrel) needs a global regeneratorRuntime.
// rspress-overrides.css: docs-site-only fixes on top of the app css
// (restores document flow so the sticky nav works — see file header).
preEntry: [
'regenerator-runtime/runtime',
path.join(__dirname, 'stubs/rspress-overrides.css'),
],
define: {
// vite injects these globals (vite.config define); app code reached via
// the `~/utils` barrel reads them at module scope — must exist here too.
@@ -95,6 +127,18 @@ export default defineConfig({
},
},
tools: {
// The docs build consumes design-token.js through a docs-only Tailwind
// config (tailwind.docs.config.cjs) — proving the SSOT drives a real
// Tailwind theme while the app's tailwind.config.cjs stays untouched.
// Replacing the plugins array here supersedes the app's postcss.config.cjs
// (which would otherwise resolve the app config), so Tailwind runs once.
postcss: (config: any) => {
config.postcssOptions = config.postcssOptions || {};
config.postcssOptions.plugins = [
tailwindcss(path.join(__dirname, 'tailwind.docs.config.cjs')),
autoprefixer(),
];
},
cssLoader: {
url: {
// Leave root-relative (/workspace/...) and $fonts urls untouched —
@@ -102,19 +146,42 @@ export default defineConfig({
filter: (url: string) => !url.startsWith('/') && !url.startsWith('$fonts'),
},
},
rspack: {
resolve: {
// Some ui components reach the `~/utils` barrel, which transitively
// imports api modules using Node builtins. Demos never execute those
// paths at runtime; stub them out so the browser bundle compiles.
fallback: {
crypto: false,
url: false,
fs: false,
path: false,
stream: false,
},
},
rspack: (config) => {
config.resolve = config.resolve || {};
// filenamify (ESM, imports node:path — an unbundlable scheme) comes
// in via the ~/hooks barrel (usePresets); no demo executes it. The
// alias must live at the raw-rspack layer — rsbuild's resolve.alias
// did not take effect for this package.
config.resolve.alias = {
...(config.resolve.alias as Record<string, unknown>),
filenamify: path.join(__dirname, 'stubs/filenamify-stub.ts'),
};
// Some ui components reach the `~/utils` barrel, which transitively
// imports api modules using Node builtins. Demos never execute those
// paths at runtime; stub them out so the browser bundle compiles.
config.resolve.fallback = {
...(config.resolve.fallback as Record<string, unknown>),
crypto: false,
url: false,
fs: false,
path: false,
stream: false,
};
// Strip internal working sections (ledgers, change logs, scan
// archives) from the spec md BEFORE the MDX compiler runs — a
// remark-level strip misses the TOC/search, which rspress extracts
// ahead of user remark plugins. Single source of truth stays the md.
config.module = config.module || { rules: [] };
config.module.rules = config.module.rules || [];
config.module.rules.push({
// .md only: rspress precompiles .mdx Node-side before webpack loaders
// run, and MDX rejects the `<!-- site-hide -->` HTML comments this
// loader relies on. Spec .mdx pages are authored reader-clean instead.
test: /\.md$/,
include: [path.join(__dirname, '../../../docs-ui-refactor')],
enforce: 'pre',
use: [path.join(__dirname, 'plugins/strip-internal-loader.cjs')],
});
},
},
},
@@ -1,5 +1,5 @@
import { HoverCardPortal, HoverCardContent } from '~/components/ui';
import './styles.module.css';
import styles from './styles.module.css';
type TPluginTooltipProps = {
content: string;
@@ -12,7 +12,7 @@ function PluginTooltip({ content, position }: TPluginTooltipProps) {
<HoverCardContent side={position} className="w-80 ">
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-300">
<div dangerouslySetInnerHTML={{ __html: content }} />
<div className={styles.tooltipHtml} dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
</HoverCardContent>
@@ -1,4 +1,8 @@
a {
/* Scoped to the tooltip's injected HTML. The old bare `a` element selector is
NOT hashed by CSS modules — it leaked a global `a { color: white }` into the
whole app (and the docs site), turning unstyled links white-on-white in
light contexts. */
.tooltipHtml a {
text-decoration: underline;
color: white;
}
+255
View File
@@ -0,0 +1,255 @@
/**
* design-token.js — Single Source of Truth (SSOT) for BiSheng client design tokens.
*
* ONE catalog, consumed by two audiences:
* • components — via Tailwind (docs build spreads `tailwindTheme` into theme.extend;
* the live app config can adopt the same import when the team migrates).
* • docs / md — the spec .mdx pages and component demo pages import the catalogs
* below and render every table / swatch straight from this file, so
* the written spec can never drift from the values components ship.
*
* CommonJS (`module.exports`) on purpose: a `.cjs` Tailwind config can `require()` it,
* and rspack/vite ESM-interop lets an .mdx page `import tokens from '~/design-token'`.
*
* ── Naming (req: no unclear numbered names) ──────────────────────────────────────────
* Semantic tokens carry ROLE names (text-title / fill-hover …). The old numeric names
* (text-1, fill-2 …) are kept as DEPRECATED `legacy` aliases so nothing breaks; the app
* migrates to the role names gradually. See MIGRATION at the bottom for the full map.
* Primitive ramps that are genuinely a scale (brand-50…900, gray-1…10) stay numbered —
* that IS their semantic (lightness step), same convention as Tailwind's own palettes.
*
* ── Runtime behaviour ────────────────────────────────────────────────────────────────
* Themeable / responsive tokens still resolve through CSS custom properties defined in
* src/style.css (brand blue⇄green switch; ≤768px type remap). This file owns the token
* NAMES + documented values and drives the Tailwind theme keys; the CSS vars remain the
* runtime carrier. Regenerating :root from this file is a later step (app migration).
*/
/* ------------------------------------------------------------------ *
* Typography — 基础-字体规范.md
* ------------------------------------------------------------------ */
const FONT_FAMILY = {
base: {
token: 'font-family-base',
cls: 'font-sans',
usage: '全局默认(已写入 body/html,无需显式加类)',
stack: [
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
'"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"',
'"Noto Sans CJK SC"', 'sans-serif',
],
},
mono: {
token: 'font-family-mono',
cls: 'font-mono',
usage: 'ID、代码、日志',
stack: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
},
};
/** Semantic type scale — each entry is a Tailwind fontSize key AND its own weight. */
const TYPE_SCALE = [
{ name: 'caption', desktop: [12, 20], mobile: [12, 20], weight: 400, cssVar: '--text-caption', leadingVar: '--leading-caption', usage: '时间戳、标签、水印' },
{ name: 'body-sm', desktop: [13, 21], mobile: [14, 22], weight: 400, cssVar: '--text-body-sm', leadingVar: '--leading-body-sm', usage: '密集表格、侧栏次要项' },
{ name: 'body', desktop: [14, 22], mobile: [16, 24], weight: 400, cssVar: '--text-body', leadingVar: '--leading-body', usage: '正文基准,表单、表格默认' },
{ name: 'h4', desktop: [16, 24], mobile: [16, 24], weight: 500, cssVar: '--text-h4', leadingVar: '--leading-h4', usage: '强调正文、四级标题' },
{ name: 'h3', desktop: [18, 26], mobile: [17, 25], weight: 500, cssVar: '--text-h3', leadingVar: '--leading-h3', usage: '卡片标题' },
{ name: 'h2', desktop: [20, 28], mobile: [18, 26], weight: 500, cssVar: '--text-h2', leadingVar: '--leading-h2', usage: '区块标题' },
{ name: 'h1', desktop: [24, 32], mobile: [22, 30], weight: 500, cssVar: '--text-h1', leadingVar: '--leading-h1', usage: '页面标题' },
{ name: 'display', desktop: [30, 38], mobile: [26, 34], weight: 500, cssVar: '--text-display', leadingVar: '--leading-display', usage: '大标题、营销场景' },
{ name: 'metric', desktop: [36, 44], mobile: [30, 38], weight: 500, cssVar: '--text-metric', leadingVar: '--leading-metric', usage: 'Dashboard 核心指标数字' },
];
const FONT_WEIGHT = [
{ name: 'regular', token: 'font-weight-regular', cls: 'font-normal', value: 400, usage: '正文、说明' },
{ name: 'medium', token: 'font-weight-medium', cls: 'font-medium', value: 500, usage: '标题、强调、按钮' },
];
/* ------------------------------------------------------------------ *
* Brand ramp — dual theme (基础-色彩规范.md §1). Documented hex per theme;
* Tailwind resolves these through --brand-N so they switch blue⇄green at
* runtime. Numbered because it is a lightness scale (that is the semantic).
* ------------------------------------------------------------------ */
const BRAND_STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
const BRAND = {
main: '500',
accentStep: '700', // darker shade used as the primary-marker accent bar
role: {
'50': '选中背景', '100': 'filled hover', '200': '触屏 active', '300': '过渡档',
'400': 'hover 态', '500': '主色', '600': '按下 active', '700': '深色档',
'800': '深色档', '900': '深色档', muted: '低饱和点缀',
},
blue: { '50': '#E8F3FF', '100': '#BEDAFF', '200': '#94BFFF', '300': '#6AA1FF', '400': '#4080FF', '500': '#165DFF', '600': '#024DE3', '700': '#0239AB', '800': '#042B80', '900': '#051D52', muted: '#5773B4' },
green: { '50': '#E4F1E7', '100': '#CCE4D2', '200': '#A3D2B0', '300': '#6FBA85', '400': '#3D9B5C', '500': '#169C47', '600': '#098B35', '700': '#076929', '800': '#074E20', '900': '#063216', muted: '#5C8A77' },
};
/* ------------------------------------------------------------------ *
* Neutral primitive — Arco gray 110 (§2.1). Numbered = the lightness
* scale itself; components consume the semantic layer below, not this.
* `channels` = "r g b" for rgb(var(--arco-gray-N)/α).
* `darkHex` / `darkChannels` = official @arco-design/color gray.dark ramp
* (lightness inverts). Runtime carrier: `.dark` override in src/style.css.
* ------------------------------------------------------------------ */
const GRAY = [
{ n: 1, hex: '#F7F8FA', channels: '247 248 250', darkHex: '#17171A', darkChannels: '23 23 26', role: 'hover 底' },
{ n: 2, hex: '#F2F3F5', channels: '242 243 245', darkHex: '#2E2E30', darkChannels: '46 46 48', role: 'filled 底' },
{ n: 3, hex: '#E5E6EB', channels: '229 230 235', darkHex: '#484849', darkChannels: '72 72 73', role: '边框' },
{ n: 4, hex: '#C9CDD4', channels: '201 205 212', darkHex: '#5F5F60', darkChannels: '95 95 96', role: '禁用 / 占位' },
{ n: 5, hex: '#A9AEB8', channels: '169 174 184', darkHex: '#78787A', darkChannels: '120 120 122', role: '过渡' },
{ n: 6, hex: '#86909C', channels: '134 144 156', darkHex: '#929293', darkChannels: '146 146 147', role: '辅助文字' },
{ n: 7, hex: '#6B7785', channels: '107 119 133', darkHex: '#ABABAC', darkChannels: '171 171 172', role: '过渡' },
{ n: 8, hex: '#4E5969', channels: '78 89 105', darkHex: '#C5C5C5', darkChannels: '197 197 197', role: '次文字' },
{ n: 9, hex: '#272E3B', channels: '39 46 59', darkHex: '#DFDFDF', darkChannels: '223 223 223', role: '过渡' },
{ n: 10, hex: '#1D2129', channels: '29 33 41', darkHex: '#F6F6F6', darkChannels: '246 246 246', role: '主文字' },
];
/* ------------------------------------------------------------------ *
* Semantic layer (§2.2) — ROLE names (canonical) + numeric `legacy` alias.
* `cssVar` is the existing runtime carrier in src/style.css.
* ------------------------------------------------------------------ */
// Role names avoid the taken shadcn keys (text-primary/secondary/tertiary);
// intensity ramp strong → muted → hint → disabled maps gray-10 → 8 → 6 → 4.
// `hex` = light value; `darkHex` = same gray ref resolved on the dark ramp.
const TEXT = [
{ name: 'strong', legacy: '1', cssVar: '--text-1', ref: 'gray-10', hex: '#1D2129', darkHex: '#F6F6F6', usage: '主文字:标题、正文主体' },
{ name: 'muted', legacy: '2', cssVar: '--text-2', ref: 'gray-8', hex: '#4E5969', darkHex: '#C5C5C5', usage: '次要文字:次要说明、默认按钮文字' },
{ name: 'hint', legacy: '3', cssVar: '--text-3', ref: 'gray-6', hex: '#86909C', darkHex: '#929293', usage: '辅助文字:弱提示、时间戳、占位符' },
{ name: 'disabled', legacy: '4', cssVar: '--text-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '禁用文字' },
];
const FILL = [
{ name: 'subtle', legacy: '1', cssVar: '--fill-1', ref: 'gray-1', hex: '#F7F8FA', darkHex: '#17171A', usage: '浅填充:hover 底、页面浅灰背景' },
{ name: 'default', legacy: '2', cssVar: '--fill-2', ref: 'gray-2', hex: '#F2F3F5', darkHex: '#2E2E30', usage: '填充:active 底、filled 控件底' },
{ name: 'hover', legacy: '3', cssVar: '--fill-3', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '深填充:filled hover' },
{ name: 'active', legacy: '4', cssVar: '--fill-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '重填充:filled active' },
];
const BORDER = [
{ name: 'base', cssVar: '--border-base', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '常规边框:输入框、卡片、分割线' },
{ name: 'deep', cssVar: '--border-deep', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '深边框:强调分割、hover 边框' },
];
/* Background surfaces — the "white that darkens" family. The gray ramp starts
* at gray-1 (#F7F8FA), so the pure page surface needs its own semantic token
* (the fixed `--white: #fff` legacy var never theme-flips — different job).
* Carrier: --bg-page in src/style.css (:root + .dark override). */
const BG = [
{ name: 'page', cssVar: '--bg-page', hex: '#FFFFFF', darkHex: '#121212', usage: '页面底色:内容区、顶栏等最底层表面' },
];
/* ------------------------------------------------------------------ *
* Functional colors (§3) — fixed hex, never theme-switched.
* ------------------------------------------------------------------ */
const FUNCTIONAL = [
{ name: 'success', label: '成功 Success', cssVar: '--success', main: '#00B42A', hover: '#23C343', active: '#009A29', tint: '#E8FFEA' },
{ name: 'warning', label: '警告 Warning', cssVar: '--warning', main: '#FF7D00', hover: '#FF9A2E', active: '#D25F00', tint: '#FFF7E8' },
{ name: 'danger', label: '危险 Danger', cssVar: '--danger', main: '#F53F3F', hover: '#D6373A', active: '#D02F33', tint: '#FFECE8' },
];
/* ------------------------------------------------------------------ *
* Tag pairs (§4) — light bg + strong text. Purple / approving-blue are
* intentional fixed exceptions (not tokenized, never theme-switched).
* ------------------------------------------------------------------ */
const TAG = [
{ label: '技能(紫 · 未 token 化)', bg: '#F5E8FF', fg: '#722ED1', note: '固定例外色' },
{ label: '助手(橙 = warning 同值)', bg: '#FFF7E8', fg: '#FF7D00', note: 'warning tint' },
{ label: '已完成', bg: '#E8FFEA', fg: '#00B42A', note: 'success tint' },
{ label: '已驳回', bg: '#FFECE8', fg: '#F53F3F', note: 'danger tint' },
{ label: '审批中(例外:永远蓝)', bg: '#E8F3FF', fg: '#165DFF', note: '固定蓝,不换肤' },
];
/* ------------------------------------------------------------------ *
* Radius (01-设计规范.md §1) & icon sizes (基础-图标规范.md §3.2)
* ------------------------------------------------------------------ */
const RADIUS = [
{ name: 'sm', px: 4, usage: 'small 控件 / 表格行内按钮' },
{ name: 'md', px: 6, usage: 'medium 控件(默认)' },
{ name: 'lg', px: 8, usage: 'large 控件 / --radius 基准' },
{ name: 'xl2', px: 16, cls: 'rounded-2xl', usage: '弹窗 / 大卡片容器' },
];
const ICON_SIZE = [
{ name: 'xs', px: 12, strokeWidth: 2.5, usage: '极小标记(badge、密集表格角标),仅纯展示' },
{ name: 'sm', px: 14, usage: 'small / medium 按钮的文字+icon' },
{ name: 'md', px: 16, usage: '默认:图标按钮、菜单项、输入框内、表格操作' },
{ name: 'lg', px: 20, usage: '导航栏、侧边栏入口、页头操作' },
{ name: 'xl', px: 24, usage: '独立展示、弹窗标题图标(原始画布尺寸)' },
{ name: 'xl2', px: 32, strokeWidth: 1.5, usage: '超大展示:空状态、引导页' },
];
/* ================================================================== *
* Derived: Tailwind theme fragment — spread into theme.extend.
* Channel-triplet form keeps `/<alpha>` opacity modifiers working.
* ================================================================== */
const withAlpha = (cssVar) => `rgb(var(${cssVar}) / <alpha-value>)`;
/** Color keys embed their category (text-/fill-/border-) so a single utility reads right. */
const colors = {};
TEXT.forEach((t) => {
colors[`text-${t.name}`] = withAlpha(t.cssVar); // canonical role name
colors[`text-${t.legacy}`] = withAlpha(t.cssVar); // DEPRECATED alias (kept for migration)
});
FILL.forEach((f) => {
colors[`fill-${f.name}`] = withAlpha(f.cssVar);
colors[`fill-${f.legacy}`] = withAlpha(f.cssVar); // DEPRECATED alias
});
BORDER.forEach((b) => {
colors[`border-${b.name}`] = withAlpha(b.cssVar);
});
BG.forEach((b) => {
colors[`bg-${b.name}`] = withAlpha(b.cssVar); // class: bg-bg-page
});
FUNCTIONAL.forEach((fn) => {
colors[fn.name] = {
DEFAULT: withAlpha(fn.cssVar),
hover: withAlpha(`${fn.cssVar}-hover`),
active: withAlpha(`${fn.cssVar}-active`),
tint: withAlpha(`${fn.cssVar}-tint`),
};
});
const fontSize = {};
TYPE_SCALE.forEach((s) => {
fontSize[s.name] = [`var(${s.cssVar})`, { lineHeight: `var(${s.leadingVar})`, fontWeight: String(s.weight) }];
});
const tailwindTheme = { colors, fontSize };
/* ================================================================== *
* Migration map — old numeric class → new role class, for the app's
* gradual adoption (both resolve identically until the old alias is
* removed). Emitted as data so tooling / codemods can read it.
* ================================================================== */
const MIGRATION = [
...TEXT.map((t) => ({ from: `text-text-${t.legacy}`, to: `text-text-${t.name}`, cssVar: t.cssVar })),
...FILL.map((f) => ({ from: `bg-fill-${f.legacy}`, to: `bg-fill-${f.name}`, cssVar: f.cssVar })),
];
module.exports = {
FONT_FAMILY,
TYPE_SCALE,
FONT_WEIGHT,
BRAND,
BRAND_STEPS,
GRAY,
TEXT,
FILL,
BORDER,
BG,
FUNCTIONAL,
TAG,
RADIUS,
ICON_SIZE,
tailwindTheme,
MIGRATION,
};
@@ -60,7 +60,7 @@ const SPEC_PAGES: PageDef[] = [
/** Progress registry — migration dashboards & ledgers, one per in-flight component. */
const PROGRESS_PAGES: PageDef[] = [
{ id: 'overview', label: '总览看板', group: '总则', Page: ProgressOverview },
{ id: 'overview', label: '现状总览', group: '总则', Page: ProgressOverview },
{ id: 'typography', label: '字体 Typography', group: '基础 Foundation', status: 'wip', Page: TypographyProgress },
{ id: 'color', label: '色彩 Colors', group: '基础 Foundation', status: 'wip', Page: ColorProgress },
{ id: 'button', label: 'Button 按钮', group: '通用 General', status: 'wip', Page: ButtonProgress },
@@ -68,9 +68,11 @@ const PROGRESS_PAGES: PageDef[] = [
{ id: 'confirm', label: '二次确认弹窗', group: '反馈 Feedback', status: 'wip', Page: ConfirmProgress },
];
const REGISTRY: Record<Mode, PageDef[]> = { spec: SPEC_PAGES, progress: PROGRESS_PAGES };
/* Order matters — Object.keys drives the segment control, and 现状梳理 is the
gallery's primary job (the written spec now lives in the separate rspress site). */
const REGISTRY: Record<Mode, PageDef[]> = { progress: PROGRESS_PAGES, spec: SPEC_PAGES };
const MODE_LABEL: Record<Mode, string> = { spec: '设计规范', progress: '迁移进度' };
const MODE_LABEL: Record<Mode, string> = { progress: '现状梳理', spec: '设计规范' };
const STATUS_DOT: Record<Status, string> = {
wip: 'bg-amber-500',
@@ -87,7 +89,7 @@ function groupsOf(pages: PageDef[]): string[] {
}
export default function GalleryApp() {
const [mode, setMode] = useState<Mode>('spec');
const [mode, setMode] = useState<Mode>('progress');
/* Remember the active page per mode, so toggling back restores where you were. */
const [activeIds, setActiveIds] = useState<Record<Mode, string>>({
spec: 'overview',
@@ -11,30 +11,71 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
export function ButtonProgress() {
return (
<ComponentPage
title="Button · 迁移"
eng="Button Progress"
title="Button 按钮 · 现状"
eng="Button Inventory"
description={
<>
2026-07-14
deprecated
退 <code>btn</code> Generations/Button
2026-07-20 <b> Button </b>
<code>&lt;Button&gt;</code> 264 <code>&lt;button&gt;</code> 485
</>
}
whenToUse={[
<>
<code>h-9</code>(36px) medium(32px)<b> 4px</b>
§6.6
API<code>color</code> <b> 1 </b>使
</>,
<>
<code>h-9</code>(36px) medium(32px) 4px
</>,
<> API </>,
]}
bodyTitle="迁移台账"
bodyTitle="现状盘点"
>
<ExampleGroup title="旧 API 兼容映射(迁移台账,§6.3)">
<ExampleGroup
title="① 按钮总盘"
subtitle="全站到底有多少种“按钮”——组件化的只是其中一部分。"
>
<CompareTable
head={['来源', '判定方式', '出现次数', '涉及文件', '受设计系统约束?']}
rows={[
[
<b key="a"> &lt;button&gt;</b>,
<code key="a2">&lt;button</code>,
<b key="a3">485</b>,
<b key="a4">222</b>,
'❌ 完全不受约束,样式各写各的',
],
[
'Button 组件',
<code key="b">&lt;Button&gt;</code>,
'264',
'136',
'✅ 走 cva 档位(但 96% 仍用旧 API 入参)',
],
[
'全局 CSS 类',
<code key="c">btn / btn-primary / btn-neutral / btn-secondary</code>,
'—',
'约 10',
'❌ LibreChat 遗留全局类:ToolItem、EditMessage、EditTextPart、HeaderNewChat、PluginStoreItem…',
],
]}
/>
<p className="mt-3 text-body-sm text-muted-foreground">
485 <code>&lt;button&gt;</code>
Button icon//
</p>
</ExampleGroup>
<ExampleGroup
title="② 旧 API 兼容映射"
subtitle="旧入参自动映射为新双轴(已标 deprecated);右列全部用旧 API 渲染,外观应与新双轴一致。"
>
<CompareTable
head={['旧写法(用量)', '映射为', '旧 API 实渲染']}
rows={[
[
<code key="o"> / variant=&quot;default&quot;116 </code>,
<code key="o"> + default98 + 7 = 105 </code>,
<code key="n">primary solid</code>,
<Button key="b" variant="default">
@@ -48,14 +89,14 @@ export function ButtonProgress() {
</Button>,
],
[
<code key="o">variant=&quot;outline&quot;78 </code>,
<code key="o">variant=&quot;outline&quot;77 </code>,
<code key="n">default outlined</code>,
<Button key="b" variant="outline">
</Button>,
],
[
<code key="o">variant=&quot;secondary&quot;17 18 primary filled</code>,
<code key="o">variant=&quot;secondary&quot;17 </code>,
<code key="n">default filled</code>,
<Button key="b" variant="secondary">
@@ -83,14 +124,14 @@ export function ButtonProgress() {
</Button>,
],
[
<code key="o">variant=&quot;link&quot;0 </code>,
<code key="o">variant=&quot;link&quot; / secondaryBrand0 </code>,
<code key="n">primary link</code>,
<Button key="b" variant="link">
</Button>,
],
[
<code key="o">size / &quot;sm&quot;249 h-9</code>,
<code key="o">size=&quot;sm&quot;48 h-9</code>,
<code key="n">medium32px</code>,
<Button key="b" variant="outline" size="sm">
@@ -104,7 +145,7 @@ export function ButtonProgress() {
</Button>,
],
[
<code key="o">size=&quot;icon&quot;18 </code>,
<code key="o">size=&quot;icon&quot;17 </code>,
<code key="n">medium + iconOnly</code>,
<Button key="b" variant="outline" size="icon" aria-label="搜索">
<Outlined.Search />
@@ -8,8 +8,8 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
export function ColorProgress() {
return (
<ComponentPage
title="色彩 · 迁移"
eng="Color Progress"
title="色彩 · 现状"
eng="Color Inventory"
description={
<>
token + Tailwind 线2026-07-15 hex <b>2469 / 215 </b>
@@ -61,22 +61,21 @@ function ConfirmDemo({
export function ConfirmProgress() {
return (
<ComponentPage
title="二次确认 · 迁移"
eng="Confirm Progress"
title="二次确认 · 现状"
eng="Confirm Inventory"
description={
<>
<code>OGDialogTemplate selection</code> 13 7 UI +
6 <code>useConfirm()</code>26 10
<b></b> UI SidePanel +
Chat/Header Modal <b></b>B
C 9 selectClasses danger / primary
9 {' '}
<code>AlertDialog</code> Modal
<code>OGDialogTemplate selection</code><b>13 </b>
selectClasses <code>useConfirm()</code><b>26 </b>B
C <b></b>
</>
}
whenToUse={[
<> UI selectClasses Loading </>,
<>0 N</>,
<>
<code>selectVariant</code> 使 <b>0</b>{' '}
<code>OGDialogTemplate</code> class
</>,
<> selectClasses </>,
]}
bodyTitle="现状盘点"
>
@@ -90,16 +89,16 @@ export function ConfirmProgress() {
<>
<code>OGDialogTemplate</code> + <code>selection</code>
</>,
'13(原 21;剩余全是死 UI 或表单)',
'13 处旧 selectClasses',
'旧页面(会话/书签/Agent/设置/Prompt…LibreChat 血统)',
'差 · 确认按钮 9 种写法',
'差 · 6 种历史写法',
],
[
'C 套服务',
<>
<code>useConfirm()</code>ConfirmContext + AlertDialog
</>,
'26(收敛完成,含已迁入 10 处)',
'26',
'新页面(知识空间 / 订阅频道 / 权限)',
'好 · 样式集中在一个文件,destructive/default 两档',
],
@@ -108,77 +107,70 @@ export function ConfirmProgress() {
</ExampleGroup>
{/* Inventory table: every selectClasses variant found in business code */}
<ExampleGroup title="确认按钮 selectClasses 清单(9 种历史写法)">
<ExampleGroup
title="确认按钮 selectClasses 清单"
subtitle="2026-07-20 实测:业务里仍有 13 处传旧 selectClasses;新档位 selectVariant 使用数为 0。"
>
<CompareTable
head={['#', '确认按钮 selectClasses(原文)', '用处', '文件数']}
head={['#', '确认按钮 selectClasses(原文)', '用处', '出现处']}
rows={[
[
'1',
<code key="c">bg-red-700 dark:bg-red-600 hover:bg-red-800 </code>,
'可达的 4 处已迁 C;剩 4 处全是死 UI(书签/分享弹窗/两个工具移除)',
'4(原 8)· 全死 UI',
'Agents/Builder 的 ActionsPanel×2、AgentTool、AssistantTool、删除书签分享链接',
<b key="n">6</b>,
],
[
'2',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-800</code>,
'删除 Agent / Assistant —— 死 UISidePanel 被注释)',
'2(原 3)· 全死 UI',
'删除 Agent / Assistant',
'2',
],
[
'3',
<code key="c">bg-red-600 hover:bg-red-700 dark:hover:bg-red-600</code>,
'清空预设 —— 死 UIChat/Header 无人引用)',
'1 · 死 UI',
'清空预设',
'1',
],
[
'4',
<code key="c">bg-destructive hover:bg-destructive/80</code>,
'清空聊天 / 删缓存 / 撤销密钥 —— ✅ 已全部迁 C 套',
'0(原 3',
],
[
'5',
<code key="c">bg-surface-destructive hover:bg-surface-destructive-hover</code>,
'删除版本 / 管理员确认 —— ✅ 已全部迁 C 套',
'0(原 2',
],
[
'6',
<code key="c">bg-green-500 hover:bg-green-600 text-white</code>,
<code key="c">bg-green-500 hover:bg-green-600 </code>,
'保存预设 / 保存 API Key(确认=绿色?!',
'2',
],
[
'7',
'5',
<>
<code>btn btn-primary</code> CSS
</>,
'提交密钥',
'提交密钥 SetKeyDialog',
'1',
],
[
'8',
'6',
<code key="c">bg-surface-submit hover:bg-surface-submit-hover</code>,
'重命名保存',
'重命名保存 DashGroupItem',
'1',
],
[
'9',
<>
<code>bg-gray-800 dark:bg-gray-200</code>
</>,
'OGDialogTemplate 内置 defaultSelect',
'—',
<code key="c">bg-destructive / bg-surface-destructive </code>,
'清空聊天 / 删缓存 / 撤销密钥 / 删除版本 / 管理员确认 —— ✅ 已全部迁 C 套',
'0(原 5',
],
]}
/>
<p className="mt-3 text-body-sm text-muted-foreground">
<code>OGDialogTemplate</code> class danger / primary
<b></b> 13
</p>
</ExampleGroup>
<ExampleGroup title="逐个打开对比(旧写法现应呈现统一外观)">
<DemoGrid cols={3}>
<ConfirmDemo
label="① red-700 系(6 处,原 8"
note="删除书签 — Bookmarks/DeleteBookmarkButton.tsx(原例子删会话已迁 C 套)"
label="① red-700 系(6 处)"
note="删除书签 · 分享链接 · Agents/Builder 工具移除"
title="删除书签"
body={
<>
@@ -189,7 +181,7 @@ export function ConfirmProgress() {
selectClasses="bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white"
/>
<ConfirmDemo
label="② red-600 系(2 处,原 3"
label="② red-600 系(2 处)"
note="删除 Agent — SidePanel/Agents/DeleteButton.tsx"
title="删除助手"
body="确定要删除这个助手吗?此操作不可撤销。"
@@ -197,7 +189,7 @@ export function ConfirmProgress() {
selectClasses="bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white"
/>
<ConfirmDemo
label=" 绿色确认(2 处)"
label=" 绿色确认(2 处)"
note="保存预设 — Endpoints/SaveAsPresetDialog.tsx"
title="另存为预设"
body="将当前配置保存为预设?"
@@ -206,7 +198,7 @@ export function ConfirmProgress() {
showCloseButton
/>
<ConfirmDemo
label="模板默认(不传 selectClasses"
label="模板默认(不传 selectClasses"
note="OGDialogTemplate 内置 defaultSelect:黑底/暗色反白"
title="确认操作"
body="这是不传 selectClasses 时的默认确认按钮。"
@@ -214,7 +206,7 @@ export function ConfirmProgress() {
/>
<ConfirmDemo
label="Loading 态(isLoading: true"
note="模板内置 Spinner · 各页自塞 Spinner 的写法已随迁移清零(原 4 处)"
note="模板内置 Spinner"
title="删除会话"
body="确认按钮处于加载中。"
selectText="删除"
@@ -222,7 +214,7 @@ export function ConfirmProgress() {
isLoading
/>
<ConfirmDemo
label=" surface-submit1 处)"
label=" surface-submit1 处)"
note="重命名保存 — Prompts/Groups/DashGroupItem.tsx"
title="重命名"
body="保存新的名称?"
@@ -230,7 +222,7 @@ export function ConfirmProgress() {
selectClasses="bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit"
/>
<ConfirmDemo
label=" btn btn-primary1 处)"
label=" btn btn-primary1 处)"
note="提交密钥 — SetKeyDialog.tsx · 走全局 CSS 类"
title="设置密钥"
body="提交这个 API Key"
@@ -70,65 +70,72 @@ function ConfirmReferenceDemo() {
export function ModalProgress() {
return (
<ComponentPage
title="Modal · 迁移"
eng="Modal Progress"
title="Modal 弹窗 · 现状"
eng="Modal Inventory"
description={
<>
2026-07-09 <b>5 64 </b>
B C A 22 + 3
AlertDialog7
2026-07-20 <b> 63 </b> <b>5 </b>
B C A 23 + 3 AlertDialog6
</>
}
whenToUse={[
<><b></b>稿"④ 待设计师定夺"</>,
<>
LibreChat <b>24</b> · <code>pages/</code> <b>15</b> ·
SidePanel <b>10</b> · 14
</>,
<>C 16 / p-5 / B </>,
<>A 22 / / </>,
<>A 23 / / "④ 待定夺"</>,
]}
bodyTitle="现状盘点"
>
{/* ① Population overview */}
<ExampleGroup title="① 用量盘子(当前精确扫描,排除 ui/ 与画廊)">
<ExampleGroup title="① 用量盘子" subtitle="2026-07-20 全仓扫描,排除 ui/ 定义、画廊与测试文件。">
<CompareTable
head={['体系', '实现', '业务文件数', '用在哪 / 备注']}
head={['体系', '判定方式', '业务文件数', '用在哪 / 备注']}
rows={[
[
'A 套 · 原语直接拼',
'Dialog + DialogContent 手拼',
<b key="a1">22</b>,
'新页面为主:知识库 8、订阅 2、审批/通知/账号/分享/appChat…(含 MainLayout 全局弹窗',
<code key="a">&lt;DialogContent</code>,
<b key="a1">23</b>,
'知识库 8、订阅 3、审批/通知/账号/分享/appChatMainLayout 全局弹窗',
],
[
'A 套 · 模板',
'DialogTemplate',
<code key="b">&lt;DialogTemplate</code>,
'3',
'EditPresetDialog、PresetItems、ContextButton(后者在 SidePanel 死树',
'EditPresetDialog、PresetItems、ContextButton(后者 0 引用,确认死代码',
],
[
'B 套 · 模板',
'OGDialogTemplate',
'16(原 25,确认迁移后)',
'书签/导出/SetKey/归档/Agent 面板…(其中 SidePanel 死树约 6 处);壳已对齐 C 套',
<code key="c">&lt;OGDialogTemplate</code>,
'16',
'书签/导出/SetKey/归档/Agent 面板…;壳已对齐 C 套',
],
[
'B 套 · 原语直接拼',
'OGDialog + OGDialogContent 手拼',
<code key="d">&lt;OGDialogContent</code>,
'16',
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上(已对齐 C 套)',
'设置(账号/数据)、Prompts、文件预览、ShareAgent…;壳同上',
],
[
'手拼 AlertDialog',
'AlertDialogContent + 自拼头尾',
'7',
'频道成员 2、爬取系 4、灵思 TaskModeInput(部分带确认性质,本期一并处理',
<code key="e">&lt;AlertDialogContent</code>,
'6',
'频道成员 2、爬取系 3、灵思 TaskModeInput(部分带确认性质)',
],
[
'C 套 · useConfirm(参照)',
'ConfirmContextAlertDialog 底层)',
<code key="f">useConfirm()</code>,
'26(已收敛 ✅)',
'二次确认已定稿的视觉基准:圆角16 / p-5 / 灰底毛玻璃 —— Modal 壳的天然候选',
],
]}
/>
<p className="mt-3 text-body-sm text-muted-foreground">
<code>AlertDialogContent</code> 1 <code>Providers/ConfirmContext.tsx</code>
C
</p>
</ExampleGroup>
{/* ② Shell anatomy */}
@@ -189,7 +196,7 @@ export function ModalProgress() {
<ExampleGroup title="③ 同一内容装进各个壳(逐个打开对比)">
<DemoGrid cols={3}>
{/* A-set raw primitives — the largest population */}
<Demo label="A 套 · 原语直接拼(22 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
<Demo label="A 套 · 原语直接拼(23 处)" note="ui/Dialog.tsx · 浅黑毛玻璃 · 圆角8 · p-5">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline"></Button>
@@ -267,7 +274,7 @@ export function ModalProgress() {
{/* Hand-rolled AlertDialog population */}
<Demo
label="手拼 AlertDialog7 处)"
label="手拼 AlertDialog6 处)"
note="p-6 · 圆角8 · 无边框阴影 · z-110 · 移动端贴底滑入"
>
<AlertDialog>
@@ -324,7 +331,7 @@ export function ModalProgress() {
<b></b>z-50 / z-[100] / z-[110] Drawer/Sheet/Popover
</li>
<li>
<b></b>A 22 A B
<b></b>A 23 A B
</li>
</ol>
</div>
@@ -8,12 +8,12 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
export function ProgressOverview() {
return (
<ComponentPage
title="迁移进度"
eng="Progress"
title="现状总览"
eng="Inventory"
description={
<>
/
/
<b></b>
rspress
</>
}
whenToUse={[
@@ -26,7 +26,7 @@ export function ProgressOverview() {
]}
bodyTitle={null}
>
<ExampleGroup title="进度看板">
<ExampleGroup title="现状看板" subtitle="2026-07-20 全仓扫描口径;数字为业务文件/出现次数实测值。">
<CompareTable
head={['组件', '状态', '现状', '基准(收敛目标)与剩余工作']}
rows={[
@@ -57,19 +57,19 @@ export function ProgressOverview() {
[
'Button 按钮',
'🟨 进行中',
'5 路并行(旧 API 已自动映射)',
'color×variant 双轴 v1 已落地;剩:设计师验收 → 逐批迁移 → 清退 btn 系全局类',
'原生 <button> 485 处 / Button 组件 264 处 / 全局 btn 类',
'双轴 v1 已落地,但新 color 属性业务仅 1 处使用;485 处野生 button 未收编',
],
[
'Modal 弹窗',
'🟨 进行中',
'5 个并行体系 · 约 64 个业务文件',
'5 个并行体系 · 63 个业务文件',
'标准待定 · C 套壳为基准候选(见 Modal 迁移页「待设计师定夺」)',
],
[
'二次确认弹窗',
'🟨 进行中',
'2 套体系(真确认已全部迁 C 套)',
'2 套体系;业务仍有 13 处旧 selectClasses',
'B 套壳与按钮已对齐 C 套;剩:死 UI 清理、表单弹窗归 Modal 期',
],
['点赞 / 点踩', '✅ 完成', '1(已统一)', 'MessageFeedbackButtons 全 6 类回答界面共用'],
@@ -8,8 +8,8 @@ import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
export function TypographyProgress() {
return (
<ComponentPage
title="字体 · 迁移"
eng="Typography Progress"
title="字体 · 现状"
eng="Typography Inventory"
description={
<>
semantic token + 2026-07-14 Tailwind
@@ -1,5 +1,6 @@
import { Outlined } from "bisheng-icons";
import { useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router-dom";
import { KnowledgeSpace, SpaceRole, SPACE_CHILDREN_STATUS_NUMS_EXCLUDE_FAILED } from "~/api/knowledge";
import {
@@ -215,8 +216,14 @@ export default function KnowledgeSpaceItem({
onClick={() => onSelect(space)}
onContextMenu={handleRowContextMenu}
>
{/* Right-click menu: an invisible cursor-anchored trigger drives the same items as the "..." menu. */}
{!hideMoreMenu && (
{/* Right-click menu: an invisible cursor-anchored trigger drives the same
items as the "..." menu. Portaled to <body> so the trigger's
`position: fixed` anchors to the viewport — the sidebar scroll container
has `container-type: inline-size`, which would otherwise become the
trigger's containing block and offset the popup from the cursor (visible
in the WeCom WebView). React events still bubble through the component
tree, so the stopPropagation calls below still guard the row's onClick. */}
{!hideMoreMenu && createPortal(
<DropdownMenu open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -231,7 +238,8 @@ export default function KnowledgeSpaceItem({
<SidebarListMoreMenuContent onClick={(e) => e.stopPropagation()}>
{moreMenuItems}
</SidebarListMoreMenuContent>
</DropdownMenu>
</DropdownMenu>,
document.body,
)}
<div className="flex items-center flex-1">
{/* Expand/collapse chevron — only shown when treeEnabled.
+19
View File
@@ -116,6 +116,7 @@
--fill-4: var(--arco-gray-4); /* filled active */
--border-base: var(--arco-gray-3); /* regular border ("border" in the spec) */
--border-deep: var(--arco-gray-4); /* emphasized divider / hover border */
--bg-page: 255 255 255; /* page surface: white, darkens in .dark */
/* Semantic — functional (never themed) */
--success: 0 180 42; /* #00B42A (Arco green-6) */
@@ -412,6 +413,24 @@ html {
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--switch-unchecked: 0 0% 40%;
/* Arco gray ramp — dark theme (official @arco-design/color gray.dark).
* Lightness inverts (gray-1 darkest, gray-10 lightest), so the semantic
* text / fill / border tokens defined in :root as var(--arco-gray-N)
* auto-flip under .dark (custom props resolve lazily at use time).
* Neutral + semantic only this round; brand and functional dark ramps
* are still pending (they keep light values in dark — known debt). */
--arco-gray-1: 23 23 26; /* #17171A */
--arco-gray-2: 46 46 48; /* #2E2E30 */
--arco-gray-3: 72 72 73; /* #484849 */
--arco-gray-4: 95 95 96; /* #5F5F60 */
--arco-gray-5: 120 120 122; /* #78787A */
--arco-gray-6: 146 146 147; /* #929293 */
--arco-gray-7: 171 171 172; /* #ABABAC */
--arco-gray-8: 197 197 197; /* #C5C5C5 */
--arco-gray-9: 223 223 223; /* #DFDFDF */
--arco-gray-10: 246 246 246; /* #F6F6F6 */
--bg-page: 18 18 18; /* #121212 — page surface, dark */
}
.gizmo {
@@ -0,0 +1,12 @@
/**
* Docs-site stub for `filenamify` (rspress.config resolve.alias).
*
* The real package imports `node:path`, which rspack cannot bundle for the
* browser ("unhandled scheme" resolve.fallback doesn't cover node:-prefixed
* ids). It only reaches the docs bundle through the ~/hooks barrel
* (Conversations/usePresets), a path no demo executes, so an identity shim is
* enough to keep the bundle compiling.
*/
export default function filenamify(input: string): string {
return input;
}
@@ -0,0 +1,267 @@
/**
* Docs-site-only overrides on top of the app's style.css (rspress.config
* loads this via source.preEntry; !important wins regardless of order).
*
* The app is an app-shell layout: `html, body, #root { height: 100% }` and
* inner containers scroll. rspress needs normal document flow with the app
* rule active, #root is pinned to viewport height, the whole page scrolls the
* window past it, and the sticky `.rspress-nav` (top: 0, scoped to #root)
* scrolls away with it. Restore document flow for the docs site.
*/
html,
body,
#root {
height: auto !important;
}
/* Page background = the SAME token the chrome uses (--rp-c-bg is mapped to
--bg-page). Without this, the app's own `.dark body` rule (a hardcoded
rgba(33,33,33) in style.css) leaks into the docs site and the content
column shows #212121 against the #121212 nav/sidebar. */
html,
body {
background-color: rgb(var(--bg-page)) !important;
}
/* Chrome: sidebar sits on gray-1 (auto-flips: #F7F8FA / #17171A) to separate
from the content column; the nav shares the page background and separates
via a downward shadow instead. Shadow color is the ramp's INK end + alpha
the dark end is gray-10 in light mode but flips to gray-1 on the dark
ramp, so each theme picks its own token (still 100% token-driven). */
.rspress-sidebar {
background-color: rgb(var(--arco-gray-1)) !important;
}
.rspress-nav {
background-color: rgb(var(--bg-page)) !important;
border-bottom: 0.5px solid rgb(var(--border-base)) !important;
box-shadow: 0 2px 8px rgb(var(--arco-gray-10) / 0.06);
}
/* Ink end of the dark ramp is gray-1 (the ramp flips); the resulting shadow
is intentionally near-invisible on the #121212 bg kept for consistency. */
.dark .rspress-nav {
box-shadow: 0 2px 8px rgb(var(--arco-gray-1) / 0.6);
}
/**
* Docs content typography = the BiSheng type spec, applied to itself.
*
* This spec site must render by its own rules, so the doc body pulls the SAME
* semantic type tokens (defined in src/style.css :root, driven by
* design-token.cjs) the app ships instead of rspress's theme defaults
* (16px body, #213547 text). Result: the page demonstrates the 14px
* `text-body` baseline, 主文字色 #1D2129 (--text-1), and the medium-weight
* (500) heading ramp it documents. `important` because rspress's own
* `.rspress-doc h*` rules share the same specificity.
*/
.rspress-doc {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
color: rgb(var(--text-1)) !important;
font-size: var(--text-body) !important;
line-height: var(--leading-body) !important;
}
.rspress-doc p,
.rspress-doc li,
.rspress-doc td,
.rspress-doc th {
font-size: var(--text-body) !important;
line-height: var(--leading-body) !important;
/* Base text color only (NOT !important): tables set per-cell muted colors
(text-2/3/4) via inline style those must win. This rule still provides
the --text-1 default and beats rspress's non-important theme color. */
color: rgb(var(--text-1));
font-weight: 400;
}
/* Blockquote = supplementary note: body size, muted (secondary) text so it
reads as a softer aside beside the main copy. */
.rspress-doc blockquote,
.rspress-doc blockquote p {
font-size: var(--text-body) !important;
line-height: var(--leading-body) !important;
color: rgb(var(--text-2)) !important;
font-weight: 400;
}
/* Heading ramp — spec sizes + medium (500) weight (600/700 are banned). */
.rspress-doc h1 {
font-size: var(--text-h1) !important;
line-height: var(--leading-h1) !important;
font-weight: 500 !important;
color: rgb(var(--text-1)) !important;
}
.rspress-doc h2 {
font-size: var(--text-h2) !important;
line-height: var(--leading-h2) !important;
font-weight: 500 !important;
color: rgb(var(--text-1)) !important;
}
.rspress-doc h3 {
font-size: var(--text-h3) !important;
line-height: var(--leading-h3) !important;
font-weight: 500 !important;
color: rgb(var(--text-1)) !important;
}
.rspress-doc h4,
.rspress-doc h5,
.rspress-doc h6 {
font-size: var(--text-h4) !important;
line-height: var(--leading-h4) !important;
font-weight: 500 !important;
color: rgb(var(--text-1)) !important;
}
/* IDs / code / logs use the mono stack (font-family-mono). */
.rspress-doc code,
.rspress-doc pre,
.rspress-doc kbd {
font-family:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
monospace;
}
/* Spec tables span the full content column. rspress ships the doc table as
display:block with a max-content cap, which shrinks these narrow token
tables to their content width; force them back to the column width. */
.rspress-doc table {
display: table !important;
width: 100% !important;
}
/* No zebra striping: rspress paints even rows with --rp-c-bg-soft. Invisible
on white (#F7F8FA) but obvious on the dark ramp (#17171A vs #121212 page).
Spec tables use uniform row backgrounds in BOTH themes. */
.rspress-doc table tr,
.rspress-doc table tr:nth-child(2n) {
background-color: transparent !important;
}
/* ONE border color for every table rule line. rspress's MDX component map
re-renders th/td with a Tailwind `border` class whose default color comes
from a different palette (#d1d1d1 in light) mixing with our token'd
inline borders inside the same table. Force everything to --border-base. */
.rspress-doc table,
.rspress-doc tr,
.rspress-doc th,
.rspress-doc td {
border-color: rgb(var(--border-base)) !important;
/* rspress puts a color transition on `tr` (th/td have none), so the row
border lags ~0.2s behind on theme switch and reads as a flicker on the
header rule line. Flip in lockstep instead. */
transition: none !important;
}
/* Palette strips on the color spec page same look & interaction as the
gallery PaletteRow (src/pages/_gallery/sections/ColorSection.tsx): seamless
flat swatches, hover lifts (scale + shadow), role label fades in on hover.
Own classes (not Tailwind JIT utilities) so the docs build never depends on
which app files the content scan happened to pick up. */
.bs-swatch {
position: relative;
display: flex;
flex: 1 1 0%;
min-width: 0;
height: 144px;
padding: 14px;
flex-direction: column;
justify-content: space-between;
cursor: default;
transition:
transform 0.2s ease-out,
box-shadow 0.2s ease-out;
}
.bs-swatch:hover {
z-index: 10;
transform: scale(1.06);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.bs-swatch-usage {
font-size: 11px;
line-height: 1.4;
opacity: 0;
transition: opacity 0.2s;
}
.bs-swatch:hover .bs-swatch-usage {
opacity: 1;
}
/* Dual-theme hex label: only the CURRENT theme's value is shown; flips with
the .dark class instantly (pure CSS, no re-render). */
.bs-hex-dark {
display: none;
}
.dark .bs-hex-light {
display: none;
}
.dark .bs-hex-dark {
display: inline;
}
/**
* Site chrome (top nav / left sidebar / right TOC) drive rspress's own
* theme tokens (--rp-*) from the BiSheng design tokens instead of rspress
* defaults (#213547 text, #0095ff brand, Inter, #fff bg).
*
* `!important` because rspress redeclares --rp-* under its dark theme; our
* source tokens (--text-*, --brand-*, --arco-gray-*, --background) ALREADY
* adapt light/dark (and bluegreen brand), so one value wins in every theme
* and the chrome follows the same system the app + docs body use.
*/
:root {
/* Side rails narrowed (rspress defaults: sidebar 320, aside/TOC 268) to
hand the freed width to the center content column. */
--rp-sidebar-width: 256px !important;
--rp-aside-width: 232px !important;
/* Neutral text */
--rp-c-text-1: rgb(var(--text-1)) !important;
--rp-c-text-2: rgb(var(--text-2)) !important;
--rp-c-text-3: rgb(var(--text-3)) !important;
--rp-c-text-4: rgb(var(--text-4)) !important;
/* Surfaces (page + nav/sidebar share --rp-c-bg; separated by dividers) */
--rp-c-bg: rgb(var(--bg-page)) !important;
--rp-c-bg-alt: rgb(var(--arco-gray-1)) !important;
--rp-c-bg-soft: rgb(var(--arco-gray-1)) !important;
--rp-c-bg-mute: rgb(var(--arco-gray-2)) !important;
/* Dividers / borders ONE color for every rule line (spec: tables must
not mix border grays; gray-2 vs gray-3 diverge visibly on the dark ramp) */
--rp-c-divider: rgb(var(--border-base)) !important;
--rp-c-divider-light: rgb(var(--border-base)) !important;
/* Brand — active sidebar/TOC item, links: follows blue⇄green theme */
--rp-c-brand: rgb(var(--brand-500)) !important;
--rp-c-brand-dark: rgb(var(--brand-600)) !important;
--rp-c-brand-darker: rgb(var(--brand-700)) !important;
--rp-c-brand-light: rgb(var(--brand-400)) !important;
--rp-c-brand-lighter: rgb(var(--brand-300)) !important;
--rp-c-brand-tint: rgb(var(--brand-500) / 0.12) !important;
/* Type stacks = BiSheng font-family tokens (system + CJK) */
--rp-font-family-base:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif !important;
--rp-font-family-mono:
ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono",
monospace !important;
}
/* Chrome font sizes BiSheng type scale (rspress hardcodes 16px). Containers
+ the stable rspress link classes; sizes flow to inner text that inherits. */
.rspress-nav,
.rspress-nav a,
.rspress-nav span {
font-size: var(--text-body) !important;
}
.rspress-sidebar {
font-size: var(--text-body) !important;
}
.rspress-sidebar-section-header {
font-size: var(--text-body-sm) !important;
}
.rspress-aside,
.aside-link-text {
font-size: var(--text-body-sm) !important;
}
@@ -0,0 +1,38 @@
/**
* Tailwind config for the DOCS SITE only (rspress) proves design-token.js is
* consumable from a Tailwind `theme` field without touching the live app config.
*
* It imports the app's tailwind.config.cjs READ-ONLY and layers the SSOT's
* `tailwindTheme` on top, so:
* every existing app utility (blue-*, text-body, bg-fill-1 ) still works in demos;
* the new SSOT-driven semantic utilities (text-text-title, bg-fill-hover ) exist too.
*
* The app's tailwind.config.cjs is unchanged; when the team is ready, the same two
* lines (`require('./src/design-token')` + spread) can move into it verbatim.
*/
const app = require('./tailwind.config.cjs');
const { tailwindTheme, TEXT, FILL, BORDER, BG } = require('./src/design-token.cjs');
// Safelist the SSOT-driven semantic utilities so they always exist in the docs
// build (Tailwind JIT would otherwise only emit classes it literally finds in
// scanned source). This both guarantees the utilities are available to spec /
// demo authors and proves this config — not the app's — is the one running.
const safelist = [
...TEXT.map((t) => `text-text-${t.name}`),
...FILL.map((f) => `bg-fill-${f.name}`),
...BORDER.map((b) => `border-border-${b.name}`),
...BG.map((b) => `bg-bg-${b.name}`),
];
module.exports = {
...app,
safelist,
theme: {
...app.theme,
extend: {
...app.theme.extend,
colors: { ...app.theme.extend.colors, ...tailwindTheme.colors },
fontSize: { ...app.theme.extend.fontSize, ...tailwindTheme.fontSize },
},
},
};