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"]' } }, ], }; 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) && ( - - )} -
- ))} -
- )} + + ))} + + )} + ); }