Skip to content

Commit f86340f

Browse files
open-design-crew[bot]Cheems
andauthored
fix(web): stabilize home examples during startup (#7037)
* fix(web): stabilize home examples during startup Keep the Home examples rail at stable geometry while Workspace discovery resolves, and reveal only the settled Workspace-scoped plugin catalog. * fix(web): preserve Home example geometry while plugins load Keep loading and settled Home example cards on one responsive size contract so plugin discovery cannot move the centered startup layout. Add browser geometry coverage at desktop, tablet, and narrow viewports. --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
1 parent d176b0d commit f86340f

6 files changed

Lines changed: 256 additions & 40 deletions

File tree

apps/web/src/components/HomeHero.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
useState,
1919
} from 'react';
2020
import { createPortal } from 'react-dom';
21+
import { VisuallyHidden } from '@open-design/components';
2122
import type {
2223
CSSProperties,
2324
DragEvent as ReactDragEvent,
@@ -2152,7 +2153,9 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
21522153
/>
21532154
) : null}
21542155

2155-
{filteredExamplePlugins.length > 0 && activeChipId ? (
2156+
{pluginsLoading ? (
2157+
<PluginPromptPresetsLoading />
2158+
) : filteredExamplePlugins.length > 0 && activeChipId ? (
21562159
<PluginPromptPresets
21572160
chipId={activeChipId}
21582161
plugins={filteredExamplePlugins}
@@ -2236,6 +2239,32 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
22362239
);
22372240
});
22382241

2242+
function PluginPromptPresetsLoading() {
2243+
const { t } = useI18n();
2244+
return (
2245+
<div
2246+
className="home-hero__prompt-examples home-hero__plugin-presets-wrap"
2247+
data-testid="home-hero-examples-loading"
2248+
aria-busy="true"
2249+
>
2250+
<div className="home-hero__prompt-examples-title">
2251+
{t('homeHero.promptExamples')}
2252+
</div>
2253+
<div className="home-hero__rail-scroller">
2254+
<div className="home-hero__plugin-presets-loading" aria-hidden="true">
2255+
{Array.from({ length: 4 }, (_, index) => (
2256+
<span className="home-hero__plugin-preset-loading" key={index}>
2257+
<span className="home-hero__plugin-preset-loading-preview" />
2258+
<span className="home-hero__plugin-preset-loading-title" />
2259+
</span>
2260+
))}
2261+
</div>
2262+
</div>
2263+
<VisuallyHidden>{t('common.loading')}</VisuallyHidden>
2264+
</div>
2265+
);
2266+
}
2267+
22392268
function PluginPromptPresets({
22402269
activePluginId,
22412270
chipId,

apps/web/src/components/HomeView.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import {
110110
currentWorkspaceAccountGeneration,
111111
useTeamProjects,
112112
useWorkspaceContext,
113+
workspaceResourceReadContext,
113114
} from '../collab/useWorkspaceContext';
114115
import { useWorkspaceInvalidation } from '../collab/workspace-events';
115116
import { useWorkspaceSnapshotActivation } from '../collab/workspace-snapshot-activation';
@@ -500,6 +501,7 @@ export function HomeView({
500501
const analytics = useAnalytics();
501502
const workspaceContextState = useWorkspaceContext();
502503
const { context: workspaceContext } = workspaceContextState;
504+
const pluginCatalogWorkspaceContext = workspaceResourceReadContext(workspaceContextState);
503505
const lastSettledLocalCatalogScopeRef = useRef<LocalCatalogScope | null>(
504506
localCatalogScopeFromWorkspaceContext(workspaceContext),
505507
);
@@ -509,9 +511,14 @@ export function HomeView({
509511
}
510512
const pluginAccountGeneration = currentWorkspaceAccountGeneration();
511513
const pluginCatalogOptions = {
512-
workspaceContext,
514+
workspaceContext: pluginCatalogWorkspaceContext,
513515
accountGeneration: pluginAccountGeneration,
514516
};
517+
// Keep the provisional local catalogue available for default-template
518+
// routing while Workspace discovery runs, but never expose that provisional
519+
// projection in HomeHero. The prop below keeps the Examples rail in its
520+
// stable loading shell until the Workspace identity and its exact cache
521+
// partition have both settled.
515522
const desiredPluginCatalogKey = workspaceContextState.identityChangePending
516523
? null
517524
: pluginCatalogCacheKey(pluginCatalogOptions);
@@ -896,7 +903,7 @@ export function HomeView({
896903
return promise;
897904
};
898905
pluginCatalogReloadRef.current = load;
899-
if (homeActiveRef.current && workspaceContext?.workspaceType !== 'team') load();
906+
if (homeActiveRef.current && pluginCatalogWorkspaceContext?.workspaceType !== 'team') load();
900907
else pluginCatalogStaleRef.current = true;
901908
const onChanged = () => {
902909
// A mutation event is newer than any pending snapshot and must supersede
@@ -922,27 +929,27 @@ export function HomeView({
922929
}
923930
window.removeEventListener('open-design:plugins-changed', onChanged);
924931
};
925-
}, [desiredPluginCatalogKey, workspaceContext?.workspaceType]);
932+
}, [desiredPluginCatalogKey, pluginCatalogWorkspaceContext?.workspaceType]);
926933

927934
useEffect(() => {
928935
if (!isActive || !desiredPluginCatalogKey || !pluginCatalogStaleRef.current) return;
929-
if (workspaceContext?.workspaceType === 'team') return;
936+
if (pluginCatalogWorkspaceContext?.workspaceType === 'team') return;
930937
pluginCatalogStaleRef.current = false;
931938
pluginCatalogReloadRef.current(true);
932-
}, [desiredPluginCatalogKey, isActive, workspaceContext?.workspaceType]);
939+
}, [desiredPluginCatalogKey, isActive, pluginCatalogWorkspaceContext?.workspaceType]);
933940

934941
const handlePluginStreamActive = useWorkspaceSnapshotActivation({
935-
enabled: isActive && workspaceContext?.workspaceType === 'team',
942+
enabled: isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team',
936943
identity: desiredPluginCatalogKey ?? 'no-plugin-catalog',
937944
refresh: () => { void pluginCatalogReloadRef.current(true, true); },
938945
});
939946

940947
useWorkspaceInvalidation({}, {
941948
workspaceContext:
942-
isActive && workspaceContext?.workspaceType === 'team'
943-
? workspaceContext
949+
isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team'
950+
? pluginCatalogWorkspaceContext
944951
: null,
945-
enabled: isActive && workspaceContext?.workspaceType === 'team',
952+
enabled: isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team',
946953
// App owns the global Skill/Design System catch-up. Home only refreshes
947954
// its plugin projection.
948955
onActive: () => {
@@ -2978,7 +2985,11 @@ export function HomeView({
29782985
onRemoveFile={removeStagedFile}
29792986
onImportFigma={() => setFigmaModalOpen(true)}
29802987
pluginOptions={plugins}
2981-
pluginsLoading={pluginsLoading}
2988+
pluginsLoading={
2989+
pluginsLoading
2990+
|| workspaceContextState.loading
2991+
|| workspaceContextState.identityChangePending === true
2992+
}
29822993
skillOptions={selectableSkills}
29832994
skillsLoading={skillsLoading}
29842995
mcpOptions={enabledMcpServers}

apps/web/src/styles/home/home-hero.css

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,6 +2497,12 @@
24972497
centered hero (they collapse to 0 when the viewport is narrower than the
24982498
hero cap and the two widths coincide). */
24992499
.home-hero__plugin-presets-wrap {
2500+
/* One geometry contract for both the loading shell and settled cards.
2501+
The fixed card height is the natural rendered height of the preview,
2502+
gap, caption line, padding, and border rounded to a whole pixel. */
2503+
--home-plugin-preset-width: 248px;
2504+
--home-plugin-preset-preview-h: 150px;
2505+
--home-plugin-preset-card-h: 194px;
25002506
width: 100cqw;
25012507
max-width: 100cqw;
25022508
margin-inline: calc((100% - 100cqw) / 2);
@@ -2506,7 +2512,10 @@
25062512
scrolling presets row (not the title above it). */
25072513
.home-hero__rail-scroller {
25082514
position: relative;
2509-
min-height: 194px;
2515+
/* Both preset rows use 1px top + 8px bottom padding. Reserving that
2516+
together with the shared card height prevents the centered Home layout
2517+
from moving when loading content is replaced. */
2518+
min-height: calc(var(--home-plugin-preset-card-h) + 9px);
25102519
}
25112520

25122521
.home-hero__plugin-presets {
@@ -2523,31 +2532,67 @@
25232532
}
25242533
.home-hero__plugin-presets::-webkit-scrollbar { display: none; }
25252534

2535+
/* Keep the Examples rail's final geometry reserved while the Workspace-scoped
2536+
plugin catalogue resolves. These placeholders are deliberately static: the
2537+
real cards already own their entrance animation, and animating the loading
2538+
shell would recreate the startup twitch this state is meant to prevent. */
2539+
.home-hero__plugin-presets-loading {
2540+
display: flex;
2541+
align-items: stretch;
2542+
gap: 10px;
2543+
overflow: hidden;
2544+
padding: 1px 2px 8px;
2545+
}
2546+
.home-hero__plugin-preset-loading {
2547+
flex: 0 0 var(--home-plugin-preset-width);
2548+
min-width: 0;
2549+
height: var(--home-plugin-preset-card-h);
2550+
min-height: var(--home-plugin-preset-card-h);
2551+
display: grid;
2552+
grid-template-rows: var(--home-plugin-preset-preview-h) minmax(0, auto);
2553+
gap: 8px;
2554+
overflow: hidden;
2555+
border: 1px solid var(--border);
2556+
border-radius: var(--radius-lg);
2557+
background: var(--bg-panel);
2558+
box-shadow: var(--shadow-xs);
2559+
}
2560+
.home-hero__plugin-preset-loading-preview {
2561+
display: block;
2562+
background: var(--bg-subtle);
2563+
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
2564+
}
2565+
.home-hero__plugin-preset-loading-title {
2566+
display: block;
2567+
width: 46%;
2568+
height: 10px;
2569+
margin: 1px 10px 0;
2570+
border-radius: var(--radius-pill);
2571+
background: color-mix(in srgb, var(--text-faint) 18%, var(--bg-subtle));
2572+
}
2573+
25262574
/* Each preset is wrapped in a positioning cell so the "Open as project"
25272575
overlay can sit over the thumbnail without nesting a button inside the
25282576
card button. The flex sizing lives on the cell; the card button fills it. */
25292577
.home-hero__plugin-preset-cell {
25302578
position: relative;
2531-
flex: 0 0 248px;
2579+
flex: 0 0 var(--home-plugin-preset-width);
25322580
min-width: 0;
25332581
display: flex;
25342582
/* One source for the preview row's height: the preview box below pins
25352583
itself to it. */
2536-
--preset-preview-h: 150px;
2584+
--preset-preview-h: var(--home-plugin-preset-preview-h);
25372585
}
25382586

25392587
.home-hero__plugin-preset {
25402588
appearance: none;
25412589
position: relative;
25422590
width: 100%;
2543-
height: auto;
2544-
/* Low enough that the caption row hugs its text (150 preview + 8 gap +
2545-
one 16px line + 6px pad ≈ 180); a taller min just reads as dead space
2546-
under the title. */
2547-
min-height: 172px;
2591+
height: var(--home-plugin-preset-card-h);
2592+
min-height: var(--home-plugin-preset-card-h);
25482593
min-width: 0;
25492594
display: grid;
2550-
grid-template-rows: 150px minmax(0, auto);
2595+
grid-template-rows: var(--home-plugin-preset-preview-h) minmax(0, auto);
25512596
gap: 8px;
25522597
padding: 0;
25532598
border: 1px solid var(--border);
@@ -2883,22 +2928,17 @@
28832928
}
28842929

28852930
@media (max-width: 900px) {
2931+
.home-hero__plugin-presets-wrap {
2932+
--home-plugin-preset-width: 224px;
2933+
--home-plugin-preset-preview-h: 136px;
2934+
--home-plugin-preset-card-h: 180px;
2935+
}
28862936
.home-hero__title {
28872937
font-size: 38.4px;
28882938
}
28892939
.home-hero__prompt-examples-grid {
28902940
grid-template-columns: repeat(2, minmax(0, 1fr));
28912941
}
2892-
.home-hero__plugin-preset-cell {
2893-
flex-basis: 224px;
2894-
}
2895-
.home-hero__plugin-preset {
2896-
min-height: 170px;
2897-
grid-template-rows: 136px minmax(0, auto);
2898-
}
2899-
.home-hero__plugin-preset-preview {
2900-
min-height: 136px;
2901-
}
29022942
.home-hero__plugin-preset-preview .plugins-home__html-iframe {
29032943
transform: scale(0.146);
29042944
}
@@ -2934,23 +2974,18 @@
29342974
}
29352975

29362976
@media (max-width: 560px) {
2977+
.home-hero__plugin-presets-wrap {
2978+
--home-plugin-preset-width: 208px;
2979+
--home-plugin-preset-preview-h: 126px;
2980+
--home-plugin-preset-card-h: 170px;
2981+
}
29372982
.home-hero__type-tabs {
29382983
justify-content: flex-start;
29392984
padding: 0;
29402985
}
29412986
.home-hero__prompt-examples-grid {
29422987
grid-template-columns: 1fr;
29432988
}
2944-
.home-hero__plugin-preset-cell {
2945-
flex-basis: 208px;
2946-
}
2947-
.home-hero__plugin-preset {
2948-
min-height: 160px;
2949-
grid-template-rows: 126px minmax(0, auto);
2950-
}
2951-
.home-hero__plugin-preset-preview {
2952-
min-height: 126px;
2953-
}
29542989
.home-hero__plugin-preset-preview .plugins-home__html-iframe {
29552990
transform: scale(0.134);
29562991
}

apps/web/tests/components/HomeHero.rail.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,15 @@ describe('HomeHero intent rail', () => {
285285
expect(screen.queryByTestId('home-hero-active-example')).toBeNull();
286286
});
287287

288+
it('reserves the example rail while the plugin catalog is still loading', () => {
289+
renderHero({ activeChipId: null, pluginsLoading: true });
290+
291+
const loading = screen.getByTestId('home-hero-examples-loading');
292+
expect(loading.getAttribute('aria-busy')).toBe('true');
293+
expect(screen.queryByTestId('home-hero-prompt-examples')).toBeNull();
294+
expect(screen.queryByTestId('home-hero-plugin-presets')).toBeNull();
295+
});
296+
288297
it('shows matching plugin presets in the example prompt area for the selected tab', () => {
289298
const deckPlugin = makePlugin('example-deck-a', 'deck', 'Investor deck');
290299
const imagePlugin = makePlugin('example-image-a', 'image', 'Product image');

apps/web/tests/components/HomeView.plugin-workspace-scope.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,57 @@ describe('HomeView workspace-scoped plugin catalog', () => {
205205
workspaceInvalidationHarness.autoActivate = true;
206206
});
207207

208+
it('masks the provisional catalog until the first Workspace-scoped read settles', async () => {
209+
const pluginRequests: Headers[] = [];
210+
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async (input, init) => {
211+
if (String(input) === '/api/plugins') {
212+
const headers = new Headers(init?.headers);
213+
pluginRequests.push(headers);
214+
const pluginId = headers.has('x-od-workspace-id')
215+
? 'scoped-plugin'
216+
: 'provisional-plugin';
217+
return new Response(JSON.stringify({ plugins: [plugin(pluginId)] }), {
218+
status: 200,
219+
headers: { 'content-type': 'application/json' },
220+
});
221+
}
222+
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
223+
}));
224+
workspaceMock.state = {
225+
context: null,
226+
loading: true,
227+
identityChangePending: false,
228+
failure: undefined,
229+
};
230+
const view = renderHome();
231+
232+
await waitFor(() => expect(pluginRequests).toHaveLength(1));
233+
expect(pluginRequests[0]?.has('x-od-workspace-id')).toBe(false);
234+
expect(screen.getByTestId('plugin-catalog').textContent).toBe('loading');
235+
236+
workspaceMock.state = {
237+
context: teamContext('workspace-startup', 'member-startup'),
238+
loading: false,
239+
identityChangePending: false,
240+
failure: undefined,
241+
};
242+
view.rerender(
243+
<HomeView
244+
projects={[]}
245+
onSubmit={() => undefined}
246+
onOpenProject={() => undefined}
247+
onViewAllProjects={() => undefined}
248+
/>,
249+
);
250+
251+
await waitFor(() => expect(pluginRequests).toHaveLength(2));
252+
expect(pluginRequests[1]?.get('x-od-workspace-id')).toBe('workspace-startup');
253+
expect(pluginRequests[1]?.get('x-od-workspace-member-id')).toBe('member-startup');
254+
await waitFor(() => {
255+
expect(screen.getByTestId('plugin-catalog').textContent).toBe('scoped-plugin');
256+
});
257+
});
258+
208259
it('parks hidden plugin invalidations and performs one bounded catch-up when Home activates', async () => {
209260
let pluginReads = 0;
210261
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async (input) => {

0 commit comments

Comments
 (0)