diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 0e45c51..25d3fa6 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; @@ -1015,6 +1087,7 @@ img.avatar { .anchor-highlight { cursor: pointer; transition: background 0.2s; + color: inherit; } .anchor-highlight--open { @@ -1355,6 +1428,68 @@ img.avatar { color: var(--color-text-muted); } +/* Settings row */ +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.settings-row__label { + 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; + margin: 0; +} + +.theme-switcher__option input[type="radio"] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.theme-switcher__btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + 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-switcher__option:last-child .theme-switcher__btn { + border-right: none; +} + +.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 */ .page-header__subtitle { color: var(--color-text-muted); diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 1031c09..f9abf2a 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -57,16 +57,24 @@ def authenticate_coplan_user! external_id = attrs[:external_id].to_s @current_coplan_user = CoPlan::User.find_or_initialize_by(external_id: external_id) - @current_coplan_user.assign_attributes(attrs.slice(:name, :username, :admin, :metadata, :avatar_url, :title, :team).compact) + sync_user_attrs(@current_coplan_user, attrs) if @current_coplan_user.new_record? || @current_coplan_user.changed? @current_coplan_user.save! end rescue ActiveRecord::RecordNotUnique @current_coplan_user = CoPlan::User.find_by!(external_id: external_id) - @current_coplan_user.assign_attributes(attrs.slice(:name, :username, :admin, :metadata, :avatar_url, :title, :team).compact) + sync_user_attrs(@current_coplan_user, attrs) @current_coplan_user.save! if @current_coplan_user.changed? end + def sync_user_attrs(user, attrs) + safe_attrs = attrs.slice(:name, :username, :admin, :avatar_url, :title, :team).compact + user.assign_attributes(safe_attrs) + if attrs.key?(:metadata) && attrs[:metadata].is_a?(Hash) + user.metadata = (user.metadata || {}).merge(attrs[:metadata]) + end + end + def set_coplan_current CoPlan::Current.user = current_user end 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..3105395 100644 --- a/engine/app/views/coplan/settings/settings/index.html.erb +++ b/engine/app/views/coplan/settings/settings/index.html.erb @@ -2,4 +2,33 @@