open_blog 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -4
  3. data/README.md +60 -32
  4. data/app/assets/builds/open_blog/blog.css +1 -1
  5. data/app/assets/builds/open_blog/themes.css +403 -0
  6. data/app/assets/fonts/open_blog/OFL-dm-sans.txt +93 -0
  7. data/app/assets/fonts/open_blog/OFL-jetbrains-mono.txt +93 -0
  8. data/app/assets/fonts/open_blog/OFL-newsreader.txt +93 -0
  9. data/app/assets/fonts/open_blog/OFL-outfit.txt +93 -0
  10. data/app/assets/fonts/open_blog/OFL-space-grotesk.txt +93 -0
  11. data/app/assets/fonts/open_blog/dm-sans-italic-latin-ext.woff2 +0 -0
  12. data/app/assets/fonts/open_blog/dm-sans-italic-latin.woff2 +0 -0
  13. data/app/assets/fonts/open_blog/dm-sans-latin-ext.woff2 +0 -0
  14. data/app/assets/fonts/open_blog/dm-sans-latin.woff2 +0 -0
  15. data/app/assets/fonts/open_blog/jetbrains-mono-latin-ext.woff2 +0 -0
  16. data/app/assets/fonts/open_blog/jetbrains-mono-latin.woff2 +0 -0
  17. data/app/assets/fonts/open_blog/newsreader-italic-latin-ext.woff2 +0 -0
  18. data/app/assets/fonts/open_blog/newsreader-italic-latin.woff2 +0 -0
  19. data/app/assets/fonts/open_blog/newsreader-latin-ext.woff2 +0 -0
  20. data/app/assets/fonts/open_blog/newsreader-latin.woff2 +0 -0
  21. data/app/assets/fonts/open_blog/outfit-latin-ext.woff2 +0 -0
  22. data/app/assets/fonts/open_blog/outfit-latin.woff2 +0 -0
  23. data/app/assets/fonts/open_blog/space-grotesk-latin-ext.woff2 +0 -0
  24. data/app/assets/fonts/open_blog/space-grotesk-latin.woff2 +0 -0
  25. data/app/controllers/open_blog/api/v1/pages_controller.rb +5 -5
  26. data/app/helpers/open_blog/faq_helper.rb +3 -1
  27. data/app/helpers/open_blog/theme_helper.rb +29 -5
  28. data/lib/generators/open_blog/generator_support.rb +5 -5
  29. data/lib/generators/open_blog/install/install_generator.rb +22 -1
  30. data/lib/generators/open_blog/install/templates/initializer.rb.tt +1 -0
  31. data/lib/generators/open_blog/install/templates/theme/blog.css +194 -93
  32. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +17 -81
  33. data/lib/generators/open_blog/install/templates/theme/theme.css +3 -85
  34. data/lib/generators/open_blog/views/views_generator.rb +1 -1
  35. data/lib/open_blog/build_css.rb +51 -8
  36. data/lib/open_blog/configuration.rb +37 -1
  37. data/lib/open_blog/doctor/files.rb +50 -11
  38. data/lib/open_blog/themes.rb +167 -0
  39. data/lib/open_blog/version.rb +1 -1
  40. data/lib/open_blog.rb +1 -0
  41. data/lib/tasks/open_blog.rake +1 -1
  42. data/skills/open-blog-install/SKILL.md +1 -1
  43. metadata +23 -2
@@ -27,17 +27,60 @@ module OpenBlog
27
27
  end
28
28
  end
29
29
 
