From 40388cd5a9f07ad8585d631ae64243482b8b3e4f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 20:06:53 -0700 Subject: [PATCH 1/2] fix(skin-market): stop the market dialog from resizing while loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening 浏览市场 flashed the empty state, swapped in a short "loading" line, then grew the dialog once the cards arrived — three height changes in a row, which read as a shake. The dialog now keeps one height: the modal content is a fixed-height flex column and each view (browse grid, detail, submissions) scrolls internally instead of stretching the dialog. While the first page is in flight the grid holds placeholder cards of the real card size, so the loaded state drops straight in; a refresh keeps the current cards mounted and only dims them, and 加载更多 keeps its row in place with a loading button. The loading text moves to an sr-only live region. --- .../config/components/AppearanceConfig.scss | 105 ++++++++- .../AppearanceMarketDialog.test.tsx | 38 ++++ .../components/AppearanceMarketDialog.tsx | 213 +++++++++++------- .../components/AppearanceMarketWorkflows.tsx | 128 +++++------ 4 files changed, 337 insertions(+), 147 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss index ab07b183b..b76daa043 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.scss @@ -420,19 +420,22 @@ } } +/* Fixed height: every view scrolls internally so the dialog never resizes while loading. */ .appearance-market__modal { min-height: min(720px, 78vh); + max-height: min(720px, 78vh); } .appearance-market { display: flex; flex: 1; flex-direction: column; - min-height: min(620px, 68vh); + min-height: 0; color: var(--bf-appearance-token-color-text-primary); &__nav { display: flex; + flex: 0 0 auto; gap: $size-gap-1; margin: 0 0 $size-gap-4; padding-bottom: $size-gap-2; @@ -468,8 +471,18 @@ flex-direction: column; } + &__workflow-body { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow-y: auto; + padding-right: $size-gap-1; + } + &__workflow-heading { display: flex; + flex: 0 0 auto; align-items: flex-start; justify-content: space-between; gap: $size-gap-3; @@ -543,9 +556,8 @@ &__submission-list { display: grid; + align-content: start; gap: $size-gap-2; - overflow-y: auto; - padding-right: $size-gap-1; } @media (max-width: 760px) { @@ -805,8 +817,16 @@ } } + &__browse { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + } + &__toolbar { display: grid; + flex: 0 0 auto; grid-template-columns: minmax(240px, 1fr) 150px 160px; gap: $size-gap-2; align-items: center; @@ -819,12 +839,24 @@ } } + &__results { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 1px $size-gap-1 $size-gap-2 1px; + transition: opacity $motion-fast $easing-standard; + + /* A refresh keeps the current cards in place and dims them — no reflow. */ + &--dimmed { + opacity: 0.55; + pointer-events: none; + } + } + &__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: $size-gap-3; - overflow-y: auto; - padding: 1px $size-gap-1 $size-gap-2 1px; } &__card { @@ -860,6 +892,51 @@ cursor: default; opacity: $opacity-disabled; } + + &--skeleton { + cursor: default; + pointer-events: none; + } + } + + &__skeleton-line { + position: relative; + display: block; + width: 100%; + height: 11px; + margin-top: $size-gap-1; + overflow: hidden; + border-radius: $size-radius-sm; + background: var(--bf-appearance-token-color-overlay-white-08); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0) 0%, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0.16) 48%, + rgba(var(--bf-appearance-token-color-static-white-rgb), 0) 100% + ); + transform: translateX(-100%); + animation: appearance-market-skeleton-shimmer 1.3s $easing-standard infinite; + } + + &--title { + width: 68%; + height: 14px; + margin-top: 0; + } + + &--meta { + width: 45%; + height: 10px; + } + + &--short { + width: 76%; + } } &__preview { @@ -1004,9 +1081,14 @@ &__detail { display: grid; + flex: 1; grid-template-columns: minmax(260px, 36%) minmax(0, 1fr); gap: $size-gap-5; align-items: start; + align-content: start; + min-height: 0; + overflow-y: auto; + padding-right: $size-gap-1; > .btn:first-child { grid-column: 1 / -1; @@ -1215,6 +1297,19 @@ } +@keyframes appearance-market-skeleton-shimmer { + 100% { + transform: translateX(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .appearance-market__skeleton-line::after { + animation: none; + } +} + + .appearance-card { position: relative; display: flex; diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 4610574b7..606b94e2b 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -248,6 +248,44 @@ describe('AppearanceMarketDialog', () => { expect(container.textContent).toContain('package.market.noAutoApply'); }); + it('holds the grid with placeholder cards while the first page loads', async () => { + let resolveBrowse: (page: unknown) => void = () => undefined; + mocks.browse.mockImplementation(() => new Promise(resolve => { + resolveBrowse = resolve; + })); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + + // Placeholder cards stand in for the real ones so the dialog keeps one size, + // and the empty state never flashes before the first page resolves. + expect(container.querySelectorAll('.appearance-market__card--skeleton').length) + .toBeGreaterThan(0); + expect(container.textContent).not.toContain('package.market.empty'); + + await act(async () => { + resolveBrowse({ items: [summary] }); + await Promise.resolve(); + }); + + await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night')); + expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); + }); + + it('keeps an empty result set on the empty state once loading settles', async () => { + mocks.browse.mockResolvedValue({ items: [] }); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + + await vi.waitFor(() => expect(container.textContent).toContain('package.market.empty')); + expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); + }); + it('shows the shared-account submissions and admin review workflows', async () => { const submission = { submissionId: 'submission-1', diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index 8141960ce..3d369be24 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -47,6 +47,9 @@ const SUPPORTED_CAPABILITIES = new Set([ 'background-media.v1', ]); +/** Placeholder cards keep the grid at its loaded height so the dialog never resizes mid-load. */ +const SKELETON_CARD_COUNT = 6; + interface AppearanceMarketDialogProps { isOpen: boolean; onClose: () => void; @@ -114,6 +117,8 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial const [nextCursor, setNextCursor] = useState(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); + const [appending, setAppending] = useState(false); + const [loadedOnce, setLoadedOnce] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [installing, setInstalling] = useState(false); const [error, setError] = useState(null); @@ -130,6 +135,7 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial const loadPage = useCallback(async (cursor?: string, append = false) => { const sequence = ++browseSequence.current; setLoading(true); + setAppending(append); setError(null); try { const page = await appearanceMarketAPI.browse({ ...browseRequest, cursor }); @@ -141,7 +147,11 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial setError(errorMessage(loadError)); if (!append) setItems([]); } finally { - if (sequence === browseSequence.current) setLoading(false); + if (sequence === browseSequence.current) { + setLoading(false); + setAppending(false); + setLoadedOnce(true); + } } }, [browseRequest]); @@ -428,6 +438,12 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial ); }; + // Placeholder cards stand in until the first page lands; a refresh keeps the + // current cards mounted and merely dims them. Both keep the dialog one size. + const showSkeletons = !loadedOnce || (loading && !appending && items.length === 0); + const refreshing = loading && !appending && items.length > 0; + const showEmpty = loadedOnce && !loading && items.length === 0 && !error; + return ( } size="xlarge" contentInset - contentClassName="appearance-market__modal" + contentClassName="modal__content--fill-flex appearance-market__modal" testId="appearance-market-dialog" >
{view !== 'browse' ? : detail ? renderDetail() : ( - <> +
- {items.map(item => { - const local = installedEntry(appearances, item); - const updateAvailable = Boolean( - local?.marketOrigin?.listingId === item.listingId - && item.latestRelease > local.marketOrigin.releaseNumber, - ); - return ( - - ); - })} +
+ {item.previewUrl + ? ( + retryOriginalMarketImage(event.currentTarget, item.previewUrl)} + /> + ) + : } + {t(`package.market.mode.${item.mode}`)} +
+
+ {item.name} + {item.author || item.owner.login} · v{item.packageVersion} +

{item.description}

+
+ {local && ( + + {updateAvailable + ? t('package.market.updateAvailable') + : local.localOverride + ? t('package.market.modified') + : t('package.market.installed')} + + )} + + ); + })} +
+ )} + + {showEmpty && ( +
+
+ )} + + {nextCursor && !showSkeletons && ( +
+ +
+ )}
- {!loading && items.length === 0 && !error && ( -
-
- )} - {loading &&

{t('package.market.loading')}

} - {nextCursor && !loading && ( -
- -
- )} - + {loading && {t('package.market.loading')}} +
)}
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx index dbd33fcdb..47ef8ed38 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketWorkflows.tsx @@ -390,71 +390,73 @@ export function AppearanceMarketWorkflows({ workflow }: AppearanceMarketWorkflow {renderError()} - {renderManualSubmit()} - {loading ?

{t('package.market.submissions.loading')}

- : submissions.length === 0 ? ( -
-
- ) : ( -
- {submissions.map(submission => ( -
-
- {submission.previewUrl - ? ( - retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} - /> - ) - : } -
-
-
- {submission.name || submission.slug} - - {t(`package.market.submissions.status.${submissionDisplayStatus(submission)}`)} - +
+ {renderManualSubmit()} + {loading ?

{t('package.market.submissions.loading')}

+ : submissions.length === 0 ? ( +
+
+ ) : ( +
+ {submissions.map(submission => ( +
+
+ {submission.previewUrl + ? ( + retryOriginalMarketImage(event.currentTarget, submission.previewUrl!)} + /> + ) + : } +
+
+
+ {submission.name || submission.slug} + + {t(`package.market.submissions.status.${submissionDisplayStatus(submission)}`)} + +
+

{submission.description || submission.slug}

+ + {submission.packageVersion ? `v${submission.packageVersion} · ` : ''} + {t('package.market.submissions.updated', { date: formattedDate(submission.updatedAt) })} + + {submission.rejectionReason && ( +

+ {t('package.market.submissions.rejection', { reason: submission.rejectionReason })} +

+ )}
-

{submission.description || submission.slug}

- - {submission.packageVersion ? `v${submission.packageVersion} · ` : ''} - {t('package.market.submissions.updated', { date: formattedDate(submission.updatedAt) })} - - {submission.rejectionReason && ( -

- {t('package.market.submissions.rejection', { reason: submission.rejectionReason })} -

+ {canWithdraw(submission) && ( + )} -
- {canWithdraw(submission) && ( - - )} -
- ))} -
- )} + + ))} + + )} + ); } From 501a077f00c02b22009d7b08d1ab0fcff4d99bec Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 20:13:39 -0700 Subject: [PATCH 2/2] fix(skin-market): register the new market parts in the Appearance contract marketBrowse, marketResults and the loading state are new data-bf hooks; the contract audit rejects any part or state a Skin cannot target. --- .../config/components/AppearanceConfig.appearance.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts index 0c85a628c..6ab27a1f3 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/AppearanceConfig.appearance.ts @@ -13,7 +13,8 @@ export const appearanceConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'packageDiagnosticsGroup' }, { id: 'packageDiagnosticIssue' }, { id: 'packageDiagnosticAllowedParts' }, { id: 'packageMissingSelection' }, - { id: 'marketDialog' }, { id: 'marketToolbar' }, { id: 'marketGrid' }, + { id: 'marketDialog' }, { id: 'marketToolbar' }, { id: 'marketBrowse' }, + { id: 'marketResults' }, { id: 'marketGrid' }, { id: 'marketCard' }, { id: 'marketPreview' }, { id: 'marketCardBody' }, { id: 'marketStatus' }, { id: 'marketEmpty' }, { id: 'marketError' }, { id: 'marketDetail' }, { id: 'marketDetailPreview' }, { id: 'marketDetailBody' }, @@ -31,5 +32,6 @@ export const appearanceConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'hover', selector: { kind: 'self', suffix: ':hover' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, { id: 'disabled', selector: { kind: 'self', suffix: '[data-bf-state~="disabled"]' } }, + { id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }, ], };