Skip to content

Commit ddec7be

Browse files
committed
Align result journey states
1 parent 6d8bef8 commit ddec7be

5 files changed

Lines changed: 158 additions & 65 deletions

File tree

frontend/src/__tests__/App.test.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,15 @@ describe('App', () => {
218218
render(<App />);
219219

220220
await waitFor(() => {
221-
expect(screen.getByText('Saved result unavailable')).toBeInTheDocument();
222-
expect(screen.getByRole('button', { name: 'Go to create' })).toBeInTheDocument();
221+
expect(screen.getByRole('heading', { name: 'Saved result unavailable' })).toBeInTheDocument();
223222
});
223+
224+
expect(screen.getByRole('alert')).toHaveClass('result-shell', 'result-recovery');
225+
expect(screen.getByRole('alert')).toHaveAttribute('data-state', 'failed');
226+
expect(document.querySelector('.result-recovery .ui-hero')).toBeInTheDocument();
227+
expect(document.querySelector('.result-recovery .layout-rail-reading')).toBeInTheDocument();
228+
expect(screen.getAllByRole('button')).toHaveLength(1);
229+
expect(screen.getByRole('button', { name: 'Go to create' })).toHaveClass('btn--primary');
224230
});
225231

226232
it('shows inline token prompt when submitting without a token', async () => {
@@ -323,9 +329,11 @@ describe('App', () => {
323329

324330
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'failed');
325331
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
332+
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
326333
expect(screen.getByRole('link', { name: 'Bookmarklet' })).toBeInTheDocument();
327334
expect(screen.getByText('Example Feed')).toBeInTheDocument();
328-
expect(screen.getAllByText('Preview unavailable right now.').length).toBeGreaterThan(0);
335+
expect(screen.getByText('Feed link created')).toBeInTheDocument();
336+
expect(screen.queryByText('Preview unavailable right now.')).not.toBeInTheDocument();
329337

330338
fireEvent.click(screen.getByRole('button', { name: 'Create another feed' }));
331339
return waitFor(() => {

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ describe('ResultDisplay', () => {
8282
expect(screen.getByText('Item Two')).toBeInTheDocument();
8383
expect(screen.getAllByText('Open original').length).toBeGreaterThan(0);
8484
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
85+
expect(screen.queryByText('This feed has been verified and is ready to use.')).not.toBeInTheDocument();
8586
});
8687
});
8788

88-
it('surfaces degraded preview metadata when the API marks the result as degraded', async () => {
89+
it('surfaces degraded empty-preview metadata as a compact result note', async () => {
8990
render(
9091
<ResultDisplay
9192
result={
@@ -112,11 +113,45 @@ describe('ResultDisplay', () => {
112113

113114
await waitFor(() => {
114115
expect(screen.getByText('Feed ready')).toBeInTheDocument();
115-
expect(screen.getByText('Preview content could not be fully verified.')).toBeInTheDocument();
116-
expect(
117-
screen.getByText('Feed is ready, but preview content is partially degraded right now.')
118-
).toBeInTheDocument();
119-
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
116+
expect(screen.getByText('Preview could not be verified.')).toHaveClass('result-status-note');
117+
expect(screen.queryByRole('button', { name: 'Check again' })).not.toBeInTheDocument();
118+
expect(screen.queryByLabelText('Feed preview status')).not.toBeInTheDocument();
119+
expect(screen.queryByText('Latest items from this feed')).not.toBeInTheDocument();
120+
});
121+
});
122+
123+
it('treats preview-unavailable feeds as usable links without preview noise', async () => {
124+
render(
125+
<ResultDisplay
126+
result={
127+
{
128+
...mockResult,
129+
preview: { items: [], error: undefined, isLoading: false },
130+
readinessPhase: 'preview_unavailable',
131+
previewStatus: 'degraded',
132+
warnings: [
133+
{
134+
code: 'preview_unavailable',
135+
message: 'Preview unavailable right now.',
136+
retryable: false,
137+
nextAction: 'none',
138+
},
139+
],
140+
} as any
141+
}
142+
workflowState="failed"
143+
onCreateAnother={mockOnCreateAnother}
144+
onRetryReadiness={mockOnRetryReadiness}
145+
/>
146+
);
147+
148+
await waitFor(() => {
149+
expect(screen.getByText('Feed link created')).toBeInTheDocument();
150+
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
151+
expect(screen.queryByRole('button', { name: 'Check again' })).not.toBeInTheDocument();
152+
expect(screen.queryByText('Preview could not be verified.')).not.toBeInTheDocument();
153+
expect(screen.queryByLabelText('Feed preview status')).not.toBeInTheDocument();
154+
expect(screen.queryByText('Preview')).not.toBeInTheDocument();
120155
});
121156
});
122157

@@ -147,10 +182,10 @@ describe('ResultDisplay', () => {
147182

148183
await waitFor(() => {
149184
expect(screen.getByText('Feed still warming up')).toBeInTheDocument();
150-
expect(screen.getByRole('button', { name: 'Try readiness check again' })).toHaveClass('btn--primary');
185+
expect(screen.getByRole('button', { name: 'Check again' })).toHaveClass('btn--primary');
151186
expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument();
152-
expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument();
153-
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
187+
expect(screen.getByText('Checking preview…')).toBeInTheDocument();
188+
expect(screen.queryByText('Latest items from this feed')).not.toBeInTheDocument();
154189
});
155190
});
156191

@@ -174,14 +209,14 @@ describe('ResultDisplay', () => {
174209

175210
await waitFor(() => {
176211
expect(screen.getByText('Feed created')).toBeInTheDocument();
177-
expect(screen.getByRole('button', { name: 'Checking readiness…' })).toHaveClass('btn--primary');
178-
expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled();
212+
expect(screen.getByRole('button', { name: 'Checking…' })).toHaveClass('btn--primary');
213+
expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled();
179214
expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument();
180-
expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument();
215+
expect(screen.getByText('Checking preview…')).toBeInTheDocument();
181216
});
182217
});
183218

184-
it('shows a generic automatic recovery notice when the backend reports recovery', async () => {
219+
it('does not surface automatic recovery filler copy when the backend reports recovery', async () => {
185220
render(
186221
<ResultDisplay
187222
result={
@@ -197,7 +232,7 @@ describe('ResultDisplay', () => {
197232
);
198233

199234
await waitFor(() => {
200-
expect(screen.getByText('Feed creation recovered automatically.')).toBeInTheDocument();
235+
expect(screen.queryByText('Feed creation recovered automatically.')).not.toBeInTheDocument();
201236
});
202237
});
203238

@@ -226,7 +261,7 @@ describe('ResultDisplay', () => {
226261
/>
227262
);
228263

229-
fireEvent.click(screen.getByRole('button', { name: 'Try readiness check again' }));
264+
fireEvent.click(screen.getByRole('button', { name: 'Check again' }));
230265
expect(mockOnRetryReadiness).toHaveBeenCalled();
231266
});
232267

frontend/src/components/App.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -335,17 +335,26 @@ export function App() {
335335
);
336336
} else if (missingResultRoute) {
337337
bodyContent = (
338-
<section class="ui-card ui-card--notice ui-card--padded notice" data-tone="error" role="alert">
339-
<div class="notice__title">Saved result unavailable</div>
340-
<p>We could not restore this feed result. Create a new feed link to continue.</p>
341-
<div class="notice__actions">
342-
<button
343-
type="button"
344-
class="btn btn--primary"
345-
onClick={() => navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined })}
346-
>
347-
Go to create
348-
</button>
338+
<section class="result-shell result-recovery layout-stack" data-state="failed" role="alert">
339+
<div class="result-hero ui-card ui-card--roomy ui-hero layout-rail-reading layout-stack">
340+
<div class="result-hero__masthead ui-hero__masthead">
341+
<div class="result-hero__icon-wrap ui-hero__icon-wrap" aria-hidden="true">
342+
<img class="result-hero__icon ui-hero__icon" src="/feed.svg" alt="" />
343+
</div>
344+
<div class="layout-stack layout-stack--tight">
345+
<h1 class="result-title ui-display-title">Saved result unavailable</h1>
346+
<p class="field-help">Create a new feed link to continue.</p>
347+
</div>
348+
</div>
349+
<div class="result-hero__actions ui-hero__actions">
350+
<button
351+
type="button"
352+
class="btn btn--primary"
353+
onClick={() => navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined })}
354+
>
355+
Go to create
356+
</button>
357+
</div>
349358
</div>
350359
</section>
351360
);

frontend/src/components/ResultDisplay.tsx

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function ResultDisplay({
1919
const [copyNotice, setCopyNotice] = useState('');
2020
const [showAllPreviewItems, setShowAllPreviewItems] = useState(false);
2121
const copyResetReference = useRef<number | undefined>(undefined);
22-
const { feed, preview, readinessPhase, previewStatus, warnings, retry } = result;
22+
const { feed, preview, readinessPhase, previewStatus } = result;
2323

2424
const fullUrl = feed.public_url.startsWith('http')
2525
? feed.public_url
@@ -28,36 +28,39 @@ export function ResultDisplay({
2828
? feed.json_public_url
2929
: `${globalThis.location.origin}${feed.json_public_url}`;
3030
const subscribeUrl = /^https?:\/\//i.test(fullUrl) ? `feed:${fullUrl}` : undefined;
31-
const isFeedReady = readinessPhase === 'feed_ready';
31+
const canUseFeed = readinessPhase === 'feed_ready' || readinessPhase === 'preview_unavailable';
3232
const canManuallyRetryReadiness =
33-
readinessPhase !== 'feed_ready' || warnings.some((warning) => warning.retryable);
33+
readinessPhase === 'link_created' || readinessPhase === 'feed_not_ready_yet';
3434
const isReadinessCheckInProgress = preview.isLoading;
3535
const previewItems = showAllPreviewItems ? preview.items : preview.items.slice(0, 3);
3636
const hasMorePreviewItems = preview.items.length > 3;
3737
const statusTitle = {
3838
link_created: 'Feed created',
3939
feed_ready: 'Feed ready',
4040
feed_not_ready_yet: 'Feed still warming up',
41-
preview_unavailable: 'Readiness check unavailable',
41+
preview_unavailable: 'Feed link created',
4242
}[readinessPhase];
4343
const statusMessage = {
4444
link_created: 'Checking readiness now.',
45-
feed_ready: 'This feed has been verified and is ready to use.',
45+
feed_ready: '',
4646
feed_not_ready_yet: 'The feed endpoint is still warming up. Try checking again in a few seconds.',
47-
preview_unavailable: 'We could not verify readiness right now. Try checking again.',
47+
preview_unavailable: '',
4848
}[readinessPhase];
4949
const previewMessage = {
50-
pending: 'Verifying feed readiness…',
51-
ready:
52-
preview.items.length > 0
53-
? ''
54-
: 'Feed is ready. Preview items will appear once the source publishes entries.',
55-
degraded:
56-
preview.items.length > 0
57-
? 'Preview content is partially degraded right now.'
58-
: 'Feed is ready, but preview content is partially degraded right now.',
59-
unavailable: 'Preview unavailable right now.',
50+
pending: 'Checking preview…',
51+
ready: '',
52+
degraded: 'Preview could not be verified.',
53+
unavailable: '',
6054
}[previewStatus];
55+
const hasPreviewItems = preview.items.length > 0;
56+
const showResultStatusNote =
57+
readinessPhase === 'feed_ready' && previewStatus === 'degraded' && !preview.isLoading && !hasPreviewItems;
58+
const showPreviewStatusOnly =
59+
!preview.isLoading &&
60+
!hasPreviewItems &&
61+
!!previewMessage &&
62+
readinessPhase !== 'feed_ready' &&
63+
readinessPhase !== 'preview_unavailable';
6164

6265
useEffect(() => {
6366
return () => {
@@ -93,13 +96,10 @@ export function ResultDisplay({
9396
<div class="layout-stack layout-stack--tight">
9497
<h1 class="result-title ui-display-title">{statusTitle}</h1>
9598
<p class="result-meta layout-rail-copy">{feed.name}</p>
96-
<p class="field-help">{statusMessage}</p>
97-
{retry?.automatic && <p class="field-help">Feed creation recovered automatically.</p>}
98-
{warnings.map((warning) => (
99-
<p key={warning.code} class="field-help field-help--warning">
100-
{warning.message}
101-
</p>
102-
))}
99+
{statusMessage && <p class="field-help">{statusMessage}</p>}
100+
{showResultStatusNote && (
101+
<p class="result-status-note field-help field-help--warning">{previewMessage}</p>
102+
)}
103103
</div>
104104
</div>
105105
<div class="result-hero__actions ui-hero__actions">
@@ -111,7 +111,7 @@ export function ResultDisplay({
111111
disabled={isReadinessCheckInProgress}
112112
aria-busy={isReadinessCheckInProgress}
113113
>
114-
{isReadinessCheckInProgress ? 'Checking readiness…' : 'Try readiness check again'}
114+
{isReadinessCheckInProgress ? 'Checking…' : 'Check again'}
115115
</button>
116116
)}
117117
</div>
@@ -130,7 +130,7 @@ export function ResultDisplay({
130130
/>
131131

132132
<div class="result-actions result-actions--quiet layout-rail-reading">
133-
{isFeedReady && (
133+
{canUseFeed && (
134134
<>
135135
<a href={fullUrl} class="btn btn--primary" target="_blank" rel="noopener noreferrer">
136136
Open feed
@@ -154,13 +154,12 @@ export function ResultDisplay({
154154
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview status">
155155
<div class="result-preview__header layout-stack layout-stack--tight">
156156
<p class="result-preview__label ui-eyebrow">Preview</p>
157-
<p class="result-preview__intro">Latest items from this feed</p>
158157
</div>
159-
<p class="field-help">Verifying feed readiness…</p>
158+
<p class="field-help">{previewMessage}</p>
160159
</section>
161160
)}
162161

163-
{!preview.isLoading && preview.items.length > 0 && (
162+
{!preview.isLoading && hasPreviewItems && (
164163
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview">
165164
<div class="result-preview__header layout-stack layout-stack--tight">
166165
<p class="result-preview__label ui-eyebrow">Preview</p>
@@ -196,11 +195,10 @@ export function ResultDisplay({
196195
</section>
197196
)}
198197

199-
{!preview.isLoading && preview.items.length === 0 && previewMessage && (
198+
{showPreviewStatusOnly && (
200199
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview status">
201200
<div class="result-preview__header layout-stack layout-stack--tight">
202201
<p class="result-preview__label ui-eyebrow">Preview</p>
203-
<p class="result-preview__intro">Latest items from this feed</p>
204202
</div>
205203
<p
206204
class={

frontend/src/styles/main.css

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,11 @@
9090
}
9191

9292
.result-shell {
93-
border: var(--border-width) solid var(--state-frame-border);
94-
border-radius: var(--state-frame-radius);
95-
background: var(--state-frame-bg);
96-
box-shadow: var(--state-frame-shadow);
93+
padding: 0;
94+
border: 0;
95+
border-radius: 0;
96+
background: transparent;
97+
box-shadow: none;
9798
gap: var(--section-gap);
9899
}
99100

@@ -514,6 +515,35 @@ a:focus-visible {
514515
text-align: left;
515516
}
516517

518+
.result-status-note {
519+
width: fit-content;
520+
max-width: min(100%, var(--layout-rail-copy));
521+
margin: 0;
522+
padding: var(--space-2) var(--space-3);
523+
border: var(--border-width) solid var(--state-frame-border);
524+
border-radius: var(--radius-md);
525+
background: rgb(var(--color-rgb-white) / 3%);
526+
color: var(--text-soft);
527+
text-align: left;
528+
font-size: var(--font-size-00);
529+
line-height: 1.35;
530+
}
531+
532+
.result-recovery {
533+
width: min(100%, var(--layout-rail-reading));
534+
max-width: none;
535+
margin: 0 auto;
536+
padding: clamp(var(--space-4), 5vw, var(--space-6));
537+
border-color: var(--state-frame-border);
538+
border-radius: var(--radius-lg);
539+
background:
540+
linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 68%),
541+
rgb(var(--color-rgb-white) / 2%);
542+
box-shadow: var(--state-frame-shadow);
543+
justify-items: start;
544+
text-align: left;
545+
}
546+
517547
.result-preview {
518548
justify-items: start;
519549
padding-top: var(--space-4);
@@ -718,12 +748,25 @@ a:focus-visible {
718748
min-height: clamp(16rem, 42vh, 26rem);
719749
}
720750

721-
.form-shell--minimal,
722-
.result-shell {
751+
.form-shell--minimal {
723752
padding: var(--space-4);
724753
border-radius: var(--radius-md);
725754
}
726755

756+
.result-shell {
757+
padding: 0;
758+
border-radius: 0;
759+
}
760+
761+
.result-status-note {
762+
width: 100%;
763+
}
764+
765+
.result-recovery {
766+
width: 100%;
767+
padding: var(--space-4);
768+
}
769+
727770
.input--lg {
728771
min-height: 4rem;
729772
padding-right: calc(var(--space-7) + var(--space-4));

0 commit comments

Comments
 (0)