30
+ OVERRIDE = <<~CSS
31
+ /*
32
+ Open Blog loads this file after the preset chosen with config.theme.
33
+ Put your token overrides here. Colours can also be set with config.theme_colors.
34
+
35
+ :root[data-ob-theme] {
36
+ --ob-accent: #1d4ed8;
37
+ --ob-font-display: Georgia, serif;
38
+ --ob-radius-card: 0.5rem;
39
+ }
40
+ :root[data-ob-theme][data-theme="dark"] {
41
+ --ob-accent: #93c5fd;
42
+ }
43
+ @media (prefers-color-scheme: dark) {
44
+ :root[data-ob-theme]:not([data-theme="light"]) {
45
+ --ob-accent: #93c5fd;
46
+ }
47
+ }
48
+ */
49
+ CSS
50
+
51
+ COLOR_SCHEME = <<~CSS
52
+ :root { color-scheme: light dark; }
53
+ :root[data-theme="light"] { color-scheme: light; }
54
+ :root[data-theme="dark"] { color-scheme: dark; }
55
+ CSS
56
+
30
57
  def self.tokens
31
- source = theme_root.join("theme.css").read
32
- block = source.match(%r{/\* open-blog:tokens:start \*/(.*?)/\* open-blog:tokens:end \*/}m)
33
- raise ConfigurationError, "The theme must contain its marked token block" unless block
34
- "#{block[1].strip}\n"
58
+ OVERRIDE
59
+ end
60
+
61
+ def self.themes
62
+ faces = Themes::FONTS.map do |font|
63
+ <<~CSS
64
+ @font-face {
65
+ font-family: "#{font.family}";
66
+ font-style: #{font.style};
67
+ font-weight: #{font.weight};
68
+ font-display: swap;
69
+ src: url("#{font.file}") format("woff2");
70
+ unicode-range: #{font.range};
71
+ }
72
+ CSS
73
+ end
74
+ presets = Themes.names.map do |name|
75
+ preset = Themes.fetch(name)
76
+ Themes.css(name, light: preset.light.merge(preset.tokens), dark: preset.dark)
77
+ end
78
+ [ *faces, COLOR_SCHEME, *presets ].join
35
79
  end
36
80
 
37
- def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"))
38
- css = render
39
- values = tokens
40
- [ [ path, css ], [ tokens_path, values ] ].each do |destination, bytes|
81
+ def self.write(path: Engine.root.join("app/assets/builds/open_blog/blog.css"), tokens_path: theme_root.join("open_blog_theme.css"),
82
+ themes_path: Pathname(path).dirname.join("themes.css"))
83
+ [ [ path, render ], [ tokens_path, tokens ], [ themes_path, themes ] ].each do |destination, bytes|
41
84
  destination = Pathname(destination)
42
85
  FileUtils.mkdir_p(destination.dirname)
43
86
  destination.binwrite(bytes)
@@ -5,6 +5,7 @@ require "active_support/core_ext/numeric/time"
5
5
  require "active_support/core_ext/numeric/bytes"
6
6
  require "open_blog/configuration_error"
7
7
  require "open_blog/page_views"
8
+ require "open_blog/themes"
8
9
 
9
10
  module OpenBlog
10
11
  class Configuration
@@ -17,12 +18,16 @@ module OpenBlog
17
18
  end
18
19
  end
19
20
 
21
+ THEMES = (Themes.names + [ :none ]).freeze
22
+ THEME_MODES = %i[light dark].freeze
23
+ THEME_COLOR_KEYS = Themes::COLOR_TOKENS.to_h { |token| [ token.tr("-", "_").to_sym, token ] }.freeze
24
+
20
25
  attr_accessor :site_name, :public_base_url, :default_author, :publisher, :locale,
21
26
  :blog_title, :blog_tagline, :layout, :parent_controller, :mount_path,
22
27
  :route_segments, :posts_per_page, :primary_list_type, :call_to_action,
23
28
  :default_social_image_url, :policy_urls, :sign_in_destinations, :body_formats,
24
29
  :default_body_format, :markdown_hardbreaks, :ai_label, :require_approval,
25
- :color_scheme, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
30
+ :color_scheme, :theme, :theme_colors, :faq_collapsed, :syntax_theme, :feed_content, :feed_size, :serve_sitemap,
26
31
  :page_views, :page_view_bot_pattern, :page_view_retention_days, :popular_posts,
27
32
  :preview_expires_in, :storage_service, :image_delivery, :max_image_bytes,
28
33
  :image_content_types, :image_fetch_policy, :api_rate_limit, :search_rate_limit
@@ -45,6 +50,9 @@ module OpenBlog
45
50
  @ai_label = :when_required
46
51
  @require_approval = false
47
52
  @color_scheme = :system
53
+ @theme = :signal
54
+ @theme_colors = {}
55
+ @faq_collapsed = true
48
56
  @syntax_theme = "github".dup
49
57
  @feed_content = :summary
50
58
  @feed_size = 20
@@ -101,6 +109,7 @@ module OpenBlog
101
109
  end
102
110
  invalid!(:default_body_format, "must be enabled in body_formats") unless body_formats.include?(default_body_format)
103
111
  preview_duration
112
+ validate_theme!
104
113
  validate_view_settings!
105
114
  invalid!(:mcp, "enabled must be true or false") unless [ true, false ].include?(mcp.enabled)
106
115
  invalid!(:mcp, "max_page_size must be a positive integer") unless mcp.max_page_size.is_a?(Integer) && mcp.max_page_size.positive?
@@ -115,8 +124,35 @@ module OpenBlog
115
124
  value.to_f
116
125
  end
117
126
 
127
+ def theme_colors_for(mode)
128
+ validate_theme!
129
+ theme_colors.except(*THEME_MODES).merge(theme_colors.fetch(mode, {})).to_h do |key, color|
130
+ [ THEME_COLOR_KEYS.fetch(key), color ]
131
+ end
132
+ end
133
+
118
134
  private
119
135
 
136
+ def validate_theme!
137
+ invalid!(:theme, "must be #{THEMES.map(&:inspect).join(', ')}") unless THEMES.include?(theme)
138
+ invalid!(:faq_collapsed, "must be true or false") unless [ true, false ].include?(faq_collapsed)
139
+ invalid!(:theme_colors, "must be a hash") unless theme_colors.is_a?(Hash)
140
+ theme_colors.each do |key, value|
141
+ next validate_theme_color!(key, key, value) unless THEME_MODES.include?(key)
142
+ invalid!("theme_colors.#{key}", "must be a hash") unless value.is_a?(Hash)
143
+ value.each { |token, color| validate_theme_color!("#{key}.#{token}", token, color) }
144
+ end
145
+ end
146
+
147
+ def validate_theme_color!(path, token, color)
148
+ unless THEME_COLOR_KEYS.key?(token)
149
+ invalid!("theme_colors.#{path}", "is not a colour token; use #{THEME_COLOR_KEYS.keys.join(', ')}")
150
+ end
151
+ unless color.is_a?(String) && color.encoding.ascii_compatible? && color.valid_encoding? && color.match?(Themes::HEX)
152
+ invalid!("theme_colors.#{path}", "must be a hex colour such as #0f766e")
153
+ end
154
+ end
155
+
120
156
  def validate_view_settings!
121
157
  invalid!(:page_views, "must be true or false") unless [ true, false ].include?(page_views)
122
158
  invalid!(:page_view_bot_pattern, "must be a regular expression") unless page_view_bot_pattern.is_a?(Regexp)
@@ -34,27 +34,66 @@ module OpenBlog
34
34
  end
35
35
 
36
36
  def theme
