From f0cd9a555ded40e6bf09500e911084098cf773d2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:54:12 +0800 Subject: [PATCH] feat(web): unify console layout and refine skill card presentation Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/frontend-analytics-manifest.json | 150 +++++++++--------- docs/skillhub/en/guide/skill-publish.md | 2 + docs/skillhub/guide/skill-publish.md | 2 + .../improve-my-skills-management/design.md | 17 +- .../improve-my-skills-management/proposal.md | 9 +- .../specs/my-skills-management/spec.md | 33 +++- .../improve-my-skills-management/tasks.md | 3 + web/src/app/layout-console.test.tsx | 71 +++++++++ web/src/app/layout-main-content.test.ts | 49 +++--- web/src/app/layout-main-content.ts | 33 ++-- web/src/app/layout.tsx | 7 +- web/src/features/skill/skill-avatar.test.tsx | 28 ++++ web/src/features/skill/skill-avatar.tsx | 22 ++- web/src/pages/dashboard.test.tsx | 7 +- web/src/pages/dashboard.tsx | 77 ++++----- .../dashboard/my-skills-interactions.test.tsx | 26 ++- web/src/pages/dashboard/my-skills.module.css | 10 ++ web/src/pages/dashboard/my-skills.tsx | 23 ++- web/src/pages/settings/accounts.tsx | 4 +- .../components/dashboard-page-header.tsx | 8 +- 20 files changed, 387 insertions(+), 194 deletions(-) create mode 100644 web/src/app/layout-console.test.tsx diff --git a/docs/frontend-analytics-manifest.json b/docs/frontend-analytics-manifest.json index 107800167..deb199fef 100644 --- a/docs/frontend-analytics-manifest.json +++ b/docs/frontend-analytics-manifest.json @@ -18,7 +18,7 @@ {"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false}, {"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false}, {"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":5,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false}, - {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":97,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, {"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":54,"export":"MySkillsPage"},"guarded":true,"roleProtected":false}, {"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false}, {"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false}, @@ -59,55 +59,55 @@ {"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true} ], "definitions": [ - {"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]}, - {"file":"web/src/app/layout.tsx","line":90,"ids":["skill.publish.open"]}, - {"file":"web/src/app/layout.tsx","line":91,"ids":["navigation.skills.open"]}, - {"file":"web/src/app/layout.tsx","line":92,"ids":["navigation.github.open"]}, - {"file":"web/src/app/layout.tsx","line":93,"ids":["navigation.suites.open"]}, - {"file":"web/src/app/layout.tsx","line":94,"ids":["navigation.dashboard.open"]}, + {"file":"web/src/app/layout.tsx","line":88,"ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":89,"ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":90,"ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":91,"ids":["navigation.github.open"]}, + {"file":"web/src/app/layout.tsx","line":92,"ids":["navigation.suites.open"]}, + {"file":"web/src/app/layout.tsx","line":93,"ids":["navigation.dashboard.open"]}, {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":26,"ids":["namespace.skills.open"]}, {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":27,"ids":["namespace.members.open"]}, {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":28,"ids":["namespace.reviews.open"]}, {"file":"web/src/features/namespace/namespace-management-nav.tsx","line":29,"ids":["namespace.settings.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.profile.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":42,"ids":["account.security.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":43,"ids":["notification.settings.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.skills"]}, - {"file":"web/src/pages/dashboard.tsx","line":50,"ids":["navigation.owned.suites"]}, - {"file":"web/src/pages/dashboard.tsx","line":51,"ids":["namespace.list.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]}, - {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]}, - {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]} + {"file":"web/src/pages/dashboard.tsx","line":40,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.security.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":42,"ids":["notification.settings.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":48,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":50,"ids":["namespace.list.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":51,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.subscriptions.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":53,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":54,"ids":["review.progress.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":60,"ids":["governance.center.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":61,"ids":["report.queue.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["github.sync.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":86,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.security.open"]} ], "sites": [ - {"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, - {"file":"web/src/app/layout.tsx","line":139,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, - {"file":"web/src/app/layout.tsx","line":159,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]}, - {"file":"web/src/app/layout.tsx","line":175,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, - {"file":"web/src/app/layout.tsx","line":193,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, - {"file":"web/src/app/layout.tsx","line":251,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, - {"file":"web/src/app/layout.tsx","line":252,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, - {"file":"web/src/app/layout.tsx","line":253,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, - {"file":"web/src/app/layout.tsx","line":260,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, - {"file":"web/src/app/layout.tsx","line":261,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, - {"file":"web/src/app/layout.tsx","line":262,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, - {"file":"web/src/app/layout.tsx","line":269,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]}, - {"file":"web/src/app/layout.tsx","line":270,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]}, - {"file":"web/src/app/layout.tsx","line":271,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]}, - {"file":"web/src/app/layout.tsx","line":272,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]}, - {"file":"web/src/app/layout.tsx","line":279,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]}, - {"file":"web/src/app/layout.tsx","line":280,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, - {"file":"web/src/app/layout.tsx","line":281,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, + {"file":"web/src/app/layout.tsx","line":125,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, + {"file":"web/src/app/layout.tsx","line":138,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":158,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]}, + {"file":"web/src/app/layout.tsx","line":174,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]}, + {"file":"web/src/app/layout.tsx","line":192,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/app/layout.tsx","line":250,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]}, + {"file":"web/src/app/layout.tsx","line":251,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]}, + {"file":"web/src/app/layout.tsx","line":252,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]}, + {"file":"web/src/app/layout.tsx","line":259,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]}, + {"file":"web/src/app/layout.tsx","line":260,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]}, + {"file":"web/src/app/layout.tsx","line":261,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]}, + {"file":"web/src/app/layout.tsx","line":268,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]}, + {"file":"web/src/app/layout.tsx","line":269,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]}, + {"file":"web/src/app/layout.tsx","line":270,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]}, + {"file":"web/src/app/layout.tsx","line":271,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]}, + {"file":"web/src/app/layout.tsx","line":278,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]}, + {"file":"web/src/app/layout.tsx","line":279,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]}, + {"file":"web/src/app/layout.tsx","line":280,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]}, {"file":"web/src/features/auth/auth-shell.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, {"file":"web/src/features/auth/auth-shell.tsx","line":28,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, {"file":"web/src/features/auth/login-button.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.oauth.request"]}, @@ -547,34 +547,34 @@ {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":138,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.open"]}, {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":148,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.list.paginate"]}, {"file":"web/src/pages/dashboard/my-namespaces.tsx","line":157,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":362,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":387,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":391,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":364,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":389,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]}, {"file":"web/src/pages/dashboard/my-skills.tsx","line":393,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":405,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":423,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":457,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":481,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":507,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":511,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":514,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":518,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":520,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":523,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":525,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":528,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":562,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":606,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":606,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":648,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, - {"file":"web/src/pages/dashboard/my-skills.tsx","line":648,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":395,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":407,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":425,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":459,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":483,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":509,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":513,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":516,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":520,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":522,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":525,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":527,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":530,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":569,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":578,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":600,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":600,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":613,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":613,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":629,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":629,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":642,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":642,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":655,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]}, + {"file":"web/src/pages/dashboard/my-skills.tsx","line":655,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]}, {"file":"web/src/pages/dashboard/my-suites.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]}, {"file":"web/src/pages/dashboard/my-suites.tsx","line":57,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.owned.search"]}, {"file":"web/src/pages/dashboard/my-suites.tsx","line":63,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.query.clear"]}, @@ -766,10 +766,10 @@ {"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]}, {"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, {"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, - {"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, - {"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, - {"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":107,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":153,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, + {"file":"web/src/pages/dashboard.tsx","line":171,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":222,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, {"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]}, {"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, {"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]}, diff --git a/docs/skillhub/en/guide/skill-publish.md b/docs/skillhub/en/guide/skill-publish.md index 4f6bdfc82..f459b4e30 100644 --- a/docs/skillhub/en/guide/skill-publish.md +++ b/docs/skillhub/en/guide/skill-publish.md @@ -35,6 +35,8 @@ At `/dashboard/skills`, submit a keyword and combine namespace and status capsul Select a card title to open its actual detail page; returning preserves filters, page and scroll position. **Update** opens the existing update form. **More** exposes visibility, review withdrawal, promotion or archive actions according to the current permissions and state. Changing the frontend entry does not grant permissions; owners who have left a namespace remain read-only. When a published version and a pending change coexist, one main status describes the change while the effective version or visibility remains separately identified. +Cards show at most two lines of a long description; open details for the complete content. Promotion relationships, visibility requests, and read-only notices share a supplementary area, with longer notices allowed to wrap. Skills without an uploaded avatar use a stable soft color based on their coordinate: refreshing or changing a display name does not change it. Uploaded images remain preferred. + Version comparison opens in a centered dialog and only compares accessible published versions; at least two are required. Comparing and closing do not change publication state. Details show content and comparison shows differences; neither replaces installation and execution checks. ### Recovering from Submission Problems diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 0f25acdda..98662421e 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -35,6 +35,8 @@ my-skill/ 卡片标题进入技能详情;返回列表时保留筛选、页码和滚动位置。“更新”进入原更新表单;“更多”菜单按当前权限和状态提供可见范围、撤回审核、推广或归档等操作。不可用操作不能通过更换前端入口获得权限;退出命名空间的所有者继续只读。已发布版本和待审变更共存时,主状态说明本次变更,当前生效版本或范围仍独立标注。 +长说明在卡片中最多显示两行,完整内容可在详情查看;推广关联、可见范围申请和只读提示集中展示,较长提示允许换行。未上传头像的技能按技能坐标显示稳定的柔和配色,刷新或更改展示名不换色;已上传图片继续优先显示。 + 版本比较在居中弹窗内进行,仅比较有权读取的已发布版本;不足两个版本时无法比较。比较与关闭弹窗不改变发布状态。详情入口用于查看内容,比较入口用于检查差异;两者都不能替代实际安装和调用验证。 ### 发布失败与处理方法 diff --git a/openspec/changes/improve-my-skills-management/design.md b/openspec/changes/improve-my-skills-management/design.md index a562f176c..cc1c63ade 100644 --- a/openspec/changes/improve-my-skills-management/design.md +++ b/openspec/changes/improve-my-skills-management/design.md @@ -2,7 +2,7 @@ ## 范围与复用 -以现有 `web/src/pages/dashboard/my-skills.tsx` 为入口,复用 TanStack Router、Query、现有 shadcn/Radix 组件、Lucide 图标和国际化资源。保留当前列表请求与分页,不增加逐卡片详情查询,不引入 UI 依赖。 +以现有 `web/src/pages/dashboard/my-skills.tsx` 和 `web/src/app/layout.tsx` 为入口,复用 TanStack Router、Query、现有 shadcn/Radix 组件、Lucide 图标和国际化资源。保留当前列表请求与分页,不增加逐卡片详情查询,不引入 UI 依赖。 筛选仍以 URL 的 `q`、`namespace`、`filter`、`page` 为事实源。结果数量仅取服务端 `total`;接口未提供完整状态统计,因此不显示各状态的伪数量。清除筛选统一清除四项条件并归零页码。 @@ -39,16 +39,25 @@ 先完成一个可执行筛选切片并创建 Draft PR,再完成卡片和操作。实现收敛后执行一次集中独立 Review,随后冻结代码,形成需求追踪矩阵并运行源检查、精确提交镜像及浏览器关键业务路径。截图只证明视觉结果,不替代接口与最终状态断言。 -## 原型视觉基线与页面隔离 +## 原型视觉基线与管理页面范围 -以已确认原生 Figma 稿及最终执行生成脚本为视觉来源,原生 Medium 字重采用500;本地 HTML 仅作辅助对照;旧覆盖前的30px操作高度不作为基线。所有尺寸仅作用于“我的技能”,不改变共享组件或其他管理页。 +以已确认原生 Figma 稿及最终执行生成脚本为视觉来源,原生 Medium 字重采用500;本地 HTML 仅作辅助对照;旧覆盖前的30px操作高度不作为基线。用户追加确认将外壳和页头推广至控制台与设置页,卡片紧凑字号仅作用于技能列表,不将11px元数据字号套用到表单正文。 | 区域 | 本页基线 | 生产适配 | | --- | --- | --- | | 标题/简介/元数据/状态 | 15px/24px、12px/20px、11px/18px、10px/20px | 桌面标题列390px、可收缩,后接状态;窄屏不固定列宽且最多两行,完整名称保留于title及详情 | | 状态与图标 | 状态圆角5px、内边距0/7px、低饱和底色;元数据图标13px | 小字前景色加深到4.5:1对比;真实头像或首字回退32px,不推断技能类别 | | 卡片与操作 | 圆角12px、卡片间距12px、桌面内边距18px/20px、移动14px | 更新72×32px、更多32×32px;768px以下更新进入菜单,保留相同权限及预填参数 | -| 页面框架 | 仅/dashboard/skills使用1440px外壳,桌面右栏左右40px、顶部36px | 手机左右16px、顶部18px;仅本页侧栏添加桌面20px水平/32px顶部及移动16px内边距,公共导航及侧栏行为和其他管理页保持原样 | +| 页面框架 | 控制台及设置页共享1440px外壳、224px侧栏,桌面右栏左右40px、顶部36px | 手机左右16px、顶部18px;侧栏桌面20px水平/32px顶部及移动16px内边距,设置表单限制阅读宽度,审核详情可使用1500px宽版 | | 筛选与菜单 | 筛选控件40px、胶囊32px;菜单宽208px、圆角10px、条目36px、图标16px;发布按钮36px | 不伪造状态计数;浅色与深色分别适配 | 原型中的彩色类别头像与固定技能数据是演示样例,不能据此生成生产语义;真实技能图片、生命周期、只读状态与审核目标仍来自现有接口。短可见性标签和图标旁保留完整范围说明,长坐标可截断但保留完整悬停文本及复制操作;768px以下隐藏下载统计,桌面仍展示。公共导航高度及内容继续遵循应用外壳,不宣称整个应用逐像素复刻原型。 + +## 统一外壳、说明高度与默认头像 + +- 控制台首页删除页内重复侧栏,由公共 Layout 与其他管理页统一组织。导航过滤仅保留现有角色与能力规则;外壳和侧栏的路径判断使用同一个已解析路由,防止加载期间组合新外壳与旧内容。 +- 发布页继续使用无侧栏流程,市场、搜索、登录及公共技能详情不进入本轮管理外壳。设置表单在右栏内左对齐并限制阅读宽度;较宽审核内容以明确模式保留,不为每个页面另写位置特例。 +- 常规管理与设置页面复用统一页头。命名空间和审核资源详情本轮继承共享外壳,保留资源型标题和专用内容结构,后续随详情页风格改造处理;不得通过管理外壳下的通用 `h1` 选择器污染 Markdown 正文或公共资源组件。 +- 技能列表常规说明位预留两行;当前页任一卡片有推广关系、申请范围或只读提示时,各卡片统一预留一行补充区。没有提示的整页不保留该空行;提示可换行,手机或风险说明、头像预览允许自然增高,不使用固定高度裁切内容。完整说明保留文本提示及真实详情入口。 +- 头像组件提供可选稳定种子,以命名空间与技能标识散列映射有限柔和配色,包含明暗主题;不调用渲染期随机数,不按展示名猜测行业或类别。列表显式传入种子,既有未选用此能力的调用保持原样。真实图片优先,失败时使用相同种子的首字回退。 +- 长短说明样例使用独立命名前缀,通过真实上传和现有审核流程建立;不得修改已有版本差异、状态和分页回归所依赖的数据。验收比较元数据对齐、提示集中、截断后的完整内容可达性,以及刷新或改名后的默认头像一致性。 diff --git a/openspec/changes/improve-my-skills-management/proposal.md b/openspec/changes/improve-my-skills-management/proposal.md index 0b68b68b1..d29497b55 100644 --- a/openspec/changes/improve-my-skills-management/proposal.md +++ b/openspec/changes/improve-my-skills-management/proposal.md @@ -1,9 +1,11 @@ -# 优化“我的技能”管理页面 +# 统一控制台布局并优化“我的技能”管理页面 ## Why 现有列表将更新、可见范围和治理动作平铺在卡片中,长名称、状态及元数据容易争抢空间。用户已确认独立卡片、统一筛选面板、权限感知的更多菜单,以及跳转详情、弹窗比较版本的交互方向。 +单页原型落地后,用户进一步确认本轮统一控制台与设置页的共享外壳。控制台首页自行组织侧栏、子页使用不同宽度框架,造成跨页标题和侧栏位置不一致;列表还需覆盖长说明、状态补充提示及稳定多色的默认头像。 + ## What Changes - 统一搜索、命名空间、胶囊状态筛选与结果总数,保留 URL 筛选和分页。 @@ -11,7 +13,10 @@ - 保留“更新”主要操作,将次要操作集中到更多菜单,遵守现有服务端权限和状态条件。 - 进入真实技能详情后支持返回列表上下文;版本比较采用居中弹窗。 - 补齐中英文本、键盘操作、窄屏及深色主题验证。 +- 将共享外壳用于控制台首页、带侧栏的管理子页和设置页,统一侧栏、内容起点、中性背景及页头;表单保留阅读宽度、审核详情保留宽版。 +- 说明区统一预留两行,当前页存在状态补充提示时统一预留补充区;保留完整的权限与审核信息,不强制裁切风险提示。 +- 默认技能头像按稳定标识选取柔和明暗配色,真实上传图片优先;以独立真实技能样例验证长短说明的显示。 ## Impact -仅调整 Web 管理页面及其复用组件、测试和文档。沿用现有接口,不修改数据库、发布审核流程、权限判定和部署配置。PR #1043 的介绍字段缺陷单独处理;本变更不宣称解决该 PR 的业务问题。 +调整 Web 控制台与设置页的共享布局、技能卡片及其复用组件、测试和文档。沿用现有接口,不修改数据库、发布审核流程、权限判定和部署配置。市场、搜索、登录和公共技能详情不在本轮重设计范围;发布页保留无侧栏专用流程。PR #1043 的介绍字段缺陷单独处理;本变更不宣称解决该 PR 的业务问题。 diff --git a/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md b/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md index f509d72a4..d081d291a 100644 --- a/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md +++ b/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md @@ -16,6 +16,11 @@ - **THEN** 卡片用一个主状态说明待审变更,同时保留当前生效版本或范围信息 - **AND** 不改变公众查看已发布版本的规则 +#### Scenario: 长短说明与补充提示混合 +- **WHEN** 当前页同时包含短说明、两行以上说明,以及推广关联、申请范围或只读提示的技能 +- **THEN** 常规信息区预留两行说明,补充提示集中在同一区域,当前页需要时各卡片统一预留该区域 +- **AND** 完整说明仍可通过既有详情和完整文本提示查看,较长风险说明及待审头像允许自然展开,不裁掉关键内容 + ### Requirement: REQ-MYS-03 权限感知的操作菜单 系统 SHALL 保留“更新”主要操作,并将可用的次要操作放入带图标、可访问名称及键盘操作的更多菜单。系统 SHALL 保持现有操作条件、互斥关系与确认步骤,不增加授权能力。 @@ -53,7 +58,31 @@ - **AND** 原行保留时恢复原入口;用户已主动聚焦其他控件时不抢夺焦点 -#### Scenario: 原型视觉基线与其他页面隔离 +#### Scenario: 原型视觉基线与页面职责 - **WHEN** 用户打开“我的技能”列表 - **THEN** 卡片、筛选、标签和图标按设计文档的本页量化视觉基线展示,窄屏保留菜单内更新操作 -- **AND** 仅该路由使用宽版内容框架,其他管理页及公共导航不受影响 +- **AND** 控制台与设置页使用统一管理外壳,公共页面及独立发布流程保留各自布局 + +### Requirement: REQ-MYS-07 稳定的默认头像配色 +系统 SHALL 支持按稳定技能标识选择有限的柔和明暗配色,不在渲染时随机换色,不根据技能名称推断类别。真实上传图片 SHALL 优先于默认头像。 + +#### Scenario: 刷新、改名与图片失败 +- **WHEN** 同一技能刷新列表、改变展示名,或头像图片加载失败 +- **THEN** 在稳定标识不变时默认头像保持相同配色,加载失败时回到该配色 +- **AND** 没有传入新配色选项的既有头像调用保持原有行为 + +### Requirement: REQ-DASH-01 共享管理外壳 +系统 SHALL 将控制台首页、带侧栏的管理子页及设置页交由同一外壳提供侧栏、内容起点、背景和页头层级;首页不得再嵌套一套侧栏。表单可限制阅读宽度,审核详情可使用声明的宽版模式。 + +#### Scenario: 跨页面切换 +- **WHEN** 用户在控制台首页、技能、套件、命名空间、收藏、订阅、Token、审核与设置页面间切换 +- **THEN** 同一视口下侧栏及右侧标题起点保持一致,不因空状态或页面切换重复加边距 +- **AND** 窄屏保留现有可操作的控制台导航,宽表格或长内容不能使整页横向溢出 + +### Requirement: REQ-DASH-02 导航权限与专用流程保持 +系统 SHALL 沿用现有角色、能力及导航入口规则;统一外壳不得扩大访问能力或改变数据请求、审核和发布行为。 + +#### Scenario: 不同身份与专用页面 +- **WHEN** 普通用户、只读所有者或平台管理身份进入管理页面,或跳转到发布、搜索、登录及公共详情 +- **THEN** 现有权限控制及导航能力保持,发布流程不重复显示侧栏,公共页面不套入管理外壳 +- **AND** 路由加载期间以一致的已解析路由选择外壳,避免旧内容与新外壳错误组合 diff --git a/openspec/changes/improve-my-skills-management/tasks.md b/openspec/changes/improve-my-skills-management/tasks.md index 0e117a43b..dd63d9562 100644 --- a/openspec/changes/improve-my-skills-management/tasks.md +++ b/openspec/changes/improve-my-skills-management/tasks.md @@ -8,6 +8,8 @@ - [x] 独立卡片、主要操作与更多菜单 - [x] 详情返回上下文与居中版本比较 - [x] 中英文、窄屏与键盘行为 +- [ ] 将共享外壳推广到控制台与设置页,统一首页侧栏与页头 +- [ ] 对齐长短说明及状态补充区,补稳定多色默认头像 ## 3. 验证与交付 - [x] 筛选首片针对性测试与静态检查 @@ -16,3 +18,4 @@ - [ ] 原型视觉专项:图标、字号、标签、路由框架及明暗/移动端浏览器对照 - [ ] 冻结代码后的需求场景矩阵、源检查与精确提交运行时验证 - [ ] 保留人工预览入口并交付验证结果 +- [ ] 独立真实长说明样例及跨页布局、权限和响应式验收 diff --git a/web/src/app/layout-console.test.tsx b/web/src/app/layout-console.test.tsx new file mode 100644 index 000000000..95773e7e5 --- /dev/null +++ b/web/src/app/layout-console.test.tsx @@ -0,0 +1,71 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react' +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +let pathname = '/dashboard' +let resolvedPathname = '/dashboard' +let roles: string[] = [] +let canChangePassword = false + +vi.mock('@tanstack/react-router', () => ({ + Outlet: () =>
page-content
, + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => {children}, + useRouterState: () => ({ pathname, resolvedPathname }), +})) +vi.mock('react-i18next', async () => ({ + ...await vi.importActual('react-i18next'), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en', resolvedLanguage: 'en' } }), +})) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ user: { userId: 'test-reader', displayName: 'Reader', platformRoles: roles, canChangePassword }, isLoading: false }) })) +vi.mock('@/shared/components/language-switcher', () => ({ LanguageSwitcher: () => null })) +vi.mock('@/shared/components/theme-toggle', () => ({ ThemeToggle: () => null })) +vi.mock('@/shared/components/user-menu', () => ({ UserMenu: () => null })) +vi.mock('@/features/notification/notification-bell', () => ({ NotificationBell: () => null })) + +import { Layout } from './layout' + +beforeEach(() => { pathname = '/dashboard'; resolvedPathname = pathname; roles = []; canChangePassword = false }) +afterEach(cleanup) + +describe('shared console layout composition', () => { + it.each(['/dashboard', '/dashboard/skills', '/dashboard/tokens', '/settings/profile'])('renders exactly one sidebar beside the content on %s', (path) => { + pathname = path + resolvedPathname = path + render() + expect(screen.getAllByRole('complementary')).toHaveLength(1) + expect(within(screen.getByRole('complementary')).queryByTestId('outlet')).toBeNull() + expect(screen.getByRole('main').contains(screen.getByTestId('outlet'))).toBe(true) + }) + + it.each(['/dashboard/publish', '/search', '/login', '/space/team/skill'])('does not add console navigation to %s', (path) => { + pathname = path + resolvedPathname = path + render() + expect(screen.queryByRole('complementary')).toBeNull() + }) + + it('retains role and account-capability filtering in the centralized sidebar', () => { + const view = render() + const sidebar = screen.getByRole('complementary') + expect(within(sidebar).queryByText('sidebar.governance')).toBeNull() + expect(within(sidebar).queryByText('sidebar.githubSync')).toBeNull() + expect(within(sidebar).queryByText('sidebar.security')).toBeNull() + roles = ['SKILL_ADMIN'] + canChangePassword = true + view.rerender() + expect(within(sidebar).getByText('sidebar.governance')).toBeTruthy() + expect(within(sidebar).queryByText('sidebar.githubSync')).toBeNull() + expect(within(sidebar).getByText('sidebar.security')).toBeTruthy() + roles = ['SUPER_ADMIN'] + view.rerender() + expect(within(sidebar).getByText('sidebar.githubSync')).toBeTruthy() + }) + + it('keeps the previous console shell while navigation to standalone publish is pending', () => { + pathname = '/dashboard/publish' + resolvedPathname = '/dashboard' + render() + expect(screen.getAllByRole('complementary')).toHaveLength(1) + }) +}) diff --git a/web/src/app/layout-main-content.test.ts b/web/src/app/layout-main-content.test.ts index e43d2fd3b..3b34ed326 100644 --- a/web/src/app/layout-main-content.test.ts +++ b/web/src/app/layout-main-content.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest' import { AUTH_MAIN_CLASS_NAME, - CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + PUBLISH_CONTENT_CLASS_NAME, CENTERED_MAIN_CLASS_NAME, CENTERED_SEARCH_CONTENT_CLASS_NAME, DEFAULT_MAIN_CLASS_NAME, - MY_SKILLS_MAIN_CLASS_NAME, - MY_SKILLS_CONTENT_CLASS_NAME, - MY_SKILLS_SIDEBAR_ROW_CLASS_NAME, - MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME, + CONSOLE_MAIN_CLASS_NAME, + CONSOLE_CONTENT_CLASS_NAME, + CONSOLE_SIDEBAR_ROW_CLASS_NAME, + CONSOLE_SIDEBAR_CONTENT_CLASS_NAME, SPLIT_AUTH_MAIN_CLASS_NAME, getAppMainContentLayout, resolveAppMainContentPathname, @@ -32,24 +32,35 @@ describe('getAppMainContentLayout', () => { expect(layout.contentClassName).toContain('max-w-[1200px]') }) - it.each(['/dashboard/tokens', '/dashboard/stars', '/settings/profile', '/dashboard/skills/unrelated'])('keeps %s in the existing dashboard frame', (pathname) => { - const layout = getAppMainContentLayout(pathname) - - expect(layout).toEqual({ - mainClassName: CENTERED_MAIN_CLASS_NAME, - contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + it.each(['/dashboard', '/dashboard/skills', '/dashboard/tokens', '/dashboard/stars', '/dashboard/namespaces', '/dashboard/suites', '/dashboard/reviews', '/dashboard/governance'])('shares the console frame and sidebar gutters on %s', (pathname) => { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: CONSOLE_MAIN_CLASS_NAME, + contentClassName: CONSOLE_CONTENT_CLASS_NAME, + sidebarRowClassName: CONSOLE_SIDEBAR_ROW_CLASS_NAME, + sidebarContentClassName: CONSOLE_SIDEBAR_CONTENT_CLASS_NAME, }) - expect(layout.contentClassName).toContain('max-w-[1100px]') }) - it('isolates the prototype-width frame and sidebar gutters to My Skills', () => { - expect(getAppMainContentLayout('/dashboard/skills')).toEqual({ - mainClassName: MY_SKILLS_MAIN_CLASS_NAME, - contentClassName: MY_SKILLS_CONTENT_CLASS_NAME, - sidebarRowClassName: MY_SKILLS_SIDEBAR_ROW_CLASS_NAME, - sidebarContentClassName: MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME, + it.each(['/settings/profile', '/settings/security', '/settings/notifications', '/settings/accounts'])('keeps %s forms readable without shifting their content origin', (pathname) => { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: CONSOLE_MAIN_CLASS_NAME, + contentClassName: CONSOLE_CONTENT_CLASS_NAME, + sidebarRowClassName: CONSOLE_SIDEBAR_ROW_CLASS_NAME, + sidebarContentClassName: CONSOLE_SIDEBAR_CONTENT_CLASS_NAME, + pageClassName: 'w-full max-w-3xl', }) - expect(MY_SKILLS_CONTENT_CLASS_NAME).toContain('max-w-[1440px]') + }) + + it('keeps publishing a standalone flow without console navigation', () => { + expect(getAppMainContentLayout('/dashboard/publish')).toEqual({ + mainClassName: CENTERED_MAIN_CLASS_NAME, + contentClassName: PUBLISH_CONTENT_CLASS_NAME, + }) + }) + + it.each(['/dashboard/reviews/42', '/dashboard/namespaces/team/reviews/42'])('retains a wider console frame for %s', (pathname) => { + expect(getAppMainContentLayout(pathname).contentClassName).toContain('max-w-[1500px]') + expect(getAppMainContentLayout(pathname).sidebarRowClassName).toBe(CONSOLE_SIDEBAR_ROW_CLASS_NAME) }) it('uses the full-bleed split layout for login and registration', () => { diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts index ef64f32fd..50f9c16d8 100644 --- a/web/src/app/layout-main-content.ts +++ b/web/src/app/layout-main-content.ts @@ -4,11 +4,11 @@ export const AUTH_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 pb-4 sm:px-6 md:p export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10' export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' -export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]' -export const MY_SKILLS_MAIN_CLASS_NAME = 'flex-1 relative z-10 bg-background' -export const MY_SKILLS_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1440px]' -export const MY_SKILLS_SIDEBAR_ROW_CLASS_NAME = 'flex flex-col lg:flex-row gap-0 [&>aside]:p-4 lg:[&>aside]:px-5 lg:[&>aside]:pt-8 lg:[&>aside]:pb-6 lg:[&>aside]:border-r lg:[&>aside]:border-border/40' -export const MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME = 'flex-1 min-w-0 px-4 pb-7 pt-[18px] lg:px-10 lg:pt-9' +export const PUBLISH_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]' +export const CONSOLE_MAIN_CLASS_NAME = 'flex-1 relative z-10 bg-background' +export const CONSOLE_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1440px]' +export const CONSOLE_SIDEBAR_ROW_CLASS_NAME = 'flex flex-col lg:flex-row gap-0 [&>aside]:p-4 lg:[&>aside]:px-5 lg:[&>aside]:pt-8 lg:[&>aside]:pb-6 lg:[&>aside]:border-r lg:[&>aside]:border-border/40' +export const CONSOLE_SIDEBAR_CONTENT_CLASS_NAME = 'flex-1 min-w-0 px-4 pb-7 pt-[18px] lg:px-10 lg:pt-9' export const REVIEW_DETAIL_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1500px]' export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const @@ -20,6 +20,7 @@ interface AppMainContentLayout { contentClassName: string sidebarRowClassName?: string sidebarContentClassName?: string + pageClassName?: string } export function resolveAppMainContentPathname( @@ -58,26 +59,22 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout } } - if (/^\/dashboard\/(?:reviews\/[^/]+|namespaces\/[^/]+\/reviews\/[^/]+)$/.test(pathname)) { + // Publishing is a standalone flow and intentionally has no console sidebar. + if (pathname === '/dashboard/publish') { return { mainClassName: CENTERED_MAIN_CLASS_NAME, - contentClassName: REVIEW_DETAIL_CONTENT_CLASS_NAME, - } - } - - if (pathname === '/dashboard/skills') { - return { - mainClassName: MY_SKILLS_MAIN_CLASS_NAME, - contentClassName: MY_SKILLS_CONTENT_CLASS_NAME, - sidebarRowClassName: MY_SKILLS_SIDEBAR_ROW_CLASS_NAME, - sidebarContentClassName: MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME, + contentClassName: PUBLISH_CONTENT_CLASS_NAME, } } if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) { + const isReviewDetail = /^\/dashboard\/(?:reviews\/[^/]+|namespaces\/[^/]+\/reviews\/[^/]+)$/.test(pathname) return { - mainClassName: CENTERED_MAIN_CLASS_NAME, - contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + mainClassName: CONSOLE_MAIN_CLASS_NAME, + contentClassName: isReviewDetail ? REVIEW_DETAIL_CONTENT_CLASS_NAME : CONSOLE_CONTENT_CLASS_NAME, + sidebarRowClassName: CONSOLE_SIDEBAR_ROW_CLASS_NAME, + sidebarContentClassName: CONSOLE_SIDEBAR_CONTENT_CLASS_NAME, + ...(pathname.startsWith('/settings/') ? { pageClassName: 'w-full max-w-3xl' } : {}), } } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a0c7c1b2c..1d8464f2e 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -39,8 +39,7 @@ export function Layout() { const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) const isAuthEntryRoute = AUTH_ENTRY_PATHS.includes(contentLayoutPathname as typeof AUTH_ENTRY_PATHS[number]) const isSplitAuthRoute = SPLIT_AUTH_PATHS.includes(contentLayoutPathname as typeof SPLIT_AUTH_PATHS[number]) - const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard') - const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/') + const showSidebar = mainContentLayout.sidebarRowClassName !== undefined const governanceVisible = canViewGovernanceCenter(user?.platformRoles) const filteredDashboardGroups = SIDEBAR_GROUPS .map((group) => ({ @@ -220,9 +219,9 @@ export function Layout() {
{showSidebar ? (
- +
- + {mainContentLayout.pageClassName ?
: }
) : ( diff --git a/web/src/features/skill/skill-avatar.test.tsx b/web/src/features/skill/skill-avatar.test.tsx index 95161becf..0ab21a564 100644 --- a/web/src/features/skill/skill-avatar.test.tsx +++ b/web/src/features/skill/skill-avatar.test.tsx @@ -19,4 +19,32 @@ describe('SkillAvatar', () => { fireEvent.error(image) expect(screen.getByText('W')).toBeTruthy() }) + + it('keeps the same fallback palette when a skill is renamed or remounted', () => { + const first = render() + const palette = first.container.firstElementChild!.className + first.rerender() + expect(first.container.firstElementChild!.className).toBe(palette) + expect(screen.getByText('F')).toBeTruthy() + first.unmount() + const remount = render() + expect(remount.container.firstElementChild!.className).toBe(palette) + }) + + it('uses varied stable palettes for distinct skill coordinates', () => { + const view = render(<>) + expect(new Set(Array.from(view.container.children, child => child.className)).size).toBe(3) + }) + + it('preserves approved images and returns to the same seeded palette after an image failure', () => { + const view = render() + const fallback = view.container.firstElementChild!.className + view.rerender() + const image = view.container.querySelector('img')! + expect(image.getAttribute('src')).toBe('/proxy/avatar') + expect(view.container.firstElementChild!.className).not.toBe(fallback) + fireEvent.error(image) + expect(view.container.querySelector('img')).toBeNull() + expect(view.container.firstElementChild!.className).toBe(fallback) + }) }) diff --git a/web/src/features/skill/skill-avatar.tsx b/web/src/features/skill/skill-avatar.tsx index 09b1d540a..9d755f67f 100644 --- a/web/src/features/skill/skill-avatar.tsx +++ b/web/src/features/skill/skill-avatar.tsx @@ -1,11 +1,29 @@ import { useState } from 'react' import { buildApiUrl } from '@/api/client' -export function SkillAvatar({ name, url, size = 'h-10 w-10' }: { name: string; url?: string | null; size?: string }) { +const fallbackPalettes = [ + 'bg-[#edf3ff] text-[#4d658d] dark:bg-[#202d45] dark:text-[#c0d0ed]', + 'bg-[#edf5ef] text-[#4c735b] dark:bg-[#22372c] dark:text-[#bad9c5]', + 'bg-[#f7f1e7] text-[#80643a] dark:bg-[#3b3020] dark:text-[#e5cfaa]', + 'bg-[#f2eef9] text-[#70578d] dark:bg-[#322840] dark:text-[#d3c1eb]', + 'bg-[#f9eeee] text-[#8b555b] dark:bg-[#40282d] dark:text-[#edc0c6]', + 'bg-[#eaf4f5] text-[#426e75] dark:bg-[#20363b] dark:text-[#b4d8de]', +] as const + +function getFallbackPalette(seed?: string) { + if (!seed) return 'bg-primary/10 text-primary' + let hash = 0 + for (let index = 0; index < seed.length; index += 1) { + hash = (Math.imul(hash, 31) + seed.charCodeAt(index)) | 0 + } + return fallbackPalettes[(hash >>> 0) % fallbackPalettes.length] +} + +export function SkillAvatar({ name, url, seed, size = 'h-10 w-10' }: { name: string; url?: string | null; seed?: string; size?: string }) { const [failedUrl, setFailedUrl] = useState(null) const imageUrl = url && failedUrl !== url ? buildApiUrl(url) : null return ( -