From fbb02e8150c662b89c016d5d7efba4ad1ce86ef0 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 29 Mar 2026 13:39:56 +0200 Subject: [PATCH] Align result and feed UI --- app/web/feeds/responder.rb | 41 ++- frontend/src/__tests__/App.contract.test.tsx | 2 +- frontend/src/__tests__/ResultDisplay.test.tsx | 6 +- frontend/src/components/ResultDisplay.tsx | 33 ++- frontend/src/styles/main.css | 86 +----- public/feed-reader-link.js | 6 + public/rss.xsl | 254 +++++++++++++++--- public/shared-ui.css | 157 +++++++++++ spec/html2rss/web/app_spec.rb | 6 +- spec/html2rss/web/feeds/responder_spec.rb | 32 ++- spec/public/rss_xsl_spec.rb | 101 +++++++ 11 files changed, 567 insertions(+), 157 deletions(-) create mode 100644 public/feed-reader-link.js create mode 100644 spec/public/rss_xsl_spec.rb diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb index d7c1522f..329fdece 100644 --- a/app/web/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -12,13 +12,9 @@ class << self # @param identifier [String] # @return [String] serialized feed body. def call(request:, target_kind:, identifier:) - feed_request = Request.call(request:, target_kind:, identifier:) - resolved_source = SourceResolver.call(feed_request) - result = Service.call(resolved_source) - normalized_identifier = feed_request.feed_name || identifier + feed_request, resolved_source, result = resolve_request(request:, target_kind:, identifier:) body = write_response(response: request.response, representation: feed_request.representation, result:) - - emit_result(target_kind:, identifier: normalized_identifier, resolved_source:, result:) + emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:) body rescue StandardError => error emit_failure(target_kind:, identifier:, error:) @@ -27,6 +23,39 @@ def call(request:, target_kind:, identifier:) private + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [Array<(Html2rss::Web::Feeds::Contracts::Request, Html2rss::Web::Feeds::Contracts::ResolvedSource, Html2rss::Web::Feeds::Contracts::RenderResult)>] + def resolve_request(request:, target_kind:, identifier:) + feed_request = Request.call(request:, target_kind:, identifier:) + resolved_source = SourceResolver.call(feed_request) + result = Service.call(resolved_source) + [feed_request, resolved_source, result] + end + + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param identifier [String] + # @return [String] + def normalized_identifier(feed_request, identifier) + feed_request.feed_name || identifier + end + + # @param target_kind [Symbol] + # @param identifier [String] + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def emit_response_result(target_kind:, identifier:, feed_request:, resolved_source:, result:) + emit_result( + target_kind:, + identifier: normalized_identifier(feed_request, identifier), + resolved_source:, + result: + ) + end + # @param response [Rack::Response] # @param representation [Symbol] # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 8a3f7e5c..f90e89fd 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -64,7 +64,7 @@ describe('App contract', () => { fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Example Feed')).toBeInTheDocument(); expect(screen.getByLabelText('Feed URL')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 818ee0f0..9646f558 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -48,10 +48,10 @@ describe('ResultDisplay', () => { it('renders the success state actions and richer preview cards', async () => { render(); - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByText('Test Feed')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Subscribe in reader' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open in feed reader' })).toHaveAttribute( 'href', 'feed:https://example.com/feed.xml' ); @@ -96,7 +96,7 @@ describe('ResultDisplay', () => { ); await waitFor(() => { - expect(screen.getByText('Your feed is ready')).toBeInTheDocument(); + expect(screen.getByText('Feed ready')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument(); expect(screen.getByText('Loading preview…')).toBeInTheDocument(); }); diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index 81a6dd36..f700821c 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -40,13 +40,28 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) { return (
-

Feed created

-

Your feed is ready

-

{feed.name}

-

Subscribe to this URL in your RSS reader.

+
+ +
+

Feed ready

+

{feed.name}