37
+ colors = Configuration::THEME_MODES.index_with { |mode| @config.theme_colors_for(mode) }
38
+ preset = Themes.fetch(@config.theme) unless @config.theme == :none
39
+ problem = theme_files(preset)
40
+ return [ "error", problem ] if problem
41
+ warnings = []
42
+ warnings << "theme_colors has no effect with theme :none." if preset.nil? && colors.values.any?(&:any?)
43
+ low = preset ? low_contrast(preset, colors) : []
44
+ warnings << "Theme colours below #{Themes::MIN_CONTRAST}:1 contrast: #{low.join(', ')}." if low.any?
45
+ stale = stale_component_styles(preset)
46
+ warnings << stale if stale
47
+ if Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
48
+ warnings << "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages."
49
+ end
50
+ return [ "warning", warnings.join(" ") ] if warnings.any?
51
+ [ "ok", "Theme #{@config.theme}: sources and compiled stylesheets are available." ]
52
+ rescue ConfigurationError => error
53
+ [ "error", error.message ]
54
+ end
55
+
56
+ def low_contrast(preset, colors)
57
+ colors.flat_map do |mode, overrides|
58
+ Themes.low_contrast(preset.public_send(mode).merge(overrides)).map do |foreground, background, ratio|
59
+ "#{mode} #{foreground.tr('-', '_')} on #{background.tr('-', '_')} #{ratio.floor(2)}:1"
60
+ end
61
+ end
62
+ end
63
+
64
+ def stale_component_styles(preset)
65
+ return if layout_source.include?("open_blog_stylesheets")
66
+ path = "app/assets/tailwind/open_blog/blog.css"
67
+ source = read(path)
68
+ absent = []
69
+ absent << "preset" if preset && !source.include?("data-ob-theme")
70
+ absent << "collapsed FAQ" if @config.faq_collapsed && !source.include?("details.ob-faq-entry")
71
+ return if absent.empty?
72
+ "#{path} has no #{absent.join(' or ')} rules. Delete it#{' and theme.css in the same directory' if preset}, then run bin/rails generate open_blog:install."
73
+ end
74
+
75
+ def theme_files(preset)
37
76
  layout = layout_source
38
77
  tailwind = @root.join("app/assets/tailwind/application.css")
39
78
  bundled = @root.join("app/assets/stylesheets/application.tailwind.css")
40
79
  if !layout.include?("open_blog_stylesheets")
