diff --git a/app.rb b/app.rb index f2b27a4..e77e043 100644 --- a/app.rb +++ b/app.rb @@ -9,6 +9,7 @@ Dotenv.load '.env.local', '.env' require 'logger' +require 'digest' require 'active_record' require './models/item' @@ -47,6 +48,14 @@ def self.log_device # be escaped. SAFE_URL_SCHEMES = %w[http https].freeze + # Validators are computed from database rows, so a template change alone + # would keep serving 304 to anyone holding an older ETag until a story + # happened to change -- indefinitely for a settled thread. Folding a digest + # of the templates into the key retires those cached copies on deploy. + TEMPLATE_VERSION = Digest::SHA256.hexdigest( + Dir[File.join(__dir__, 'views', '*.erb')].sort.map { |f| File.read f }.join + )[0, 12] + helpers do def h(value) Rack::Utils.escape_html value.to_s @@ -79,7 +88,9 @@ def safe_url(value, item_id: nil) def cache_for(records) cache_control :public, :must_revalidate, max_age: 30 newest = records.filter_map(&:updated_at).max - etag Digest::SHA256.hexdigest("#{newest&.to_f}-#{records.map(&:id).join(',')}") + etag Digest::SHA256.hexdigest( + "#{TEMPLATE_VERSION}-#{newest&.to_f}-#{records.map(&:id).join(',')}" + ) end end diff --git a/tests/app_test.rb b/tests/app_test.rb index f98b56c..14d73e7 100644 --- a/tests/app_test.rb +++ b/tests/app_test.rb @@ -275,6 +275,22 @@ def test_comment_html_is_still_rendered assert_includes last_response.body, 'this' end + # Structural guard, not a rendering guarantee: the dark palette is gated on + # .hn-dark, which only the script adds. If that gate is ever dropped while + # the background rule stays, dark mode paints #666 text onto black. Actual + # colours are verified out of band with a real browser. + def test_dark_palette_is_gated_on_the_scripted_class + create_item id: 1, title: 'A', score: 100 + + get '/' + body = last_response.body + + assert_includes body, 'prefers-color-scheme: dark' + assert_includes body, 'hn-dark' + refute_match(/@media \(prefers-color-scheme: dark\)\s*\{\s*html\s*\{/, body, + 'dark background must not apply without the scripted class') + end + def test_javascript_urls_are_not_rendered_as_links create_item id: 1, title: 'Bad', score: 100, url: 'javascript:alert(1)' diff --git a/views/layout.erb b/views/layout.erb index 57f5d6e..898a6a8 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -40,10 +40,54 @@ a { color: #0d0d0d; } } + /* Dark mode is applied by the script in , which adds .hn-dark to + and UIkit's .uk-light to . Scoping the dark palette to + .hn-dark means that with JS disabled the page stays on the readable + light palette rather than dark text on a dark background. */ @media (prefers-color-scheme: dark) { - html { background-color: rgb(34, 34, 34) } + /* Pure black rather than #222 so OLED pixels switch off entirely. */ + html.hn-dark, html.hn-dark body { background-color: #000 } + + /* .uk-light paints every link #fff, leaving a story title nearly + indistinguishable from the translucent white body text around it. + #58a6ff restores the link affordance and measures 8.3:1 on black + (WCAG AAA); UIkit's own #1e87f0 only reaches 5.8:1. */ + .hn-dark .uk-light .story a, + .hn-dark .uk-light table a { color: #58a6ff } + + /* Visited links in the listing stay dimmed, the way HN does it. */ + .hn-dark .uk-light table a:visited { color: #999 } + + /* .uk-light also forces .uk-text-primary to #fff !important, which + loses the high-score highlight completely. */ + .hn-dark .uk-light .uk-text-primary { color: #58a6ff !important } + + /* #DDD thread borders measure 15:1 on black, louder than the text + they sit beside. */ + .hn-dark .uk-comment-list li { border-left-color: #2a2a2a } + .hn-dark .uk-comment-list li:hover { border-left-color: #58a6ff } } + +
@@ -53,21 +97,6 @@ <%= yield %> - - - <% if ENV['GA_TRACKING_ID'] %>