+
+
+
+ {subscribeUrl && ( + + Open in feed reader + + )} + + Open feed + +
{result.retry && (

{`Retried automatically with ${result.retry.to} after ${result.retry.from} could not finish the page.`} @@ -67,14 +82,6 @@ export function ResultDisplay({ result, onCreateAnother }: ResultDisplayProps) { />

- {subscribeUrl && ( - - Subscribe in reader - - )} - - Open feed - Open JSON Feed diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 40721669..1e268b87 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -263,71 +263,6 @@ a:focus-visible { box-shadow: var(--focus-ring); } -.btn { - min-height: 3rem; - padding: 0 1.25rem; - display: inline-flex; - align-items: center; - justify-content: center; - border: var(--border-width) solid transparent; - border-radius: 999px; - background: transparent; - color: var(--text-strong); - text-decoration: none; - cursor: pointer; - font-weight: 600; - transition: - transform var(--transition-fast), - background-color var(--transition-fast), - border-color var(--transition-fast), - color var(--transition-fast), - opacity var(--transition-fast); -} - -.btn:hover:not(:disabled) { - transform: translateY(-0.04rem); -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn--primary { - background: var(--accent); - color: var(--text-inverse); -} - -.btn--primary:hover:not(:disabled) { - background: var(--accent-strong); -} - -.btn--ghost { - border-color: var(--border-subtle); - background: var(--surface-elevated); -} - -.btn--ghost:hover:not(:disabled) { - border-color: var(--border-strong); - background: rgba(255, 255, 255, 0.08); -} - -.btn--quiet, -.btn--linkish { - min-height: auto; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - color: var(--text-muted); -} - -.btn--quiet:hover:not(:disabled), -.btn--linkish:hover:not(:disabled) { - background: transparent; - color: var(--text-strong); -} - .notice { display: grid; gap: var(--space-2); @@ -504,18 +439,9 @@ a:focus-visible { text-align: left; } -.result-hero { - justify-items: start; - text-align: left; -} - -.result-title { - margin: 0; - color: var(--text-strong); - font-family: var(--font-family-display); - font-size: clamp(1.9rem, 4.2vw, 2.85rem); - line-height: 0.98; - letter-spacing: -0.03em; +.result-hero__reader { + border-color: rgba(255, 147, 0, 0.24); + background: rgba(255, 147, 0, 0.12); } .result-meta { @@ -526,12 +452,6 @@ a:focus-visible { text-align: left; } -.result-lede { - margin: 0; - color: var(--text-muted); - font-size: var(--font-size-1); -} - .result-preview { justify-items: start; padding-top: var(--section-gap); diff --git a/public/feed-reader-link.js b/public/feed-reader-link.js new file mode 100644 index 00000000..f24b7bcf --- /dev/null +++ b/public/feed-reader-link.js @@ -0,0 +1,6 @@ +document.addEventListener('DOMContentLoaded', function () { + var readerLink = document.querySelector('[data-feed-reader-link]'); + if (!readerLink) return; + + readerLink.setAttribute('href', 'feed:' + window.location.href); +}); diff --git a/public/rss.xsl b/public/rss.xsl index cc74adbe..4bf52fd9 100644 --- a/public/rss.xsl +++ b/public/rss.xsl @@ -10,6 +10,7 @@ <xsl:value-of select="rss/channel/title" /> (Feed) + @@ -168,25 +261,57 @@
-
-

- - - -

- - -

- - - - - - - - -

-
+
+
+
+ +

+ + + +

+
+ + +

+ + + + + + + + +

+
+ + +

+ + + Updated + + + + Published + + + + Latest item + + + +

+
+ +
@@ -273,6 +398,10 @@ Untitled item + + + +

@@ -282,7 +411,7 @@

- +

@@ -291,10 +420,31 @@

- -

- Open original -

+ + @@ -342,19 +492,49 @@ - + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + < + + + + + diff --git a/public/shared-ui.css b/public/shared-ui.css index 9bb4b48a..97fec73c 100644 --- a/public/shared-ui.css +++ b/public/shared-ui.css @@ -165,6 +165,79 @@ textarea { border-radius: var(--radius-md); } +.ui-hero { + justify-items: start; + text-align: left; + position: relative; + overflow: hidden; + box-shadow: var(--shadow-elevated); +} + +.ui-hero::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(135deg, rgba(255, 147, 0, 0.12), transparent 46%), + linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 32%); +} + +.ui-hero > * { + position: relative; + z-index: 1; +} + +.ui-hero__masthead { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +.ui-hero__icon-wrap { + width: clamp(3.6rem, 8vw, 4.6rem); + height: clamp(3.6rem, 8vw, 4.6rem); + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 1.15rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02)), + rgba(255, 255, 255, 0.03); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.ui-hero__icon { + width: 62%; + height: 62%; + display: block; +} + +.ui-hero__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; +} + +.ui-display-title { + margin: 0; + color: var(--text-strong); + font-family: var(--font-family-display); + font-size: clamp(1.9rem, 4.2vw, 2.85rem); + line-height: 0.98; + letter-spacing: -0.03em; +} + +.ui-lede { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-1); +} + .ui-eyebrow { margin: 0; color: var(--eyebrow-color); @@ -174,6 +247,78 @@ textarea { font-weight: 600; } +.btn { + min-height: 3rem; + padding: 0 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: var(--border-width) solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-strong); + text-decoration: none; + cursor: pointer; + font-weight: 600; + transition: + transform var(--transition-fast), + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + opacity var(--transition-fast); +} + +.btn:hover:not(:disabled) { + transform: translateY(-0.04rem); + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--border-strong); +} + +.btn--primary { + background: var(--accent); + color: var(--text-inverse); +} + +.btn--primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.btn--ghost { + border-color: var(--border-subtle); + background: var(--surface-elevated); +} + +.btn--ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.08); +} + +.btn--quiet, +.btn--linkish { + min-height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-muted); +} + +.btn--quiet:hover:not(:disabled), +.btn--linkish:hover:not(:disabled) { + background: transparent; + color: var(--text-strong); +} + .brand-lockup { display: inline-grid; justify-items: center; @@ -219,3 +364,15 @@ textarea { font-weight: 600; letter-spacing: 0.01em; } + +@media (max-width: 47.9375rem) { + .ui-hero__masthead { + grid-template-columns: 1fr; + } + + .ui-hero__actions { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } +} diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index d387fa86..f34ae32e 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'climate_control' +require 'securerandom' require_relative '../../../app' @@ -97,9 +98,8 @@ def app = described_class end it 'serves static feed routes with caching headers' do - stub_static_feed - - get '/legacy' + stub_static_feed(rss_body: '') + get "/legacy-#{SecureRandom.hex(4)}" expect(last_response.status).to eq(200) expect(last_response.headers['Content-Type']).to eq('application/xml') diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index b449723a..d1898199 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -49,17 +49,8 @@ it 'resolves the source through the real request and source resolver path', :aggregate_failures do write_response - expect(Html2rss::Web::Feeds::Service).to have_received(:call).with( - have_attributes( - source_kind: :static, - cache_identity: a_string_starting_with('static:example:'), - generator_input: include(strategy: :faraday, channel: { url: 'https://example.com', ttl: 10 }), - ttl_seconds: 600 - ) - ) - expect(response['Cache-Control']).to include('max-age=600') - expect(response['Cache-Control']).to include('public') - expect(response['Vary']).to eq('Accept') + expect_resolved_static_source + expect_cache_headers end it 'emits success after writing the response' do @@ -157,4 +148,23 @@ def request_for(path:, accept:) def response_tuple(body) [response.status, response['Content-Type'], body] end + + # @return [void] + def expect_resolved_static_source + expect(Html2rss::Web::Feeds::Service).to have_received(:call).with( + have_attributes( + source_kind: :static, + cache_identity: a_string_starting_with('static:example:'), + generator_input: include(strategy: :faraday, channel: { url: 'https://example.com', ttl: 10 }), + ttl_seconds: 600 + ) + ) + end + + # @return [void] + def expect_cache_headers + expect(response['Cache-Control']).to include('max-age=600') + expect(response['Cache-Control']).to include('public') + expect(response['Vary']).to eq('Accept') + end end diff --git a/spec/public/rss_xsl_spec.rb b/spec/public/rss_xsl_spec.rb new file mode 100644 index 00000000..1e9545e2 --- /dev/null +++ b/spec/public/rss_xsl_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'nokogiri' +require_relative '../../app' + +# rubocop:disable RSpec/MultipleExpectations +RSpec.describe 'public/rss.xsl' do + subject(:rendered_html) do + Nokogiri::XSLT(File.read(File.expand_path('../../public/rss.xsl', __dir__))).transform(Nokogiri::XML(feed_xml)).to_s + end + + let(:feed_xml) do + <<~XML + + + + The Example Feed + Example feed description with enough detail to exercise the hero copy. + https://example.com/articles + html2rss V. 1.0.0 + Mon, 01 Jan 2024 00:00:00 GMT + + First article + First article excerpt.

]]>
+ https://example.com/articles/1 + Mon, 01 Jan 2024 10:00:00 GMT + Policy + editor@example.com + +
+ + Second article + Math 1 < 2 > 0

]]>
+ https://example.com/articles/2 + Tue, 02 Jan 2024 10:00:00 GMT +
+ + Math 1 < 2 > 0 + Math 1 < 2 > 0 + https://example.com/articles/3 + Wed, 03 Jan 2024 10:00:00 GMT + +
+
+ XML + end + + it 'uses the feed icon in the hero and as the favicon' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('link[rel="icon"]')['href']).to eq('/feed.svg') + expect(doc.at_css('.feed-hero__icon')['src']).to eq('/feed.svg') + end + + it 'renders the feed-reader hero action with client-side wiring' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('[data-feed-reader-link]')).not_to be_nil + expect(doc.at_css('[data-feed-reader-link]').text.strip).to eq('Open in feed reader') + expect(doc.at_css('[data-feed-reader-link]')['href']).to eq('#') + expect(doc.at_css('script')['src']).to eq('/feed-reader-link.js') + end + + it 'uses the shared ui stylesheet' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('link[rel="stylesheet"]')['href']).to eq('/shared-ui.css') + end + + it 'preserves plain-text angle brackets while stripping actual html tags' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.css('.feed-card__title').last.text.strip).to eq('Math 1 < 2 > 0') + expect(doc.css('.feed-card__excerpt')[1].text.strip).to eq('Math 1 < 2 > 0') + end + + it 'surfaces last build time in the hero instead of decorative quality pills' do + doc = Nokogiri::HTML(rendered_html) + hero_stamp = doc.at_css('.feed-hero__stamp') + + expect(hero_stamp.text.gsub(/\s+/, ' ').strip).to eq('Updated Mon, 01 Jan 2024 00:00:00 GMT') + expect(doc.css('.feed-quality__pill')).to be_empty + end + + it 'uses the shared brand lockup in the feed header' do + doc = Nokogiri::HTML(rendered_html) + + expect(doc.at_css('.brand-lockup')).not_to be_nil + expect(doc.at_css('.brand-lockup__wordmark').text.strip).to eq('html2rss') + end + + it 'shows muted quality indicators instead of item metadata values' do + doc = Nokogiri::HTML(rendered_html) + + first_card_signals = doc.css('.feed-card').first.css('.feed-signal').map { |node| node.text.strip } + + expect(first_card_signals).to include('Summary', 'Image', 'Tags', 'Byline') + end +end +# rubocop:enable RSpec/MultipleExpectations