41
80
  asset = layout[/stylesheet_link_tag\s*\(?\s*["']([^"']+)["']/, 1]
42
- return [ "error", "The blog layout must load the installed stylesheet." ] unless %w[tailwind application].include?(asset)
81
+ return "The blog layout must load the installed stylesheet." unless %w[tailwind application].include?(asset)
82
+ return "The blog layout must call open_blog_theme_stylesheets to load the #{preset.name} theme." if preset && !layout.include?("open_blog_theme_stylesheets")
43
83
  entry = asset == "tailwind" ? tailwind : bundled
44
- return [ "error", "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." ] unless entry.file?
84
+ return "Missing Tailwind entry stylesheet #{entry.relative_path_from(@root)}." unless entry.file?
45
85
  source = entry.read
46
- return [ "error", "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." ] unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
86
+ return "Import open_blog/theme.css in #{entry.relative_path_from(@root)}." unless source.match?(/@import\s+["'][^"']*open_blog\/theme(?:\.css)?["']/)
47
87
  absent = %w[theme blog syntax].reject { |name| @root.join("app/assets/tailwind/open_blog/#{name}.css").file? }
48
- return [ "error", "Missing theme sources: #{absent.join(', ')}." ] if absent.any?
49
- asset = "#{asset}.css"
88
+ return "Missing theme sources: #{absent.join(', ')}." if absent.any?
89
+ assets = [ "#{asset}.css" ]
50
90
  else
51
- return [ "error", "Install app/assets/stylesheets/open_blog_theme.css." ] unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
52
- asset = "open_blog/blog.css"
53
- return [ "error", "The blog layout must load open_blog_stylesheets." ] unless layout.include?("open_blog_stylesheets")
91
+ return "Install app/assets/stylesheets/open_blog_theme.css." unless @root.join("app/assets/stylesheets/open_blog_theme.css").file?
92
+ assets = [ "open_blog/blog.css" ]
54
93
  end
55
- return [ "error", "Build the missing #{asset} stylesheet." ] unless @application.assets&.resolver&.resolve(asset)
56
- all = Dir[@root.join("app/views/layouts/**/*")].select { |path| File.file?(path) }.any? { |path| File.read(path).match?(/stylesheet_link_tag\s*(?:\(?\s*):all\b/) }
57
- all ? [ "warning", "Replace stylesheet_link_tag :all to avoid loading the blog reset on other pages." ] : [ "ok", "Theme sources and compiled stylesheet are available." ]
94
+ assets << "open_blog/themes.css" if preset
95
+ missing = assets.reject { |asset| @application.assets&.resolver&.resolve(asset) }
96
+ "Build the missing #{missing.join(', ')} stylesheet." if missing.any?
58
97
  end
59
98
  end
60
99
  end
@@ -0,0 +1,167 @@
1
+ module OpenBlog
2
+ module Themes
3
+ Preset = Data.define(:name, :light, :dark, :tokens)
4
+ Font = Data.define(:family, :style, :weight, :file, :range)
5
+
6
+ MIN_CONTRAST = 4.5
7
+
8
+ COLOR_TOKENS = %w[
9
+ surface surface-raised surface-sunken heading text text-muted text-subtle border border-strong
10
+ accent accent-hover accent-soft accent-contrast accent-2 accent-2-soft
11
+ code-bg code-text code-border notice-bg notice-border notice-text note tip warning
12
+ ].freeze
13
+
14
+ TOKENS = %w[
15
+ font-display font-body font-mono radius-card radius-media radius-pill prose-size prose-leading
16
+ content-width page-width shadow heading-weight heading-tracking heading-transform rule-width lede-style
17
+ ].freeze
18
+
19
+ CONTRAST_PAIRS = [
20
+ %w[heading surface], %w[text surface], %w[text-muted surface], %w[text-subtle surface],
21
+ %w[heading surface-raised], %w[text surface-raised], %w[text-muted surface-raised], %w[text-subtle surface-raised],
22
+ %w[heading surface-sunken], %w[text surface-sunken],
23
+ %w[accent surface], %w[accent-hover surface], %w[accent surface-raised], %w[accent-hover surface-raised], %w[accent accent-soft],
24
+ %w[heading accent-soft], %w[text accent-soft], %w[heading accent-2-soft],
25
+ %w[accent-contrast accent], %w[notice-text notice-bg], %w[code-text code-bg],
26
+ %w[note surface-raised], %w[tip surface-raised], %w[warning surface-raised]
27
+ ].map(&:freeze).freeze
28
+
29
+ SANS = "ui-sans-serif, system-ui, sans-serif"
30
+ MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace'
31
+
32
+ PRESETS = {
33
+ signal: {
34
+ light: %w[
35
+ #ffffff #f6fbfa #eaf4f2 #0b1f22 #2b3f42 #4d6165 #5f7579 #cfe0dd #5f8a84
36
+ #0f766e #115e59 #d9f0ec #ffffff #b45309 #fdebd3
37
+ #f6fbfa #0b1f22 #b3cfca #fff7e8 #f0b35b #6b3f0a #1d5fa8 #166534 #92400e
38
+ ],
39
+ dark: %w[
40
+ #071a1c #0d2528 #041214 #eaf6f4 #c9dedb #a3bdb9 #8ba8a4 #1f4145 #5f8a84
41
+ #5eead4 #99f6e4 #0f3d3a #06201f #fbbf24 #3d2a0a
42
+ #041214 #e6f1ef #2a4d51 #33250c #b07d2c #fde7bd #7cc4f8 #86efac #fcd34d
43
+ ],
44
+ tokens: [
45
+ %("Outfit", #{SANS}), %("DM Sans", #{SANS}), MONO, "1rem", "0.75rem", "999px", "1.125rem", "1.8",
46
+ "46rem", "72rem", "0 18px 40px -20px #0b1f2259", "750", "-0.02em", "none", "1px", "normal"
47
+ ]
48
+ },
49
+ editorial: {
50
+ light: %w[
51
+ #fbf7f1 #f4ede2 #ece3d4 #1f1a16 #3a322c #5c524a #6b6158 #dccfbd #8c7b66
52
+ #8a2d2d #6f2222 #f1dedb #ffffff #b7791f #f4e6c9
53
+ #f9f4ec #1f1a16 #c9b9a3 #f6efe4 #d0b58a #5a4420 #2c5282 #2f6b3a #8a5a0b
54
+ ],
55
+ dark: %w[
56
+ #1a1512 #241d18 #120e0c #f6eee2 #ddd2c3 #b9ab9a #a39483 #3d332b #8c7b66
57
+ #e9a09a #f4c2bd #43201e #1a1512 #e0b25a #3a2c10
58
+ #110d0b #f3ebdf #4a3e34 #2e2515 #8a7040 #f0e0be #9cc3e8 #a3d9a5 #ebc46a
59
+ ],
60
+ tokens: [
61
+ '"Newsreader", Georgia, "Times New Roman", serif', '"Newsreader", Georgia, "Times New Roman", serif', MONO,
62
+ "0.375rem", "0.25rem", "999px", "1.25rem", "1.75", "42rem", "72rem", "none", "500", "-0.015em", "none", "1px", "italic"
63
+ ]
64
+ },
65
+ ink: {
66
+ light: %w[
67
+ #ffffff #f5f5f4 #ececea #0a0a0a #262626 #525252 #636363 #d4d4d4 #0a0a0a
68
+ #1f3bd6 #172c9f #e3e8ff #ffffff #c6f432 #f1fbc9
69
+ #fafafa #0a0a0a #0a0a0a #fffbe6 #0a0a0a #0a0a0a #1f3bd6 #166534 #8a4b00
70
+ ],
71
+ dark: %w[
72
+ #000000 #111111 #1a1a1a #ffffff #e5e5e5 #b5b5b5 #9a9a9a #333333 #ffffff
73
+ #8ea2ff #b4c1ff #141c4d #000000 #c6f432 #2a3505
74
+ #0d0d0d #f5f5f5 #ffffff #1f1c05 #ffffff #ffffff #8ea2ff #c6f432 #facc15
75
+ ],
76
+ tokens: [
77
+ %("Space Grotesk", #{SANS}), %("DM Sans", #{SANS}), MONO, "0", "0", "0", "1.0625rem", "1.7",
78
+ "44rem", "72rem", "none", "700", "-0.03em", "uppercase", "2px", "normal"
79
+ ]
80
+ }
81
+ }.to_h do |name, values|
82
+ [ name, Preset.new(name: name, light: COLOR_TOKENS.zip(values[:light]).to_h.freeze,
83
+ dark: COLOR_TOKENS.zip(values[:dark]).to_h.freeze, tokens: TOKENS.zip(values[:tokens]).to_h.freeze) ]
84
+ end.freeze
85
+
86
+ RANGES = {
87
+ "latin" => "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, " \
88
+ "U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD",
89
+ "latin-ext" => "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, " \
90
+ "U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"
91
+ }.freeze
92
+
93
+ FONTS = [
94
+ [ "Outfit", "normal", "100 900", "outfit" ],
95
+ [ "DM Sans", "normal", "100 1000", "dm-sans" ],
96
+ [ "DM Sans", "italic", "100 1000", "dm-sans-italic" ],
97
+ [ "JetBrains Mono", "normal", "100 800", "jetbrains-mono" ],
98
+ [ "Newsreader", "normal", "200 800", "newsreader" ],
99
+ [ "Newsreader", "italic", "200 800", "newsreader-italic" ],
100
+ [ "Space Grotesk", "normal", "300 700", "space-grotesk" ]
101
+ ].flat_map do |family, style, weight, file|
102
+ RANGES.map { |subset, range| Font.new(family: family, style: style, weight: weight, file: "#{file}-#{subset}.woff2", range: range) }
103
+ end.freeze
104
+
105
+ HEX = /\A#(?:\h{3}|\h{6}|\h{8})\z/
106
+
107
+ class << self
108
+ def names
109
+ PRESETS.keys
110
+ end
111
+
112
+ def fetch(name)
113
+ PRESETS.fetch(name.to_s.to_sym) do
114
+ raise ConfigurationError, "Unknown theme #{name.inspect}; use #{names.join(', ')}"
115
+ end
116
+ end
117
+
118
+ def css(name, light: {}, dark: {})
119
+ root = %(:root[data-ob-theme="#{fetch(name).name}"])
120
+ system = "@media (prefers-color-scheme: dark) {\n#{rule(%(#{root}:not([data-theme="light"])), dark, " ")}}\n"
121
+ [
122
+ (rule(root, light) if light.any?),
123
+ (rule(%(#{root}[data-theme="dark"]), dark) if dark.any?),
124
+ (system if dark.any?)
125
+ ].compact.join
126
+ end
127
+
128
+ def contrast(foreground, background, backdrop = background)
129
+ under = blend(rgba(background), rgba(backdrop).first(3))
130
+ lighter, darker = [ blend(rgba(foreground), under), under ].map { |color| luminance(color) }.sort.reverse
131
+ (lighter + 0.05) / (darker + 0.05)
132
+ end
133
+
134
+ def low_contrast(colors)
135
+ CONTRAST_PAIRS.filter_map do |foreground, background|
136
+ ratio = contrast(colors.fetch(foreground), colors.fetch(background), colors.fetch("surface"))
137
+ [ foreground, background, ratio ] if ratio < MIN_CONTRAST
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def rule(selector, values, indent = "")
144
+ lines = values.map { |token, value| "#{indent} --ob-#{token.to_s.tr('_', '-')}: #{value};\n" }
145
+ "#{indent}#{selector} {\n#{lines.join}#{indent}}\n"
146
+ end
147
+
148
+ def rgba(color)
149
+ raise ArgumentError, "Expected a hex colour, got #{color.inspect}" unless color.is_a?(String) && color.match?(HEX)
150
+ digits = color.delete_prefix("#")
151
+ digits = digits.chars.map { |digit| digit * 2 }.join if digits.length == 3
152
+ digits.ljust(8, "f").scan(/../).map { |channel| channel.to_i(16) / 255.0 }
153
+ end
154
+
155
+ def blend(color, under)
156
+ *channels, alpha = color
157
+ channels.zip(under).map { |top, bottom| top * alpha + bottom * (1 - alpha) }
158
+ end
159
+
160
+ def luminance(channels)
161
+ channels.zip([ 0.2126, 0.7152, 0.0722 ]).sum do |value, weight|
162
+ weight * (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055)**2.4)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,3 +1,3 @@
1
1
  module OpenBlog
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/open_blog.rb CHANGED
@@ -40,6 +40,7 @@ module OpenBlog
40
40
  autoload :Renderer, "open_blog/renderer"
41
41
  autoload :SyntaxCss, "open_blog/syntax_css"
42
42
  autoload :BuildCss, "open_blog/build_css"
43
+ autoload :Themes, "open_blog/themes"
43
44
  autoload :ReaderPage, "open_blog/reader_page"
44
45
  autoload :ReaderDates, "open_blog/reader_dates"
45
46
  autoload :JsonFeed, "open_blog/json_feed"
@@ -37,7 +37,7 @@ namespace :open_blog do
37
37
  task :syntax_css, [ :path ] => :environment do |_task, arguments|
38
38
  puts "Wrote #{OpenBlog::SyntaxCss.write(path: arguments[:path])}"
39
39
  end
40
- desc "Build the packaged stylesheet and token-only theme override"
40
+ desc "Build the packaged stylesheets and the theme override stub"
41
41
  task build_css: :environment do
42
42
  options = ENV["OUT"].present? ? { path: ENV["OUT"] } : {}
43
43
  puts "Wrote #{OpenBlog::BuildCss.write(**options)}"
@@ -3,7 +3,7 @@ name: open-blog-install
3
3
  description: Install and configure Open Blog in a Rails host, customize its reader theme, and check setup.
4
4
  ---
5
5
 
6
- Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; use the generated theme override for branding.
6
+ Install the gem in the requested Rails application, then run `bin/rails generate open_blog:install`. Inspect the host's JavaScript, Tailwind, image processor, mount path, and authentication setup before selecting options. Use `--skip-tailwind` when the host should keep its CSS configuration; select a reader preset with `--theme=signal`, `--theme=editorial`, or `--theme=ink`. For branding, set hex colors in `config.theme_colors`, and use the generated theme override for fonts and shape.
7
7
 
8
8
  Run `bin/rails open_blog:doctor` or `blog_doctor` and resolve relevant errors. Configure the actual publisher, author, public base URL, and policy links. Replace placeholders with user-supplied identities. Keep the initial API token secret; installation prints it once and repeat runs do not recover it. A host authentication hook takes precedence over tokens.
9
9
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_blog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TechWright Labs
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-03 00:00:00.000000000 Z
10
+ date: 2026-10-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: json
@@ -233,6 +233,26 @@ files:
233
233
  - LICENSE.txt
234
234
  - README.md
235
235
  - app/assets/builds/open_blog/blog.css
236
+ - app/assets/builds/open_blog/themes.css
237
+ - app/assets/fonts/open_blog/OFL-dm-sans.txt
238
+ - app/assets/fonts/open_blog/OFL-jetbrains-mono.txt
239
+ - app/assets/fonts/open_blog/OFL-newsreader.txt
240
+ - app/assets/fonts/open_blog/OFL-outfit.txt
241
+ - app/assets/fonts/open_blog/OFL-space-grotesk.txt
242
+ - app/assets/fonts/open_blog/dm-sans-italic-latin-ext.woff2
243
+ - app/assets/fonts/open_blog/dm-sans-italic-latin.woff2
244
+ - app/assets/fonts/open_blog/dm-sans-latin-ext.woff2
245
+ - app/assets/fonts/open_blog/dm-sans-latin.woff2
246
+ - app/assets/fonts/open_blog/jetbrains-mono-latin-ext.woff2
247
+ - app/assets/fonts/open_blog/jetbrains-mono-latin.woff2
248
+ - app/assets/fonts/open_blog/newsreader-italic-latin-ext.woff2
249
+ - app/assets/fonts/open_blog/newsreader-italic-latin.woff2
250
+ - app/assets/fonts/open_blog/newsreader-latin-ext.woff2
251
+ - app/assets/fonts/open_blog/newsreader-latin.woff2
252
+ - app/assets/fonts/open_blog/outfit-latin-ext.woff2
253
+ - app/assets/fonts/open_blog/outfit-latin.woff2
254
+ - app/assets/fonts/open_blog/space-grotesk-latin-ext.woff2
255
+ - app/assets/fonts/open_blog/space-grotesk-latin.woff2
236
256
  - app/assets/javascripts/open_blog/controllers/code_copy_controller.js
237
257
  - app/assets/javascripts/open_blog/controllers/reading_progress_controller.js
238
258
  - app/assets/javascripts/open_blog/controllers/search_controller.js
@@ -474,6 +494,7 @@ files:
474
494
  - lib/open_blog/surface_report/record_checks.rb
475
495
  - lib/open_blog/surface_report/robots.rb
476
496
  - lib/open_blog/syntax_css.rb
497
+ - lib/open_blog/themes.rb
477
498
  - lib/open_blog/unpublish.rb
478
499
  - lib/open_blog/version.rb
479
500
  - lib/open_blog/write_post.rb