Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Dotenv.load '.env.local', '.env'

require 'logger'
require 'digest'
require 'active_record'
require './models/item'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions tests/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,22 @@ def test_comment_html_is_still_rendered
assert_includes last_response.body, '<a href="https://x.test">this</a>'
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)'

Expand Down
61 changes: 45 additions & 16 deletions views/layout.erb
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,54 @@
a { color: #0d0d0d; }
}

/* Dark mode is applied by the script in <head>, which adds .hn-dark to
<html> and UIkit's .uk-light to <body>. 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 }
}
</style>

<script>
// In <head> and synchronous so the dark background is set before first
// paint; deferring this flashes a white page.
(function () {
var query = window.matchMedia('(prefers-color-scheme: dark)')

function applyScheme () {
document.documentElement.classList.toggle('hn-dark', query.matches)
if (document.body) document.body.classList.toggle('uk-light', query.matches)
}

applyScheme()
document.addEventListener('DOMContentLoaded', applyScheme)

// Safari below 14 only has the deprecated addListener.
if (query.addEventListener) query.addEventListener('change', applyScheme)
else if (query.addListener) query.addListener(applyScheme)
})()
</script>
</head>
<body>
<div class="uk-container uk-container-small uk-margin-bottom">
Expand All @@ -53,21 +97,6 @@

<%= yield %>

<!-- Enable dark mode -->
<script>
function toggleDarkMode() {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.body.classList.add('uk-light')
} else {
document.body.classList.remove('uk-light')
}
}

toggleDarkMode()

setInterval(toggleDarkMode, 500)
</script>

<% if ENV['GA_TRACKING_ID'] %>
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= ENV['GA_TRACKING_ID'] %>"></script>
<script>
Expand Down
Loading