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
137 changes: 136 additions & 1 deletion engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
}

@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme]) {
color-scheme: dark;
--color-bg: #0b1220;
--color-bg-muted: #111a2b;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1015,6 +1087,7 @@ img.avatar {
.anchor-highlight {
cursor: pointer;
transition: background 0.2s;
color: inherit;
}

.anchor-highlight--open {
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions engine/app/javascript/controllers/coplan/theme_controller.js
Original file line number Diff line number Diff line change
@@ -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}`
})
}
}
11 changes: 11 additions & 0 deletions engine/app/models/coplan/user.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist theme outside auth-synced metadata

Saving theme_preference inside metadata will be clobbered in common host setups because authenticate_coplan_user! reassigns attrs[:metadata] on every request (and the integration guide’s example returns metadata: {}). In that flow, a user can select Dark, but the next request overwrites metadata and resets to System, so the new setting is not actually persistent. This preference needs to live in a field that is not rewritten by auth sync (or merge preserved keys before assignment).

Useful? React with 👍 / 👎.

end

end
end
29 changes: 29 additions & 0 deletions engine/app/views/coplan/settings/settings/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,33 @@
<h1>Settings</h1>
</div>

<div class="card mb-md" data-controller="coplan--theme" data-coplan--theme-url-value="<%= coplan.settings_theme_path %>">
<div class="settings-row">
<span class="settings-row__label">Theme</span>
<div class="theme-switcher">
<label class="theme-switcher__option" title="System">
<input type="radio" name="theme" value="system" <%= "checked" if current_user.theme_preference == "system" %> data-action="coplan--theme#select">
<span class="theme-switcher__btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
System
</span>
</label>
<label class="theme-switcher__option" title="Light">
<input type="radio" name="theme" value="light" <%= "checked" if current_user.theme_preference == "light" %> data-action="coplan--theme#select">
<span class="theme-switcher__btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
Light
</span>
</label>
<label class="theme-switcher__option" title="Dark">
<input type="radio" name="theme" value="dark" <%= "checked" if current_user.theme_preference == "dark" %> data-action="coplan--theme#select">
<span class="theme-switcher__btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Dark
</span>
</label>
</div>
</div>
</div>

<%= render "coplan/settings/tokens/tokens", api_tokens: @api_tokens %>
4 changes: 2 additions & 2 deletions engine/app/views/layouts/coplan/application.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html>
<html<% if signed_in? && current_user.theme_preference != "system" %> data-theme="<%= current_user.theme_preference %>"<% end %>>
<head>
<title><%= content_for(:title) || "CoPlan" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="color-scheme" content="<%= signed_in? && current_user.theme_preference != 'system' ? current_user.theme_preference : 'light dark' %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= coplan_favicon_tag %>
Expand Down
1 change: 1 addition & 0 deletions engine/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading