From 94f80a44320906f23911a26c146fc57e44cbeb16 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 20 Apr 2026 15:58:44 -0500 Subject: [PATCH 1/5] Fix comment highlight text color in dark mode The element used for anchor highlights has a browser-default color: black, making highlighted/commented text unreadable in dark mode. Adding color: inherit to .anchor-highlight ensures the text color follows the page theme instead of the browser default. Amp-Thread-ID: https://ampcode.com/threads/T-019dac90-a21f-721a-96de-81ec6c0ec184 Co-authored-by: Amp --- engine/app/assets/stylesheets/coplan/application.css | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 0e45c51..eae3ea0 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1015,6 +1015,7 @@ img.avatar { .anchor-highlight { cursor: pointer; transition: background 0.2s; + color: inherit; } .anchor-highlight--open { From b61a55e8f4c95906e092ffb6587f320bd09d8f6b Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 20 Apr 2026 16:04:50 -0500 Subject: [PATCH 2/5] Add theme preference setting (System/Light/Dark) Adds a user-facing Appearance section to Settings with three theme options: System (follows device), Light, and Dark. Theme switches instantly via a Stimulus controller that sets data-theme on and persists the preference to the user's metadata JSON column. CSS changes: - Media query dark tokens now target :root:not([data-theme]) so they only apply when no explicit theme is set - Added :root[data-theme="dark"] with the same dark tokens for forced dark mode regardless of system preference - Added :root[data-theme="light"] to force light mode - Added .theme-option component styles for the settings UI Backend: - User model: theme_preference getter/setter backed by metadata JSON - Settings controller: PATCH /settings/theme endpoint - Routes: added theme route in settings namespace Frontend: - Stimulus theme_controller.js: instant DOM update + async PATCH - Layout: conditional data-theme attribute + dynamic color-scheme meta Amp-Thread-ID: https://ampcode.com/threads/T-019dac90-a21f-721a-96de-81ec6c0ec184 Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 114 +++++++++++++++++- .../coplan/settings/settings_controller.rb | 9 ++ .../controllers/coplan/theme_controller.js | 29 +++++ engine/app/models/coplan/user.rb | 11 ++ .../coplan/settings/settings/index.html.erb | 30 +++++ .../views/layouts/coplan/application.html.erb | 4 +- engine/config/routes.rb | 1 + 7 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 engine/app/javascript/controllers/coplan/theme_controller.js diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index eae3ea0..a86a563 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -99,7 +99,7 @@ } @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme]) { color-scheme: dark; --color-bg: #0b1220; --color-bg-muted: #111a2b; @@ -166,6 +166,78 @@ } } +/* Forced dark theme — overrides system preference */ +:root[data-theme="dark"] { + color-scheme: dark; + --color-bg: #0b1220; + --color-bg-muted: #111a2b; + --color-surface: #152033; + --color-surface-muted: #111a2b; + --color-input-bg: #0f172a; + --color-text: #e5eefc; + --color-text-muted: #99a7bf; + --color-border: #243145; + --color-border-strong: #334155; + --color-primary: #60a5fa; + --color-primary-hover: #3b82f6; + --color-primary-light: rgba(96, 165, 250, 0.16); + --color-success: #34d399; + --color-success-soft: rgba(52, 211, 153, 0.15); + --color-warning: #f59e0b; + --color-warning-soft: rgba(245, 158, 11, 0.16); + --color-danger: #f87171; + --color-danger-hover: #ef4444; + --color-danger-soft: rgba(248, 113, 113, 0.16); + --color-agent: #c7d2fe; + --color-agent-bg: rgba(129, 140, 248, 0.18); + --color-type-bg: rgba(52, 211, 153, 0.14); + --color-type-text: #86efac; + --color-tag-bg: #1e293b; + --color-tag-text: #cbd5e1; + --color-tag-hover-bg: #27364a; + --color-tag-hover-text: #f8fafc; + --color-tag-active-bg: #475569; + --color-tag-active-hover-bg: #64748b; + --color-code-bg: #0f172a; + --color-overlay: rgba(2, 6, 23, 0.4); + --color-focus-ring: rgba(96, 165, 250, 0.24); + --color-status-brainstorm-bg: rgba(139, 92, 246, 0.18); + --color-status-considering-bg: rgba(245, 158, 11, 0.16); + --color-status-developing-bg: rgba(59, 130, 246, 0.18); + --color-status-live-bg: rgba(16, 185, 129, 0.16); + --color-status-abandoned-bg: rgba(148, 163, 184, 0.16); + --color-diff-ins-bg: rgba(52, 211, 153, 0.16); + --color-diff-ins-text: #a7f3d0; + --color-diff-del-bg: rgba(248, 113, 113, 0.16); + --color-diff-del-text: #fecaca; + --color-quote-warning-bg: rgba(245, 158, 11, 0.16); + --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); + --color-quote-info-border: rgba(96, 165, 250, 0.8); + --color-quote-info-bg: rgba(96, 165, 250, 0.12); + --color-highlight-open-bg: rgba(96, 165, 250, 0.18); + --color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28); + --color-highlight-open-border: rgba(96, 165, 250, 0.85); + --color-highlight-pending-bg: rgba(245, 158, 11, 0.18); + --color-highlight-pending-hover-bg: rgba(245, 158, 11, 0.28); + --color-highlight-pending-border: rgba(245, 158, 11, 0.85); + --color-highlight-todo-bg: rgba(59, 130, 246, 0.22); + --color-highlight-todo-hover-bg: rgba(59, 130, 246, 0.32); + --color-highlight-todo-border: rgba(59, 130, 246, 0.85); + --color-highlight-active-bg: rgba(96, 165, 250, 0.28); + --color-highlight-active-outline: rgba(96, 165, 250, 0.35); + --color-inbox-unread-bg: rgba(96, 165, 250, 0.12); + --color-inbox-unread-hover-bg: rgba(96, 165, 250, 0.18); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.45); + --shadow-top: 0 -2px 10px rgba(0, 0, 0, 0.3); + --shadow-pop: 0 6px 16px rgba(0, 0, 0, 0.35); +} + +/* Forced light theme — overrides system dark preference */ +:root[data-theme="light"] { + color-scheme: light; +} + /* Reset */ *, *::before, *::after { box-sizing: border-box; @@ -1356,6 +1428,46 @@ img.avatar { color: var(--color-text-muted); } +/* Theme options */ +.theme-options { + display: flex; + gap: var(--space-md); + margin-top: var(--space-md); +} + +.theme-option { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + flex: 1; +} + +.theme-option:hover { + border-color: var(--color-border-strong); + background: var(--color-bg-muted); +} + +.theme-option:has(input:checked) { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.theme-option input[type="radio"] { + margin-top: 3px; + cursor: pointer; +} + +.theme-option__label { + display: flex; + flex-direction: column; + gap: 2px; +} + /* Page header */ .page-header__subtitle { color: var(--color-text-muted); diff --git a/engine/app/controllers/coplan/settings/settings_controller.rb b/engine/app/controllers/coplan/settings/settings_controller.rb index 0247e9d..f67c7dc 100644 --- a/engine/app/controllers/coplan/settings/settings_controller.rb +++ b/engine/app/controllers/coplan/settings/settings_controller.rb @@ -4,6 +4,15 @@ class SettingsController < ApplicationController def index @api_tokens = current_user.api_tokens.order(created_at: :desc) end + + def update_theme + theme = params[:theme] + if CoPlan::User::THEME_PREFERENCES.include?(theme) + current_user.theme_preference = theme + current_user.save! + end + head :ok + end end end end diff --git a/engine/app/javascript/controllers/coplan/theme_controller.js b/engine/app/javascript/controllers/coplan/theme_controller.js new file mode 100644 index 0000000..af794cb --- /dev/null +++ b/engine/app/javascript/controllers/coplan/theme_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { url: String } + + select(event) { + const theme = event.target.value + if (theme === "system") { + document.documentElement.removeAttribute("data-theme") + } else { + document.documentElement.setAttribute("data-theme", theme) + } + + const meta = document.querySelector('meta[name="color-scheme"]') + if (meta) { + meta.content = theme === "system" ? "light dark" : theme + } + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + fetch(this.urlValue, { + method: "PATCH", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrfToken + }, + body: `theme=${theme}` + }) + } +} diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index a30fb35..29bb1bc 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -1,5 +1,7 @@ module CoPlan class User < ApplicationRecord + THEME_PREFERENCES = %w[system light dark].freeze + has_many :api_tokens, dependent: :destroy has_many :created_plans, class_name: "CoPlan::Plan", foreign_key: :created_by_user_id, dependent: :nullify, inverse_of: :created_by_user has_many :plan_collaborators, dependent: :destroy @@ -22,5 +24,14 @@ def self.ransackable_associations(auth_object = nil) %w[api_tokens plan_collaborators] end + def theme_preference + metadata&.dig("theme_preference") || "system" + end + + def theme_preference=(value) + self.metadata ||= {} + self.metadata["theme_preference"] = value + end + end end diff --git a/engine/app/views/coplan/settings/settings/index.html.erb b/engine/app/views/coplan/settings/settings/index.html.erb index d764517..7e316d7 100644 --- a/engine/app/views/coplan/settings/settings/index.html.erb +++ b/engine/app/views/coplan/settings/settings/index.html.erb @@ -2,4 +2,34 @@

