Generated monogram fallback + optional dark-mode icon per application
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

When an application has no icon attached, render a deterministic
monogram SVG instead of the generic picture-frame placeholder. Initials
are picked from capital letters in the name (ShelfLife -> SL); fall
back to the first two letters when fewer than two capitals exist
(Audiobookshelf -> AU). Background colour is hashed from the name for
stable per-app identity across visits.

Adds an optional second icon attachment, icon_dark, alongside the main
icon. When present, render a <picture> with a prefers-color-scheme:
dark source so the browser swaps automatically; when absent, the main
icon is used in both modes. The SVG sanitization, content-type fix,
and size/format validation now run over both attachments uniformly.

Bumps to 0.14.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-07 17:02:53 +10:00
parent 5b41db2c6a
commit bfad9c4e9d
12 changed files with 201 additions and 65 deletions

View File

@@ -44,4 +44,45 @@ module ApplicationHelper
else "border-gray-200 dark:border-gray-700"
end
end
# Picks 1-2 character initials for a monogram fallback when an Application
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
# first two letters of the name (Audiobookshelf -> AU).
MONOGRAM_PALETTE = %w[
#4f46e5 #0891b2 #16a34a #ca8a04
#db2777 #9333ea #ea580c #475569
].freeze
def monogram_initials(name)
return "?" if name.blank?
caps = name.scan(/[A-Z]/)
initials = if caps.size >= 2
caps.first(2).join
else
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
end
initials.presence || "?"
end
def monogram_color(name)
return MONOGRAM_PALETTE.first if name.blank?
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
MONOGRAM_PALETTE[index]
end
# Renders an application icon as a <picture> that swaps based on the user's
# color-scheme preference. If only `icon` is attached, the same image is used
# in both modes. Caller is responsible for ensuring at least app.icon is
# attached; the monogram fallback handles the no-icon case separately.
def app_icon_picture(app, class:, alt: nil)
img_class = binding.local_variable_get(:class)
alt ||= "#{app.name} icon"
light = url_for(app.icon)
dark = app.icon_dark.attached? ? url_for(app.icon_dark) : nil
tag.picture do
sources = []
sources << tag.source(media: "(prefers-color-scheme: dark)", srcset: dark) if dark
safe_join(sources + [image_tag(app.icon, class: img_class, alt: alt)])
end
end
end