Settings

+

Appearance

+

Choose how CoPlan looks to you

+ +
+

Theme

+
+ + + +
+
+ <%= render "coplan/settings/tokens/tokens", api_tokens: @api_tokens %> diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index 5892329..6c76d78 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -1,9 +1,9 @@ - + data-theme="<%= current_user.theme_preference %>"<% end %>> <%= content_for(:title) || "CoPlan" %> - + <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= coplan_favicon_tag %> diff --git a/engine/config/routes.rb b/engine/config/routes.rb index e91ff85..eebe72d 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -22,6 +22,7 @@ namespace :settings do root "settings#index" resources :tokens, only: [:index, :create, :destroy] + patch "theme", to: "settings#update_theme" end namespace :api do From 080de513a30db29f6ecbd5dd026c7c362b9fffa7 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 20 Apr 2026 16:13:18 -0500 Subject: [PATCH 3/5] Redesign theme switcher as compact segmented control with icons Replace the large card-based radio buttons with a sleek inline segmented control: monitor icon for System, sun for Light, moon for Dark. Uses a settings-row layout pattern for clean label/control alignment. Amp-Thread-ID: https://ampcode.com/threads/T-019dac90-a21f-721a-96de-81ec6c0ec184 Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 74 +++++++++++++------ .../coplan/settings/settings/index.html.erb | 33 ++++----- 2 files changed, 65 insertions(+), 42 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index a86a563..ad95d4b 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1428,44 +1428,70 @@ img.avatar { color: var(--color-text-muted); } -/* Theme options */ -.theme-options { +/* Settings row */ +.settings-row { display: flex; - gap: var(--space-md); - margin-top: var(--space-md); + align-items: center; + justify-content: space-between; + padding: var(--space-md) 0; + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-xl); } -.theme-option { - display: flex; - align-items: flex-start; - gap: var(--space-sm); - padding: var(--space-md); +.settings-row__label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text); +} + +/* Theme switcher (segmented control) */ +.theme-switcher { + display: inline-flex; border: 1px solid var(--color-border); border-radius: var(--radius); + overflow: hidden; +} + +.theme-switcher__option { cursor: pointer; - transition: border-color 0.15s, background 0.15s; - flex: 1; + margin: 0; } -.theme-option:hover { - border-color: var(--color-border-strong); - background: var(--color-bg-muted); +.theme-switcher__option input[type="radio"] { + position: absolute; + opacity: 0; + pointer-events: none; } -.theme-option:has(input:checked) { - border-color: var(--color-primary); - background: var(--color-primary-light); +.theme-switcher__btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + transition: background 0.15s, color 0.15s; + border-right: 1px solid var(--color-border); } -.theme-option input[type="radio"] { - margin-top: 3px; - cursor: pointer; +.theme-switcher__option:last-child .theme-switcher__btn { + border-right: none; } -.theme-option__label { - display: flex; - flex-direction: column; - gap: 2px; +.theme-switcher__btn svg { + flex-shrink: 0; +} + +.theme-switcher__option:hover .theme-switcher__btn { + background: var(--color-bg-muted); + color: var(--color-text); +} + +.theme-switcher__option:has(input:checked) .theme-switcher__btn { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 600; } /* Page header */ diff --git a/engine/app/views/coplan/settings/settings/index.html.erb b/engine/app/views/coplan/settings/settings/index.html.erb index 7e316d7..bde1ce5 100644 --- a/engine/app/views/coplan/settings/settings/index.html.erb +++ b/engine/app/views/coplan/settings/settings/index.html.erb @@ -2,31 +2,28 @@

Settings

-

Appearance

-

Choose how CoPlan looks to you

- -
-

Theme

-
-